Skip to content

Pagination

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.

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.

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: 42
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"

The pagination flow has two sides:

  1. Request parsing — the paginate() middleware reads query parameters from acc.url.query and stores structured pagination data at acc.paginate.
  2. Response generationsend() reads acc.paginate (parsed params) and response.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).

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)

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)

For offset pagination, send() generates:

  • Link headerRFC 8288 links for first, prev (when page > 1), next (when page < lastPage), and last
  • X-Total-Count header — total item count

For cursor pagination, send() generates:

  • Link headerfirst always, plus prev and next when 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.

send() silently skips pagination headers when any gate condition fails. All five must be true for headers to emit:

  1. send() paginate option is true — standalone: handler(pipeline, {paginate: true}); ergo-router: auto-enabled when paginate is in the route config
  2. acc.paginate is present — the paginate() middleware must be in the pipeline
  3. response.paginate is present — the execute handler must return a paginate property on the response accumulator
  4. statusCode < 400 — error responses never include pagination headers
  5. Offset only: Number.isFinite(Number(total)) — the source applies Number() coercion, then checks isFinite. undefined, NaN, and Infinity cause silent skipping. Note: null coerces to 0 (finite), so it produces headers with total=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.

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", ...
  • total not a finite numbersend() silently skips Link and X-Total-Count headers. undefined, NaN, and Infinity all fail the Number.isFinite(Number(total)) gate. Note: null coerces to 0 (finite), producing headers with total=0 rather 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 a paginate property 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.

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.

When paginate is set in a route config, ergo-router automatically:

  1. Includes url() parsingacc.url.query is available without adding url: true separately
  2. Enables send({paginate: true}) — Link headers are generated without additional handler options
  3. Preserves other send options — the pipeline builder uses spread merge ({...(send ?? {}), paginate: true}), so existing send options like errorFormatter or responseSchema are 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;
// ...
},
});

For fine-grained control in standalone pipelines, ergo also provides pure utility functions in @centralping/ergo/lib/paginate:

  • parseOffsetParams(query, options?) — parse offset parameters
  • parseCursorParams(query, options?) — parse cursor parameters
  • offsetResponse(items, options) — build response with Link headers and X-Total-Count
  • cursorResponse(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.