Zum Inhalt springen

12. Middleware und Validierung

Zu Zen-Modus wechseln

Manche Aufgaben gehören zu jeder Anfrage: Zugriffe protokollieren, Berechtigungen prüfen, Umleitungen. Sie in jeden einzelnen Route Handler zu kopieren wäre das Ende jeder Wartbarkeit; solche Querschnittsaufgaben brauchen einen eigenen Ort. Der heißt Middleware: Code, der zwischen dem Eintreffen einer Anfrage und ihrem eigentlichen Ziel läuft. Der Lehrplan nennt Middleware ausdrücklich neben den Frameworks, und dieses Kapitel liefert beides: das Konzept samt Umsetzung in Next.js und die wichtigste Grenzaufgabe überhaupt, die Validierung eingehender Daten mit einer Schema-Bibliothek.

Middleware bildet eine Kette vor den Endpunkten: Jede Station kann die Anfrage untersuchen, anreichern, durchwinken oder abbrechen.

flowchart LR
    req["Request"] --> mw1["Logging"] --> mw2["Auth-Check"] --> h["Route Handler / Seite"]
    mw2 -- "not allowed" --> stop["401 / Redirect"]

Der Gewinn ist derselbe wie bei den Schichten: einmal statt überall. Ein Auth-Check in der Middleware gilt für dreißig Endpunkte, und ein vergessener Check in einem neuen Handler ist unmöglich, weil der Handler ihn gar nicht enthält. Das Konzept existiert in jedem Web-Framework (Express, Django, Spring, …); Next.js erwartet dafür eine einzelne Datei mit festem Namen an fester Stelle - unmittelbar neben dem app/-Ordner, nicht darin:

mein-projekt/
├── app/
│ ├── api/
│ ├── layout.tsx
│ └── page.tsx
├── middleware.ts <-- hier, auf derselben Ebene wie app/
├── package.json
└── next.config.ts

Wer ein src/-Verzeichnis nutzt (Next.js erlaubt das), legt die Datei entsprechend nach src/middleware.ts. Der Name ist Konvention: Next.js findet die Datei nur unter genau diesem Namen an genau dieser Stelle - anders als bei den Route Handlern gibt es hier keine Ordnerstruktur, sondern eine Datei fürs ganze Projekt.

middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
console.log(`${request.method} ${request.nextUrl.pathname}`);
// protect the admin API with a key from the environment
if (request.nextUrl.pathname.startsWith("/api/admin")) {
const key = request.headers.get("x-api-key");
if (key !== process.env.ADMIN_API_KEY) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "missing or invalid API key" } },
{ status: 401 });
}
}
return NextResponse.next(); // hand over to the actual target
}
export const config = {
matcher: ["/api/:path*"], // run only for API requests
};

Die Bausteine: NextResponse.next() reicht die Anfrage weiter, eine eigene Response bricht ab (Fehler oder NextResponse.redirect(...)), und der matcher begrenzt, für welche Pfade die Middleware überhaupt läuft. Der API-Schlüssel kommt aus einer Umgebungsvariablen (Datei .env.local, die in der .gitignore steht): Geheimnisse wohnen in der Umgebung, nie im Code.

Genau derselbe Ort eignet sich, um ganze Seitenbereiche hinter eine Anmeldung zu legen. Statt in jeder geschützten Seite zu prüfen, ob jemand angemeldet ist, entscheidet das die Middleware zentral: Sie liest das Anmelde-Merkmal aus einem Cookie und schickt nicht angemeldete Besucher auf die Login-Seite - der geschützte Handler wird dann gar nicht erst erreicht.

middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session")?.value;
// no session? send the visitor to the login page
if (!session) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", request.nextUrl.pathname); // remember the target
return NextResponse.redirect(loginUrl);
}
return NextResponse.next(); // logged in: hand over to the actual page
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"], // only the protected areas
};

Zwei Dinge sind wichtig. Erstens ist der Cookie-Wert hier nur ein Signal, dass jemand angemeldet ist; wer das ist und ob das Merkmal echt ist, muss der Server sicher prüfen (bei echten Systemen über ein signiertes Token oder eine serverseitige Sitzung). Die Middleware ist die erste, grobe Sperre, nicht die alleinige Sicherheit. Zweitens bleibt die Middleware bei HTTP und Zugang: Sie entscheidet durchlassen oder umleiten, aber sie lädt keine Benutzerdaten und trifft keine Geschäftsentscheidung - das gehört weiter in Service und Repository.

Dieses Cookie-Muster ist die einfachste Form von Benutzer-Authentifizierung und genügt zum Kennenlernen; die Tiefen echter Anmeldung (Passwort-Hashing, sichere Sitzungen, Login-Formulare) folgen demselben Middleware-Gedanken, sind aber bewusst nicht Stoff dieses Jahres.

