How to paginate an API properly

How-to 9 min read Updated August 2026

Every list endpoint needs pagination — the only question is whether you design it deliberately or discover it when a table hits a million rows. This guide builds a pagination contract that's predictable for clients and cheap for your database, and shows exactly when to switch from offset to cursor.

Define the contract first

Pagination is an API contract, so pin down the shape before the implementation. Clients send how many and where from; you return the data plus page metadata so they can request the next page without guessing.

GET /orders?limit=20&sort=-createdAt

{
  "data": [ /* up to 20 orders */ ],
  "page": {
    "limit": 20,
    "nextCursor": "eyJpZCI6MTQzfQ",
    "hasMore": true
  }
}

Return hasMore and a nextCursor (or a total for offset) so the client never has to fetch an empty page to discover it's done.

Set sane limits

Never trust the client's limit blindly — an unbounded page size is a one-line denial of service.

  • Default to something small, around 20.
  • Cap it with a hard maximum near 100; clamp anything larger.
  • Reject non-numeric or negative values with a 400.
// clamp the limit — never let a client request a million rows
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100)

Offset pagination: simple, until it isn't

Offset is the obvious approach — ?page=3&limit=20 skips (page - 1) × limit rows. It's fine for small, mostly-static datasets and it supports jumping to any page.

Two problems show up at scale. First, speed: to serve page 10,000 the database scans and throws away every earlier row, so deep pages get slow. Second, drift: if a row is inserted or deleted while a user pages, the offset shifts and they see a duplicated or skipped item at the page boundary.

Cursor pagination: stable and fast

Cursor (keyset) pagination fixes both. Instead of an offset, you remember the last row's sort key and fetch what comes after it with an indexed WHERE:

// cursor = the createdAt + id of the last row on the previous page
const rows = await db.orders.find({
  createdAt: { $lt: cursor.createdAt },
})
  .sort({ createdAt: -1, _id: -1 })
  .limit(limit + 1)          // fetch one extra to compute hasMore

const hasMore = rows.length > limit
const data = rows.slice(0, limit)

Cost stays flat at any depth because it's an index seek, not a scan, and inserts elsewhere in the list never shift a user's window. The trade-off: no "jump to page 47" — cursors only go next/previous, which is exactly what infinite scroll and feeds want anyway.

Practice this
Build the endpoint for real
Implement paginated APIs in Node and Express challenges, graded automatically.
Practice backend

Whitelist sorting and filtering

Cursor pagination only works if the sort is stable and indexed, which makes a whitelist mandatory:

  • Allow sorting only on indexed, named fields — map sort=-createdAt to a known column, never reflect raw input into the query.
  • Break ties with a unique field (like _id) so rows with identical timestamps have a deterministic order — otherwise the cursor can skip or repeat at ties.
  • Document the allowed filters and validate them the same way.

Get these four things right — a clear contract, clamped limits, cursor paging past the point offset hurts, and a sort whitelist — and your list endpoints stay fast and correct from the first row to the millionth.

FAQ

What's the difference between offset and cursor pagination?

Offset pagination skips a fixed number of rows (page × limit); it's simple but slows down on deep pages and can skip or repeat rows when data changes. Cursor pagination remembers a pointer to the last row seen and fetches what comes after it — stable and fast, but you can't jump to an arbitrary page number.

Why is offset pagination slow on large tables?

To return page 10,000 the database still has to scan and discard every earlier row before the offset. Cursor pagination uses an indexed WHERE clause instead, so cost stays flat no matter how deep you go.

Should I let clients sort by any field?

No. Only allow sorting on fields you've indexed and explicitly whitelisted. Sorting on an unindexed field forces a full scan, and reflecting arbitrary field names into a query is a common injection and performance footgun.

Put it into practice

Solve real challenges in a full browser IDE — graded automatically.

Browse frameworks