JSON API Pagination: Cursor, Offset, and Keyset Patterns

Last updated:

JSON API pagination has three main patterns: offset/limit (?page=2&limit=20), cursor-based (?cursor=eyJpZCI6MTAwfQ), and keyset (?after_id=100) — use cursor or keyset for large datasets since offset pagination degrades to O(n) at scale. Offset pagination at page 1000 with limit 20 requires the database to scan and discard 19,980 rows before returning 20 — keyset pagination (WHERE id > last_id ORDER BY id LIMIT 20) always runs in O(log n) with an index. The GitHub API uses cursor-based pagination via Link headers: <https://api.github.com/repos?page=2>; rel="next".

This guide covers the response envelope design (data, meta, links), offset vs cursor vs keyset performance benchmarks, base64 cursor encoding, the RFC 5988 Link header, JSON:API pagination (links.next, links.prev), and the GraphQL connection pattern (edges/nodes/pageInfo). See also: JSON API design for the broader response envelope shape, and JSON best practices for field naming and consistency conventions that apply to pagination envelopes.

Offset/Limit Pagination: Simple but Slow at Scale

Offset/limit pagination maps directly to SQL: SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 980. Page number and page size appear as query parameters (?page=50&limit=20), and the API computes OFFSET = (page - 1) * limit. This simplicity makes offset pagination the default in most frameworks — Rails, Django, Laravel, and Spring Data all generate offset queries out of the box.

The performance problem is fundamental: OFFSET is not a seek, it is a skip. At OFFSET 19980 LIMIT 20, the database traverses the index from the beginning, counts 19,980 entries, and only then reads the 20 target rows. Even with a covering index, this is O(n) in the offset value. Benchmarks on a 10 million row PostgreSQL table show page 1 returning in 2ms; page 1000 (OFFSET 19,980) returning in 180ms; page 10,000 (OFFSET 199,980) returning in 1.8 seconds.

The correctness problem is page drift: if 5 rows are inserted before page 2 loads, OFFSET 20now skips 5 rows that were on page 1. Those rows appear on both page 1 and page 2. Deletions cause the inverse — rows that should be on page 2 shift into page 1's range and are never returned. Use offset pagination only for small static datasets (<10,000 rows) where random page access and total page count matter for UI — for example, an admin table of users or an archived report.

-- Offset pagination: simple, but O(n) at large offsets
SELECT id, name, created_at
FROM items
ORDER BY id
LIMIT 20 OFFSET 19980;
-- At OFFSET 19980: scans 20,000 index entries before reading 20 rows

-- Offset pagination API request/response
-- GET /items?page=1000&limit=20
{
  "data": [ { "id": 19981, "name": "Widget A" }, ... ],
  "meta": {
    "page": 1000,
    "limit": 20,
    "total": 50000,
    "totalPages": 2500
  },
  "links": {
    "self":  "/items?page=1000&limit=20",
    "next":  "/items?page=1001&limit=20",
    "prev":  "/items?page=999&limit=20",
    "first": "/items?page=1&limit=20",
    "last":  "/items?page=2500&limit=20"
  }
}

The total and totalPages fields require a separate SELECT COUNT(*) query, adding another O(n) scan. Some APIs skip the count and return only hasNextPage — fetching limit + 1 rows and checking if the extra row exists is cheaper than COUNT(*) on large tables. Avoid offset pagination for any API endpoint that is called with high page numbers or on tables that receive frequent inserts.

Cursor-Based Pagination: Stable and Scalable

Cursor-based pagination replaces the page number with an opaque token (the cursor) that encodes the position of the last seen record. The cursor is base64url-encoded JSON: eyJpZCI6MTAwfQ decodes to {"id":100}. On the next request, the server decodes the cursor and issues a keyset WHERE clause instead of OFFSET. The query is O(log n) with an index, regardless of how deep into the dataset the cursor points.

