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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A described envelope with no extension | 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 | 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 | 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 | 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.
"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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
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 theX-Prefix. Its advice to anyone creating a new parameter is that they SHOULD NOT prefix its name withX-, 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 -
Fern’s
x-fern-paginationfinds the rows at$response.results, in what its documentation calls dot-access notation. Speakeasy’sx-speakeasy-paginationfinds 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 -
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-andx-oas-, which are reserved for the OpenAPI Initiative. Everything else beginningx-belongs to somebody a validator has no way to identify. ↩︎ Back to text