API Design

API Pagination Metadata Best Practices

Learn how to design pagination metadata for REST APIs, including pageInfo, cursors, hasNextPage, limits, totals, links, sorting, and compatibility.

Pagination metadata tells API clients how to continue reading a list response.

The list items matter, but the metadata is what makes pagination usable, debuggable, and safe to evolve. Without clear metadata, clients guess whether there is another page, how large the page was, what cursor to send next, and whether total counts are reliable.

Quick answer

A good paginated REST response separates items from pagination metadata.

Example:

{
  "items": [
    {
      "id": "evt_123",
      "type": "invoice.paid"
    }
  ],
  "pageInfo": {
    "limit": 50,
    "hasNextPage": true,
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTAxVDEyOjAwOjAwWiJ9"
  }
}

For public APIs and changing datasets, prefer cursor metadata such as nextCursor and hasNextPage. Return exact totals only when they are cheap, correct, and useful.

Read Pagination Strategies for REST APIs before choosing offset, cursor, or keyset pagination.

Keep items and metadata separate

Do not overload the item array with pagination details.

Clear:

{
  "items": [],
  "pageInfo": {
    "limit": 20,
    "hasNextPage": false,
    "nextCursor": null
  }
}

Harder for clients:

[
  {
    "id": "item_1"
  }
]

The second shape may be fine for tiny internal endpoints, but it leaves no room for cursors, limits, totals, warnings, or links without a breaking response change.

Common metadata fields

A cursor-based response often includes:

FieldMeaning
limitPage size applied by the server
hasNextPageWhether another page is available
nextCursorOpaque token for the next page
prevCursorOptional token for backward pagination
sortOptional description of the active sort
requestIdOptional correlation ID for debugging

An offset-based response may include:

FieldMeaning
pageCurrent page number
pageSizePage size applied
totalCountTotal matching items, if reliable
totalPagesDerived from total count, if useful

Do not include fields just because they are familiar. Include fields that clients can rely on.

Prefer opaque cursors

A cursor should usually be opaque.

Clients should treat this as a token:

GET /events?limit=50&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTAxIn0

They should not depend on the cursor being base64 JSON, a timestamp, or an ID. That gives the backend freedom to change cursor internals later.

Good cursor metadata:

{
  "pageInfo": {
    "hasNextPage": true,
    "nextCursor": "opaque_token_here"
  }
}

Avoid naming the cursor field after the current implementation, such as lastCreatedAt, unless you intend that implementation detail to become public contract.

Include the applied limit

Clients may request one page size, but the server should enforce a maximum.

Example:

GET /events?limit=1000

Response:

{
  "items": [],
  "pageInfo": {
    "limit": 100,
    "hasNextPage": true,
    "nextCursor": "..."
  }
}

Returning the applied limit makes behavior clear. It also helps SDKs, logs, and support teams understand why a response contains fewer items than requested.

Be careful with totalCount

totalCount is useful for admin tables, reports, and small filtered datasets.

It is risky for large or fast-changing datasets:

  • Counts can be expensive.
  • Counts can become stale while the client paginates.
  • Counts can require different database plans than the item query.
  • Counts can imply page numbers that cursor pagination does not support.

If totals are approximate, say so explicitly:

{
  "pageInfo": {
    "approximateTotalCount": 120000
  }
}

Do not return a precise-looking count if it is not precise.

Stable sorting metadata

Pagination depends on stable ordering.

A good API should document the sort order:

{
  "pageInfo": {
    "sort": "createdAt:desc,id:desc",
    "nextCursor": "..."
  }
}

The unique tie-breaker matters. Sorting only by createdAt can be unstable when many records share the same timestamp. Adding id as a second sort key makes cursor and keyset pagination safer.

Read API Versioning Explained before changing default sorting for an existing endpoint.

Some APIs include pagination links:

{
  "links": {
    "next": "/events?limit=50&cursor=opaque_token_here"
  }
}

Links are convenient for generic clients and documentation. Cursors are convenient for SDKs and application code.

You can include both if the API style benefits from it:

{
  "pageInfo": {
    "hasNextPage": true,
    "nextCursor": "opaque_token_here"
  },
  "links": {
    "next": "/events?limit=50&cursor=opaque_token_here"
  }
}

Keep the contract consistent across list endpoints.

Filtering and cursor context

A cursor is only valid for the query context that created it.

If the client changes filters, search terms, tenant, user, or sort order, the old cursor should not be reused.

For opaque cursors, the backend can encode or verify context internally. For explicit keyset parameters, document which filters and sort fields must stay stable.

When a cursor is invalid, return a clear error:

{
  "error": "invalid_cursor",
  "message": "The pagination cursor is invalid or expired.",
  "requestId": "req_123"
}

Read REST API Error Handling Best Practices for shared error shape design.

Empty pages and end states

Clients need predictable end behavior.

At the end of a cursor-paginated list:

{
  "items": [],
  "pageInfo": {
    "limit": 50,
    "hasNextPage": false,
    "nextCursor": null
  }
}

Do not omit pageInfo just because the page is empty. Empty responses are often where clients need metadata most.

OpenAPI documentation

Pagination metadata belongs in the API contract.

Document:

  • Query parameters such as limit, cursor, page, or offset.
  • Maximum page size.
  • Default sort.
  • Response metadata shape.
  • Cursor opacity.
  • Total count behavior.
  • Error response for invalid cursors.

Read OpenAPI Explained for Backend Developers and JSON Schema vs OpenAPI Schema when documenting reusable pagination schemas.

Common mistakes

The first mistake is returning only an array and later needing metadata. Adding a response wrapper later can break clients.

The second mistake is exposing cursor internals as if they were public fields.

The third mistake is promising totalCount on large endpoints without understanding the query cost.

The fourth mistake is using unstable sorting.

The fifth mistake is changing pagination metadata names across endpoints.

The sixth mistake is omitting metadata on empty pages.

Practical recommendation

For a first public REST API, use a consistent shape:

{
  "items": [],
  "pageInfo": {
    "limit": 50,
    "hasNextPage": false,
    "nextCursor": null
  }
}

Then add optional fields only when clients need them: prevCursor, sort, approximateTotalCount, or links.next.

Keep metadata stable, document it in OpenAPI, and treat pagination behavior as part of the API contract.

Read Pagination Strategies for REST APIs for choosing offset, cursor, or keyset pagination, and REST API Design Checklist for reviewing list endpoints before launch.

For contract documentation, read OpenAPI Explained for Backend Developers, JSON Schema vs OpenAPI Schema, and API Versioning Explained. For the full sequence, browse the API Design Learning Path and the Topics map.