REST-API-Design: mehr als nur die Grundlagen
Sobald die HTTP-Verben sitzen, beginnt die eigentliche Arbeit: Pagination, Filterung, Fehlerformate und Versionierung, die APIs angenehm machen.

Jedes REST-Tutorial behandelt dasselbe: Substantive für Ressourcen, HTTP-Verben für Aktionen, korrekte Statuscodes zurückgeben. Das ist die Grundvoraussetzung. Der schwierige Teil beginnt, wenn echte Clients Tausende von Datensätzen paginieren, nach komplexen Kriterien filtern und Fehler über verschiedene API-Versionen hinweg sauber behandeln müssen.
Ressourcenbenennung, die skaliert
URLs sollten Ressourcen beschreiben, keine Operationen. Die eigentliche Feinheit liegt aber darin, zu entscheiden, wo eine Ressource endet und eine andere beginnt.
// ❌ Verbs in URLs — REST anti-pattern
app.post("/api/createUser", handler);
app.get("/api/getUserOrders", handler);
app.put("/api/updateOrderStatus", handler);
// ✅ Resources and sub-resources
app.post("/api/users", createUser);
app.get("/api/users/:userId/orders", getUserOrders);
app.patch("/api/orders/:orderId", updateOrder);Verschachtele Sub-Ressourcen nur eine Ebene tief. Darüber hinaus solltest du die verschachtelte Ressource zu einer Top-Level-Ressource mit einem Filterparameter machen. /api/users/:userId/orders/:orderId/items/:itemId ist zu tief verschachtelt — verwende stattdessen /api/order-items?orderId=abc.
Pagination richtig gemacht
Offset-basierte Pagination ist einfach, bricht aber bei gleichzeitigen Schreibzugriffen. Cursor-basierte Pagination ist stabil und performant.
// ❌ Offset pagination — skips or duplicates records when data changes
// GET /api/orders?page=3&limit=20
// If an order is inserted while user is on page 2, page 3 shows a duplicate
// ✅ Cursor-based pagination — stable regardless of mutations
// GET /api/orders?cursor=eyJpZCI6MTAwfQ&limit=20
interface PaginatedResponse<T> {
data: T[];
pagination: {
nextCursor: string | null;
hasMore: boolean;
limit: number;
};
}
function paginateOrders(cursor: string | null, limit: number) {
const decodedCursor = cursor
? JSON.parse(Buffer.from(cursor, "base64url").toString())
: null;
const orders = db.orders.findMany({
where: decodedCursor ? { id: { gt: decodedCursor.id } } : undefined,
take: limit + 1,
orderBy: { id: "asc" },
});
const hasMore = orders.length > limit;
const data = hasMore ? orders.slice(0, -1) : orders;
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: data.at(-1)!.id })).toString("base64url")
: null;
return { data, pagination: { nextCursor, hasMore, limit } };
}Gib immer einen hasMore-Boolean zurück. Clients sollten nie raten müssen, ob es eine nächste Seite gibt.
Filtern und Sortieren
Unterstütze Filterung über Query-Parameter mit einem konsistenten Muster. Erfinde keine eigenen Abfragesprachen.
// GET /api/products?category=electronics&minPrice=100&maxPrice=500&sort=-createdAt
interface ProductFilters {
category?: string;
minPrice?: number;
maxPrice?: number;
search?: string;
sort?: string;
}
function parseSort(sort: string): { field: string; direction: "asc" | "desc" } {
if (sort.startsWith("-")) {
return { field: sort.slice(1), direction: "desc" };
}
return { field: sort, direction: "asc" };
}
function buildProductQuery(filters: ProductFilters) {
const where: Record<string, unknown> = {};
if (filters.category) where.category = filters.category;
if (filters.minPrice) where.price = { gte: filters.minPrice };
if (filters.maxPrice) where.price = { ...where.price, lte: filters.maxPrice };
if (filters.search)
where.name = { contains: filters.search, mode: "insensitive" };
const orderBy = filters.sort
? { [parseSort(filters.sort).field]: parseSort(filters.sort).direction }
: { createdAt: "desc" };
return { where, orderBy };
}Stelle Sortierfeldern für absteigende Sortierung ein - voran. Erlaube kommagetrennte Werte für Mehrfachsortierung: ?sort=-price,name.
Konsistente Fehlerantworten
Jeder Fehler deiner API sollte derselben Struktur folgen. Clients sollten Fehler mit einem einzigen Parser behandeln können, nicht mit Logik pro Endpoint.
// ❌ Inconsistent errors — different shapes per endpoint
// { error: "Not found" }
// { message: "Validation failed", errors: [...] }
// { status: "error", reason: "Unauthorized" }
// ✅ Single error format across the entire API
interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
function errorResponse(
status: number,
code: string,
message: string,
details?: Record<string, string[]>,
): Response {
return Response.json({ error: { code, message, details } }, { status });
}
// Usage
errorResponse(404, "ORDER_NOT_FOUND", "Order abc-123 does not exist");
errorResponse(422, "VALIDATION_ERROR", "Request body is invalid", {
email: ["must be a valid email address"],
quantity: ["must be greater than 0"],
});Verwende maschinenlesbare Fehlercodes (ORDER_NOT_FOUND), nicht nur Nachrichten. Codes erlauben es Clients, ihre Logik zu verzweigen; Nachrichten sind für die menschliche Fehlersuche.
Strategie zur API-Versionierung
Versioniere deine API von Anfang an. Die beiden praktikablen Ansätze:
| Strategie | URL-Beispiel | Vorteile | Nachteile |
|---|---|---|---|
| URL-Pfad | /api/v2/users | Offensichtlich, leicht zu routen | Dupliziert Routen |
| Header | Accept: application/vnd.api+json;version=2 | Saubere URLs | Versteckt, schwerer zu testen |
// URL-based versioning — explicit and simple
import { v1Router } from "./routes/v1";
import { v2Router } from "./routes/v2";
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
// Shared logic lives in services, not route handlers
// v1 and v2 route handlers call the same service layer
// but shape the response differentlyURL-basierte Versionierung ist für die meisten Teams die beste Wahl. Sie ist sofort sichtbar, von CDNs cachebar und mit curl testbar.
Rate-Limiting-Header
Kommuniziere Rate Limits über Standard-Header, damit Clients sich selbst drosseln können, bevor sie an die Grenze stoßen.
function rateLimitHeaders(
limit: number,
remaining: number,
resetAt: Date,
): Record<string, string> {
return {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(Math.floor(resetAt.getTime() / 1000)),
"Retry-After": String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)),
};
}Wenn ein Client das Rate Limit überschreitet, gib 429 Too Many Requests mit dem Retry-After-Header zurück. Wohlerzogene Clients nutzen das, um sich automatisch zurückzuhalten.
Die wichtigsten Punkte
- Verschachtele Sub-Ressourcen nur eine Ebene tief — hebe tiefere Verschachtelung mit Filtern auf Top-Level an
- Verwende cursor-basierte Pagination für stabile Ergebnisse bei gleichzeitigen Schreibzugriffen
- Standardisiere Fehlerantworten mit maschinenlesbaren Codes, nicht nur Nachrichten
- Versioniere von Anfang an — URL-basierte Versionierung ist für die meisten Teams am einfachsten
- Kommuniziere Rate Limits über Header, damit Clients sich selbst drosseln können
- Filtere und sortiere über Query-Parameter mit einem konsistenten, vorhersehbaren Muster


