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

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' }):

PropertyTypeDescription
strategy'offset'Active strategy identifier
pagenumberCurrent page (clamped to ≥ 1)
perPagenumberItems per page (clamped to ≤ maxPerPage)
offsetnumberComputed: (page - 1) * perPage
limitnumberSame as perPage

Cursor strategy (paginate: { strategy: 'cursor' }):

PropertyTypeDescription
strategy'cursor'Active strategy identifier
cursorstring | undefinedOpaque cursor token; undefined on first page
limitnumberItems to fetch (clamped to ≤ maxLimit)

For offset pagination, send() generates:

  • Link header — RFC 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.

StrategyParameterDefaultMaximum
offsetpage1
offsetper_page20100
cursorlimit20100

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. This includes undefined, null, NaN, and Infinity.
  • 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 {fn: url(), setPath: 'url'} before {fn: paginate(), setPath: 'paginate'}.

ergo-router handles this automatically — setting paginate in the route config auto-includes url() parsing.

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.