Route Handlers: APIs in Next.js schreiben
1. Übersicht
Im App Router von Next.js brauchst du keinen separaten Express-Server, um eine API zu haben. Route Handlers ermöglichen es dir, HTTP-Endpoints direkt in den app-Ordner zu schreiben, indem du die Standard-Web-APIs Request und Response verwendest.
In diesem Artikel bauen wir eine kleine API zur Verwaltung von Bestellungen (Order). Du wirst lernen, wie du HTTP-Methoden definierst, Query-Strings und Bodies liest, Dynamic Segments verarbeitest, mit Headers/Cookies arbeitest, Caching kontrollierst und Tests mit Vitest schreibst. Die Beispiele verwenden Next.js 15+ mit TypeScript.
2. Der erste Route Handler
Ein Route Handler ist eine Datei namens route.ts (oder route.js) im app-Ordner. Der Pfad des Ordners bestimmt die URL. Jede HTTP-Methode ist eine Funktion, die mit einem großgeschriebenen Namen exportiert wird.
Zuerst brauchst du einen einfachen „Speicher” für Daten im Speicher:
// lib/orders.ts
export type Order = {
id: string;
customer: string;
total: number;
};
const orders: Order[] = [
{ id: "1", customer: "An", total: 250_000 },
{ id: "2", customer: "Bình", total: 1_200_000 },
];
export const orderStore = {
findAll: () => [...orders],
findById: (id: string) => orders.find((o) => o.id === id),
create: (data: Omit<Order, "id">): Order => {
const order = { id: String(orders.length + 1), ...data };
orders.push(order);
return order;
},
remove: (id: string) => {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
},
};
Erstelle jetzt einen GET /api/orders-Endpoint:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET() {
return Response.json(orderStore.findAll());
}
Erklärung:
- Da die Datei unter
app/api/orders/route.tsliegt, ist die URL/api/orders. Das Präfixapiist nur eine Konvention. Next.js erzwingt es nicht. export async function GET()verarbeitetGET-Anfragen. Next.js gibt automatisch405 Method Not Allowedfür Methoden zurück, die du nicht exportierst.Response.json(...)ist eine Standard-Web-API. Sie serialisiert Daten und setzt den HeaderContent-Type: application/json.
Führe npm run dev aus und versuche dann, sie aufzurufen:
curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]
Hinweis: In demselben Ordner kannst du nicht sowohl
route.tsals auchpage.tsxhaben. Zum Beispiel würdenapp/orders/page.tsxundapp/orders/route.tskollidieren. Deshalb gruppieren Menschen APIs normalerweise unterapp/api.
3. Query-Strings und Bodies lesen
3.1. Query-Strings
Der Handler-Parameter ist ein Standard-Request. Um den Query-String zu lesen, erstellst du eine URL aus request.url:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const minTotal = Number(searchParams.get("minTotal") ?? 0);
const result = orderStore.findAll().filter((o) => o.total >= minTotal);
return Response.json(result);
}
Wenn du /api/orders?minTotal=500000 aufrufst, wird nur Bình’s Bestellung zurückgegeben:
[{"id":"2","customer":"Bình","total":1200000}]
Wenn du möchtest, kannst du den Parameter als NextRequest deklarieren (von next/server) und request.nextUrl.searchParams verwenden. Hier verwende ich Standard-Request, da es in Tests leichter zu erstellen ist.
3.2. JSON-Body und Validierung
Vertraue nicht den Daten des Clients. Wir verwenden zod, um den Body zu validieren. Installiere die Bibliothek:
npm install zod
Füge einen POST-Handler zur gleichen Datei hinzu:
// app/api/orders/route.ts (beilage)
import { z } from "zod";
const CreateOrderSchema = z.object({
customer: z.string().min(1),
total: z.number().positive(),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Ungültiger JSON im Body" }, { status: 400 });
}
const parsed = CreateOrderSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: "Ungültige Daten", issues: parsed.error.issues },
{ status: 422 },
);
}
const order = orderStore.create(parsed.data);
return Response.json(order, {
status: 201,
headers: { Location: `/api/orders/${order.id}` },
});
}
Erklärung:
request.json()wirft einen Fehler, wenn der Body leer oder syntaktisch falsch ist. Deshalb wickeln wir es intry/catchein und geben400zurück.safeParsewirft keinen Fehler. Es gibt{ success, data }oder{ success, error }zurück. Falsch typisierte Daten erhalten422.- Der zweite Parameter von
Response.jsonermöglicht das Setzen vonstatusundheaders. Hier geben wir201 Createdmit dem HeaderLocationzurück.
curl -i -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json
{"id":"3","customer":"Chi","total":99000}
Bei HTML-Formularen verwendest du await request.formData(). Bei Rohdaten verwendest du await request.text(). Beachte, dass der Body nur einmal gelesen werden kann.
4. Dynamic Segments
Um GET /api/orders/:id zu haben, erstellst du einen Ordner mit einem Namen in Klammern:
// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const order = orderStore.findById(id);
if (!order) {
return Response.json({ error: `Bestellung ${id} nicht gefunden` }, { status: 404 });
}
return Response.json(order);
}
export async function DELETE(_request: Request, { params }: Context) {
const { id } = await params;
const removed = orderStore.remove(id);
return new Response(null, { status: removed ? 204 : 404 });
}
Erklärung:
- Der zweite Parameter enthält
params. Ab Next.js 15 istparamsein Promise, daher musst du es mitawaitaufrufen. DELETEgibt204 No Contentmit Bodynullzurück. Du solltestResponse.jsonnicht für diesen Fall verwenden, da204keinen Body haben darf.- Der Wert in
paramsist immer einstring. Wenn die ID eine Zahl ist, musst du sie selbst konvertieren und überprüfen.
Du kannst auch Catch-All [...slug] (akzeptiert string[]) oder optionales Catch-All [[...slug]] verwenden.
Hinweis: Alter Code, der
params.idohneawaitschreibt, erhält Warnungen oder Fehler auf Next.js 15+. Bei der Aktualisierung von Next.js 14 ist dies der häufigste Fehlerpunkt.
5. Headers, Cookies und Umleitung
Du kannst Headers direkt von request.headers lesen. Außerdem bietet Next.js die Funktionen cookies() und headers() von next/headers an. Beide sind asynchrone Funktionen.
// app/api/me/route.ts
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const headerList = await headers();
const session = cookieStore.get("session")?.value;
if (!session) {
return Response.json({ error: "Nicht angemeldet" }, { status: 401 });
}
return Response.json({
session,
userAgent: headerList.get("user-agent"),
});
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete("session");
return new Response(null, { status: 204 });
}
cookieStore.get(...)wird zum Lesen von Cookies verwendet.setunddeletefunktionieren nur in Route Handlers oder Server Actions, nicht in Server Components.- Mit
cookies()oderheaders()wird die Route dynamisch, d.h. sie wird bei jeder Anfrage gerendert.
Wenn du umleiten musst, verwendest du Response.redirect(new URL("/login", request.url), 307) oder die Funktion redirect() von next/navigation.
Bei CORS setzt du selbst den Header und exportierst zusätzlich OPTIONS für Preflight:
// app/api/public/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "https://shop.example.com",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
return Response.json({ status: "ok" }, { headers: corsHeaders });
}
6. Caching
Das ist der Teil, den viele verwirren. Ab Next.js 15 wird der GET-Handler nicht mehr standardmäßig zwischengespeichert. Jede Anfrage führt die Funktion erneut aus. Andere Methoden wie POST, PUT, DELETE werden nie zwischengespeichert.
Wenn sich die Daten selten ändern und du sie beim Build als statisch erstellen möchtest, aktiviere sie mit der Route-Segment-Konfiguration:
// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // neu höchstens jede Stunde
export async function GET() {
const categories = ["Elektronik", "Bücher", "Mode"];
return Response.json({ categories, generatedAt: new Date().toISOString() });
}
dynamic = "force-static"erzwingt, dass die Route bei Build gerendert wird. Das Ergebnis wird dann als statische Datei bereitgestellt.revalidate = 3600ermöglicht Next.js, die Response höchstens jede Stunde neu zu erstellen (ISR).- Während der Ausführung von
next devwird die Route bei jeder Anfrage ausgeführt. Um zu sehen, dassgeneratedAtstabil bleibt, brauchst dunext build && next start.
Hinweis: Wenn eine
force-static-Routecookies(),headers()oder Query-Strings liest, sind diese Werte beim Build leer. Verwende Static nicht für benutzerabhängige Endpoints.
7. Route Handlers mit Vitest testen
Ein Route Handler ist nur eine asynchrone Funktion, die Request akzeptiert und Response zurückgibt, daher kannst du sie direkt aufrufen, ohne den Server zu starten. Installiere Vitest:
npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: "node" },
});
Das Plugin vite-tsconfig-paths hilft, dass der Alias @/lib/... in Tests funktioniert. Jetzt schreiben wir Tests:
// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
function jsonRequest(body: unknown) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("/api/orders", () => {
it("Bestellungen nach minTotal filtern", async () => {
const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
const data = await res.json();
expect(res.status).toBe(200);
expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
});
it("erstelle eine gültige Bestellung und gebe 201 zurück", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
});
it("lehne negative total mit 422 ab", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
expect(res.status).toBe(422);
});
});
npx vitest run
✓ app/api/orders/route.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
Wenn der Handler cookies() oder headers() von next/headers verwendet, wird ein direkter Aufruf einen Fehler werfen, da der Anforderungskontext von Next.js fehlt. Du hast dann zwei Optionen: das Modul mit vi.mock("next/headers", ...) mocken oder die Werte von request.headers lesen, um den Code leichter testbar zu machen.
8. Häufige Fehler und wann Route Handlers nicht verwendet werden sollten
Einige häufige Fehler:
- Fetch Route Handler von einer Server Component aufrufen. Das Aufrufen von
fetch("/api/orders")in einer Server Component führt zu einem redundanten HTTP-Loop. Beim Build schlägt dieser Aufruf fehl, da der Server noch nicht läuft. Stattdessen rufeorderStore.findAll()oder die Datenschicht direkt auf. - Route Handler für interne Form-Mutations verwenden. Wenn nur die UI der App diese aufruft, sind Server Actions kompakter. Sie bieten End-to-End-Typ-Sicherheit und sind integriert mit
revalidatePath. - State in Modulvariablen speichern, wie
orderStoreoben, bei serverlosem Deployment. Jede Instanz hat seinen eigenen Speicher und kann jederzeit neu gestartet werden. Das Beispiel in diesem Artikel ist nur zu Demonstrationszwecken. In Wirklichkeit musst du eine Datenbank verwenden. - Vergessen, dass der Body nur einmal gelesen werden kann. Wenn du es zweimal lesen musst, verwende
request.clone(). - Fehler ohne Struktur zurückgeben. Ein nicht abgefangener Throw wird zu
500mit einer HTML-Fehlerseite. Gib immer JSON mit klaremstatuszurück.
Route Handlers sind geeignet, wenn du:
- Einen Endpoint für externe Clients brauchst, wie eine Mobile-App oder einen Partner.
- Ein Webhook brauchst, das Daten empfängt, z.B. von Stripe oder GitHub.
- Einen Endpoint brauchst, der Dateien, RSS oder Streaming-Daten zurückgibt.
- Einen Endpoint brauchst, den Client-seitiger Code mit
fetchaufruft.
9. Fazit
Route Handlers verwandeln jede route.ts-Datei in einen HTTP-Endpoint, der Standard-Web-Request/Response verwendet. In diesem Artikel hast du:
- HTTP-Methoden
GET,POST,DELETEdefiniert. - Query-Strings und Bodies gelesen, den Body mit
zodvalidiert. - Dynamic Segments mit
paramsals Promise verarbeitet. - Mit Headers, Cookies und CORS gearbeitet.
- Static Caching bei Bedarf aktiviert.
- Handler direkt mit Vitest getestet, ohne einen Server zu starten.
Das wichtigste Prinzip: Verwende Route Handlers für öffentliche APIs oder Webhooks. Bei internen Daten frage direkt in einer Server Component ab. Bei Mutationen aus Formularen deiner App verwende Server Actions.