[ API design ]
@sdxc/pagination
Offset and keyset pagination with parameter parsing and Link headers
- Used by
- uptime, auth-saas, reader, blog
- Source
- packages/pagination
Offset and keyset pagination with parameter parsing and Link headers.
A Pagination value object holds the page arithmetic, two statics page a remix/data-table
query you already composed, parsePageParams() validates the request's paging parameters, and
paginate() writes the navigation into a response's headers. Nothing reads request context, so
the same calls work in a route handler, an export job or a feed generator.
Installation
npm add @sdxc/pagination
Fallible calls answer a Result from @sdxc/result,
parameter parsing fails with the ValidationError from
@sdxc/validate, and queries come from
remix/data-table in remix. parseLinkHeader and
serializeLinkHeader are re-exports of @sdxc/link-header.
Usage
The examples page articles and events tables declared with remix/data-table, through a
db database from the same package.
Page arithmetic
import { Pagination } from "@sdxc/pagination";
let pagination = new Pagination({ page: 3, perPage: 25, total: 892 });
pagination.pages; // 36
pagination.offset; // 50
pagination.from; // 51
pagination.to; // 75
pagination.next; // 4
new Pagination({ page: 500, perPage: 25, total: 892 }).page; // 36, clamped
Offset paging from request parameters
import { Pagination, parsePageParams } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
import { articles } from "./schema.js";
let params = parsePageParams(url.searchParams); // ?page=3&perPage=25
if (isFailure(params)) return new Response("Bad Request", { status: 400 });
let page = await Pagination.byOffset(db.query(articles).where({ author_id: authorId }), {
page: params.data.page,
perPage: params.data.perPage,
});
if (isFailure(page)) throw page.error;
page.data.items; // the rows of page 3
page.data.pagination.total; // 892
byOffset() runs a count and a fetch; pass total when the count is already known and only the
fetch runs.
Keyset paging for a long feed
import { Pagination } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
import { events } from "./schema.js";
let page = await Pagination.byKeyset(db.query(events).where({ team_id: teamId }), {
orderBy: [
["created_at", "desc"],
["id", "desc"],
],
cursor: url.searchParams.get("cursor"),
limit: 50,
});
if (isFailure(page)) throw page.error;
page.data.items;
page.data.cursors; // { next: string | null, prev: string | null }
byKeyset() applies the ordering itself, so pass a query with its joins and filters but no
orderBy. The last sort key breaks ties, and every ordering column must be in the selected row.
Advertise the page in the response
import { paginate } from "@sdxc/pagination";
return Response.json(page.data.items, {
headers: paginate(new Headers(), page.data, { url }),
});
// Link: <https://api.example.com/articles?page=1>; rel="first",
// <https://api.example.com/articles?page=2>; rel="prev",
// <https://api.example.com/articles?page=4>; rel="next",
// <https://api.example.com/articles?page=36>; rel="last"
// X-Total-Count: 892
API
new Pagination(init: PaginationInit)
A frozen value object built from page, perPage and total. Inputs are truncated to whole
numbers, and page is clamped to 1..pages, so every derived value agrees with it.
| Getter | Value |
|---|---|
page, perPage | The resolved page and its size |
total, pages | Rows across every page, and the page count (1 when empty) |
offset, limit | Rows to skip and to take, for a query builder |
from, to | 1-based indexes of the first and last row, 0 when empty |
hasPrev, hasNext | Whether a page precedes or follows this one |
prev, next | The neighbouring page number, or null at either end |
pagination.series(options?: PageSeriesOptions): PageSeries
The pager range: the first and last pages, window pages either side of the current one
(default 1), and a { type: "gap" } item wherever numbers are elided.
new Pagination({ page: 18, perPage: 25, total: 892 }).series();
// 1, gap, 17, 18 (current), 19, gap, 36
pagination.toJSON(): PaginationJSON
Every getter as a plain property, which is what JSON.stringify() writes. The values live on
the prototype, so spread the result of toJSON() rather than the instance.
Pagination.byOffset(query, options: OffsetOptions): Promise<Result<Page<T>, PaginationError>>
Counts the query, then fetches options.page (1-based, clamped against the total) of
options.perPage rows. options.total skips the count. A database failure answers
QueryFailedError.
Pagination.byKeyset(query, options: KeysetOptions): Promise<Result<KeysetPage<T>, PaginationError>>
Seeks the query from an opaque cursor and returns limit rows with the cursors of the
neighbouring pages.
orderBy:[column, direction]sort keys, most significant first; the last breaks tiesunique:trueallows a single sort key that is already unique, such as a primary keycursor: seeks in the direction the cursor was minted forafter/before: seek forward or backward from a cursorlimit: rows per page
At most one of cursor, after and before may be given; an empty string counts as absent.
Rows always come back in orderBy order, whichever way the page was sought.
parsePageParams(searchParams, options?: ParsePageParamsOptions): Result<PageParams, ValidationError>
Reads page, perPage and cursor into { page, perPage, cursor }. page must be a whole
number from 1 and defaults to 1; perPage must be a whole number within 1..options.maxPerPage
(default 100) and defaults to options.perPage (default 25); cursor is null when absent.
A blank parameter counts as absent, and a page past the end parses, for Pagination to clamp.
createPaging(options?: CreatePagingOptions): Paging
Binds parameter names and page-size limits once, returning parse(searchParams) and
paginate(headers, page, { url }), so a route reads the same spelling it writes into its links.
options.names overrides any of page, perPage and cursor; options.perPage and
options.maxPerPage are the parsing limits.
paginate(headers, page, options: PaginateOptions): Headers
Writes navigation for a Page<T> or a KeysetPage<T> into headers and returns the same
instance. An offset page gets first, prev, next and last links plus X-Total-Count; a
keyset page gets prev and next. Links are built from options.url with every other query
parameter kept. Existing paging relations in
Link are replaced
and every other relation is kept, so the call is safe to repeat.
parseLinkHeader(header: string | null): LinkValue[]
Re-export of parse from @sdxc/link-header.
Splits a Link header into { target, rels, params, raw } entries in source order, per
RFC 8288. A null header answers [], and a
malformed entry is skipped.
serializeLinkHeader(links: readonly LinkValue[] | readonly string[]): string | null
Re-export of stringify from @sdxc/link-header.
Joins link-value strings, or the raw of parsed entries, into one header value, or null
for an empty list.
serializeLink(target: string, rel: string, params?: Record<string, string>): string
Re-export of serializeLink from @sdxc/link-header.
Renders one link-value with a quoted rel and any extra parameters after it.
encodeCursor(direction, columns, values): Result<string, UnencodableCursorValueError>
Encodes a page boundary as a URL-safe base64url cursor. Cursors are opaque but readable, so
order by columns the client may see. A null or non-primitive value fails.
decodeCursor(cursor: string): Result<DecodedCursor, InvalidCursorError>
Decodes a cursor into { direction, columns, values }; any malformed input answers
InvalidCursorError.
Defaults
DEFAULT_PAGING_NAMES is { page: "page", perPage: "perPage", cursor: "cursor" },
DEFAULT_PER_PAGE is 25 and DEFAULT_MAX_PER_PAGE is 100.
Errors
Every error extends PaginationError.
| Error | Cause |
|---|---|
InvalidCursorError | A malformed cursor, one minted for another ordering, or two cursors given |
InvalidOrderingError | No sort keys, a repeated column, a lone non-unique key, or a query that already has an orderBy |
UnencodableCursorValueError | A row's ordering value is null or not a primitive |
QueryFailedError | The database threw; the original error is in cause |
Types
Page<T> is { items, pagination } and KeysetPage<T> is { items, cursors }, with
KeysetCursors holding next and prev. PageSeries is an array of PageSeriesItem, either
{ type: "page", page, current } or { type: "gap" }. OrderByTuple is
[column, OrderDirection], where a column may be table-qualified ("events.created_at").
LinkValue is a parsed Link entry (target, rels, params, raw) and DecodedCursor a decoded cursor, with CursorDirection
and CursorValue. OffsetQuery and KeysetQuery describe the query methods each strategy
calls. The options and results above are PaginationInit, PaginationJSON, PageSeriesOptions,
OffsetOptions, KeysetOptions, ParsePageParamsOptions, PageParams, CreatePagingOptions,
PagingNames, Paging and PaginateOptions.
Pattern: One API, Both Strategies
Bind the parameter names once so both handlers read ?per_page= and write it into their links.
import { createPaging, Pagination } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
import { articles, events } from "./schema.js";
let paging = createPaging({ names: { perPage: "per_page" }, perPage: 20, maxPerPage: 50 });
export async function listArticles(url: URL) {
let params = paging.parse(url.searchParams);
if (isFailure(params)) return Response.json(params.error.issues, { status: 400 });
let page = await Pagination.byOffset(db.query(articles), params.data);
if (isFailure(page)) return new Response(null, { status: 500 });
return Response.json(page.data.items, {
headers: paging.paginate(new Headers(), page.data, { url }),
});
}
export async function listEvents(url: URL) {
let params = paging.parse(url.searchParams);
if (isFailure(params)) return Response.json(params.error.issues, { status: 400 });
let page = await Pagination.byKeyset(db.query(events), {
orderBy: [
["created_at", "desc"],
["id", "desc"],
],
cursor: params.data.cursor,
limit: params.data.perPage,
});
if (isFailure(page)) return new Response(null, { status: 500 });
return Response.json(page.data.items, {
headers: paging.paginate(new Headers(), page.data, { url }),
});
}
Pattern: Answering Each Error
A bad cursor is the client's mistake, a bad ordering is the code's, and a failed query is the database's.
import {
InvalidCursorError,
InvalidOrderingError,
Pagination,
QueryFailedError,
} from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
import { events } from "./schema.js";
export async function listEvents(url: URL) {
let page = await Pagination.byKeyset(db.query(events), {
orderBy: [["id", "asc"]],
unique: true,
after: url.searchParams.get("after"),
before: url.searchParams.get("before"),
limit: 50,
});
if (isFailure(page)) {
if (page.error instanceof InvalidCursorError) {
return Response.json({ error: page.error.message }, { status: 400 });
}
if (page.error instanceof QueryFailedError) reportError(page.error.cause);
if (page.error instanceof InvalidOrderingError) reportError(page.error);
return new Response(null, { status: 500 });
}
return Response.json({
data: page.data.items,
next: page.data.cursors.next,
prev: page.data.cursors.prev,
});
}
Pattern: Rendering A Numbered Pager
series() states what each item is, so the pager is a loop with no arithmetic.
import type { Handle } from "remix/component";
import type { PageSeries } from "@sdxc/pagination";
import { css } from "remix/component";
export function Pager(handle: Handle<{ series: PageSeries }>) {
return () => (
<nav aria-label="Pages" mix={css({ display: "flex", gap: "0.5rem" })}>
{handle.props.series.map((item) => {
if (item.type === "gap") return <span aria-hidden="true">…</span>;
if (item.current) return <span aria-current="page">{item.page}</span>;
return <a href={`?page=${item.page}`}>{item.page}</a>;
})}
</nav>
);
}
Render it with <Pager series={page.data.pagination.series({ window: 2 })} />.
Pattern: Page Numbers In The Body
When a client wants the numbers in the payload, put the value object there; toJSON() writes
every getter.
import { paginate, Pagination } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
import { articles } from "./schema.js";
let page = await Pagination.byOffset(db.query(articles), { page: 2, perPage: 25 });
if (isFailure(page)) throw page.error;
return Response.json(
{ data: page.data.items, meta: page.data.pagination },
{ headers: paginate(new Headers(), page.data, { url }) },
);
// meta: { page: 2, perPage: 25, total: 892, pages: 36, offset: 25, ..., next: 3 }
Pattern: Following Next Links As A Client
parseLinkHeader() reads any server's Link header, so a client walks a paginated API until
rel="next" runs out.
import { parseLinkHeader } from "@sdxc/pagination";
export async function fetchAll<T>(start: string): Promise<T[]> {
let items: T[] = [];
let next: string | null = start;
while (next !== null) {
let response = await fetch(next);
items.push(...((await response.json()) as T[]));
let links = parseLinkHeader(response.headers.get("Link"));
next = links.find((link) => link.rels.includes("next"))?.target ?? null;
}
return items;
}
Pattern: Paging Links Beside Other Links
paginate() keeps relations it does not own, so a preload or canonical link set earlier
survives, and serializeLinkHeader() rebuilds a header from parsed entries.
import { paginate, parseLinkHeader, serializeLinkHeader } from "@sdxc/pagination";
let headers = new Headers({ Link: '</app.css>; rel="preload"; as="style"' });
paginate(headers, page.data, { url });
// Link: </app.css>; rel="preload"; as="style", <…?page=1>; rel="first", …
let withoutPreload = parseLinkHeader(headers.get("Link"))
.filter((link) => !link.rels.includes("preload"))
.map((link) => link.raw);
let value = serializeLinkHeader(withoutPreload);
if (value === null) headers.delete("Link");
else headers.set("Link", value);