API pagination examples: offset, page, cursor, keyset, Link header and HAL
Six pagination styles over the same data, with a live response and a fetch-every-page loop for each.
Examples last run against the API on
APIs split long lists into pages in six common ways. Offset and limit skip a number of items. Page number and size count pages, as Spring Data does. A cursor is an opaque token, as Stripe uses, and keyset paging asks for items after the last id. Some APIs put the next URL in a Link header, as GitHub does, and HAL puts it in hypermedia links. https://api.testingapis.com/api/paginationsign in to see serves the same 100 items in all six styles, so you can compare the responses side by side and test a client against each one. It's free with an account: send your API key in the X-TestingApis-Key header. Each style is below with a live response.
The six styles at a glance
| Style | Request | Next page comes from | Used by |
|---|---|---|---|
| Offset / limit | /offset?offset=20&limit=10 | offset + limit | Many SQL-backed APIs |
| Page number (0-based) | /page?page=2&size=10 | number + 1, until last is true | Spring Data |
| Cursor | /cursor?limit=10&cursor=… | next_cursor, until has_more is false | Stripe |
| Keyset | /keyset?after_id=20&limit=10 | next_after_id | Large tables, feeds |
| Link header (1-based) | /link?page=3&per_page=10 | The rel="next" URL in the Link header | GitHub |
| HAL | /hal?page=2&size=10 | _links.next.href | Spring HATEOAS |
All paths are under /api/paginationsign in to see. Page sizes run from 1 to 100, with a default of 10.
Offset and limit
The simplest style: skip offset items and return the next limit. The total is in the body and in X-Total-Count.
$ curl -i "https://api.testingapis.com/api/pagination/offset?offset=0&limit=2"
HTTP/1.1 200 OK
x-total-count: 100
{"data":[{"id":1,"name":"Smart Headset",...},{"id":2,"name":"Compact Speaker",...}],
"pagination":{"offset":0,"limit":2,"total":100,"hasMore":true}}Page number and size
Spring Data's envelope. Pages start at 0, which is the bug to test for: a client written for 1-based pages silently skips the first page.
$ curl "https://api.testingapis.com/api/pagination/page?page=0&size=2"
{"content":[{"id":1,...},{"id":2,...}],
"pageable":{"pageNumber":0,"pageSize":2,"offset":0,"paged":true},
"totalElements":100,"totalPages":50,"number":0,"size":2,"numberOfElements":2,
"first":true,"last":false,"empty":false}Cursor
The response carries a next_cursor. Send it back unchanged to get the next page, and stop when has_more is false. The cursor happens to be base64-encoded JSON, but a client must treat it as opaque. An altered cursor gets a 400:
$ curl "https://api.testingapis.com/api/pagination/cursor?limit=2"
{"object":"list","url":"/api/pagination/cursor",
"data":[{"id":1,...},{"id":2,...}],
"has_more":true,"next_cursor":"eyJsYXN0SWQiOjJ9"}
$ curl "https://api.testingapis.com/api/pagination/cursor?cursor=garbage"
HTTP/1.1 400 Bad Request
{"status":400,"error":"Bad Request","message":"Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."}/api/pagination/cursor?limit=3Keyset
Like a cursor, but readable: ask for items with an id greater than the last one you saw. Inserts and deletes on earlier pages can't shift the results, which is why feeds and very large tables use it.
$ curl "https://api.testingapis.com/api/pagination/keyset?after_id=10&limit=2"
{"data":[{"id":11,"name":"Smart Lamp",...},{"id":12,"name":"Compact Headset",...}],
"has_more":true,"next_after_id":12}Link header
The body is a plain array, and navigation is in the Link header (RFC 8288), with absolute URLs for next, prev, first and last. A page past the end returns an empty array, not a 404.
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=2"
HTTP/1.1 200 OK
link: <https://api.testingapis.com/api/pagination/link?page=3&per_page=2>; rel="next",
<https://api.testingapis.com/api/pagination/link?page=50&per_page=2>; rel="last",
<https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="first",
<https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="prev"
x-total-count: 100
[{"id":3,"name":"Deluxe Blender",...},{"id":4,"name":"Eco Bottle",...}]HAL
Items sit under _embedded.items, each with its own self link, and the page links are under _links. The content type is application/hal+json, which some clients don't parse as JSON unless told to.
$ curl "https://api.testingapis.com/api/pagination/hal?page=0&size=2"
{"_embedded":{"items":[
{"id":1,...,"_links":{"self":{"href":"/api/pagination/items/1"}}},
{"id":2,...,"_links":{"self":{"href":"/api/pagination/items/2"}}}]},
"_links":{"self":{"href":"/api/pagination/hal?page=0&size=2"},
"first":{"href":"/api/pagination/hal?page=0&size=2"},
"next":{"href":"/api/pagination/hal?page=1&size=2"},
"last":{"href":"/api/pagination/hal?page=49&size=2"}},
"page":{"size":2,"totalElements":100,"totalPages":50,"number":0}}Fetch every page
Most pagination bugs show up when a client walks the whole list: it stops one page early, loops forever on the last page, or counts pages from the wrong number. This script fetches all 100 items twice, once by cursor and once by Link header. It ends with id 100 both times, so nothing was skipped or repeated.
const BASE = "https://api.testingapis.com/api/pagination";
// Cursor: follow next_cursor until has_more is false.
async function allByCursor(limit = 25) {
const items = [];
let cursor;
do {
const url = new URL(`${BASE}/cursor`);
url.searchParams.set("limit", limit);
if (cursor) url.searchParams.set("cursor", cursor);
const page = await (await fetch(url)).json();
items.push(...page.data);
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
return items;
}
// Link header: follow rel="next" until there is none.
async function allByLinkHeader(perPage = 25) {
const items = [];
let url = `${BASE}/link?per_page=${perPage}`;
while (url) {
const res = await fetch(url);
items.push(...(await res.json()));
const next = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/);
url = next?.[1];
}
return items;
}
const byCursor = await allByCursor();
const byLink = await allByLinkHeader();
console.log("cursor:", byCursor.length, "items, last id", byCursor.at(-1).id);
console.log("link: ", byLink.length, "items, last id", byLink.at(-1).id);cursor: 100 items, last id 100
link: 100 items, last id 100Which style to choose
- Offset or page number when users jump to page N and the data changes slowly. A row inserted or deleted between two requests shifts every later page, so an item can be skipped or shown twice.
- Cursor or keysetfor feeds, infinite scroll and exports, where the data changes while someone pages through it. You can't jump straight to page 40.
- Link header or HAL when you want clients to follow the URLs the server gives them instead of building their own. The server can then change the paging scheme without breaking them.