Zur Einordnung in die Architektur: Middleware ist keine vierte Schicht, sondern ein Querschnitt durch die Präsentationsgrenze. Sie behandelt, was mit HTTP und Zugang zu tun hat; Geschäftsregeln haben in ihr genauso wenig verloren wie Datenzugriff.

Die wichtigste Regel jeder Schnittstelle: Eingehenden Daten wird nicht getraut. Der eigene Client schickt brav das vereinbarte JSON, aber die API ist öffentlich erreichbar, und “der Client prüft das schon” ist keine Sicherheit, sondern eine Hoffnung; curl kennt keine Formularvalidierung. Jede Eingabe wird deshalb serverseitig an der Grenze geprüft, bevor sie die Logik erreicht.

Die Handprüfungen des API-Kapitels (if (!body.filename) ...) skalieren dafür nicht: Bei zehn Feldern mit Typen, Grenzen und optionalen Teilen entsteht ein Prüf-Roman, der mit dem eigentlichen Vertrag nicht verbunden ist und von ihm wegdriftet. Das Werkzeug der Wahl sind Schema-Bibliotheken; der Standard im TypeScript-Ökosystem heißt zod:

// lib/schemas.ts: the contract, executable
import { z } from "zod";
export const newJobSchema = z.object({
filename: z.string().trim().min(1, "filename must not be empty").max(200),
preset: z.enum(["web-1080p", "web-4k", "archive"]).default("web-1080p"),
priority: z.number().int().min(1).max(5).optional(),
});
export type NewJob = z.infer<typeof newJobSchema>;

Zwei Eigenschaften machen das Muster stark. Erstens ist das Schema ausführbare Vertragsdokumentation: Es prüft zur Laufzeit echte Daten, statisch typisiert es zugleich das Programm, denn z.infer leitet den TypeScript-Typ direkt aus dem Schema ab. Vertrag, Prüfung und Typ sind eine Quelle; sie können nicht auseinanderlaufen. Zweitens sind die Fehlermeldungen strukturiert und feldgenau, wie es das Fehlerformat des API-Vertrags braucht:

app/api/jobs/route.ts
import { newJobSchema } from "@/lib/schemas";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const result = newJobSchema.safeParse(body); // Call zod validation
if (!result.success) { // Check, if validation failed
return NextResponse.json({
error: {
code: "VALIDATION_FAILED",
message: "request body is invalid",
// result.error.issues contains the list of validation errors
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
issue: issue.message,
})),
},
}, { status: 400 });
}
const job = await jobService.createJob(result.data); // result.data: NewJob
return NextResponse.json(job, { status: 201 });
}

safeParse wirft nicht, sondern liefert ein Ergebnisobjekt: bei Erfolg die geprüften und normalisierten Daten (getrimmt, mit Defaults befüllt, korrekt typisiert), bei Misserfolg die Liste der Verstöße. Ungültige Anfragen beantwortet die Grenze als 400 mit details, die dem Client feldgenau sagen, was zu korrigieren ist; genau das verlangte der Entwurf im Schnittstellenkapitel.

Die Arbeitsteilung mit dem Service bleibt dabei sauber: Das Schema prüft Form (Typen, Längen, Wertemengen), der Service prüft Fachlichkeit, die Daten braucht (existiert das Preset noch, läuft der Job bereits). Formprüfungen wandern aus dem Service an die Grenze; die Geschäftsregeln bleiben, wo sie sind.

Auch die Fehler-Übersetzung gehört an eine gemeinsame Stelle. Statt dass jeder Handler seine Exceptions selbst in Statuscodes übersetzt, erledigt das eine kleine Hilfsfunktion für alle: ValidationError wird zu 400, ConflictError zu 409, alles Unbekannte zu 500 (ohne interne Details preiszugeben). So sieht das Fehlerformat über die ganze API gleich aus - unabhängig davon, wie sorgfältig der einzelne Handler geschrieben ist.

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:

  • Erklären: das Middleware-Konzept (Querschnitt vor den Endpunkten) erklären und von den Schichten abgrenzen.
  • Anwenden: Next.js-Middleware mit Matcher für Logging, einen einfachen Schlüssel-Auth-Check und einen Cookie-basierten Login-Schutz mit Umleitung einsetzen; Geheimnisse über Umgebungsvariablen verwalten.
  • Erklären: begründen, warum eingehenden Daten nie getraut wird und warum Client-Validierung Sicherheit nicht ersetzt.
  • Anwenden: Ein- und Ausgaben mit zod-Schemas validieren und Typen mit z.infer aus dem Schema ableiten.
  • Anwenden: Validierungsfehler feldgenau im Vertragsformat beantworten und die Fehlerübersetzung als Querschnitt zentralisieren.
  • Beurteilen: Formprüfung an der Grenze von fachlichen Regeln im Service unterscheiden und Prüfungen der richtigen Stelle zuordnen.
  • Aufgabe 20 - Der Türsteher