Zum Inhalt springen

9.1 Next.js: Route Handlers, die eigene API

Zu Zen-Modus wechseln

Nach dieser Einheit kannst du:

  • einen API-Entwurf als Route Handlers umsetzen, für die Sammlung und für ein einzelnes Element.
  • den Body einer Anfrage lesen, prüfen und kaputte Eingaben mit 400 im vereinbarten Fehlerformat beantworten.
  • Header einer Anfrage am Server lesen und den Fehlwert null behandeln.
  • die eigene API mit curl von außen testen, samt aller dokumentierten Fehlerfälle.

Am Buffet hängt die Preisliste schon seit Wochen, nur steht hinter dem Schalter noch niemand. Genau so weit ist der Vertrag aus Kapitel 6: Die Render-Job-API ist auf Papier entworfen, aber keine Anfrage bekommt eine Antwort. In dieser Einheit besetzen wir den Schalter: Jede Anfrage wird angenommen, geprüft und so beantwortet, wie es auf der Preisliste steht.

Next.js macht dafür aus demselben Projekt, das die Seiten ausliefert, auch den API-Server: Route Handlers setzen die Endpunkte um, und der Browser spricht sie genauso über HTTP an wie ein fremdes Programm. Du hast Schnittstellen benutzt (Kapitel 5), entworfen (Kapitel 6) und baust sie jetzt selbst. Die Daten bleiben vorerst eine Liste im Arbeitsspeicher; sauber gespeichert wird in den Kapiteln 10 und 11.

  • Route Handler: eine Funktion in einer Datei route.ts, die Anfragen mit einer bestimmten HTTP-Methode an eine Adresse beantwortet.
  • Request und Response: die Objekte für Anfrage und Antwort; dieselben Klassen, die fetch im Browser verwendet.
  • In-Memory-Speicher: Daten, die nur in einer Variable des laufenden Programms liegen und beim Neustart verschwinden.

API-Endpunkte folgen derselben Ordnerlogik wie Seiten aus Einheit 8.1. Nur heißt die Datei route.ts statt page.tsx, und statt einer Komponente exportiert sie pro HTTP-Methode eine Funktion mit dem Namen der Methode:

app/
└── api/
└── jobs/
├── route.ts -> GET /api/jobs, POST /api/jobs
└── [id]/
└── route.ts -> GET /api/jobs/42, DELETE /api/jobs/42
flowchart LR
    accTitle: Ordner unter app/api werden zu Endpunkten
    api["app/api/jobs/"] --> r1["route.ts"]
    r1 --> g1["export GET<br/>GET /api/jobs"]
    r1 --> p1["export POST<br/>POST /api/jobs"]
    api --> id["[id]/"]
    id --> r2["route.ts"]
    r2 --> g2["export GET<br/>GET /api/jobs/42"]
    r2 --> d2["export DELETE<br/>DELETE /api/jobs/42"]

Abb. 9.1: Der Ordner bestimmt die Adresse, der Name der exportierten Funktion die Methode (eigene Darstellung).

Der vorläufige Speicher ist eine Liste in einem eigenen Modul. Beide route.ts-Dateien importieren es, also arbeiten Sammlung und Element mit denselben Daten:

// lib/jobs.ts: the in-memory store, used by all route handlers
import type { Job, NewJob } from "./types";
let jobs: Job[] = [];
let nextId = 1;
export function listJobs(): Job[] {
return jobs;
}
export function addJob(input: NewJob): Job {
const job: Job = {
id: nextId++,
filename: input.filename,
preset: input.preset,
state: "queued",
progress: 0,
createdAt: new Date().toISOString(),
};
jobs.push(job);
return job;
}
// findJob(id) returns one job or undefined, deleteJob(id) removes one

Job und NewJob sind die Interfaces aus Einheit 6.1, in lib/types.ts. Die Felder, die der Server vergibt (id, state, progress, createdAt), setzt addJob; vom Client kommen nur filename und preset. Darauf bauen die Endpunkte der Sammlung auf:

