# How to describe pagination so generators can follow it

> Put enough in the OpenAPI document that a generated client can walk a collection by itself, instead of handing every consumer a single-page call.

Source: https://voxgig.com/howto/describe-pagination-in-openapi-for-generators

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

A generator needs three things in the description. A parameter that carries the cursor in, a named field in the response that carries the next one out, and an array it can yield from. Put the rows in an envelope rather than returning a bare array, name the cursor field, and declare the page size cap. Add the vendor extension your generator reads on top of that.

---
## You will need

An OpenAPI 3 description of a list endpoint, and Node 22 or later. The parameter and response rules
are ordinary [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0.html#parameter-object), and the
pagination hints are vendor extensions, which [the specification
allows](https://spec.openapis.org/oas/v3.1.0.html#specification-extensions) and does not define.[^1]

Voxgig maintains sdkgen. This page compares its paging feature with the equivalents from Fern and
Speakeasy, and with a description that carries no extension at all.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A described envelope with no extension](https://spec.openapis.org/oas/v3.1.0.html#response-object) | You want the description portable and no generator locked in | Each generator guesses, and most of them emit a single-page call | One generator produces the clients your users install |
| [Fern pagination config](https://buildwithfern.com/learn/api-definition/openapi/extensions/pagination) | You already build with Fern and can hold the config beside the document | Configuration in a second file, which a consumer of the raw document never sees | Consumers generate their own clients from your document |
| [sdkgen paging feature](https://github.com/voxgig/sdkgen) | You generate several languages and want one paging behavior in all of them | An opt-in feature to activate, and a model to regenerate when the shape moves | You publish the description and let consumers pick a generator |
| [Speakeasy x-speakeasy-pagination](https://www.speakeasy.com/docs/customize-sdks/pagination) | Speakeasy is your generator and you can edit the document | An extension named for one vendor, sitting in a document others read | You want the hint to mean something to more than one tool |

Every one of these is a vendor extension, which is what keeps it from being portable.[^2] That is where the field
stands. OpenAPI describes a request and a response, and says nothing about the relationship between
two of them. The part that is portable is the shape underneath, and it matters more than the
extension. A description with an envelope, a named cursor field, and a cursor parameter gives every
generator something to work with. An extension over a bare array gives none of them anything. Fix
the shape first, then add whichever hint your generator reads.

## Return an envelope, not a bare array

The response shape decides what any generator can do.

```json title="openapi.json"
"schema": {
  "type": "object",
  "required": ["data"],
  "properties": {
    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Meter" } },
    "next_cursor": { "type": ["string", "null"] }
  }
}
```

A bare array has nowhere to put the next cursor. The cursor then ends up in a header or in a
separate call, and a generator has to be told about it out of band. An envelope carries both, and it
leaves room to add a count or a warning later without a breaking change.

Declare the page size parameter with its `maximum`. A generated client that knows the cap can
request the largest page allowed instead of the default, which is often the difference between three
requests and thirty. Say what happens when a caller asks for more than the cap, too: silently
clamping and returning an error are both defensible, and a client cannot guess which you chose.

## Add the extension, and check the shape underneath

The extension is a hint, and a hint over a shape that cannot support it changes nothing.

```js title="audit.mjs"
export function verdict(audit) {
  if (!audit.itemsAt) return 'no array in the response, so nothing to yield'
  if (!audit.cursorIn || !audit.cursorOut) {
    return `generator emits a single-page call: cursor in ${audit.cursorIn ?? 'missing'}, out ${audit.cursorOut ?? 'missing'}`
  }
  return `auto-paginates on ${audit.cursorIn} -> ${audit.cursorOut}`
}
```

Resolve the references before you look at the shape. A description that names its page schema in
`components` is the normal case, not the exotic one, and an audit reading the reference object sees
no array and no cursor. It then reports a well described endpoint as unable to paginate, and sends
somebody to fix a document that was already correct.

Run this over the document rather than reading the generated client. It answers the question
directly, it runs on every change, and it names the operation. The alternative is somebody noticing
a missing iterator two releases later, in a support thread.

## Check it worked

Audit both operations in the description.

```bash
node demo.mjs
```

```text output
listMeters
  declared   x-speakeasy-pagination
  cursor in  cursor
  cursor out next_cursor
  items at   data
  page size  capped at 200
  verdict    auto-paginates on cursor -> next_cursor
listReadings
  declared   nothing
  cursor in  -
  cursor out -
  items at   (the body itself)
  page size  undeclared
  verdict    generator emits a single-page call: cursor in missing, out missing
```

The second operation is the one to fix first, and it does not need an extension to improve. Wrap the
rows in an envelope, name a cursor field, add a cursor parameter, and every generator gets further
with it than it would with a vendor hint over a bare array.

```bash
node --test audit.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 123.133611
```

## When it goes wrong

The generator ignores your extension. The name or the version is wrong, and an unknown `x-` key is
silently valid. Generate once and read the client, then keep the audit to catch a regression. An
unknown extension is valid OpenAPI, so no validator will ever tell you the name is wrong.[^3]

The iterator fetches forever. The response advertises a cursor that never empties. Describe the
terminal value, and make the API send it: `null` and an empty string are both fine as long as one of
them is the rule.

Clients hit the rate limit during a walk. The page size cap is undeclared, so clients request the
default and take ten times as many requests. Declare `maximum` and let the generated client use it.

The description passes, but the client still pages wrongly. The cursor field is nested inside a
`meta` object that the extension path does not reach. Keep the cursor at the top of the envelope,
where every extension syntax can address it with a one-segment path.

## When not to do this

Do not add three vendor extensions to keep every generator happy. They drift, and a document
carrying contradictory hints will produce two clients that disagree about your API. Pick one, and
make the underlying shape good enough for the rest.

Do not turn on the sdkgen paging feature without checking what the model inferred. A feature that
walks the shapes it knows will emit a single-page call for an operation it could not classify, and
the generated code looks finished either way.

Do not describe pagination you have not implemented consistently. One endpoint with a different
cursor field is worse than none, because the generated client applies one rule everywhere. Fix the
odd endpoint first, even if that means a deprecation window for its old field name.

## Related how-tos

- [Loop over every page of a REST collection](/howto/loop-over-every-page-of-a-collection)

- [Negotiate page size between an API and its clients](/howto/negotiate-page-size-with-clients)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: The prefix is older than the specification, and so is the advice against it. Swagger 2.0,
released on 2014-09-08, gave each of its objects a patterned field, `^x-`, and a section on
[specification extensions](https://spec.openapis.org/oas/v2.0.html#specification-extensions). Two
years before that, in June 2012, the IETF had published [RFC
6648](https://www.rfc-editor.org/rfc/rfc6648.html), a Best Current Practice whose title begins
Deprecating the `X-` Prefix. Its advice to anyone creating a new parameter is that they SHOULD NOT
prefix its name with `X-`, in capitals, on the grounds that the convention causes more problems than
it solves. OpenAPI 3.1.0 arrived in February 2021 with the field pattern unchanged.

[^2]: Fern's `x-fern-pagination` finds the rows at `$response.results`, in what [its
documentation](https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination) calls
dot-access notation. Speakeasy's `x-speakeasy-pagination` finds them at `$.resultArray`, in
[JSONPath](https://www.speakeasy.com/docs/sdks/customize/runtime/pagination). Both begin at a dollar
sign and walk down to the array of rows, without either document mentioning the other. Two vendors met
the same silence in the specification with the same first character and different grammars.

[^3]: The specification is candid on the point. The value of an extension can be null, a primitive,
an array, or an object, and the extensions, [it
says](https://spec.openapis.org/oas/v3.1.0.html#specification-extensions), may or may not be
supported by the available tooling. The only names it polices are those beginning `x-oai-` and
`x-oas-`, which are reserved for the OpenAPI Initiative. Everything else beginning `x-` belongs to
somebody a validator has no way to identify.