Pagination
How an endpoint asks for the next page. Most APIs follow a convention that already has a name — declare the preset. When none fits, six strategies are written out by hand.
This page serves REST and GraphQL: both are the same engine, and
relay is what a GraphQL endpoint declares.
Presets
Most APIs follow a convention that already has a name. Declare the preset and the engine expands it at apply time into the real strategy:
| Preset | For | Expands to |
|---|---|---|
spring-page |
Spring Data Page |
page ($.content, $.totalPages, 0-indexed) |
spring-slice |
Spring Data Slice (no total) |
page + last_path: $.last |
spring-hateoas |
Spring Data REST | body-link ($._links.next.href, $._embedded.{items} — needs items) |
micronaut |
Micronaut Data Page |
page ($.content, $.totalPages) |
drf-page |
Django REST PageNumberPagination |
body-link ($.next, $.results) |
drf-limit-offset |
Django REST LimitOffsetPagination |
offset ($.count, $.results) |
fastapi |
fastapi-pagination | page ($.items, $.pages, 1-indexed) |
nestjs |
nestjs-paginate | body-link ($.links.next, $.data) |
json-api |
JSON:API | body-link ($.links.next, $.data) |
sequelize |
Node/Sequelize | offset ($.count, $.rows) |
stripe |
Stripe-style cursor | cursor ($.has_more, $.data) |
pagination: { kind: spring-page } # 7 lines → 1
pagination: { kind: spring-page, size: 500 } # override any expanded field
pagination: { kind: spring-hateoas, items: products } # {items} → $._embedded.products
nestjs and json-api expand identically — pick the one that names your source, the engine
sees the same thing. Only spring-hateoas requires an override: items is what its
$._embedded.{items} placeholder needs.
Strategies
When no preset fits, write the strategy out — every field the preset would have expanded to, in the manifest:
| Strategy | Use for | Required fields | Optional fields (default) |
|---|---|---|---|
cursor |
Stripe, OpenAI, most modern APIs | cursor_param, cursor_path, has_more_path, response_path | cursor_binding (query) |
page |
Spring, Laravel, anything with page numbers | page_param, response_path | size_param, size (50), start_page (1), total_pages_path, total_path, has_more_path, last_path, empty_check (true) |
offset |
legacy REST | offset_param, limit_param, response_path | limit (100), total_path |
link-header |
GitHub, JSON:API | response_path | rel (next) |
body-link |
Django REST, JSON:API, NestJS, HATEOAS | link_path, response_path | total_path |
relay |
GraphQL Connections | cursor_path, has_more_path, response_path; plus cursor_param or cursor_var | cursor_binding (query) |
custom |
— | response_path | a Kotlin script hook, not wired yet — see below |
page stops on whichever of total_pages_path / has_more_path / last_path is present —
and, with empty_check: true (the default), on the first empty page.
total_path does a second job beyond stopping the walk: it becomes a witness the run is
checked against — see The declared
total.
kind: custom validates, then fails on the second page
The Kotlin-script strategy is a stub in this release. A manifest declaring it applies
cleanly, lm source rest test reads its first page and answers ok, and the run dies as
soon as a second page is needed:
✖ run failed: Custom Kotlin pagination scheduled for Task 9 — KotlinScriptHost.executeSource integration
Use one of the six strategies above, or the path: query-string trick, until it lands.
Page size is a knob on page and offset only
cursor, body-link and link-header send no size parameter at all — their first
request is a bare GET — so the server's default page size governs the whole run. A server
defaulting to 50 rows turns 2 400 partners into 48 requests instead of 5. To ask for a
larger page, put the query string in the endpoint's path:
(path: /drf/partners?page_size=500, as in the manifest above): it survives, because the
request builder appends its own parameters with &. relay is different again — its page
size is the first: argument you write in the query yourself.
The link-header strategy written out, with an API key on the side — the next page lives in an
RFC 5988 header and the body says nothing about paging:
# 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 # ApiKeyAuthProviderFactory has NO inline value field
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
88481a89 acme-header partners Completed 2400 2400 0 0 2026-09-10 09:54 2026-09-10 09:54
Cursor binding
For cursor and relay, cursor_binding picks where the next-page cursor is injected:
| 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 |
Both halves of that last rule are refused at apply, and the refusal names the way out:
$ lm apply -f bad-binding.yaml
✗ bad-binding.yaml — 2 error(s), 0 warning(s)
ERROR spec.endpoints[0].pagination.cursor_binding
cursor_binding=variable is only valid for graphql endpoints; non-graphql endpoints use cursor_binding=query|header
ERROR spec.endpoints[0].pagination.cursor_binding
cursor_binding=variable is only valid for pagination.kind=relay; cursor pagination uses cursor_binding=query|header
Fix errors above before running 'lm apply -f bad-binding.yaml'.
Error: validation failed: 2 error(s)
Two errors on one field, both true: a manifest that fixes only the first is still refused.
A pure GraphQL API (POST a query, cursor in
variables): see GraphQL.
See also
- REST — the engine underneath
- GraphQL —
relay, and why its binding is a variable - The demo API — the dialects these examples read