app/api/jobs/route.ts
import { connection, NextResponse } from "next/server";
import { addJob, listJobs } from "@/lib/jobs";
import type { NewJob } from "@/lib/types";
export async function GET() {
await connection(); // wait for a real request, explained in unit 9.2
return NextResponse.json(listJobs());
}
export async function POST(request: Request) {
let body: Partial<NewJob> | null;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: { code: "INVALID_JSON", message: "body is not valid JSON" } },
{ status: 400 });
}
const filename = typeof body?.filename === "string" ? body.filename.trim() : "";
if (filename === "") {
return NextResponse.json(
{ error: { code: "VALIDATION_FAILED", message: "filename must not be empty" } },
{ status: 400 });
}
const job = addJob({ filename, preset: body?.preset ?? "web-1080p" });
return NextResponse.json(job, { status: 201 });
}

Beispielprojekt: 09_nextjs_api_datenfluss_examples/ex01_job_api

Die Bausteine:

  • Der Handler erhält das Request-Objekt und liest den Body mit await request.json().
  • Er antwortet mit NextResponse.json(daten, { status }). Ohne Angabe ist der Status 200.
  • Alles aus Kapitel 6 wird wörtlich umgesetzt: 201 beim Anlegen, das vereinbarte Fehlerformat bei 400, die Interfaces aus der gemeinsamen Typdatei.

Drei Zeilen verdienen einen zweiten Blick:

  • await connection() kennst du aus Einheit 8.2: Die Liste wird bei jeder Anfrage frisch gelesen. Was ohne die Zeile passiert, zeigt Einheit 9.2; bis dahin beginnt jeder GET, der veränderliche Daten liefert, mit ihr.
  • try/catch um request.json(): Ein kaputter Body ist ein Fehler des Clients (400), kein Absturz des Servers. Ohne den Block antwortet Next.js mit 500 und leerem Body.
  • typeof: Der Body kann gültiges JSON sein und trotzdem null, eine Zahl oder ein Objekt ohne filename enthalten. TypeScript prüft das nicht, denn Typen gelten nur für deinen eigenen Code, nicht für Daten von außen.
sequenceDiagram
    accTitle: Weg einer POST-Anfrage durch den Route Handler
    participant c as Client
    participant h as POST in route.ts
    participant s as lib/jobs.ts
    c->>h: POST /api/jobs mit Body
    alt Body ist kein JSON
        h-->>c: 400 INVALID_JSON
    else filename fehlt oder ist leer
        h-->>c: 400 VALIDATION_FAILED
    else alles in Ordnung
        h->>s: addJob
        s-->>h: Job mit id
        h-->>c: 201 mit dem Job
    end

Abb. 9.2: Der Handler prüft zuerst und speichert erst dann; jeder Ausgang hat seinen Statuscode (eigene Darstellung).

Die Endpunkte für ein einzelnes Element lesen die ID aus dem Pfad, genau wie die dynamischen Seiten aus Einheit 8.1:

app/api/jobs/[id]/route.ts
import { NextResponse } from "next/server";
import { deleteJob, findJob } from "@/lib/jobs";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const job = findJob(Number(id));
if (!job) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: `job ${id} does not exist` } },
{ status: 404 });
}
return NextResponse.json(job);
}

Beispielprojekt: 09_nextjs_api_datenfluss_examples/ex01_job_api

id ist ein String, darum Number(id) vor dem Vergleich. PATCH und DELETE folgen demselben Muster: finden, prüfen, ändern oder entfernen, passender Status. DELETE antwortet mit 204 und leerem Body, also mit new Response(null, { status: 204 }).

Der Speicher in lib/jobs.ts ist flüchtig: Ein Neustart des Servers leert ihn. Für die Übungen dieses Kapitels reicht das, und es ist zugleich der Grund, warum Kapitel 11 eine Datenbank einführt.

Wo begegnet dir das? Öffne in einer beliebigen Web-App die DevTools und sieh dir im Netzwerk-Tab die Anfragen vom Typ fetch an. Hinter jeder Adresse wie /api/cart oder /api/search steht am Server eine Funktion wie diese: Anfrage lesen, prüfen, antworten.