Cursors are opaque to clients — they must be treated as unstructured strings and passed back to the server verbatim. Never allow clients to construct, parse, or modify cursors. Keeping cursors opaque means the server can change cursor encoding (switching from ID-based to timestamp-based) without a breaking change. Base64url encoding (using - and _ instead of + and /) is URL-safe without percent-encoding, making it safe to use directly as a query parameter value.

// Cursor encode/decode — Node.js
function encodeCursor(record: { id: number }): string {
  return Buffer.from(JSON.stringify({ id: record.id })).toString('base64url')
}
function decodeCursor(cursor: string): { id: number } {
  try {
    const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
    if (typeof decoded.id !== 'number') throw new Error('Invalid cursor shape')
    return decoded
  } catch {
    throw new Error('Invalid cursor — 400 Bad Request')
  }
}

// eyJpZCI6MTAwfQ decodes to { id: 100 }
// eyJpZCI6MTAzfQ decodes to { id: 103 }

// Express endpoint — cursor pagination
app.get('/items', async (req, res) => {
  const cursorParam = req.query.cursor as string | undefined
  const pageSize = Math.min(Number(req.query.limit) || 20, 100)

  const where = cursorParam
    ? { id: { gt: decodeCursor(cursorParam).id } }
    : {}

  // Fetch pageSize + 1 to check for next page without COUNT(*)
  const rows = await db.item.findMany({
    where,
    orderBy: { id: 'asc' },
    take: pageSize + 1,
  })

  const hasNextPage = rows.length > pageSize
  const data = hasNextPage ? rows.slice(0, pageSize) : rows
  const nextCursor = hasNextPage
    ? encodeCursor(data[data.length - 1])
    : null

  res.json({
    data,
    meta: { pageSize, hasNextPage, nextCursor },
  })
})

The pageSize + 1 trick avoids an extra COUNT(*) query: fetch one more row than requested, and if it exists, there is a next page. Slice the extra row off before returning. Return nextCursor: null (not absent) on the last page so clients can reliably test meta.nextCursor !== null. Always validate and sanitize the decoded cursor before using it in a database query — never trust client input in SQL predicates.

Keyset Pagination: O(log n) with an Index

Keyset pagination — also called the seek method — is the SQL technique that powers cursor-based APIs. It replaces OFFSET n with a WHERE predicate on indexed columns: WHERE id > :last_id ORDER BY id LIMIT 20. The database uses the index to seek directly to last_id and reads the next 20 rows — O(log n) with no row-skip overhead. For a primary key column, the existing B-tree index makes this query equally fast at row 1 or row 10,000,000.

Multi-column sorts require a composite keyset predicate. Sorting by created_at alone is unstable because multiple rows can share the same timestamp — two rows with identical created_at can drift across pages. The correct pattern is to sort by (created_at, id) and use both values in the WHERE clause to eliminate ties deterministically. PostgreSQL supports row-value comparisons natively; MySQL 8+ also supports them.

-- Single-column keyset: sort by primary key
SELECT id, name, created_at
FROM items
WHERE id > 100          -- cursor: last seen id = 100
ORDER BY id ASC
LIMIT 20;
-- Index seek on id — O(log n), no row scan overhead

-- Multi-column keyset: sort by (created_at, id) for stable ordering
-- PostgreSQL / MySQL 8+ — row-value comparison
SELECT id, name, created_at
FROM items
WHERE (created_at, id) > ('2024-03-15T10:00:00Z', 103)
ORDER BY created_at ASC, id ASC
LIMIT 20;

-- MySQL 5.7 / older engines — expanded equivalent
SELECT id, name, created_at
FROM items
WHERE created_at > '2024-03-15T10:00:00Z'
   OR (created_at = '2024-03-15T10:00:00Z' AND id > 103)
ORDER BY created_at ASC, id ASC
LIMIT 20;

