Zum Inhalt springen

9. Next.js II - API-Routen und Datenfluss

Zu Zen-Modus wechseln

Der Vertrag aus dem Kapitel Schnittstellen entwerfen liegt auf Papier; jetzt wird er eingelöst. Next.js macht aus demselben Projekt, das die Seiten ausliefert, auch den API-Server: Route Handlers implementieren die Endpunkte, und Client wie fremde Programme sprechen sie über HTTP an. Damit schließt sich der Kreis des Lehrplans: Schnittstellen wurden konsumiert (HTTP-Kapitel), entworfen (Entwurfskapitel) und werden nun implementiert.

Das Kapitel baut die Render-Job-API aus dem Entwurf, verbindet anschließend die React-Seiten mit ihr (Daten lesen, Daten verändern) und testet das Ergebnis mit den bekannten Werkzeugen. Die Datenhaltung bleibt vorerst bewusst primitiv (eine In-Memory-Liste); sie sauber zu machen ist Aufgabe der Architektur- und Persistenzkapitel.

API-Endpunkte folgen derselben Ordnerlogik wie Seiten, nur heißt die Datei route.ts statt page.tsx, und exportiert wird pro HTTP-Methode eine Funktion:

app/
└── api/
└── jobs/
├── route.ts -> GET /api/jobs, POST /api/jobs
└── [id]/
└── route.ts -> GET/PATCH/DELETE /api/jobs/42

Die Sammlungs-Endpunkte, mit einer Modul-Liste als vorläufigem Speicher:

app/api/jobs/route.ts
import { NextResponse } from "next/server";
import type { Job, NewJob } from "@/lib/types";
let jobs: Job[] = [];
let nextId = 1;
export async function GET() {
return NextResponse.json(jobs);
}
export async function POST(request: Request) {
let body: NewJob;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: { code: "INVALID_JSON", message: "body is not valid JSON" } },
{ status: 400 });
}
if (!body.filename || body.filename.trim() === "") {
return NextResponse.json(
{ error: { code: "VALIDATION_FAILED", message: "filename must not be empty" } },
{ status: 400 });
}
const job: Job = {
id: nextId++,
filename: body.filename.trim(),
preset: body.preset ?? "web-1080p",
state: "queued",
progress: 0,
createdAt: new Date().toISOString(),
};
jobs.push(job);
return NextResponse.json(job, { status: 201 });
}

Die Bausteine: Der Handler erhält das Request-Objekt (dieselbe Klasse, die fetch auf der Gegenseite verwendet), liest den Body mit await request.json() und antwortet mit NextResponse.json(daten, { status }). Alles aus dem Entwurfskapitel wird hier wörtlich umgesetzt: 201 beim Anlegen, das vereinbarte Fehlerformat bei 400, die Interfaces aus einer gemeinsamen Typdatei, die auch der Client importiert. Der try/catch um request.json() gehört dazu: Ein kaputter Body ist ein Client-Fehler (400), kein Serverabsturz.

Die Element-Endpunkte lesen die ID aus dem Pfad, analog zu dynamischen Seiten:

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

PATCH und DELETE folgen demselben Muster (finden, prüfen, ändern oder entfernen, passender Status; DELETE antwortet mit 204 und leerem Body). Wichtig zur Einordnung des In-Memory-Speichers: Er ist flüchtig (Neustart des Dev-Servers leert ihn) und lebt im Modul-Namespace der Route. Für die Übungen dieses Kapitels reicht das; es ist zugleich die Motivation für die beiden Folgekapitel.

Der Body ist nicht das Einzige, was eine Anfrage mitbringt. Aus dem HTTP-Kapitel bekannt: Jede Anfrage trägt Header, Schlüssel-Wert-Paare mit Metadaten. Dasselbe Request-Objekt, dessen Body wir mit await request.json() lesen, macht diese Header über request.headers.get("name") zugänglich - serverseitig, im Route Handler:

export async function GET(request: Request) {
const accept = request.headers.get("accept"); // z. B. "application/json"
const auth = request.headers.get("authorization"); // null, wenn nicht gesetzt
// ...
}

Zwei Dinge sind zu beachten: Header-Namen sind case-insensitiv ("Accept" und "accept" liefern dasselbe), und get gibt null zurück, wenn der Header fehlt - der Fehlwert muss also immer eingeplant werden.

Nützlich ist das überall, wo eine Anfrage etwas über sich selbst mitteilt, das nicht in den Body gehört. Ein typisches Beispiel und der Anlass in der Buffet-Übung: wer stellt die Anfrage? Solange es noch keine echte Anmeldung gibt (die kommt in einem späteren Kapitel), lässt sich die Rolle über einen selbst gewählten Header simulieren, etwa x-role:

export async function POST(request: Request) {
const role = request.headers.get("x-role") ?? "anonymous";
if (role !== "buffet") {
return NextResponse.json(
{ error: { code: "FORBIDDEN", message: "only the buffet may do this" } },
{ status: 403 });
}
// ... die eigentliche Arbeit ...
}

