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 http from 'node:http';import {compose, url, paginate, handler} from '@centralping/ergo';
const pipeline = compose( url(), 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 http from 'node:http';import {compose, url, paginate, handler} from '@centralping/ergo';
const pipeline = compose( url(), paginate({strategy: 'cursor'}), 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) |
The response.paginate Contract
Section titled “The response.paginate Contract”Your execute handler returns pagination metadata on response.paginate.
The shape depends on the strategy:
Offset strategy:
| Property | Type | Description |
|---|---|---|
total |
number |
Total item count — must be Number.isFinite() |
Cursor strategy:
| Property | Type | Description |
|---|---|---|
nextCursor |
string | undefined |
Opaque token for the next page |
prevCursor |
string | undefined |
Opaque token for the previous page (optional — omit for forward-only) |
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.
When Headers Are NOT Generated
Section titled “When Headers Are NOT Generated”send() silently skips pagination headers when any gate condition
fails. All five must be true for headers to emit:
send()paginateoption istrue— standalone:handler(pipeline, {paginate: true}); ergo-router: auto-enabled whenpaginateis in the route configacc.paginateis present — thepaginate()middleware must be in the pipelineresponse.paginateis present — the execute handler must return apaginateproperty on the response accumulatorstatusCode < 400— error responses never include pagination headers- Offset only:
Number.isFinite(Number(total))— the source appliesNumber()coercion, then checksisFinite.undefined,NaN, andInfinitycause silent skipping. Note:nullcoerces to0(finite), so it produces headers withtotal=0
When debugging missing pagination headers, check these conditions in
order — the most common cause is a missing response.paginate return
or a non-finite total.
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.undefined,NaN, andInfinityall fail theNumber.isFinite(Number(total))gate. Note:nullcoerces to0(finite), producing headers withtotal=0rather than skipping.- 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
url() before paginate() in the pipeline.
ergo-router handles this automatically — setting paginate in the
route config auto-includes url() parsing.
ergo-router Auto-Configuration
Section titled “ergo-router Auto-Configuration”When paginate is set in a route config, ergo-router automatically:
- Includes
url()parsing —acc.url.queryis available without addingurl: trueseparately - Enables
send({paginate: true})— Link headers are generated without additional handler options - Preserves other send options — the pipeline builder uses spread
merge (
{...(send ?? {}), paginate: true}), so existing send options likeerrorFormatterorresponseSchemaare not clobbered
This means the ergo-router form is fully declarative:
router.get('/articles', { paginate: true, send: {responseSchema: {200: listSchema}}, execute: async (req, res, acc) => { // url() and send({ paginate: true }) are auto-configured const {offset, limit} = acc.paginate; // ... },});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.