-- Required composite index for multi-column keyset
CREATE INDEX idx_items_created_id ON items (created_at ASC, id ASC);
// Multi-column cursor encoding — encode both sort columns
function encodeMultiCursor(record: { createdAt: string; id: number }): string {
  return Buffer.from(
    JSON.stringify({ createdAt: record.createdAt, id: record.id })
  ).toString('base64url')
}
// Decode: JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
// => { createdAt: '2024-03-15T10:00:00Z', id: 103 }

// Performance benchmark — same table, 10 million rows
// OFFSET 19980  LIMIT 20:  ~180ms  (O(n) row scan)
// WHERE id > 100 LIMIT 20:  ~2ms   (O(log n) index seek)
// OFFSET 999980 LIMIT 20: ~1800ms
// WHERE id > 999980 LIMIT 20: ~2ms  (same cost regardless of depth)

Always create a dedicated composite index on the sort columns used in the keyset WHERE clause. Without the index, the database falls back to a full table scan — worse than OFFSET for large tables. Use EXPLAIN ANALYZE (PostgreSQL) or EXPLAIN (MySQL) to verify the query uses an index seek rather than a sequential scan. The index scan cost should be O(log n) — look for Index Scan in PostgreSQL EXPLAIN output, not Seq Scan. See JSON performance for additional payload and serialization optimization patterns.

Response Envelope Design: data, meta, links

A consistent pagination envelope makes APIs predictable across endpoints. The three-key structure — data (items array), meta (pagination metadata), links (navigation URLs) — is adopted by JSON:API, GitHub's API, and most well-designed REST APIs. Avoid embedding pagination metadata inside the data array or mixing items and metadata at the same level, which makes typed parsing error-prone.

// Cursor pagination envelope — recommended structure
{
  "data": [
    { "id": 101, "name": "Widget A", "createdAt": "2024-03-15T10:00:00Z" },
    { "id": 102, "name": "Widget B", "createdAt": "2024-03-15T10:05:00Z" },
    { "id": 103, "name": "Widget C", "createdAt": "2024-03-15T10:10:00Z" }
  ],
  "meta": {
    "pageSize": 20,
    "hasNextPage": true,
    "nextCursor": "eyJpZCI6MTAzfQ",
    "totalCount": null
  },
  "links": {
    "self": "/items?cursor=eyJpZCI6MTAwfQ&limit=20",
    "next": "/items?cursor=eyJpZCI6MTAzfQ&limit=20",
    "prev": null
  }
}

// Last page — nextCursor null, hasNextPage false, links.next null
{
  "data": [
    { "id": 498, "name": "Widget X" },
    { "id": 499, "name": "Widget Y" }
  ],
  "meta": {
    "pageSize": 20,
    "hasNextPage": false,
    "nextCursor": null,
    "totalCount": null
  },
  "links": {
    "self": "/items?cursor=eyJpZCI6NDk3fQ&limit=20",
    "next": null,
    "prev": "/items?cursor=eyJpZCI6NDc3fQ&limit=20"
  }
}
// TypeScript types for the pagination envelope
interface PaginatedResponse<T> {
  data: T[]
  meta: {
    pageSize: number
    hasNextPage: boolean
    nextCursor: string | null
    totalCount: number | null
  }
  links: {
    self: string
    next: string | null
    prev: string | null
  }
}

// Client usage — type-safe pagination
const response: PaginatedResponse<Item> = await fetchItems(cursor)
if (response.meta.nextCursor !== null) {
  const nextPage = await fetchItems(response.meta.nextCursor)
}

Design decisions to standardize: use null for absent cursors and links (not missing fields) so clients can always access meta.nextCursor without optional chaining. Include totalCount: null rather than omitting it to signal that the field exists but is not computed with cursor pagination. Never mix cursor and offset fields in the same envelope — pick one strategy per endpoint and document it. For JSON API design conventions that apply across all endpoints including pagination structure, the data/meta/links split is the most widely adopted pattern.

