Ohne Pagination liefert eine API tausende Datensätze auf einmal – bis der Server kippt oder der Client das Timeout erreicht. Ohne durchdachtes Filtering öffnest du die Tür für Query-Injection und Performance-Probleme. Und ohne klare Sortierregeln bekommt jeder Client andere Ergebnisse.
Dieser Artikel hilft dir, robuste und sichere Query-Konventionen zu etablieren. Am Ende hast du ein dokumentiertes Schema für Pagination, Filtering und Sorting, das über alle List-Endpoints hinweg konsistent funktioniert.
Zielbild
Nach diesem Artikel kannst du:
- Zwischen Cursor- und Offset-Pagination begründet wählen
- Sichere Filter implementieren, die keine Injection erlauben
- Sorting-Optionen definieren und Default-Verhalten festlegen
- Limits setzen, die Server und Clients schützen
- Eine Query-Konvention dokumentieren, die für alle Endpoints gilt
Kernfragen
Bevor du weiterliest, versuche diese Fragen für dein Projekt zu beantworten:
- Cursor oder Offset? Welche Pagination passt zu euren Daten?
- Welche Filter brauchen Clients? Alle Felder oder nur bestimmte?
- Wie verhindert ihr Filter-Injection? Allowlist oder Denylist?
- Was ist das Default-Sorting? Neueste zuerst? Alphabetisch?
- Was ist das Maximum Limit? 100? 1000? Unbegrenzt?
Pagination: Cursor vs. Offset
Pagination verhindert, dass APIs riesige Datenmengen auf einmal liefern. Zwei Hauptansätze haben sich etabliert.
Offset-Pagination
Client gibt an, wie viele Einträge übersprungen werden sollen:
GET /orders?offset=20&limit=10
Response:
{
"data": [
{
"id": "order_001",
"status": "pending"
}
],
"pagination": {
"offset": 20,
"limit": 10,
"total": 1234
}
}
Vorteile:
- Einfach zu verstehen und implementieren
- Springe zu Seite X möglich
- Total Count verfügbar
Nachteile:
- Instabil bei Änderungen (Eintrag gelöscht → Einträge rutschen)
- Performance-Probleme bei großem Offset (
OFFSET 10000ist teuer) - Duplikate oder fehlende Einträge bei konkurrierenden Änderungen
Cursor-Pagination
Client nutzt einen opaken Cursor, der auf einen bestimmten Punkt zeigt:
GET /orders?cursor=eyJpZCI6MTIzfQ&limit=10
Response:
{
"data": [
{
"id": "order_001",
"status": "pending"
}
],
"pagination": {
"next_cursor": "eyJpZCI6MTMzfQ",
"has_more": true
}
}
Vorteile:
- Stabil bei Änderungen (Cursor zeigt auf festen Punkt)
- Konstante Performance (kein OFFSET in der DB)
- Minimiert Duplikate oder fehlende Einträge (bei stabilem Sort-Key)
Nachteile:
- Kein Springe zu Seite X
- Kein Total Count (oder nur mit zusätzlichem Query)
- Cursor ist opak, Debugging schwieriger
Wann was verwenden?
| Szenario | Empfehlung |
|---|---|
| Kleine, statische Datenmengen | Offset OK |
| Große, dynamische Datenmengen | Cursor |
| UI mit Seitenzahlen (Seite 1, 2, 3...) | Offset |
| Infinite Scroll, Feed | Cursor |
| Daten ändern sich häufig | Cursor |
| Export aller Daten | Cursor |
| Öffentliche API | Cursor (stabiler) |
Empfehlung: Cursor-Pagination als Default. Offset nur wenn explizit Seitenzahlen nötig sind.
Cursor-Implementierung
Ein Cursor ist typischerweise ein Base64url-kodierter Wert, der danach signiert oder verschlüsselt wird:
// Cursor erstellen
const cursor = base64url(JSON.stringify({id: lastItem.id, created_at: lastItem.created_at}));
// Cursor dekodieren
const {id, created_at} = JSON.parse(base64urlDecode(cursor));
Wichtig: Cursor sollten:
- Opak sein (Clients sollen sie nicht parsen)
- Signiert oder verschlüsselt sein (gegen Manipulation)
- Zeitlich begrenzt gültig sein (z.B. 24h)
Pagination Response Format
Konsistentes Format für alle List-Endpoints:
{
"data": [
{
"id": "order_001",
"status": "pending"
},
{
"id": "order_002",
"status": "shipped"
}
],
"pagination": {
"cursor": "abc123",
"next_cursor": "def456",
"has_more": true,
"limit": 20
}
}
Für Offset-Pagination:
{
"data": [
{
"id": "order_001",
"status": "pending"
}
],
"pagination": {
"offset": 0,
"limit": 20,
"total": 1234,
"has_more": true
}
}
Filtering: Sicher und performant
Filter erlauben Clients, nur relevante Daten abzurufen. Unkontrollierte Filter sind aber ein Sicherheits- und Performance-Risiko.
Einfache Filter
Für einfache Gleichheits-Filter nutze Query-Parameter:
GET /orders?status=pending&customer_id=user_abc123
Regel: Nur explizit erlaubte Felder sind filterbar (Allowlist). Keine generische Filter-auf-beliebiges-Feld-Funktion.
Filter-Allowlist
Definiere, welche Felder filterbar sind. Das hält den Query-Raum klein, erleichtert die Validierung und macht Performance planbar.
Beispiel: Filterbare Felder für /orders:
| Feld | Operatoren | Beispiel |
|---|---|---|
| status | eq | ?status=pending |
| customer_id | eq | ?customer_id=user_abc |
| created_at | gte, lte | ?created_at_gte=2026-01-01 |
| total_amount | gte, lte | ?total_amount_gte=1000 |
Komplexe Filter
Für komplexere Queries gibt es verschiedene Ansätze:
Suffix-Notation:
GET /orders?created_at_gte=2026-01-01&created_at_lte=2026-01-31
GET /orders?total_amount_gt=1000
Übliche Suffixe:
| Suffix | Bedeutung | SQL |
|---|---|---|
| (keiner) | Gleich | = value |
_ne |
Ungleich | != value |
_gt |
Größer als | > value |
_gte |
Größer oder gleich | >= value |
_lt |
Kleiner als | < value |
_lte |
Kleiner oder gleich | <= value |
_in |
In Liste | IN (a, b, c) |
_contains |
Enthält | LIKE %value% |
Bracket-Notation (alternative):
GET /orders?filter[status]=pending&filter[created_at][gte]=2026-01-01
Empfehlung: Suffix-Notation ist einfacher zu parsen und URL-freundlicher.
Filter-Injection verhindern
Niemals Filter direkt in SQL/Queries übernehmen:
// GEFÄHRLICH – SQL Injection möglich
const query = `SELECT * FROM orders WHERE ${filterField} = '${filterValue}'`;
// SICHER – Allowlist + Prepared Statements
const allowedFilters = ['status', 'customer_id', 'created_at'];
if (!allowedFilters.includes(filterField)) {
throw new BadRequestError('Invalid filter field');
}
const query = `SELECT * FROM orders WHERE ${filterField} = $1`;
Regeln:
- Allowlist für erlaubte Filter-Felder
- Allowlist für erlaubte Operatoren pro Feld
- Prepared Statements / Parameter Binding
- Input-Validierung (Typ, Format, Länge)
Volltext-Suche
Für Freitext-Suche nutze einen separaten Parameter:
GET /products?q=smartphone
Wichtig:
qdurchsucht vordefinierte Felder (z.B. name, description)- Dokumentiere, welche Felder durchsucht werden
- Limitiere Mindestlänge (z.B. min. 3 Zeichen)
- Rate-Limiting für Suche, weil sie teurer ist als einfache Filter
Sorting
Sorting definiert die Reihenfolge der Ergebnisse. Ohne explizites Sorting ist die Reihenfolge undefiniert – schlecht für Pagination und UX.
Einfaches Sorting
GET /orders?sort=created_at
GET /orders?sort=-created_at
Absteigend mit Minus-Präfix.
Oder expliziter:
GET /orders?sort=created_at&order=desc
Multi-Field Sorting
Mehrere Felder, kommasepariert:
GET /orders?sort=-created_at,customer_id
→ Erst nach created_at absteigend, dann nach customer_id aufsteigend.
Sortierbare Felder begrenzen
Nicht jedes Feld sollte sortierbar sein. Sorting auf nicht-indexierten Feldern kann die Datenbank überlasten. Dokumentiere die erlaubten Felder pro Endpoint.
Beispiel: Sortierbare Felder für /orders:
| Feld | Indexiert | Default |
|---|---|---|
| created_at | Ja | Ja (desc) |
| updated_at | Ja | Nein |
| total_amount | Ja | Nein |
| status | Nein | Nein |
Regel: Nur indexierte Felder sind sortierbar. Dokumentiere das Default-Sorting.
Default-Sorting
Jeder List-Endpoint braucht ein Default-Sorting. Sonst ist die Reihenfolge zufällig (oder schlimmer: abhängig von DB-Interna).
Empfehlung: -created_at (neueste zuerst) als sinnvoller Default für die
meisten Ressourcen.
Limits und Defaults
Limits schützen Server und Clients vor Überlastung.
Limit-Parameter
GET /orders?limit=50
Sinnvolle Werte
| Parameter | Default | Maximum | Anmerkung |
|---|---|---|---|
| limit | 20 | 100 | Mehr nur mit expliziter Begründung |
| offset | 0 | 10000 | Verhindert Deep Pagination |
| q (Suche) | - | - | Min. 3 Zeichen |
Deep Pagination verhindern
Bei Offset-Pagination: Limitiere den maximalen Offset.
GET /orders?offset=100000&limit=20
→ Response: 400 Bad Request – Offset zu groß, nutze Cursor-Pagination.
Warum? OFFSET 100000 bedeutet: Datenbank muss 100.000 Rows lesen und
verwerfen. Das ist teuer und wird mit wachsenden Daten schlimmer.
Total Count – mit Vorsicht
total ist praktisch für UI (1234 Ergebnisse), aber teuer bei großen
Datenmengen.
Optionen:
- Immer liefern: Einfach, aber kann langsam werden
- Nur auf Anfrage:
?include_total=true - Approximate:
total_approximate: 1200(schneller, aber ungenau) - Nie: Nur
has_moreliefern
Empfehlung: has_more reicht für die meisten UIs. Total nur wenn explizit
nötig.
Kombinierte Queries
Alle Parameter zusammen:
GET /orders?status=pending&created_at_gte=2026-01-01&sort=-created_at&limit=50&cursor=abc123
Reihenfolge der Verarbeitung
- Filter anwenden (WHERE)
- Sorting anwenden (ORDER BY)
- Pagination anwenden (LIMIT/OFFSET oder Cursor)
Konsistenz mit Cursor
Bei Cursor-Pagination muss das Sorting im Cursor kodiert sein. Sonst funktioniert nächste Seite nicht.
Regel: Sorting darf sich zwischen Seiten nicht ändern. Der Cursor enthält die Sortierung.
Regeln & Anti-Patterns
Do
- Cursor-Pagination als Default für große/dynamische Daten
- Filter-Allowlist mit erlaubten Feldern und Operatoren
- Prepared Statements für alle Filter-Queries
- Default-Sorting für jeden List-Endpoint
- Sinnvolle Limits (Default 20, Max 100)
- Deep Pagination verhindern
Don't
- Unbegrenzte Ergebnismengen (
?limit=999999) - Beliebige Felder filterbar machen
- Filter direkt in Queries interpolieren
- Sorting auf nicht-indexierten Feldern erlauben
- Undefinierte Reihenfolge (kein Default-Sorting)
- Total Count ohne Performance-Überlegung
Artefakt: Query-Konvention
# Query-Konvention
## Pagination
- **Methode:** Cursor-based (Offset nur für /reports)
- **Parameter:**
- `limit`: Anzahl Ergebnisse (Default: 20, Max: 100)
- `cursor`: Opaker Cursor für nächste Seite
- **Response:**
```json
{
"data": [...],
"pagination": {
"next_cursor": "abc123",
"has_more": true
}
}
```
## Filtering
- **Syntax:** Query-Parameter mit Suffix für Operatoren
- **Operatoren:** (none)=eq, `_ne`, `_gt`, `_gte`, `_lt`, `_lte`, `_in`
- **Beispiel:** `?status=pending&created_at_gte=2026-01-01`
### Filterbare Felder pro Endpoint
| Endpoint | Felder |
|-----------|-----------------------------------------------|
| /orders | status, customer_id, created_at, total_amount |
| /users | email, role, is_active, created_at |
| /products | category, price, is_available |
## Volltext-Suche (Kurzüberblick)
- **Parameter:** `q`
- **Min. Länge:** 3 Zeichen
- **Durchsuchte Felder:** Dokumentiert pro Endpoint
## Sorting (Kurzüberblick)
- **Parameter:** `sort`
- **Syntax:** Feldname, `-` Präfix für absteigend
- **Multi-Sort:** Kommasepariert (`sort=-created_at,name`)
- **Default:** `-created_at` (neueste zuerst)
### Sortierbare Felder pro Endpoint
| Endpoint | Felder | Default |
|-----------|--------------------------------------|-------------|
| /orders | created_at, updated_at, total_amount | -created_at |
| /users | created_at, name, email | -created_at |
| /products | created_at, name, price | -created_at |
## Limits
| Parameter | Default | Maximum |
|---------------------|---------|-----------|
| limit | 20 | 100 |
| offset (wo erlaubt) | 0 | 10000 |
| q (min. Länge) | - | 3 Zeichen |
Checkliste
Bevor du zum nächsten Artikel gehst, prüfe:
- [ ] Pagination-Methode ist gewählt (Cursor empfohlen)
- [ ] Response-Format für Pagination ist definiert
- [ ] Filter-Allowlist existiert für jeden List-Endpoint
- [ ] Filter-Operatoren sind dokumentiert
- [ ] Injection-Schutz ist implementiert (Prepared Statements)
- [ ] Sortierbare Felder sind definiert und indexiert
- [ ] Default-Sorting ist festgelegt
- [ ] Limits sind definiert (Default und Maximum)
- [ ] Deep Pagination ist verhindert oder limitiert
- [ ] Query-Konvention ist dokumentiert
Wie es weitergeht
Im nächsten Teil geht es um Fehlerbehandlung und Validierung: Wie sieht ein gutes Fehlerformat aus? Welche Infos gehören in Fehlermeldungen – und welche nicht?
Alle Teile der Serie: Serie: API-Design