Cursor pagination on a mock API β€” test infinite scroll before the backend exists

The API you'll ship against almost certainly paginates with cursors: Stripe returns has_more and asks for starting_after, Slack hands you a next_cursor, GitHub's GraphQL API speaks endCursor. But every free mock API β€” mockapi.io, DummyJSON, JSONPlaceholder, json-server β€” only does ?page=2-style offset pages (checked July 2026). So the code path your users actually exercise β€” follow the token, stop on has_more: false, recover from a bad cursor β€” goes untested until the real backend shows up.

Mockbird serves Stripe-style cursor pages on any list endpoint. Free, no signup, works on the public demo right now.

The 10-second version

curl "https://HOST/m/demo/products?mock_cursor=1&limit=5"
{
  "data": [ { "id": 1, "name": "…", "price": … }, … 5 records … ],
  "next_cursor": "eyJ2IjoxLCJvIjo1fQ",
  "has_more": true
}

Follow the token (once you pass ?cursor=, cursor mode is implied β€” mock_cursor=1 is only needed on the first request):

curl "https://HOST/m/demo/products?cursor=eyJ2IjoxLCJvIjo1fQ&limit=5"
# β†’ records 6–10, a new next_cursor, has_more: true

When the collection is exhausted, next_cursor is null and has_more is false β€” your loop's exit condition. The token also rides on every response as the x-mockbird-next-cursor header (CORS-exposed), handy when your client reads headers instead of the body.

Treat the token as opaque β€” real APIs rotate their cursor encoding without notice, and code that decodes tokens breaks in production. Mockbird's are short base64url blobs; resist the urge.

Walk the whole collection in bash

URL="https://HOST/m/demo/products"
RESP=$(curl -s "$URL?mock_cursor=1&limit=10")
while :; do
  echo "$RESP" | jq -r '.data[].name'
  CUR=$(echo "$RESP" | jq -r '.next_cursor // empty')
  [ -z "$CUR" ] && break
  RESP=$(curl -s "$URL?cursor=$CUR&limit=10")
done

Three pages of 10, then a clean stop β€” the demo has 30 products.

Infinite scroll in the browser

This is the whole client an infinite-scroll prototype needs β€” an IntersectionObserver on a sentinel div, a loadMore() that follows the cursor, and a guard for the end:

let cursor = null, done = false, loading = false;

async function loadMore() {
  if (done || loading) return;
  loading = true;
  const url = new URL("https://HOST/m/demo/products");
  url.searchParams.set("limit", "10");
  if (cursor) url.searchParams.set("cursor", cursor);
  else url.searchParams.set("mock_cursor", "1");
  const res = await fetch(url);
  if (!res.ok) { loading = false; return; }   // see "the failure paths" below
  const { data, next_cursor, has_more } = await res.json();
  data.forEach(renderRow);
  cursor = next_cursor;
  done = !has_more;
  loading = false;
}

new IntersectionObserver((e) => e[0].isIntersecting && loadMore())
  .observe(document.querySelector("#sentinel"));

The same shape drops into React Query's useInfiniteQuery β€” getNextPageParam: (last) => last.has_more ? last.next_cursor : undefined is the entire adapter.

The failure paths nobody tests

Cursor bugs live in the edges. All of them are reproducible here:

ScenarioHowWhat you get
Stale / garbage cursor?cursor=garbageClean 400 β€” your "reset to page one" path (real APIs reject expired cursors the same way)
Slow page mid-scroll&mock_delay=1500 or &mock_jitter=800Spinner / skeleton state while the sentinel is visible
Page 3 dies&mock_chaos=0.3Random real 500/502/503/429s β€” does your loader retry, or does the list silently end?
Rate-limited scroller&mock_ratelimit=5429 + live Retry-After after 5 requests/min

That third row is the one to try first: most infinite-scroll implementations treat a failed page as the end of the list, and users never see the rest of the data.

It composes with everything

Filters, search, sort, field selection, relations and nested routes all work inside cursor mode β€” the cursor walks the filtered, sorted result:

curl "https://HOST/m/demo/products?mock_cursor=1&limit=3&price_gte=500&_sort=price&_order=desc&select=name,price"
curl "https://HOST/m/demo/orders?mock_cursor=1&limit=2&_expand=customer"

Details: cursor mode takes precedence over response-envelope reshaping, doesn't combine with CSV / streaming modes (that's a 400, not a surprise), and a field literally named cursor can't be used as an exact-match filter. Full reference in the simulation-params docs.

Honest notes

Under the hood Mockbird's cursor is an opaque offset token. Real keyset-paginated APIs guarantee no skipped/duplicated rows when records are inserted or deleted mid-walk; an offset cursor (like most quick backend implementations, to be fair) shifts instead. For UI work, loop logic, and failure-path testing that difference doesn't matter β€” but if you're specifically testing concurrent-mutation semantics, a mock won't get you there. Also worth saying: if your real API paginates with ?page=2, just use Mockbird's default page/limit params β€” cursor mode is opt-in, nothing changes until you ask.

Get your own (with your schema)

curl -s -X POST https://HOST/api/projects \
  -H 'content-type: application/json' \
  -d '{"name":"my-api","preset":"ecommerce"}'

…or the one-click dashboard link. Import your own OpenAPI spec, db.json or CSV and the cursor walks your records β€” 10,000 requests/day free, plus GraphQL, mock JWT auth and snapshots on the same project.