Beispiele für API-Paginierung: Offset, Seite, Cursor, Keyset, Link-Header und HAL
Sechs Paginierungsarten über dieselben Daten, jede mit einer echten Antwort und einer Schleife, die alle Seiten abruft.
Beispiele zuletzt am gegen die API ausgeführt
APIs teilen lange Listen auf sechs gängige Arten in Seiten. Offset und Limit überspringen eine Anzahl Einträge. Seitennummer und -größe zählen Seiten, wie Spring Data es tut. Ein Cursor ist ein undurchsichtiges Token, wie Stripe es nutzt, und Keyset-Paginierung fragt nach den Einträgen nach der letzten ID. Manche APIs legen die nächste URL in einen Link-Header, wie GitHub, und HAL legt sie in Hypermedia-Links. https://api.testingapis.com/api/paginationanmelden zum Anzeigen liefert dieselben 100 Einträge in allen sechs Arten, damit du die Antworten nebeneinander vergleichen und einen Client gegen jede testen kannst. Kostenlos und ohne Schlüssel. Unten jede Art mit einer echten Antwort.
Die sechs Arten im Überblick
| Art | Request | Die nächste Seite kommt aus | Genutzt von |
|---|---|---|---|
| Offset / Limit | /offset?offset=20&limit=10 | offset + limit | Viele SQL-basierte APIs |
| Seitennummer (ab 0) | /page?page=2&size=10 | number + 1, bis last true ist | Spring Data |
| Cursor | /cursor?limit=10&cursor=… | next_cursor, bis has_more false ist | Stripe |
| Keyset | /keyset?after_id=20&limit=10 | next_after_id | Große Tabellen, Feeds |
| Link-Header (ab 1) | /link?page=3&per_page=10 | Die URL mit rel="next" im Link-Header | GitHub |
| HAL | /hal?page=2&size=10 | _links.next.href | Spring HATEOAS |
Alle Pfade liegen unter /api/paginationanmelden zum Anzeigen. Seitengrößen reichen von 1 bis 100, Standard ist 10.
Offset und Limit
Die einfachste Art: offset Einträge überspringen und die nächsten limit zurückgeben. Die Gesamtzahl steht im Body und 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}}Seitennummer und -größe
Der Envelope von Spring Data. Seiten beginnen bei 0, und genau darauf solltest du testen: Ein Client, der für Seiten ab 1 geschrieben ist, überspringt stillschweigend die erste Seite.
$ 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
Die Antwort enthält einen next_cursor. Schick ihn unverändert zurück, um die nächste Seite zu bekommen, und hör auf, wenn has_more false ist. Der Cursor ist zufällig base64-kodiertes JSON, ein Client muss ihn aber als undurchsichtig behandeln. Ein veränderter Cursor bekommt ein 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
Wie ein Cursor, aber lesbar: Frag nach Einträgen mit einer ID größer als die letzte, die du gesehen hast. Einfügungen und Löschungen auf früheren Seiten können die Ergebnisse nicht verschieben, deshalb nutzen Feeds und sehr große Tabellen diese Art.
$ 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
Der Body ist ein einfaches Array, und die Navigation steht im Link-Header (RFC 8288), mit absoluten URLs für next, prev, first und last. Eine Seite hinter dem Ende liefert ein leeres Array, kein 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
Die Einträge liegen unter _embedded.items, jeder mit eigenem self-Link, und die Seitenlinks unter _links. Der Content-Type ist application/hal+json, den manche Clients nur als JSON parsen, wenn man es ihnen sagt.
$ 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}}Jede Seite abrufen
Die meisten Paginierungsfehler zeigen sich, wenn ein Client die ganze Liste durchläuft: Er hört eine Seite zu früh auf, hängt auf der letzten Seite in einer Schleife oder zählt Seiten ab der falschen Zahl. Dieses Skript holt alle 100 Einträge zweimal, einmal per Cursor und einmal per Link-Header. Beide Male endet es bei ID 100, nichts wurde also übersprungen oder doppelt geholt.
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 100Welche Art du wählen solltest
- Offset oder Seitennummer, wenn Nutzer zu Seite N springen und sich die Daten langsam ändern. Eine zwischen zwei Requests eingefügte oder gelöschte Zeile verschiebt jede spätere Seite, ein Eintrag kann also fehlen oder doppelt erscheinen.
- Cursor oder Keyset für Feeds, Endlos-Scrollen und Exporte, wo sich die Daten ändern, während jemand blättert. Direkt zu Seite 40 springen geht nicht.
- Link-Header oder HAL, wenn Clients den URLs folgen sollen, die der Server ihnen gibt, statt eigene zu bauen. Dann kann der Server das Paginierungsschema ändern, ohne sie zu brechen.