@sdxc/pagination
Offset and keyset pagination with parameter parsing and Link headers
- Installs with
- @sdxc/crypto@sdxc/result@sdxc/validate
- Depends on
remix- Used by
- uptime, auth-saas, reader
- Source
- packages/pagination
Offset and keyset pagination with parameter parsing and Link headers.
One vocabulary for both strategies: a Pagination value object holding the arithmetic, two
statics that page a query you already composed, one place where request parameters are
validated, and one function that writes the navigation into a response's headers.
The package returns data and writes headers. It constructs no responses and renders nothing, and it reads no request context, so the same calls work in a route handler, an export job, or a feed generator.
Installation
npm add @sdxc/pagination
Every fallible call answers with a Result from
@sdxc/result, which is where isFailure comes
from, and parameter parsing fails with a ValidationError from
@sdxc/validate. The two strategies page a
query built with remix/data-table, from
remix. All three install alongside this package.
Usage
Page Arithmetic On Its Own
import { Pagination } from "@sdxc/pagination";
let pagination = new Pagination({ page: 3, perPage: 25, total: 892 });
pagination.pages; // 36
pagination.offset; // 50
pagination.limit; // 25
pagination.from; // 51
pagination.to; // 75
The constructor is the one place clamping happens, so a request for page 500 of 36 resolves to page 36 and every derived value agrees with it.
Offset Paging, With A Numbered Pager
import { Pagination, parsePageParams } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
let params = parsePageParams(url.searchParams);
if (isFailure(params)) return redirect(url.pathname);
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; // Article[]
page.data.pagination.total; // 892
page.data.pagination.series(); // the pager range, gaps included
Offset paging costs two queries per page, one to count and one to fetch. Pass total when it
is already known and the count is skipped.
Keyset Paging, For A Long Feed
import { Pagination } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
let page = await Pagination.byKeyset(db.query(events).where({ team_id: teamId }), {
orderBy: [
["created_at", "desc"],
["id", "desc"],
],
cursor: params.data.cursor,
limit: 50,
});
if (isFailure(page)) throw page.error;
page.data.items;
page.data.cursors; // { next: string | null, prev: string | null }
byKeyset() owns the ordering, so hand it a query carrying joins and predicates but no sort
keys. It reads one row past the limit to learn whether a further page exists, rather than
counting.
Advertising A Page In The Response
import { paginate } from "@sdxc/pagination";
let headers = new Headers();
headers.set("Cache-Control", "private, max-age=60");
paginate(headers, page.data, { url });
headers.get("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"
headers.get("X-Total-Count"); // "892"
API
new Pagination(init: PaginationInit)
Page arithmetic for one page of one query, from page, perPage, and total. The instance is
frozen, and non-finite, fractional, and negative inputs are normalized rather than trusted,
because they arrive from query strings.
Every value beyond the three inputs is a getter on the prototype, computed on read:
| Getter | Type | Description |
|---|---|---|
page | number | Resolved page, always within 1..pages |
perPage | number | Rows per page |
total | number | Total rows across every page |
pages | number | Page count; 1 even for an empty result, so page stays valid |
offset | number | Rows to skip to reach this page |
limit | number | Rows to take; the same as perPage, named for the query builder |
from | number | 1-based index of the first row, or 0 when there are no rows |
to | number | 1-based index of the last row, or 0 when there are no rows |
hasPrev | boolean | Whether a page precedes this one |
hasNext | boolean | Whether a page follows this one |
prev | number | null | Previous page, or null on the first page |
next | number | null | Next page, or null on the last page |
pagination.series(options?: PageSeriesOptions): PageSeries
Builds the pager range, with a gap marker only where numbers are actually elided. window is
how many pages show either side of the current one, default 1; the first and last pages are
always present regardless of it.
new Pagination({ page: 18, perPage: 25, total: 892 }).series();
// [
// { type: "page", page: 1, current: false },
// { type: "gap" },
// { type: "page", page: 17, current: false },
// { type: "page", page: 18, current: true },
// { type: "page", page: 19, current: false },
// { type: "gap" },
// { type: "page", page: 36, current: false },
// ]
Page numbers need a total, so series() belongs to offset paging. A keyset page carries
cursors instead, and renders older and newer links from those.
pagination.toJSON(): PaginationJSON
Returns every value as own properties, which JSON.stringify() calls for you. It is the only
way an instance serializes: the getters live on the prototype, so { ...pagination } produces
{} and Object.keys(pagination) produces [].
Pagination.byOffset(query, options): Promise<Result<Page<T>, PaginationError>>
Counts a composed query, then executes it with limit and offset applied. options.page is
1-based and clamped against the total, options.perPage is the page size, and options.total
supplies a count that is already known.
The query runs twice, which is safe because the builder's chaining returns new query values
rather than mutating in place, and its count() wraps the composed predicate in a subquery
that ignores limit, offset, and ordering. A database that refuses comes back as
QueryFailedError.
Pagination.byKeyset(query, options): Promise<Result<KeysetPage<T>, PaginationError>>
Seeks a composed query from an opaque cursor.
options.orderBy: Sort keys, most significant first; the last one is the tiebreakeroptions.unique: Declares that a one-column ordering is already unique, such as a primary keyoptions.after: Seek forward from this cursoroptions.before: Seek backward from this cursoroptions.cursor: Seek in whichever direction the cursor was minted foroptions.limit: Rows per page
The ordering must be deterministic, so a single sort key is refused unless unique: true says
it is already unique — rows sharing a sort value otherwise straddle the page boundary and are
skipped or served twice. At most one of after, before, and cursor may be given. cursor
is what lets one query parameter carry both directions, since the direction rides inside the
opaque value, and paging backward runs the query reversed and reverses the rows again so a page
always reads in the requested order.
let page = await Pagination.byKeyset(db.query(events), {
orderBy: [["id", "desc"]],
unique: true,
limit: 50,
});
parsePageParams(searchParams, options?): Result<PageParams, ValidationError>
Validates the paging parameters on a request URL under the default names page, perPage, and
cursor, answering { page, perPage, cursor } with cursor null when absent.
options.perPage is the size used when the request does not ask for one, and
options.maxPerPage the largest a request may ask for.
A page that is not a whole number at or above 1 fails, and so does a page size outside
1..maxPerPage, which is what stops a client asking for every row. A blank parameter
(?page=) is treated as absent. A page past the end succeeds: clamping belongs to
Pagination, which needs the total to know where the end is.
createPaging(options?): Paging
Binds parameter names and page-size limits to the two functions that need them, returning
parse(searchParams) and paginate(headers, page, options) with those names applied.
options.names spells each parameter, defaulted per field, while options.perPage and
options.maxPerPage carry the limits.
Two halves care what the parameters are called: parsing reads them off an incoming URL, and
paginate() writes them into the Link URLs it advertises. Custom names exist only in this
factory, so an API cannot accept ?per_page=50 while advertising ?perPage=50.
let PAGING = createPaging({
names: { page: "page", perPage: "per_page", cursor: "cursor" },
perPage: 25,
maxPerPage: 100,
});
let params = PAGING.parse(url.searchParams);
PAGING.paginate(headers, page, { url }); // ?per_page=… in every Link
paginate(headers, page, options): Headers
Writes a page's navigation into a response's own headers and returns the same instance, so the
call can sit inline in a response's argument list. page is a Page<T> or a KeysetPage<T>
and the shape decides what is emitted; options.url is the URL the links are built from.
An offset page emits first, prev, next, last, and X-Total-Count. A keyset page emits
prev and next only, and no total: it runs no count query, so it cannot know how many pages
there are. Every other query parameter is carried over, so filters and sort options survive
paging, and the parameter belonging to the other strategy is dropped so an offset link never
advertises a stale cursor.
X-Total-Count is replaced, and
Link is merged:
rel="preload", rel="canonical", and rel="alternate" share that header, so only the four
paging relations are dropped before the new ones are appended. That also makes the call
idempotent. Splitting the existing value respects angle brackets and quoted strings, per
RFC 8288, so a hint whose URL contains a comma comes
back out byte for byte.
The url is explicit rather than configured, so a service behind a proxy passes its public URL
and advertises that instead of an internal hostname. The total uses X-Total-Count because
that is the name existing client libraries look for, which is worth the X- prefix
RFC 6648 otherwise discourages.
parseLinkHeader(header): LinkValue[]
Parses a Link header value into { target, rels, raw } entries, in source order, the
RFC 8288 splitting paginate() itself runs on the way
in. Exported for a client reading a page it did not build: following rel="next" off a response
this package wrote, or off any other server's Link header. A null header answers [], and a
malformed entry is dropped rather than raising, so one bad link never fails a whole header.
import { parseLinkHeader } from "@sdxc/pagination";
let links = parseLinkHeader(response.headers.get("Link"));
let next = links.find((link) => link.rels.includes("next"))?.target ?? null;
raw carries the entry's exact source text, which is what lets a caller re-emit a link it does
not otherwise understand byte for byte.
serializeLinkHeader(links): string | null
Joins link-value strings — built with serializeLink(target, rel) or carried over as raw from
a parsed entry — back into one header value, or null for an empty list. paginate() uses it to
write the header it merges into; a caller assembling its own Link value from scratch uses it the
same way.
encodeCursor(direction, columns, values): Result<string, UnencodableCursorValueError>
Encodes a page boundary as an opaque, URL-safe cursor. byKeyset() calls it for you; it is
exported for tests and for a store that pages itself. A cursor is plain base64url — opaque but
not secret — so it carries only ordering keys the client is already allowed to see.
decodeCursor(cursor): Result<DecodedCursor, InvalidCursorError>
Decodes and validates a cursor into { direction, columns, values }. Bad base64url, bad UTF-8,
bad JSON, and a merely plausible payload all collapse into InvalidCursorError, so a
client-supplied cursor never reaches JSON.parse and throws.
Defaults
DEFAULT_PAGING_NAMES is { page: "page", perPage: "perPage", cursor: "cursor" }, the spelling
the standalone functions read and write. DEFAULT_PER_PAGE is 25 and DEFAULT_MAX_PER_PAGE
is 100.
Errors
All of them extend PaginationError, so one instanceof check covers paging while the
subclasses let a handler tell a client's mistake apart from an infrastructure failure.
| Error | Meaning | Typical answer |
|---|---|---|
InvalidCursorError | Undecodable cursor, or one issued for another ordering | 400 |
InvalidOrderingError | The ordering cannot page deterministically | a bug, not input |
UnencodableCursorValueError | A row's ordering value is null or not a primitive | a schema problem |
QueryFailedError | The database refused; the throw is kept in cause | 500 |
Types
type PageSeriesItem = { type: "page"; page: number; current: boolean } | { type: "gap" };
type PageSeries = PageSeriesItem[];
type OrderByTuple = readonly [column: string, direction: "asc" | "desc"];
interface Page<T> {
items: T[];
pagination: Pagination;
}
interface KeysetPage<T> {
items: T[];
cursors: { next: string | null; prev: string | null };
}
interface LinkValue {
target: string;
rels: string[];
raw: string;
}
PageSeriesItem is a discriminated union, so a pager is a switch over type that reads
properties, with no arithmetic and no comparison back to pagination.page. An OrderByTuple
column may be qualified ("events.created_at"); the unqualified segment is what is read off a
result row when a cursor is minted, so an ordering column must be present in the query's
projection.
The options and result shapes named in the signatures above — PaginationInit,
PaginationJSON, PageSeriesOptions, OffsetOptions, KeysetOptions, KeysetCursors,
OffsetQuery, KeysetQuery, PaginateOptions, PagingNames, Paging, CreatePagingOptions,
ParsePageParamsOptions, PageParams, CursorValue, CursorDirection, DecodedCursor, and
LinkValue — are exported as types too.
Pattern: One Route, Both Strategies
Binding the parameter names once means both handlers spell the page size the same way in the
query string they read and in the Link URLs they emit.
import { createPaging, Pagination } from "@sdxc/pagination";
import { isFailure } from "@sdxc/result";
let PAGING = createPaging({
names: { page: "page", perPage: "per_page", cursor: "cursor" },
perPage: 25,
maxPerPage: 100,
});
/** A rendered list: numbered pager, all four relations, and a total. */
export async function listArticles(url: URL, authorId: string) {
// A malformed page or size is not worth an error page; drop the query string
// and let the canonical URL render the first page.
let params = PAGING.parse(url.searchParams);
if (isFailure(params)) return Response.redirect(new URL(url.pathname, url), 302);
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;
return Response.json(
{ data: page.data.items, series: page.data.pagination.series() },
{ headers: PAGING.paginate(new Headers(), page.data, { url }) },
);
}
/** A JSON feed: cursor links only, walked by following them. */
export async function listEvents(url: URL, teamId: string) {
let params = PAGING.parse(url.searchParams);
if (isFailure(params)) return new Response("Bad Request", { status: 400 });
let page = await Pagination.byKeyset(db.query(events).where({ team_id: teamId }), {
orderBy: [
["created_at", "desc"],
["id", "desc"],
],
cursor: params.data.cursor,
limit: params.data.perPage,
});
if (isFailure(page)) throw page.error;
return Response.json(page.data.items, {
headers: PAGING.paginate(new Headers(), page.data, { url }),
});
}
Either strategy works with either response kind; this pairing is the common one, since a numbered pager needs a total and a long feed is cheaper to seek than to offset.
Pattern: Rendering A Pager From series()
import type { PageSeries } from "@sdxc/pagination";
function Pager({ series }: { series: PageSeries }) {
return (
<nav>
{series.map((item) => {
if (item.type === "gap") return <span aria-hidden>…</span>;
if (item.current) return <span aria-current="page">{item.page}</span>;
return <a href={`?page=${item.page}`}>{item.page}</a>;
})}
</nav>
);
}
No arithmetic, no typeof narrowing, and no comparison back to the current page: the item
states what it is. PageSeries is a plain type, so a hydrating client imports it and renders
the array the server already computed while the class stays on the server.
Pattern: Serializing Into An Envelope
Headers are the interoperable way to express navigation, and they keep the response body the resource itself. When a client genuinely needs the numbers in the body, serialize the value object rather than rebuilding it:
return Response.json({ data: page.data.items, meta: page.data.pagination });
// meta is the full plain shape, via toJSON()