Pagination
How an endpoint asks for the next page. Most APIs follow a convention that already has a name: declare its preset. When none fits, write one of seven strategies.
This page serves REST and GraphQL: both are the same engine, and
relay is what a GraphQL endpoint declares.
Presets
Declare the preset that matches the API's framework:
| Preset | For | Same as |
|---|---|---|
spring-page |
Spring Data Page, serialized as is |
page ($.content, $.totalPages, 0-indexed, total $.totalElements) |
spring-paged-model |
Spring Data Page under VIA_DTO (PagedModel) |
page ($.content, $.page.totalPages, 0-indexed, total $.page.totalElements) |
spring-slice |
Spring Data Slice (no total) |
page + last_path: $.last |
spring-hateoas |
Spring Data REST | body-link ($._links.next.href, $._embedded.{items}, total $.page.totalElements — needs items) |
micronaut |
Micronaut Data Page |
page ($.content, 0-indexed, total $.totalSize; $.totalPages too, which Micronaut 3 sends and 4 does not) |
drf-page |
Django REST PageNumberPagination |
body-link ($.next, $.results, total $.count) |
drf-limit-offset |
Django REST LimitOffsetPagination |
offset ($.results, total $.count) |
fastapi |
fastapi-pagination | page ($.items, $.pages, 1-indexed, total $.total) |
nestjs |
nestjs-paginate | body-link ($.links.next, $.data, total $.meta.totalItems) |
json-api |
JSON:API | body-link ($.links.next, $.data) |
sequelize |
Node/Sequelize | offset ($.rows, total $.count) |
stripe |
Stripe-style cursor | cursor ($.has_more, $.data) |
prestashop |
PrestaShop's Webservice | offset in one parameter (limit=0,50, $.{resource} — needs resource) |
odata-v2 |
SAP Gateway, OData v2 | body-link ($.d.__next, $.d.results, total $.d.__count, which is text: "130") |
odata-v4 |
OData v4 | body-link ($['@odata.nextLink'], $.value, total $['@odata.count']) |
pagination: { kind: spring-page } # 7 lines → 1
pagination: { kind: spring-page, size: 500 } # add or override a field
pagination: { kind: spring-hateoas, items: products } # {items} → $._embedded.products
pagination: { kind: prestashop, resource: products, limit: 50 } # ?limit=0,50 then ?limit=50,50… → $.products
A Spring Page comes in two shapes. Look at one response:
{"content":[…],"totalPages":65,"totalElements":130,…} → spring-page
{"content":[…],"page":{"size":50,"number":0,"totalPages":65,…}} → spring-paged-model
The second is the shape Spring recommends since Spring Data 3.3
(@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)). Read with spring-page, it
still loads every row, but asks for one empty page after the last.
nestjs and json-api differ by one line: nestjs also reads the total its pages carry.
json-api has none by the spec.
Two presets require an override:
spring-hateoasneedsitems, the name under$._embedded.prestashopneedsresource, the list's name (products,categories,customers…).
PrestaShop answers XML unless asked. Either read it as it answers, with response_format: xml and
response_path: $.products.product (An answer in XML), or put
output_format=JSON in the endpoint's path:. It gives no total, so the read stops on a short page.
An OData service sends the next link and the total only when asked, so the endpoint's path: carries
the query option. odata-v2 needs $format=json&$inlinecount=allpages, odata-v4 needs $count=true.
Without it the page has no total: the read still follows the next link, but the run has no count to
check itself against (the declared total).
- id: sales-orders
path: /sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$format=json&$inlinecount=allpages
pagination: { kind: odata-v2 }
Spring Data REST puts no id in its items: key them on their self link's last segment, a key read from a link.
Strategies
When no preset fits, write the strategy out. Each one below shows what the API answers, then the block that reads it.
cursor — the next page starts after the last item:
{ "data": [ {"id": "cus_41"}, {"id": "cus_42"} ], "has_more": true }
pagination:
kind: cursor
cursor_param: starting_after # sent as ?starting_after=cus_42
cursor_path: $.data[-1:].id # the id of the last item
has_more_path: $.has_more
response_path: $.data
An API whose cursor is the "more" signal — a token that is empty or absent on the last page, like
the Kubernetes API's metadata.continue — needs no has_more_path:
{ "metadata": { "continue": "eyJ2IjoibWV0YS5rOHMuaW8vdjEi" }, "items": [ … ] }
path: /api/v1/pods?limit=500
pagination:
kind: cursor
cursor_param: continue # sent as ?continue=eyJ2…
cursor_path: $.metadata.continue # empty on the last page: the read stops
response_path: $.items
- With
has_more_path, the flag decides. It must read a boolean: a string there is refused aspagination.has_more_path must read a boolean, not a string. - A flag that says "more" on a page with no cursor fails the run:
pagination.has_more_path says more pages, but cursor_path reads no cursor. - Without
has_more_path, the cursor decides: an empty or absent cursor ends the read. So does an empty page, whatever cursor it carries.lm validatewarns that the cursor decides. - A source that sends back the cursor it was just given, while saying there is more, fails the
run:
Endpoint 'events': the source sent back the cursor it was given while saying more pages — the cursor is not reaching it (check cursor_binding, and cursor_param or cursor_var). The usual cause is a cursor sent where the API does not read it: in the query when it expects a header, or under another variable name for a GraphQLrelay(Cursor binding). - A path reads one value. A slice such as
$.data[-1:].idis fine: it is empty on an empty page. A path that reads several values ($..endCursoracross nested connections) is refused, never one of them picked. total_pathandtotal_pages_pathread a number, or its digits sent as text ("1234").
page — page numbers:
{ "content": [ … ], "number": 0, "totalPages": 5 }
pagination:
kind: page
page_param: page # ?page=0, ?page=1, …
size_param: size
size: 500
start_page: 0 # the default is 1
total_pages_path: $.totalPages
response_path: $.content
page stops at total_pages_path, has_more_path or last_path (whichever you declare), and
on the first empty page.
offset — a row offset and a limit:
{ "data": [ … ], "total": 2400 }
pagination:
kind: offset
offset_param: offset # ?offset=0&limit=500, then ?offset=500…
limit_param: limit
limit: 500
response_path: $.data
Some APIs carry both in one parameter, offset,count — PrestaShop's ?limit=0,50, MySQL's
LIMIT 0,50. Name the same parameter twice: offset_param: limit and limit_param: limit send
?limit=0,500 first, then ?limit=500,500…
body-link — the answer carries the next page's URL:
{ "results": [ … ], "next": "https://api.example.com/partners?page=2" }
pagination:
kind: body-link
link_path: $.next
response_path: $.results
The link is a URL, or a link object carrying it as href — JSON:API allows both, and Drupal writes
the object ("next": { "href": "…" }): link_path: $.links.next reads either. Anything else is
refused, naming the JSON type it read: pagination.link_path must read a URL or a link object with
href, not an object for an object without href, … not a number for a number.
none — the answer holds the whole list, and there is no next page. Older applications
often answer this way, such as Spring PetClinic before Spring Boot:
{ "vetList": [ {"id": 1, "lastName": "Carter"}, {"id": 3, "lastName": "Douglas"} ] }
pagination:
kind: none
response_path: $.vetList
One request, every row it holds. If the source does page, none reads the first page only:
check the count against the application, or declare total_path.
link-header — the next URL is in the Link header (GitHub style). Only response_path is
needed: the example below.
relay — GraphQL Connections: GraphQL.
total_path is accepted by every strategy. It checks the run's row count against what the API
declares: The declared total. When the API
sends that count in a response header (X-Total-Count), name it with total_header instead.
Page size is a knob on page and offset only
cursor, body-link and link-header send no size parameter: the server's default
page size governs the run.
To ask for a larger page, put it in path:, as
20-two-endpoints.yaml does with
path: /drf/partners?page_size=500. relay takes its size from first: in the query.
Not in path:: a parameter the strategy sends itself
page_param, size_param, offset_param, limit_param and a query cursor_param go out on every
request. Written in path: too, one would reach the API twice, and the API would keep one of the
two. lm validate refuses it. Set the page size with size: (or limit:) instead.
A full example: link-header
The next page is in the response's Link header, and the API wants a key:
# 30-link-header.yaml — the next page is in a HEADER, and the credential is an API key.
#
# RFC 5988: the response body says nothing about paging; `Link: <url>; rel="next"` does. The
# strategy follows that URL until the header stops carrying a `next` relation. `rel` defaults
# to next and is spelled out here for readability; `response_path` is what unwraps the rows.
#
# Where the key goes is declarative — `in: header` + `name:`. Use `in: query` and the key rides
# the query string instead, which is what APIs that pre-date bearer tokens tend to want.
#
# lm secret set ACME_API_KEY # the demo API answers to demo-key
#
# Expect: 2 400 rows in ext.acme_header_partners.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: acme-header
connector_type: rest-generic
scopes: [billing]
spec:
base_url: http://host.docker.internal:8099
auth:
kind: api-key
in: header
name: X-API-Key
value_env: ACME_API_KEY # api_key takes no inline value: name a secret
endpoints:
- id: partners
path: /link/partners?limit=500
method: GET
pagination:
kind: link-header
rel: next
response_path: "$.data"
target:
kind: hybrid
table: ext.acme_header_partners
unique_key: "$.id"
promote:
- { name: name, path: "$.name", type: text }
$ lm run list --connector acme-header
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
539d998f acme-header partners Completed 2400 2400 0 0 2026-10-19 01:40 2026-10-19 01:40
Cursor binding
For cursor and relay, cursor_binding says where the cursor goes:
| Binding | Cursor goes into | Needs |
|---|---|---|
query (default) |
a query parameter | cursor_param |
header |
an HTTP header | cursor_param (the header name) |
variable |
the POST body's variables.<name> |
cursor_var — relay on a graphql endpoint only |
An API that wants the cursor in a header:
pagination:
kind: cursor
cursor_binding: header
cursor_param: X-Next-Cursor # the header's name
cursor_path: $.next_cursor
has_more_path: $.has_more
response_path: $.items
variable anywhere else than a GraphQL relay is refused at lm apply.
A pure GraphQL API (POST a query, cursor in
variables): see GraphQL.
Every field
| Strategy | Required fields | Optional fields (default) |
|---|---|---|
cursor |
cursor_param, cursor_path, response_path | has_more_path (none: a non-empty cursor means more), cursor_binding (query), total_path or total_header |
page |
page_param, response_path | size_param, size (50), start_page (1), total_pages_path, total_path (with size_param, the read ends once the rows read reach it) or total_header, has_more_path, last_path, empty_check (true) |
offset |
offset_param, limit_param, response_path | limit (100), total_path or total_header |
link-header |
response_path | rel (next), total_header or total_path |
body-link |
link_path, response_path | total_path or total_header |
none |
response_path | total_path or total_header |
relay |
cursor_path, has_more_path, response_path; plus cursor_param or cursor_var | cursor_binding (query), total_path or total_header |
See also
- REST — the engine underneath
- GraphQL —
relaywith a variable binding - The demo API — the dialects these examples read