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.

Five list states; a fixed array produces one Five panels: empty result, single partial page, first of many pages, a middle page, and the final short page. Only the third is reachable from a fixed-array mock. The other four are marked as never exercised, with the specific client behaviour each one governs named underneath. Empty 0 records empty state, call to action Partial only 7 of 20 per page no "load more" control at all First of many 20 of 87 the only state a fixed array reaches Middle page cursor round-trip append vs replace, duplicate keys Final short 7 of 20, next null termination — the loop must stop Four of the five panels are dashed because a constant array can never produce them, and each governs distinct client code. Slicing a real dataset makes all five reachable from the same handler, selected purely by the request. The empty state is the one most often first seen by a customer rather than by a developer.

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.

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.

Three pagination styles, three terminal signals A comparison table of offset, cursor and link-header pagination across four columns: where the page state lives, what the client sends, what signals the last page, and the failure that appears when a mock gets it wrong. Style Client sends End signal Failure when mocked wrong Offset state on the client ?limit=20&offset=40 hasMore false, or a short page unclamped limit hides truncation Cursor state on the server ?cursor=eyJpIjo0MH0 nextCursor is null omitted key loops forever Link header state in the headers ?page=3&per_page=20 no rel="next" in Link enveloped body breaks the parser Mock the style your API actually publishes — a client written against the wrong one is not portable to the real service.

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=999 returns zero items but with total: 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 placedAt and 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.


The assertions worth keeping Three assertions that catch the pagination bugs that matter: that the walk terminates in the expected number of requests, that no record appears on two pages, and that the empty result renders the empty state rather than a bare list. Each catches a bug class no single-page assertion can. The walk terminates count the requests, not just the rows catches an infinite scroll loop No record appears twice collect ids across pages and deduplicate catches an unstable sort The empty case renders a genuinely empty collection catches a missing empty state All three need a dataset large enough to paginate, which is why the fixture size is part of the test setup.

← Back to Response Shaping Techniques