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.ts liegt, ist die URL /api/orders. Das Präfix api ist nur eine Konvention. Next.js erzwingt es nicht.
  • export async function GET() verarbeitet GET-Anfragen. Next.js gibt automatisch 405 Method Not Allowed für Methoden zurück, die du nicht exportierst.
  • Response.json(...) ist eine Standard-Web-API. Sie serialisiert Daten und setzt den Header Content-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.ts als auch page.tsx haben. Zum Beispiel würden app/orders/page.tsx und app/orders/route.ts kollidieren. Deshalb gruppieren Menschen APIs normalerweise unter app/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 in try/catch ein und geben 400 zurück.
  • safeParse wirft keinen Fehler. Es gibt { success, data } oder { success, error } zurück. Falsch typisierte Daten erhalten 422.
  • Der zweite Parameter von Response.json ermöglicht das Setzen von status und headers. Hier geben wir 201 Created mit dem Header Location zurü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 ist params ein Promise, daher musst du es mit await aufrufen.
  • DELETE gibt 204 No Content mit Body null zurück. Du solltest Response.json nicht für diesen Fall verwenden, da 204 keinen Body haben darf.
  • Der Wert in params ist immer ein string. 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.id ohne await schreibt, 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. set und delete funktionieren nur in Route Handlers oder Server Actions, nicht in Server Components.
  • Mit cookies() oder headers() 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 = 3600 ermöglicht Next.js, die Response höchstens jede Stunde neu zu erstellen (ISR).
  • Während der Ausführung von next dev wird die Route bei jeder Anfrage ausgeführt. Um zu sehen, dass generatedAt stabil bleibt, brauchst du next build && next start.

Hinweis: Wenn eine force-static-Route cookies(), 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 rufe orderStore.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 orderStore oben, 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 500 mit einer HTML-Fehlerseite. Gib immer JSON mit klarem status zurü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 fetch aufruft.

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, DELETE definiert.
  • Query-Strings und Bodies gelesen, den Body mit zod validiert.
  • Dynamic Segments mit params als 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.

nextjsroute-handlersapitypescriptapp-router