TestingAPIs is still being built. If something breaks, email support@testingapis.com.

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

StyleRequestNext page comes fromUsed by
Offset / limit/offset?offset=20&limit=10offset + limitMany SQL-backed APIs
Page number (0-based)/page?page=2&size=10number + 1, until last is trueSpring Data
Cursor/cursor?limit=10&cursor=…next_cursor, until has_more is falseStripe
Keyset/keyset?after_id=20&limit=10next_after_idLarge tables, feeds
Link header (1-based)/link?page=3&per_page=10The rel="next" URL in the Link headerGitHub
HAL/hal?page=2&size=10_links.next.hrefSpring 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.

Terminal
$ 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}}
This example uses endpoints for signed-in members.Sign inCreate a free account

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.

Terminal
$ 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}
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

Terminal
$ 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."}
This example uses endpoints for signed-in members.Sign inCreate a free account
GET/api/pagination/cursor?limit=3
This request uses an endpoint for signed-in members.Sign inCreate a free account

Keyset

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.

Terminal
$ 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}
This example uses endpoints for signed-in members.Sign inCreate a free account

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.

Terminal
$ 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",...}]
This example uses endpoints for signed-in members.Sign inCreate a free account

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.

Terminal
$ 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}}
This example uses endpoints for signed-in members.Sign inCreate a free account

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.

paginate.mjs
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);
This example uses endpoints for signed-in members.Sign inCreate a free account
Output
cursor: 100 items, last id 100
link:   100 items, last id 100

Which 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.