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.
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.
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.
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.
Cursor bugs live in the edges. All of them are reproducible here:
| Scenario | How | What you get |
|---|---|---|
| Stale / garbage cursor | ?cursor=garbage | Clean 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=800 | Spinner / skeleton state while the sentinel is visible |
| Page 3 dies | &mock_chaos=0.3 | Random real 500/502/503/429s β does your loader retry, or does the list silently end? |
| Rate-limited scroller | &mock_ratelimit=5 | 429 + 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.
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.
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.
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.