Auflösung

(a) app/api/jobs/[id]/route.ts, Funktion PATCH. (b) app/api/presets/route.ts, Funktion GET. (c) Next.js antwortet von selbst mit 405 Method Not Allowed: Die Adresse gibt es, die Methode nicht. Dafür musst du nichts schreiben.

Der Body ist nicht das Einzige, was eine Anfrage mitbringt. Aus Einheit 5.1 kennst du die Header: Schlüssel-Wert-Paare mit Angaben über die Anfrage. Dasselbe Request-Objekt, dessen Body du mit await request.json() liest, gibt sie über request.headers.get("name") heraus:

export async function GET(request: Request) {
const accept = request.headers.get("accept"); // e.g. "application/json"
const auth = request.headers.get("authorization"); // null if not set
// ...
}

Zwei Dinge musst du wissen:

  • Header-Namen unterscheiden nicht zwischen Groß- und Kleinschreibung: "Accept" und "accept" liefern dasselbe.
  • get liefert null, wenn der Header fehlt. Diesen Fall planst du immer ein.

Nützlich sind Header überall, wo eine Anfrage etwas über sich selbst mitteilt, das nicht in den Body gehört. Das typische Beispiel: Wer fragt an? Eine echte Anmeldung kommt erst in Kapitel 12. Bis dahin lässt sich die Rolle über einen selbst gewählten Header nachstellen, etwa x-role. In der Render-Farm darf nur die Rolle operator Jobs löschen:

app/api/jobs/[id]/route.ts
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const role = request.headers.get("x-role") ?? "anonymous";
if (role !== "operator") {
return NextResponse.json(
{ error: { code: "FORBIDDEN", message: "only the operator may do this" } },
{ status: 403 });
}
// ... delete the job: 204, or 404 if it does not exist ...
}

Beispielprojekt: 09_nextjs_api_datenfluss_examples/ex01_job_api

flowchart LR
    accTitle: Rollenprüfung über den Header x-role
    req["DELETE /api/jobs/1"] --> read["x-role lesen,<br/>fehlt er: anonymous"]
    read --> check{"Rolle ist<br/>operator?"}
    check -- nein --> f403["403 FORBIDDEN"]
    check -- ja --> work["löschen:<br/>204 oder 404"]

Abb. 9.3: Die Rolle wird vor der eigentlichen Arbeit geprüft (eigene Darstellung).

Das vollständige DELETE steht im Beispielprojekt. Ein Client setzt den Header so, wie er Content-Type setzt: im fetch-Aufruf unter headers oder mit curl -H "x-role: operator". Das ist keine Sicherheit, denn jeder kann den Header frei setzen. Es ist eine Attrappe, mit der du die Rollenlogik schon jetzt bauen kannst; die fälschungssichere Fassung folgt in Kapitel 12.

Auflösung

(a) 403: get liefert null, der Fehlwert "anonymous" ist nicht "operator". (b) 204: Beim Namen des Headers spielt Groß- und Kleinschreibung keine Rolle. (c) 403: Beim Wert spielt sie sehr wohl eine Rolle, "Operator" ist ein anderer String als "operator".

Die eigene API behandelst du wie eine fremde: zuerst von außen ansprechen, erst dann Code dagegen schreiben. Das Werkzeug kennst du aus Einheit 5.1:

Terminal-Fenster
curl -i http://localhost:3000/api/jobs
curl -i -X POST http://localhost:3000/api/jobs \
-H "Content-Type: application/json" \
-d '{"filename": "intro.mp4"}' # expect 201
curl -i -X POST http://localhost:3000/api/jobs \
-H "Content-Type: application/json" \
-d '{"filename": ""}' # expect 400
curl -i http://localhost:3000/api/jobs/999 # expect 404
curl -i -X DELETE http://localhost:3000/api/jobs/1 # expect 403
curl -i -X DELETE http://localhost:3000/api/jobs/1 \
-H "x-role: operator" # expect 204