RFC 5988 Link Header for Pagination

RFC 5988 (updated by RFC 8288) defines the Link HTTP response header as the standard mechanism for expressing typed relationships between resources. For pagination, it communicates next/prev/first/last page URLs in the header rather than the response body — separating navigation metadata from application data. The GitHub REST API uses this pattern exclusively: the response body contains only data, and all pagination is in the Link header.

# RFC 5988 Link header — cursor-based (next only)
HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.example.com/items?cursor=eyJpZCI6MTAzfQ&limit=20>; rel="next"

# Offset-based — all four rel values available
HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.example.com/items?page=4&limit=20>; rel="next",
      <https://api.example.com/items?page=2&limit=20>; rel="prev",
      <https://api.example.com/items?page=1&limit=20>; rel="first",
      <https://api.example.com/items?page=10&limit=20>; rel="last"

# GitHub API example (actual format)
Link: <https://api.github.com/repositories?since=364>; rel="next",
      <https://api.github.com/repositories{?since}>; rel="first"
// Server — building RFC 5988 Link header (Node.js / Express)
function buildLinkHeader(
  baseUrl: string,
  nextCursor: string | null,
  prevCursor: string | null
): string {
  const links: string[] = []
  if (nextCursor) {
    const next = `${baseUrl}?cursor=${encodeURIComponent(nextCursor)}`
    links.push(`<${next}>; rel="next"`)
  }
  if (prevCursor) {
    const prev = `${baseUrl}?cursor=${encodeURIComponent(prevCursor)}`
    links.push(`<${prev}>; rel="prev"`)
  }
  return links.join(', ')
}

app.get('/items', async (req, res) => {
  // ... fetch data ...
  const link = buildLinkHeader('https://api.example.com/items', nextCursor, prevCursor)
  if (link) res.setHeader('Link', link)
  res.json({ data })  // body: data only, no pagination envelope
})

// Client — parsing RFC 5988 Link header
function parseLinkHeader(header: string | null): Record<string, string> {
  if (!header) return {}
  return Object.fromEntries(
    header.split(',')
      .map(part => part.trim().match(/^<([^>]+)>;s*rel="([^"]+)"$/))
      .filter(Boolean)
      .map(m => [m![2], m![1]])
  )
}

const res = await fetch('/api/items')
const links = parseLinkHeader(res.headers.get('Link'))
// links.next => 'https://api.example.com/items?cursor=eyJpZCI6MTAzfQ'
if (links.next) {
  const nextPage = await fetch(links.next)
}

Always percent-encode the cursor value when embedding it in a URL parameter — encodeURIComponent(cursor) — because base64 padding characters (=) have special meaning in query strings. Omit rel="last" with cursor pagination; computing the final cursor requires a full table scan. The rel="first" link for cursor pagination is simply the base URL without a cursor parameter. Prefer body envelopes over Link headers when your clients are browser-based JavaScript — the fetch API exposes headers through response.headers.get('Link'), but it is less ergonomic than a JSON property. See JSON API versioning for how to evolve the Link header format across API versions.

JSON:API Pagination: links.next and links.prev

The JSON:API specification (jsonapi.org) defines a standard response envelope that places pagination URLs in a top-level links object and optional counts in a meta object. Unlike the Link header approach, JSON:API embeds pagination metadata in the response body, making it accessible to clients that cannot easily read response headers. The spec defines five pagination link keys: self, first, last, prev, and next.

// JSON:API page-number pagination response
{
  "data": [
    {
      "type": "items",
      "id": "101",
      "attributes": { "name": "Widget A", "createdAt": "2024-03-15T10:00:00Z" }
    },
    {
      "type": "items",
      "id": "102",
      "attributes": { "name": "Widget B", "createdAt": "2024-03-15T10:05:00Z" }
    }
  ],
  "links": {
    "self":  "/items?page[number]=3&page[size]=20",
    "first": "/items?page[number]=1&page[size]=20",
    "prev":  "/items?page[number]=2&page[size]=20",
    "next":  "/items?page[number]=4&page[size]=20",
    "last":  "/items?page[number]=10&page[size]=20"
  },
  "meta": {
    "total": 200
  }
}

