Paginating Mock List Endpoints
Your orders list mock returns the same twelve records for every request, so the “load more” button works forever, the empty state has never rendered, and nobody has seen what happens on the last page. This page makes a mocked list endpoint paginate for real, in whichever style your API actually uses.
Context: a fixed array hides three whole classes of bug
Returning a constant array from a list handler is the default because it is the fastest thing to write. It also removes every interesting behaviour from the endpoint.
Termination disappears. A client that keeps requesting until the response is empty or the cursor is null never terminates against a mock that ignores both, so an infinite-scroll loop runs forever locally and looks like a hang.
Boundaries disappear. The last page — the one with fewer records than the page size — is where off-by-one errors live, and a fixed array never produces one.
Emptiness disappears. The empty state is often the least-tested screen in an application precisely because no local fixture ever returns zero rows.
Solution
1. Back the handler with a real dataset
Generate enough records that pagination has something to do, using the seeded factory pattern from building a reusable fixture factory:
// src/mocks/data/orders.ts
import { faker } from '@faker-js/faker';
export interface Order {
id: string;
status: 'pending' | 'paid' | 'shipped' | 'cancelled';
totalMinor: number;
placedAt: string;
}
export function buildOrders(count = 87, seed = 20260731): Order[] {
faker.seed(seed);
return Array.from({ length: count }, (_, i) => ({
id: `ord_${String(i + 1).padStart(4, '0')}`,
status: faker.helpers.arrayElement(['pending', 'paid', 'shipped', 'cancelled'] as const),
totalMinor: faker.number.int({ min: 500, max: 99_000 }),
// Descending, so the newest is first — the ordering the UI expects.
placedAt: new Date(Date.UTC(2026, 6, 31) - i * 3_600_000).toISOString(),
}));
}
export const ORDERS = buildOrders();
Eighty-seven records with a page size of twenty gives four full pages and a final page of seven — every boundary in one dataset.
2. Offset pagination
The simplest style, and the one most internal APIs use:
import { http, HttpResponse } from 'msw';
import { ORDERS } from './data/orders';
const MAX_LIMIT = 100;
http.get('https://api.example.com/orders', ({ request }) => {
const url = new URL(request.url);
const rawLimit = Number(url.searchParams.get('limit') ?? '20');
const rawOffset = Number(url.searchParams.get('offset') ?? '0');
// Clamp exactly as a real API would — a client asking for 10000 must not get it.
const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 20, 1), MAX_LIMIT);
const offset = Math.max(Number.isFinite(rawOffset) ? rawOffset : 0, 0);
const items = ORDERS.slice(offset, offset + limit);
return HttpResponse.json({
items,
limit,
offset,
total: ORDERS.length,
hasMore: offset + items.length < ORDERS.length,
});
});
The clamping is not padding. A client that requests limit=10000 and receives all 87 records will paint an unpaginated list locally and a truncated one in production, and the difference will surface as “the last few orders are missing” months later.
3. Cursor pagination
Cursor APIs hand back an opaque token. Making the mock’s token genuinely opaque is what forces the client to treat it as one:
const encodeCursor = (index: number): string =>
Buffer.from(JSON.stringify({ i: index, v: 1 })).toString('base64url');
const decodeCursor = (cursor: string | null): number => {
if (!cursor) return 0;
try {
const { i } = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as { i: number };
return Number.isInteger(i) && i >= 0 ? i : 0;
} catch {
return -1; // signal "malformed" rather than silently starting over
}
};
http.get('https://api.example.com/orders', ({ request }) => {
const url = new URL(request.url);
const limit = Math.min(Number(url.searchParams.get('limit') ?? '20'), MAX_LIMIT);
const start = decodeCursor(url.searchParams.get('cursor'));
if (start < 0) {
return HttpResponse.json(
{ error: 'invalid_cursor', message: 'The cursor could not be decoded.', retryable: false },
{ status: 400 }
);
}
const items = ORDERS.slice(start, start + limit);
const next = start + items.length;
return HttpResponse.json({
items,
// null — not an empty string, and not omitted — on the final page.
nextCursor: next < ORDERS.length ? encodeCursor(next) : null,
});
});
Returning nextCursor: null rather than omitting the key matters. A client checking if (body.nextCursor) handles both, but one checking if ('nextCursor' in body) loops forever on the omitted form — and which check your client uses is exactly the sort of thing you want the mock to reveal.
The invalid_cursor branch is worth the six lines. Clients that mangle or truncate a cursor produce a 400 from the real API, and a mock that silently restarts from the beginning turns a visible error into a subtle duplicate-rows bug.
4. Link headers
Some APIs paginate entirely in headers, following RFC 8288:
http.get('https://api.example.com/orders', ({ request }) => {
const url = new URL(request.url);
const perPage = Math.min(Number(url.searchParams.get('per_page') ?? '20'), MAX_LIMIT);
const page = Math.max(Number(url.searchParams.get('page') ?? '1'), 1);
const lastPage = Math.max(Math.ceil(ORDERS.length / perPage), 1);
const items = ORDERS.slice((page - 1) * perPage, page * perPage);
const link = (p: number, rel: string) =>
`<${url.origin}${url.pathname}?page=${p}&per_page=${perPage}>; rel="${rel}"`;
const links = [
page < lastPage ? link(page + 1, 'next') : null,
page > 1 ? link(page - 1, 'prev') : null,
link(lastPage, 'last'),
link(1, 'first'),
].filter(Boolean).join(', ');
return HttpResponse.json(items, {
headers: { Link: links, 'X-Total-Count': String(ORDERS.length) },
});
});
Note that the body is a bare array here, not an envelope — that is the convention link-header APIs use, and mocking it as an envelope would let a client be written that cannot parse the real response.
Verification
# Walk to the end and confirm it terminates
cursor=""
for i in $(seq 1 10); do
body=$(curl -s "http://localhost:5173/api/orders?limit=20${cursor:+&cursor=$cursor}")
n=$(echo "$body" | jq '.items | length')
cursor=$(echo "$body" | jq -r '.nextCursor // empty')
echo "page $i: $n item(s), next=${cursor:-<none>}"
[ -z "$cursor" ] && break
done
The expected output is four pages of twenty, one page of seven, and next=<none> — five requests, not ten. A loop that runs all ten iterations means the terminal signal is missing.
Gotchas and edge cases
-
The empty dataset needs its own fixture, not offset past the end. Requesting
offset=999returns zero items but withtotal: 87, so a UI that shows the empty state only when the total is zero still renders nothing useful. Add a handler override that serves a genuinely empty collection, and assert the empty state on that. -
Sorting must be stable or pages overlap. If two records share a
placedAtand the sort is not tie-broken by id, the same record can appear on two consecutive pages. Sort by the timestamp then by id in the fixture generator so the mock cannot produce a duplicate the real API would not. -
Cursor pagination hides mutations mid-scroll. A record created between page one and page two shifts everything in an offset scheme and is invisible in a cursor scheme. If your list is live-updating, mock both the pagination and the streaming updates together, or the interaction between them is never exercised.
Related
- Best Practices for Dynamic Response Shaping — deriving responses from the request more generally
- Generating Realistic Relational Mock Data — building the dataset these pages slice
- Simulating Network Latency in MSW — making each page arrive slowly enough to see the loading state
← Back to Response Shaping Techniques