Zum Handwerk gehört, auch die Fehlerfälle von Hand auszulösen: leerer Dateiname, kaputtes JSON, unbekannte ID, fehlende Rolle. Jede Antwort, die der Vertrag dokumentiert, siehst du einmal mit eigenen Augen. Automatische Tests für die API folgen in Kapitel 13; diese Handgriffe sind ihre Vorstufe.

  1. Datei heißt nicht route.ts: app/api/jobs/jobs.ts erzeugt keinen Endpunkt, und die Funktionen müssen genau wie die Methode heißen, in Großbuchstaben (GET, nicht get).
  2. GET ohne connection(): Mit npm run dev stimmt alles, nach npm run build liefert der Endpunkt für immer die Antwort vom Build (Einheit 9.2).
  3. request.json() ohne try/catch: Ein kaputter Body wird zu 500 mit leerem Body statt zu 400 im Fehlerformat.
  4. Status vergessen: NextResponse.json({ error: ... }) ohne { status: 400 } antwortet mit 200; der Client hält den Fehler für einen Erfolg.
  5. id als Zahl behandelt: Werte aus dem Pfad sind Strings; j.id === id ist immer falsch, erst Number(id) vergleicht richtig.
  6. Fehlender Header: request.headers.get("x-role") liefert null; ohne Fehlwert vergleicht der Code mit null und lässt je nach Bedingung alle oder niemanden durch.
  7. Daten weg nach dem Neustart: Der In-Memory-Speicher lebt nur, solange der Server läuft.
  • Next.js: Route Handlers - Konvention und unterstützte Methoden.
  • Next.js: route.js - alle Einzelheiten zu Parametern, params und Antworten.
  • Next.js: connection - die Funktion, die auf eine echte Anfrage wartet.
  • MDN: Request und Headers - die Objekte, die Browser und Server teilen (englisch).

Du kannst dir diese Einheit kostenlos mit der Vorlesefunktion deines Browsers oder Betriebssystems vorlesen lassen (etwa „Laut vorlesen” in Edge), ohne Konto und ohne Upload; die Codebeispiele und Diagramme liest du dabei am Bildschirm mit. Freiwillig kannst du mit Google NotebookLM (Google-Konto nötig) aus diesem öffentlichen Text eine gesprochene Zusammenfassung erzeugen, auch auf Deutsch.

Du kannst jetzt:

  • einen API-Entwurf als Route Handlers umsetzen, für die Sammlung und für ein einzelnes Element.
  • den Body einer Anfrage lesen, prüfen und kaputte Eingaben mit 400 im vereinbarten Fehlerformat beantworten.
  • Header einer Anfrage am Server lesen und den Fehlwert null behandeln.
  • die eigene API mit curl von außen testen, samt aller dokumentierten Fehlerfälle.
  • Aufgabe 09-1a - Die eigene API
  • Route Handlers, unterstützte Methoden, 405 bei nicht exportierter Methode → Next.js: Route Handlers, route.js
  • NextResponse.json → Next.js: NextResponse; connection() → Next.js: connection
  • 500 mit leerem Body ohne try/catch, 405 bei PUT, alle curl-Aufrufe samt Statuscodes → eigener Test mit Next.js 16.4.0 und Cache Components (Oktober 2026)
  • Request, request.json(), Headers.get und Groß-/Kleinschreibung der Header-Namen → MDN: Request, Headers.get
  • Vorsilbe X- für eigene Header als überholt eingestuft → IETF: RFC 6648 (Juni 2012)
  • Postman und Bruno → postman.com, usebruno.com
  • Abb. 9.1 (Ordner und Endpunkte), Abb. 9.2 (Weg einer POST-Anfrage), Abb. 9.3 (Rollenprüfung) → eigene Darstellung, alle als Mermaid-Diagramm
  • Oliver Maurhart (MAO)
  • Michael Gabriel (GAM)

CC-BY-SA

https://qiz.at/rules