How-to › Move data in and out

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.

Audience
API producer
Level
intermediate
Topic
Paginate collections
Languages
TypeScript and JavaScript
Verified

Your generated TypeScript client has a listMeters that returns 50 rows and no way to ask for the rest. The API paginates, the documentation explains the cursor, and the generator saw a list operation with a string parameter it could make nothing of. Every consumer writes the same walk by hand, so the stop condition comes out slightly different in each one.

What you get

You will end up with a description that carries the cursor in both directions, so a generator can emit an iterator instead of a single call. You also get an audit that says which of your operations qualify. This is for you if you publish a description and a generated client.

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, and the pagination hints are vendor extensions, which the specification allows 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A described envelope with no extensionYou want the description portable and no generator locked inEach generator guesses, and most of them emit a single-page callOne generator produces the clients your users install
Fern pagination configYou already build with Fern and can hold the config beside the documentConfiguration in a second file, which a consumer of the raw document never seesConsumers generate their own clients from your document
sdkgen paging featureYou generate several languages and want one paging behavior in all of themAn opt-in feature to activate, and a model to regenerate when the shape movesYou publish the description and let consumers pick a generator
Speakeasy x-speakeasy-paginationSpeakeasy is your generator and you can edit the documentAn extension named for one vendor, sitting in a document others readYou 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.

"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.

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.

node demo.mjs
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.

node --test audit.test.mjs
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.

Last verified

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

Footnotes

  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. Two years before that, in June 2012, the IETF had published RFC 6648, 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. ↩︎ Back to text

  2. Fern’s x-fern-pagination finds the rows at $response.results, in what its documentation calls dot-access notation. Speakeasy’s x-speakeasy-pagination finds them at $.resultArray, in JSONPath. 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. ↩︎ Back to text

  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, 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.