SDK generation

OpenAPI pagination patterns that produce usable SDKs

OpenAPI has no dedicated pagination construct — no pagination: keyword, no built-in cursor type. What determines whether a generated SDK gets a clean, typed way to fetch the next page is entirely how you model the response: a structured object with a typed cursor field generates a usable, loopable type; pagination metadata left in an HTTP Link header generates nothing typed at all, because a generator can't see inside a header value it doesn't parse. Verified against real OpenAPI Generator output on 2026-09-21.

The three patterns, briefly

  • Offset/page — caller sends page and limit (or offset/limit); response includes a total or totalPages count. Simple, but breaks under concurrent writes (items shift between pages).
  • Cursor — caller sends an opaque cursor string; response includes a nextCursor and usually hasMore. Stable under concurrent writes, the pattern most API-first companies (Stripe, GitHub's GraphQL API) default to today.
  • Link header — pagination URLs delivered via the HTTP Link header (rel="next", rel="prev"), per RFC 8288. Keeps the response body clean but puts pagination state somewhere most generators don't look.

All three are valid to describe in OpenAPI. Only cursor and offset/page, modeled as response body fields, reliably produce a typed, loopable SDK method.

What a well-modeled cursor pattern actually generates

Model the cursor and its metadata as real schema fields, not free-form additions:

paths:
  /widgets:
    get:
      parameters:
        - name: cursor
          in: query
          description: Opaque cursor from the previous page's nextCursor. Omit for the first page.
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Widget'
                  pagination:
                    type: object
                    required: [nextCursor, hasMore]
                    properties:
                      nextCursor:
                        type: string
                        nullable: true
                        description: Pass as `cursor` to fetch the next page. Null when there are no more results.
                      hasMore:
                        type: boolean

Generating a TypeScript client from this with OpenAPI Generator v7.25.0 (-g typescript-fetch) produces, unedited:

export interface ListWidgets200ResponsePagination {
    /**
     * Pass as `cursor` to fetch the next page. Null when there are no more results.
     */
    nextCursor: string | null;
    hasMore: boolean;
}

and a request method with cursor and limit as typed, optional parameters, both carrying the description text straight through as doc comments. A caller can write a real loop against this — while (hasMore) { cursor = response.pagination.nextCursor; ... } — with full type-checking on every field, because every piece of pagination state is a named, typed field the generator actually saw.

What Link-header pagination generates instead

Model the same pagination via the Link response header — spec-valid, and arguably cleaner for the response body — and describe it in OpenAPI as a header:

responses:
  "200":
    headers:
      Link:
        description: 'rel="next" and rel="prev" links per RFC 8288'
        schema:
          type: string
    content:
      application/json:
        schema:
          type: array
          items:
            $ref: '#/components/schemas/Widget'

OpenAPI 3.1's Link Object — despite the name — isn't for this; it documents design-time relationships between operations (a create response linking to the resource's get-by-id operation), not runtime pagination cursors. There's no first-class OpenAPI construct for "this header contains structured, parseable pagination state." The Header object above just describes the Link header as an opaque string, which is the most any generator can do with it — the RFC 8288 syntax inside (</widgets?cursor=abc>; rel="next") is invisible to schema-based codegen.

The practical result: the generated client's raw response object still exposes the underlying HTTP Response (in OpenAPI Generator's typescript-fetch output, via .raw.headers.get('Link')), so nothing is lost — but nothing is generated either. The caller gets a string | null they have to parse by hand with their own RFC 8288 logic, on every call site, with no typed nextLink field, no hasMore boolean, and no generated helper. Compare that to the cursor pattern's pagination.nextCursor above: same underlying capability, very different amount of hand-written glue code a caller needs before they can loop.

Comparison table

Pattern Where state lives What a generator produces Caller effort
Offset/page Response body fields (total, page) Typed fields, easy to compute "last page" Low — arithmetic on typed numbers
Cursor Response body fields (nextCursor, hasMore) Typed fields, direct to loop on Lowest — pass the field straight back
Link header HTTP header, RFC 8288 syntax Untyped raw header string; no generated helper Highest — hand-parse the header every time

How to model pagination so the generated SDK is actually usable

  1. Put pagination state in the response body as named schema fields, not headers — as reproduced above, that's the difference between a typed nextCursor field and a raw string a caller parses by hand.
  2. Prefer cursor over offset/page for anything with concurrent writes — an offset-based page number silently skips or repeats rows when data changes between requests; a cursor tied to a stable sort key doesn't have this problem.
  3. Name the field the same way across every paginated endpoint (nextCursor, hasMore) — a generator has no concept of "this means the same thing everywhere," so inconsistent naming between endpoints means inconsistent generated types, and callers relearning the pattern per resource.
  4. Add a description to every pagination field — as shown above, that description carries straight through into the generated SDK's doc comments in mainstream generators, which is effectively free documentation for every caller who never reads the OpenAPI spec directly.
  5. Validate the response schema with the free in-browser OpenAPI validator before generation, and check the actual generated client — not just the spec — to confirm the pagination fields came through as typed properties rather than collapsing into an untyped catch-all. For the rest of the spec, not just pagination, see OpenAPI best practices for SDK-friendly specs.

Honest scope

If your API already uses Link headers for pagination and changing that is out of scope, you're not stuck — mirror the header's cursor into a response body field alongside it (pagination.nextCursor and a Link header carrying the same value) so generated clients get a typed field while HTTP-native consumers that expect the header still get it. Duplicating the value costs little and serves both audiences. And if your pagination model is already offset/page and it's working for your data shape (no concurrent-write problem, dataset small enough that skipped rows don't matter), there's no need to migrate to cursors just because it's the more commonly recommended pattern — match the pattern to what actually breaks for your data, not to what's trendiest.