// JSON:API cursor pagination — links.last omitted, meta.total omitted
{
  "data": [ ... ],
  "links": {
    "self":  "/items?page[cursor]=eyJpZCI6MTAwfQ&page[size]=20",
    "first": "/items?page[size]=20",
    "prev":  "/items?page[cursor]=eyJpZCI6ODB9&page[size]=20",
    "next":  "/items?page[cursor]=eyJpZCI6MTIwfQ&page[size]=20"
  },
  "meta": {
    "pageSize": 20,
    "hasNextPage": true
  }
}
// JSON:API pagination — Express builder
function buildJsonApiLinks(
  base: string,
  page: number,
  pageSize: number,
  total: number
): object {
  const totalPages = Math.ceil(total / pageSize)
  return {
    self:  `${base}?page[number]=${page}&page[size]=${pageSize}`,
    first: `${base}?page[number]=1&page[size]=${pageSize}`,
    last:  `${base}?page[number]=${totalPages}&page[size]=${pageSize}`,
    prev:  page > 1 ? `${base}?page[number]=${page - 1}&page[size]=${pageSize}` : null,
    next:  page < totalPages ? `${base}?page[number]=${page + 1}&page[size]=${pageSize}` : null,
  }
}

res.json({
  data: serializedItems,
  links: buildJsonApiLinks('/items', 3, 20, 200),
  meta: { total: 200 },
})

The JSON:API spec states that absent link keys and null link values both indicate the page does not exist — a null links.next or a missing links.next both mean no next page. Standardize on null (not absent) for predictable client parsing. The page[number] and page[size] query parameter naming is the JSON:API convention — it avoids collisions with application-level query parameters. Ensure your server-side framework does not conflict on bracket-notation query strings; Express and Rails handle them natively.

GraphQL Connection Pattern: edges, nodes, and pageInfo

The Relay Cursor Connections Specification defines a standardized GraphQL pagination pattern adopted by most GraphQL APIs. A connection type wraps a list with edges (each containing a node and a per-edge cursor) and a pageInfo object exposing hasNextPage, hasPreviousPage, startCursor, and endCursor. This structure enables Apollo Client, Relay, and urql to automatically merge and normalize pages in their caches.

# GraphQL schema — Relay cursor connection
type Query {
  items(first: Int, after: String, last: Int, before: String): ItemConnection!
}