Ein Client setzt diesen Header genauso, wie er Content-Type setzt - im fetch-Aufruf unter headers, oder auf der Kommandozeile mit curl -H "x-role: buffet". Der Handler liest ihn und entscheidet. Das ist noch keine Sicherheit (jeder kann den Header frei setzen), sondern nur eine Simulation, die die Rollenlogik schon jetzt erlaubt; die echte, fälschungssichere Variante folgt im Middleware-Kapitel.

Die eigene API wird genauso behandelt wie eine fremde: erst von außen ansprechen, dann Code dagegen schreiben.

Terminal-Fenster
http GET localhost:3000/api/jobs
http POST localhost:3000/api/jobs filename=intro.mp4 preset=web-1080p
http POST localhost:3000/api/jobs filename="" # expect 400
http GET localhost:3000/api/jobs/999 # expect 404

Zum Handwerk gehört, auch die Fehlerfälle von Hand auszulösen: leerer Dateiname, kaputtes JSON, unbekannte ID. Jede dokumentierte Antwort des Vertrags wird einmal real beobachtet. Automatisierte Tests für die API folgen im Kapitel Qualität im Team; die Handgriffe hier sind deren Vorstufe.

Für die Anzeige der Jobs gibt es zwei legitime Datenwege, und die Wahl ist eine Architekturentscheidung:

Weg 1: Server-Komponente liest direkt. Die Seite läuft ohnehin auf dem Server; sie kann die Datenquelle direkt fragen, ohne den Umweg über HTTP:

app/jobs/page.tsx
import { listJobs } from "@/lib/jobs"; // same function the API uses
export default async function JobsPage() {
const jobs = await listJobs();
return <JobTable jobs={jobs} />;
}

Das setzt voraus, dass die Logik (hier listJobs) in einem eigenen Modul liegt, das Route Handler und Seite importieren, statt dass die Seite ihre eigene API per fetch aufruft. Genau diese Auslagerung ist der erste Schritt in Richtung Schichten, den das nächste Kapitel systematisch macht.

Weg 2: Client-Komponente holt per fetch. Interaktive Ansichten, die sich ohne Seitenwechsel aktualisieren (Suchfeld, Auto-Refresh), laden im Browser über die eigene API, mit useEffect wie im React-Kapitel:

"use client";
export function LiveJobList() {
const [jobs, setJobs] = useState<Job[]>([]);
useEffect(() => {
const timer = setInterval(async () => {
const response = await fetch("/api/jobs");
if (response.ok) setJobs(await response.json());
}, 5000);
return () => clearInterval(timer);
}, []);
return <JobTable jobs={jobs} />;
}

Faustregel: Erstanzeige über die Server-Komponente (schnell, ohne Ladeflackern, suchmaschinenlesbar), Aktualisierung und Interaktion über den Client. Die eigene API ist außerdem der einzige Weg für alles, was von außerhalb des Projekts zugreift; sie ist der öffentliche Vertrag, die Server-Komponente nutzt die Abkürzung hinter der Theke.

Schreiboperationen (Mutationen) laufen vom Formular über fetch an die eigene API. Das Formular ist ein controlled Component nach dem Muster des React-Kapitels; neu ist der Umgang mit dem Ergebnis:

"use client";
import { useRouter } from "next/navigation";
export function NewJobForm() {
const router = useRouter();
const [filename, setFilename] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (submitting) return; // no double submits
setSubmitting(true);
setError(null);
try {
const response = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, preset: "web-1080p" }),
});
if (!response.ok) {
const problem = await response.json();
setError(problem.error.message); // the contract's error format
return;
}
setFilename("");
router.refresh(); // re-render server components
} catch {
setError("network error, please try again");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={filename} onChange={(e) => setFilename(e.target.value)} />
<button type="submit" disabled={submitting || filename.trim() === ""}>
{submitting ? "creating ..." : "create job"}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}

Hier laufen die Fäden des Halbjahres zusammen: das In-Flight-Flag gegen die Doppelklick-Race-Condition, die drei Fehlerfälle des fetch-Handwerks, das Fehlerformat des API-Vertrags als Quelle der Benutzermeldung, und router.refresh(), das die Server-Komponenten der Seite mit frischen Daten neu rendern lässt, damit die neue Zeile in der Liste erscheint. Löschen und Ändern folgen demselben Muster mit DELETE/PATCH.

router stammt aus dem Hook useRouter() aus next/navigation. Er ist das programmatische Gegenstück zur Link-Komponente aus dem vorigen Kapitel: Was <Link> deklarativ für einen Klick erledigt, macht das router-Objekt aus dem Code heraus — also dann, wenn erst nach einer Aktion feststeht, wohin oder ob navigiert werden soll (etwa nach einem erfolgreichen fetch). Weil es auf Browser-Ereignisse und Interaktion reagiert, ist useRouter() nur in Client-Komponenten verfügbar; die Datei muss also "use client" tragen.