Once your pagination fields are modeled as typed schema properties, Sourced's hosted docs and SDK pipeline generates the TypeScript and Python SDKs directly from them, so the loop shown above is what customers actually get — no separate pagination helper library to maintain. Start a free report.

FAQ

Does OpenAPI have a built-in pagination keyword?

No. Neither OpenAPI 3.0 nor 3.1 defines a dedicated pagination construct — the Link Object exists but documents design-time operation relationships, not runtime pagination cursors, as confirmed against the OpenAPI 3.1 spec. Every pagination pattern in OpenAPI is convention, modeled with ordinary parameters, response schema fields, and headers.

Is cursor pagination always better than offset/page?

Not universally — cursor pagination is more resilient to concurrent writes shifting rows between pages, which matters for large or frequently-updated datasets. For a small, rarely-changing resource, offset/page is simpler to implement and reason about, and the concurrent-write problem may never actually occur in practice. Pick based on your data's write patterns, not by default.

Why does the Link header generate worse SDK code than a body field, if both describe the same next-page URL?

Because OpenAPI's Header object can only describe a header's overall type (here, string) — it has no syntax for saying "this string internally contains one or more RFC 8288 link-relation entries." A generator reads the Schema Object for typed output; a header typed as a bare string is exactly what it generates: a bare string, with none of the internal structure exposed.

Can I generate a typed helper for Link-header pagination anyway?

Not from the OpenAPI document alone with mainstream generators as tested here — you'd need custom generator templates or a hand-written wrapper around the generated client that parses response.raw.headers.get('Link') itself. That's maintainable but is code you own and test, not something the spec produces for you the way a body-field cursor does.

Does adding both a Link header and a body cursor field violate anything in the spec?

No — nothing in OpenAPI or HTTP prevents a response from carrying the same pagination information in two places. It's redundant by design, and the redundancy is the point: HTTP-native clients that already know how to follow Link headers keep working, and OpenAPI-generated SDK clients get a typed field to loop on, without either audience needing the other's mechanism.

How does pagination modeling affect existing SDKs if I change the response shape later?

Any change to a paginated response's field names or types is a public-surface change for every generated SDK built from it — renaming nextCursor to next_cursor, for instance, breaks every caller's loop. Treat pagination fields as part of your API's stable public contract, and check changes with /openapi-diff/ or a compatibility report before publishing a new SDK version.