sdxc

[ API design ]

@sdxc/pagination

Offset and keyset pagination with parameter parsing and Link headers

npm add @sdxc/pagination remix
pnpm add @sdxc/pagination remix
yarn add @sdxc/pagination remix
bun add @sdxc/pagination remix
Used by
uptime, auth-saas, reader, blog

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.

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.

GetterValue
page, perPageThe resolved page and its size
total, pagesRows across every page, and the page count (1 when empty)
offset, limitRows to skip and to take, for a query builder
from, to1-based indexes of the first and last row, 0 when empty
hasPrev, hasNextWhether a page precedes or follows this one
prev, nextThe 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 ties

  • unique: true allows a single sort key that is already unique, such as a primary key

  • cursor: seeks in the direction the cursor was minted for

  • after / before: seek forward or backward from a cursor

  • limit: 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.

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.

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.

ErrorCause
InvalidCursorErrorA malformed cursor, one minted for another ordering, or two cursors given
InvalidOrderingErrorNo sort keys, a repeated column, a lone non-unique key, or a query that already has an orderBy
UnencodableCursorValueErrorA row's ordering value is null or not a primitive
QueryFailedErrorThe 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 }

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;
}

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);

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.