Pagination
Problem
Section titled “Problem”You need paginated list endpoints that return RFC 8288 Link headers
(first, prev, next, last) and X-Total-Count, with bounded
page sizes to prevent unbounded queries.
Solution
Section titled “Solution”ergo’s paginate middleware parses pagination
query parameters and stores them at acc.paginate. When
send() has paginate: true enabled, it reads
the parsed parameters and your response metadata to auto-generate the
Link headers and X-Total-Count.
Offset Pagination
Section titled “Offset Pagination”import { compose, url, paginate, handler } from "@centralping/ergo";
const pipeline = compose( {fn: url(), setPath: "url"}, {fn: paginate(), setPath: "paginate"}, async (req, res, acc) => { const { offset, limit } = acc.paginate; const items = await db.find(offset, limit); const total = await db.count(); return { response: { body: items, paginate: { total } }, }; },);
const server = http.createServer( handler(pipeline, { paginate: true }),);// GET /articles?page=2&per_page=10// → Link: </articles?page=1&per_page=10>; rel="first", ...// → X-Total-Count: 42router.get("/articles", { paginate: true, execute: async (req, res, acc) => { const { offset, limit } = acc.paginate; const items = await db.find(offset, limit); const total = await db.count(); return { response: { body: items, paginate: { total } }, }; },});// GET /articles?page=2&per_page=10// → Link: </articles?page=1&per_page=10>; rel="first", ...// → X-Total-Count: 42Cursor Pagination
Section titled “Cursor Pagination”import { compose, url, paginate, handler } from "@centralping/ergo";
const pipeline = compose( {fn: url(), setPath: "url"}, {fn: paginate({ strategy: "cursor" }), setPath: "paginate"}, async (req, res, acc) => { const { cursor, limit } = acc.paginate; const { items, nextCursor } = await db.findAfter(cursor, limit); return { response: { body: items, paginate: { nextCursor }, }, }; },);
const server = http.createServer( handler(pipeline, { paginate: true }),);// GET /events?cursor=abc123&limit=10// → Link: </events>; rel="first", </events?cursor=def456&limit=10>; rel="next"router.get("/events", { paginate: { strategy: "cursor" }, execute: async (req, res, acc) => { const { cursor, limit } = acc.paginate; const { items, nextCursor } = await db.findAfter(cursor, limit); return { response: { body: items, paginate: { nextCursor }, }, }; },});// GET /events?cursor=abc123&limit=10// → Link: </events>; rel="first", </events?cursor=def456&limit=10>; rel="next"Explanation
Section titled “Explanation”How It Works
Section titled “How It Works”The pagination flow has two sides:
- Request parsing — the
paginate()middleware reads query parameters fromacc.url.queryand stores structured pagination data atacc.paginate. - Response generation —
send()readsacc.paginate(parsed params) andresponse.paginate(your response metadata) to auto-generate RFC 8288 Link headers.
Your execute handler bridges the two: read acc.paginate for query
parameters, run your data query, then return { response: { body, paginate: { total } } } (offset) or { response: { body, paginate: { nextCursor } } } (cursor).
The acc.paginate Shape
Section titled “The acc.paginate Shape”Offset strategy (paginate: true or paginate: { strategy: 'offset' }):
| Property | Type | Description |
|---|---|---|
strategy | 'offset' | Active strategy identifier |
page | number | Current page (clamped to ≥ 1) |
perPage | number | Items per page (clamped to ≤ maxPerPage) |
offset | number | Computed: (page - 1) * perPage |
limit | number | Same as perPage |
Cursor strategy (paginate: { strategy: 'cursor' }):
| Property | Type | Description |
|---|---|---|
strategy | 'cursor' | Active strategy identifier |
cursor | string | undefined | Opaque cursor token; undefined on first page |
limit | number | Items to fetch (clamped to ≤ maxLimit) |
What send() Generates
Section titled “What send() Generates”For offset pagination, send() generates:
Linkheader — RFC 8288 links forfirst,prev(whenpage > 1),next(whenpage < lastPage), andlastX-Total-Countheader — total item count
For cursor pagination, send() generates:
Linkheader —firstalways, plusprevandnextwhen cursor tokens are provided
Unlike offset pagination, cursor responses do not include
X-Total-Count or a last link because the total is typically
unknown in cursor-based schemes.
Default Bounds
Section titled “Default Bounds”| Strategy | Parameter | Default | Maximum |
|---|---|---|---|
| offset | page | 1 | — |
| offset | per_page | 20 | 100 |
| cursor | limit | 20 | 100 |
Override defaults and maximums via the middleware options:
paginate({ defaultPerPage: 50, maxPerPage: 200,})Preserving Non-Pagination Query Parameters
Section titled “Preserving Non-Pagination Query Parameters”send() automatically strips pagination-specific keys (page,
per_page for offset; cursor, limit for cursor) from the query
string and preserves all other parameters in the generated Link URLs:
GET /articles?sort=date&filter=active&page=2&per_page=10→ Link: </articles?sort=date&filter=active&page=1&per_page=10>; rel="first", ...Edge Cases
Section titled “Edge Cases”totalnot a finite number —send()silently skips Link and X-Total-Count headers. This includesundefined,null,NaN, andInfinity.- 4xx or 5xx status — pagination headers are skipped entirely
(only generated for
statusCode < 400). - Missing
response.paginate— if the execute handler does not return apaginateproperty on the response,send()skips pagination headers. - Missing
acc.paginate— if the paginate middleware is not in the pipeline,send()skips pagination headers even when the option is enabled.
URL Middleware Dependency
Section titled “URL Middleware Dependency”The paginate() middleware reads parsed query parameters from
acc.url.query. When using standalone compose(), you must include
{fn: url(), setPath: 'url'} before {fn: paginate(), setPath: 'paginate'}.
ergo-router handles this automatically — setting paginate in the
route config auto-includes url() parsing.
Alternative: Utility Functions
Section titled “Alternative: Utility Functions”For fine-grained control in standalone pipelines, ergo also provides
pure utility functions in @centralping/ergo/lib/paginate:
parseOffsetParams(query, options?)— parse offset parametersparseCursorParams(query, options?)— parse cursor parametersoffsetResponse(items, options)— build response with Link headers and X-Total-CountcursorResponse(items, options)— build response with cursor Link headers
These functions handle parameter parsing and response formatting directly, without the middleware + send integration. The declarative middleware approach shown above is recommended for most use cases.