Build a Cursor-Pagination REST API
A read-only REST API that serves a large synthetic dataset using opaque cursor pagination, stable ordering, and page-size limits, with tests that prove no record is skipped or repeated across pages.
Project goal
Build an API that paginates a large dataset with opaque cursors instead of offset numbers, guarantees stable ordering with a deterministic tie-breaker, enforces a maximum page size, and returns next and previous cursors.
Final features
- Serve items using an opaque cursor that encodes the last seen sort key.
- Guarantee stable ordering with an id tie-breaker so pages never overlap.
- Enforce a maximum page size and default when none is supplied.
- Return a next cursor and a hasMore flag on every response.
- Validate and reject malformed cursors with a 400 rather than an empty page.
Architecture
A data source exposes a keyed, sorted collection; a cursor codec encodes and decodes the last key into a URL-safe opaque string; a paginator seeks past that key and slices the requested size; and an HTTP layer maps query parameters to those calls. Encoding is isolated so the cursor format can change without touching the data source.
Prerequisites
- Node.js 20 LTS or later
- Understanding of HTTP query parameters and status codes
- Basic knowledge of sorted collections
Dependencies
- node >=20 — HTTP server and base64url encoding for cursors
- vitest ^2.1.0 — Pagination and cursor codec tests
Directory structure
cursor-api/
├── src/
│ ├── data.js
│ ├── cursor.js
│ ├── paginate.js
│ ├── routes.js
│ └── server.js
├── test/
│ ├── cursor.test.js
│ └── paginate.test.js
└── package.json
Phases
1. Sorted data source
Expose a deterministic, totally ordered collection and a comparison function used by every later stage.
Files: src/data.js
export function compareKey(a, b) {
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
export function generate(n, seed = 1) {
let x = seed;
const rand = () => (x = (x * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const items = [];
for (let i = 0; i < n; i++) {
items.push({
id: 'doc-' + String(i).padStart(5, '0'),
createdAt: new Date(1700000000000 + Math.floor(rand() * 1e9)).toISOString(),
title: 'Article ' + i,
});
}
return items.sort(compareKey);
}Expected behavior: The generated set is identical across runs for the same seed and is sorted so that no two items compare equal.
2. Cursor codec
Encode and decode a sort key into a URL-safe opaque token, rejecting anything malformed.
Files: src/cursor.js
export function encodeCursor(item) {
const payload = JSON.stringify({ t: item.createdAt, i: item.id });
return Buffer.from(payload, 'utf8').toString('base64url');
}
export function decodeCursor(token) {
try {
const parsed = JSON.parse(Buffer.from(String(token), 'base64url').toString('utf8'));
if (typeof parsed.t !== 'string' || typeof parsed.i !== 'string') throw new Error('shape');
return { createdAt: parsed.t, id: parsed.i };
} catch {
throw new Error('invalid_cursor');
}
}Expected behavior: Encoding then decoding returns the original key, a truncated token throws invalid_cursor, and no decoded output leaks raw JSON.
3. Paginator
Seek past the cursor key in a totally ordered collection and return a slice with a next cursor.
Files: src/paginate.js
import { decodeCursor, encodeCursor } from './cursor.js';
import { compareKey } from './data.js';
export function page(sorted, { cursor, size }) {
let start = 0;
if (cursor) {
const key = decodeCursor(cursor);
start = sorted.findIndex((item) => compareKey(item, key) > 0);
if (start < 0) start = sorted.length;
}
const items = sorted.slice(start, start + size);
const hasMore = start + size < sorted.length;
const last = items[items.length - 1];
return { items, hasMore, nextCursor: hasMore && last ? encodeCursor(last) : null };
}Expected behavior: Walking page by page with the returned cursor visits every item exactly once, and the final page reports hasMore false.
4. Route and parameter handling
Parse the cursor and size query parameters, clamp the page size to a maximum, and translate codec errors into 400 responses.
Files: src/routes.js
import { page } from './paginate.js';
const MAX_SIZE = 50;
const DEFAULT_SIZE = 20;
export function handleList(sorted, query) {
const rawSize = Number(query.get('size') ?? DEFAULT_SIZE);
const size = Number.isFinite(rawSize) && rawSize > 0 ? Math.min(MAX_SIZE, rawSize) : DEFAULT_SIZE;
const cursor = query.get('cursor') ?? undefined;
try {
const result = page(sorted, { cursor, size });
return { status: 200, body: result };
} catch (err) {
return { status: 400, body: { error: err.message } };
}
}Expected behavior: A size of 1000 returns at most 50 items, a valid cursor returns a page, and an invalid cursor returns 400 with error invalid_cursor.
5. HTTP server
Serve the paginated endpoint, generate the dataset once at boot, and return JSON with correct content type.
Files: src/server.js
import { createServer } from 'node:http';
import { generate } from './data.js';
import { handleList } from './routes.js';
const sorted = generate(1000, 42);
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/articles') {
const result = handleList(sorted, url.searchParams);
res.writeHead(result.status, { 'content-type': 'application/json' });
return res.end(JSON.stringify(result.body));
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
});
server.listen(process.env.PORT ?? 3000);
export { server };Expected behavior: A GET to /articles returns a JSON page with items and a next cursor, and an unknown path returns 404 with a JSON error.
6. Invariant tests
Prove that walking all pages reconstructs the dataset exactly and that insertion between pages never duplicates an item.
Files: test/paginate.test.js
import { page } from '../src/paginate.js';
import { generate } from '../src/data.js';
test('full walk yields every item once', () => {
const sorted = generate(137, 7);
const seen = [];
let cursor;
for (;;) {
const result = page(sorted, { cursor, size: 10 });
seen.push(...result.items.map((i) => i.id));
if (!result.hasMore) break;
cursor = result.nextCursor;
}
expect(seen).toEqual(sorted.map((i) => i.id));
expect(new Set(seen).size).toBe(seen.length);
});Expected behavior: The full-walk test passes for a non-multiple page size, and no id appears twice in the reconstructed sequence.
Testing
Run npx vitest. Cursor tests cover encode and decode round-trips, tampered cursors, and Unicode keys. Pagination tests walk an entire dataset page by page and assert the concatenation equals the full set with no duplicates. An HTTP test asserts that page size is capped and that a malformed cursor returns 400 rather than an empty result.
Troubleshooting
The last item is missing when the total is an exact multiple of the page size
End pagination on hasMore rather than on an empty page; a page exactly filling the size reports hasMore false without issuing one more request.
Two items with the same timestamp cause overlapping pages
Add a unique tie-breaker such as the id to the sort key and the cursor; without a total order, seeking past a key cannot identify a unique position.
A client sends a cursor from a different ordering
Include a version tag in the cursor payload and reject unknown versions; cursors are only meaningful against the ordering that produced them.
Deployment
Run the server behind a proxy and cache pages by cursor where the dataset is append-only. In a real system, the same seek becomes a WHERE-clause keyset query against the database index on the sort key.
Known limitations
- The dataset is in memory; a real deployment pages over a database index rather than an array.
- Backwards pagination is not implemented, so clients can only move forward through the collection.
Project overview
Offset pagination breaks the moment data changes underneath it: records shift, pages overlap, and clients silently miss rows. Cursor pagination fixes that by remembering a position in a stable order rather than a count, and this project makes the mechanism explicit and testable.
The API serves a synthetic dataset of articles, pages through it with an opaque cursor, and proves with tests that walking every page yields the full set with no duplicates, even when new rows are inserted mid-walk.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
Pagination reduces to seeking past the last returned key and taking a slice, which is the whole trick in one function:
export function page(sorted, { cursor, size }) {
let start = 0;
if (cursor) {
const key = decodeCursor(cursor);
start = sorted.findIndex((item) => compareKey(item, key) > 0);
if (start === -1) start = sorted.length;
}
const slice = sorted.slice(start, start + size);
const last = slice[slice.length - 1];
return {
items: slice,
nextCursor: last && start + size < sorted.length ? encodeCursor(last) : null,
hasMore: start + size < sorted.length,
};
}
The stable compareKey is what makes the seek correct: as long as ordering is total, the cursor identifies a unique position.
Setup
Zero runtime dependencies keeps the pagination logic front and centre; the only addition is the test runner.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | HTTP server and base64url encoding for cursors |
| vitest | ^2.1.0 | Pagination and cursor codec tests |
Create the project and install:
mkdir cursor-api && cd cursor-api
npm init -y
npm install -D vitest@^2.1.0
Wire the server and the tests in package.json:
{
"name": "cursor-api",
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "vitest run"
}
}
The dataset is generated deterministically from a seed so that pagination tests are reproducible across machines.
Phase 1: Sorted data source
Expose a deterministic, totally ordered collection and a comparison function used by every later stage. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
export function compareKey(a, b) {
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
export function generate(n, seed = 1) {
let x = seed;
const rand = () => (x = (x * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const items = [];
for (let i = 0; i < n; i++) {
items.push({
id: 'doc-' + String(i).padStart(5, '0'),
createdAt: new Date(1700000000000 + Math.floor(rand() * 1e9)).toISOString(),
title: 'Article ' + i,
});
}
return items.sort(compareKey);
}
Acceptance for this phase: The generated set is identical across runs for the same seed and is sorted so that no two items compare equal.
Phase 2: Cursor codec
Encode and decode a sort key into a URL-safe opaque token, rejecting anything malformed. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function encodeCursor(item) {
const payload = JSON.stringify({ t: item.createdAt, i: item.id });
return Buffer.from(payload, 'utf8').toString('base64url');
}
export function decodeCursor(token) {
try {
const parsed = JSON.parse(Buffer.from(String(token), 'base64url').toString('utf8'));
if (typeof parsed.t !== 'string' || typeof parsed.i !== 'string') throw new Error('shape');
return { createdAt: parsed.t, id: parsed.i };
} catch {
throw new Error('invalid_cursor');
}
}
Acceptance for this phase: Encoding then decoding returns the original key, a truncated token throws invalid_cursor, and no decoded output leaks raw JSON.
Phase 3: Paginator
Seek past the cursor key in a totally ordered collection and return a slice with a next cursor. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
import { decodeCursor, encodeCursor } from './cursor.js';
import { compareKey } from './data.js';
export function page(sorted, { cursor, size }) {
let start = 0;
if (cursor) {
const key = decodeCursor(cursor);
start = sorted.findIndex((item) => compareKey(item, key) > 0);
if (start < 0) start = sorted.length;
}
const items = sorted.slice(start, start + size);
const hasMore = start + size < sorted.length;
const last = items[items.length - 1];
return { items, hasMore, nextCursor: hasMore && last ? encodeCursor(last) : null };
}
Acceptance for this phase: Walking page by page with the returned cursor visits every item exactly once, and the final page reports hasMore false.
Phase 4: Route and parameter handling
Parse the cursor and size query parameters, clamp the page size to a maximum, and translate codec errors into 400 responses. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { page } from './paginate.js';
const MAX_SIZE = 50;
const DEFAULT_SIZE = 20;
export function handleList(sorted, query) {
const rawSize = Number(query.get('size') ?? DEFAULT_SIZE);
const size = Number.isFinite(rawSize) && rawSize > 0 ? Math.min(MAX_SIZE, rawSize) : DEFAULT_SIZE;
const cursor = query.get('cursor') ?? undefined;
try {
const result = page(sorted, { cursor, size });
return { status: 200, body: result };
} catch (err) {
return { status: 400, body: { error: err.message } };
}
}
Acceptance for this phase: A size of 1000 returns at most 50 items, a valid cursor returns a page, and an invalid cursor returns 400 with error invalid_cursor.
Phase 5: HTTP server
Serve the paginated endpoint, generate the dataset once at boot, and return JSON with correct content type. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { createServer } from 'node:http';
import { generate } from './data.js';
import { handleList } from './routes.js';
const sorted = generate(1000, 42);
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/articles') {
const result = handleList(sorted, url.searchParams);
res.writeHead(result.status, { 'content-type': 'application/json' });
return res.end(JSON.stringify(result.body));
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
});
server.listen(process.env.PORT ?? 3000);
export { server };
Acceptance for this phase: A GET to /articles returns a JSON page with items and a next cursor, and an unknown path returns 404 with a JSON error.
Phase 6: Invariant tests
Prove that walking all pages reconstructs the dataset exactly and that insertion between pages never duplicates an item. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { page } from '../src/paginate.js';
import { generate } from '../src/data.js';
test('full walk yields every item once', () => {
const sorted = generate(137, 7);
const seen = [];
let cursor;
for (;;) {
const result = page(sorted, { cursor, size: 10 });
seen.push(...result.items.map((i) => i.id));
if (!result.hasMore) break;
cursor = result.nextCursor;
}
expect(seen).toEqual(sorted.map((i) => i.id));
expect(new Set(seen).size).toBe(seen.length);
});
Acceptance for this phase: The full-walk test passes for a non-multiple page size, and no id appears twice in the reconstructed sequence.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Cursor tests cover encode and decode round-trips, tampered cursors, and Unicode keys. Pagination tests walk an entire dataset page by page and assert the concatenation equals the full set with no duplicates. An HTTP test asserts that page size is capped and that a malformed cursor returns 400 rather than an empty result.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The full-walk assertion is the centrepiece: it is the property offset pagination cannot guarantee and the reason cursor pagination exists.
Extending the project
Filtering composes naturally because the sort key already includes the fields under the cursor; adding a filter means narrowing the collection before paging. Backwards pagination is a second cursor direction, and keyset pagination over SQL is the same seek translated into a WHERE clause.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.