type ItemConnection {
  edges: [ItemEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type ItemEdge {
  node: Item!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Item { id: ID!  name: String!  createdAt: String! }
# GraphQL query — forward pagination
query GetItems($after: String) {
  items(first: 20, after: $after) {
    edges {
      node { id name createdAt }
      cursor
    }
    pageInfo { hasNextPage endCursor }
    totalCount
  }
}

# Response
{
  "data": {
    "items": {
      "edges": [
        { "node": { "id": "101", "name": "Widget A", "createdAt": "2024-03-15T10:00:00Z" },
          "cursor": "eyJpZCI6MTAxfQ" },
        { "node": { "id": "102", "name": "Widget B", "createdAt": "2024-03-15T10:05:00Z" },
          "cursor": "eyJpZCI6MTAyfQ" }
      ],
      "pageInfo": { "hasNextPage": true, "endCursor": "eyJpZCI6MTAyfQ" },
      "totalCount": 200
    }
  }
}
// Apollo Client — paginate forward with fetchMore
const { data, fetchMore } = useQuery(GET_ITEMS, { variables: { after: null } })

function loadNextPage() {
  fetchMore({
    variables: { after: data.items.pageInfo.endCursor },
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev
      return {
        items: {
          ...fetchMoreResult.items,
          edges: [...prev.items.edges, ...fetchMoreResult.items.edges],
        },
      }
    },
  })
}
// Backward pagination: use last + before with pageInfo.startCursor

Use first + after for forward pagination, last + before for backward. Always use pageInfo.endCursor as the after argument — it is identical to the cursor on the last edge but avoids extracting it from the edges array. The per-edge cursor field enables fine-grained cursor extraction for edge-level operations, but in practice most clients use only pageInfo.endCursor. For JSON best practices applied to GraphQL responses, follow the Relay spec strictly so Apollo Client and Relay can normalize the connection data automatically. Server-side, the graphql-relay npm package provides connectionFromArray and connectionFromPromisedArray helpers that implement the full Relay connection spec.

Key Terms

offset pagination
A pagination strategy that uses a numeric OFFSET and LIMIT to select a slice of results from a query: SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 980. Simple to implement and supports random page access, but suffers from O(n) scan cost at large offsets and page drift — concurrent inserts or deletes cause rows to appear on multiple pages or be skipped entirely. Best suited for small, static datasets under ~10,000 rows where random page access (jump to page 50) or total page count matter for the UI.
cursor pagination
A pagination strategy that encodes the position of the last seen record as an opaque token (the cursor), typically a base64url-encoded JSON object. The server decodes the cursor on each request and issues a keyset WHERE clause instead of OFFSET: WHERE id > :last_id ORDER BY id LIMIT 20. O(log n) with an index regardless of dataset depth. Eliminates page drift because the cursor encodes a stable position, not a row count. Cannot support random page access. The GitHub API implements cursor pagination via Link headers.
keyset pagination
Also called the seek method. A SQL query technique that replaces OFFSET n with a WHERE predicate on indexed columns: WHERE id > :last_id ORDER BY id LIMIT n. The database performs an index seek to last_id and reads the next n rows — O(log n) with no row-skip overhead. The SQL foundation of cursor-based API pagination. For multi-column sorts, the keyset predicate expands to cover all sort columns: WHERE (created_at, id) > (:ts, :id). Requires a composite index on the sort columns to avoid falling back to a full table scan.
pagination envelope
The top-level JSON object structure that wraps paginated API responses. The standard pattern uses three keys: data (the array of items for the current page), meta (pagination metadata such as nextCursor, hasNextPage, pageSize, and totalCount), and links (navigation URLs for the current, next, previous, first, and last pages). A consistent envelope makes APIs predictable and enables generic client-side pagination handling across endpoints.
RFC 5988 Link header
An HTTP response header (RFC 5988, updated by RFC 8288) that expresses typed relationships between HTTP resources using rel attribute values. For pagination: rel="next" (next page URL), rel="prev" (previous page URL), rel="first" (first page URL), and rel="last" (last page URL — omit with cursor pagination). Separates pagination navigation from the response body. Format: Link: <https://api.example.com/items?cursor=abc>; rel="next". Used by the GitHub REST API for all list endpoints.
GraphQL connection
A GraphQL pagination pattern defined by the Relay Cursor Connections Specification. A connection type wraps a list with edges (each containing a node and a cursor) and a pageInfo object exposing hasNextPage, hasPreviousPage, startCursor, and endCursor. Forward pagination uses first + after; backward uses last + before. Standardized and supported by Apollo Client, Relay, and urql for automatic page normalization and cache merging. The graphql-relay npm package provides server-side connection helpers.

FAQ

What is the best pagination approach for a JSON API?

Use cursor or keyset pagination for production APIs on large datasets. Offset pagination is O(n) at scale — at OFFSET 19,980, the database scans 19,980 rows before returning 20. Cursor pagination (WHERE id > :cursor LIMIT 20) is O(log n) with an index at any depth. Use offset pagination only for small (<10,000 rows), static datasets where clients need random page access or total page counts for UI. For feeds, infinite scroll, or any high-write dataset, cursor pagination eliminates page drift — the stale-offset problem where concurrent inserts cause duplicate or skipped records.

What is cursor-based pagination?

Cursor-based pagination uses an opaque token (the cursor) encoding the last seen record's position. The cursor is typically base64url(JSON.stringify(({id: lastId}))) — for example, eyJpZCI6MTAwfQ decodes to {"id":100}. The server decodes the cursor and uses its value in a keyset WHERE clause: WHERE id > 100 ORDER BY id LIMIT 20. Because the cursor encodes a stable position (not a row count), it is unaffected by concurrent inserts or deletes. Cursors must be opaque to clients — treat them as unstructured strings and pass them back verbatim.

Why is offset pagination slow for large datasets?

OFFSET is not a seek, it is a skip. At OFFSET 19980 LIMIT 20, the database traverses 19,980 index entries before reading the 20 target rows — O(n) in the offset value. Even with a covering index, all skipped entries must be counted. At page 1000 with limit 20 on a 10 million row table, this means scanning 20,000 rows to return 20. Keyset pagination (WHERE id > :last_id) does an index seek to last_id in O(log n) and reads 20 rows sequentially — equally fast at row 1 or row 10,000,000.

How do I implement keyset pagination in SQL?

Replace OFFSET n with a WHERE clause on an indexed column: SELECT * FROM items WHERE id > :last_id ORDER BY id LIMIT 20. For multi-column sorts, use row-value comparison in PostgreSQL/MySQL 8+: WHERE (created_at, id) > (:ts, :id). For older MySQL, expand it: WHERE created_at > :ts OR (created_at = :ts AND id > :id). Always create a composite index on the sort columns: CREATE INDEX idx ON items(created_at, id). Without the index, keyset pagination falls back to a full table scan — worse than OFFSET on large tables. Verify with EXPLAIN ANALYZE that the query uses an index seek.

What should a paginated JSON API response look like?

Use a three-key envelope: data (items array), meta (pagination metadata), links (navigation URLs). For cursor pagination: meta contains nextCursor (string or null), hasNextPage (boolean), and pageSize. links contains next and prev as full URLs or null. Set fields to null when absent — never omit them — so clients can reliably test meta.nextCursor !== null. Include totalCount: null (not missing) to signal the field exists but is not computed. Omit links.last with cursor pagination since computing it requires a full table scan.

What is the Link header in pagination?

The RFC 5988 Link header communicates pagination URLs in the HTTP response header rather than the body, using rel values: rel="next", rel="prev", rel="first", rel="last". Example: Link: <https://api.example.com/items?cursor=abc>; rel="next". GitHub REST API uses this pattern — the response body contains only data. Parse with: header.split(",").map(p => p.trim().match(/<([^>]+)>;\s*rel="([^"]+)"/)). Always percent-encode cursor values in URLs since base64 = padding has special meaning in query strings.

How does JSON:API handle pagination?

JSON:API defines pagination URLs in a top-level links object with keys self, first, last, prev, and next. Absent or null keys mean the page does not exist — null links.next means no next page. Total record count goes in meta.total. The spec uses bracket-notation query parameters: page[number] and page[size]. For cursor pagination, omit links.last (requires full table scan) and set page[cursor] as the cursor parameter. Both offset and cursor strategies are supported through the links values.

How does GraphQL pagination work?

GraphQL uses the Relay Cursor Connections Specification: a connection type wraps a list with edges (each containing a node and cursor) and a pageInfo object (hasNextPage, endCursor). To paginate forward, pass the previous pageInfo.endCursor as the after argument with first: N. For backward pagination, use last + before with pageInfo.startCursor. Apollo Client and Relay automatically merge pages using this structure. Use the graphql-relay npm package for server-side connection helpers implementing the full spec.

Further reading and primary sources