Das Objekt bietet einige wenige Methoden, die im Rest des Jahres immer wieder vorkommen:

MethodeWirkung
router.push("/jobs/42")Navigiert zur angegebenen URL (neuer Eintrag im Browser-Verlauf, “Zurück” führt zurück). Typisch nach dem Anlegen: weiter zur Detailseite des neuen Elements.
router.replace("/login")Wie push, ersetzt aber den aktuellen Verlaufseintrag, statt einen neuen anzulegen. Für Weiterleitungen, zu denen man nicht “zurück” können soll.
router.back() / router.forward()Einen Schritt im Browser-Verlauf zurück oder vor, wie die Pfeiltasten des Browsers.
router.refresh()Lädt die Daten der aktuellen Route neu: Next.js rendert die Server-Komponenten der Seite erneut und übernimmt das frische Ergebnis, ohne den Client-Zustand (Formularinhalte, Scrollposition) zu verlieren. Genau das brauchen wir nach einer Mutation, damit die geänderten Serverdaten sichtbar werden.

Wichtig ist die Abgrenzung von push/replace zu refresh: Erstere wechseln die Seite, refresh() bleibt auf derselben Seite und beschafft nur deren Serverdaten neu. Nach dem Anlegen eines Jobs auf derselben Listenseite ist refresh() richtig; soll die Anwendung stattdessen zur Detailseite des neuen Jobs springen, wäre es push.

Für Mutationen bietet Next.js neben dem expliziten fetch gegen die eigene API einen zweiten Weg an: Server Actions. Das ist eine Funktion mit der Direktive "use server", die auf dem Server läuft, aber direkt aus einer Komponente heraus aufgerufen werden kann — ein Formular ruft sie über sein action-Attribut auf, ganz ohne sichtbaren fetch, ohne Route Handler und ohne selbst geschriebenen Client-Code für die Anfrage:

app/jobs/actions.ts
"use server";
import { addJob } from "@/lib/jobs";
import { revalidatePath } from "next/cache";
export async function createJob(formData: FormData) {
const filename = String(formData.get("filename"));
addJob({ filename });
revalidatePath("/jobs"); // re-render the list with fresh data
}
// app/jobs/new-job-form.tsx: the form calls the action directly
import { createJob } from "./actions";
export function NewJobForm() {
return (
<form action={createJob}>
<input name="filename" />
<button type="submit">Add</button>
</form>
);
}

Das ist auffällig kürzer als der explizite Weg weiter oben: kein fetch, keine manuelle Statuscode-Prüfung, kein router.refresh(). Next.js verdrahtet Formular, Serveraufruf und Neuaufbau der Seite selbst. Genau diese Bequemlichkeit hat aber eine Kehrseite.

Warum wir in diesem Jahr trotzdem beim expliziten Weg bleiben. Eine Server Action versteckt genau das, was dieses Jahr lehren soll: den HTTP-Vertrag. Beim Weg über die eigene API entsteht ein POST /api/jobs mit einem dokumentierten Request-Body, definierten Statuscodes und einem Fehlerformat — eine Schnittstelle, die jeder Client nutzen kann: das eigene Formular, ein Skript, eine Mobile-App, ein fremdes System. Eine Server Action dagegen ist an Next.js gebunden und taucht in keiner API-Dokumentation auf; von außen ist sie nicht wie eine normale REST-Ressource ansprechbar. Wer zuerst den expliziten Weg beherrscht, versteht, was eine Server Action im Hintergrund abkürzt — umgekehrt nicht.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Anwenden: einen API-Entwurf als Route Handlers implementieren (GET/POST/PATCH/DELETE, dynamische Segmente, Statuscodes, Fehlerformat).
  • Anwenden: Request-Bodys lesen, Grundvalidierung durchführen und kaputte Eingaben als 400 statt als Serverfehler beantworten.
  • Anwenden: Request-Header serverseitig mit request.headers.get(...) auslesen (etwa eine über x-role simulierte Rolle) und den Fehlwert null behandeln.
  • Anwenden: die eigene API mit HTTPie/curl und DevTools testen, einschließlich aller dokumentierten Fehlerfälle.
  • Beurteilen: zwischen Datenbeschaffung in der Server-Komponente und fetch im Client begründet wählen.
  • Anwenden: Mutationen aus Formularen umsetzen: Submit-Handling, In-Flight-Schutz, Fehleranzeige aus dem Vertragsformat, Aktualisierung der Anzeige.
  • Erklären: warum Logik in gemeinsame Module gehört, die Seite und API teilen, und welche Grenzen der In-Memory-Speicher hat.
  • Aufgabe 15 - Die eigene API
  • Aufgabe 16 - Client trifft Server