Der Spickzettel für alles, was schreibt, schützt und in Betrieb geht: Formulare und Server Actions, eigene Endpunkte, Datenbank, Anmeldung, Rechte, Tests und Deployment. Was davor kommt — Routing, Layouts, die Server-Client-Grenze, Streaming und Caching — steht auf dem ersten Blatt: Next.js 16 Kern.
Maßgeblich ist und bleibt die offizielle Next.js-Dokumentation; dieses Blatt ist eine Auswahl für den Alltag, keine Referenz. Es beschreibt Next.js 16.3 mit React 19.2.
Formulare und Server Actions
'use client'
const [stand, tun, warte] = useActionState(buchen, null)
return <form action={tun}>…<button disabled={warte}>Buchen</button></form>
Der Wartezustand kommt aus dem Hook, nicht aus eigenem useState, und gehört
an die Schaltfläche statt an die Seite. Mit useActionState ändert sich die
Signatur der Aktion: Der Zustand kommt als erstes Argument.
'use server'
export async function buchen(_stand, formData: FormData) {
// 1 Berechtigung 2 Validierung 3 Änderung 4 Tag invalidieren 5 schmal zurückgeben
}
Ein Formular in einer Server Component sendet klassisch per POST — die Aktion läuft, auch wenn das Bundle noch nicht geladen ist. Fortschrittliche Verbesserung ist damit der Standardfall, nicht die Kür.
| Ziel nach der Aktion | Mittel |
|---|---|
| auf derselben Seite bleiben | Zustand zurückgeben |
| zu einer anderen Seite | redirect in der Aktion |
| Liste aktualisieren | updateTag oder revalidatePath |
| Router auffrischen | refresh |
redirect wirft — erst invalidieren, dann umleiten.
Modul: Formulare und Server Actions
Validierung und Rückgabe
const geprueft = BuchungSchema.safeParse({ liegeplatz: formData.get('liegeplatz') })
if (!geprueft.success) return geprueft.error.flatten() // safeParse wirft nicht
Die Aktion ist per direktem POST erreichbar, ohne dass ein Formular beteiligt
ist; required im HTML ist in Sekunden entfernt. Verbindlich ist allein die
Prüfung, die nicht umgangen werden kann. FormData liefert Zeichenketten, ein
fehlendes Feld kommt als null — und fachliche Regeln wie
Überschneidungsfreiheit prüft kein Schema.
await db.buchung.create({ data })
return { erfolg: true, nummer: buchung.nummer } // nicht den ganzen Datensatz
Optimistische Oberflächen lohnen nicht überall — die Frage lautet nicht, ob es geht, sondern was ein Rückzieher kostet:
| Vorgang | Optimistisch | Grund |
|---|---|---|
| Merken, Notiz ändern | ja | folgenlos, leicht korrigierbar |
| Buchung bestätigen | nein | verbindlich |
| Rechnung stornieren | nein | Geldfluss |
Bei Uploads ist der gemeldete Inhaltstyp eine Behauptung des Clients: Größe und Inhalt zusätzlich prüfen, und die Datei in einen Objektspeicher legen — das Dateisystem des Servers ist beim nächsten Deploy leer.
Modul: Formulare und Server Actions
Aktion oder Route Handler
| Verbraucher | Aktion | Handler |
|---|---|---|
| eigenes Formular | ja | — |
| eigene Client-Komponente | ja | ja |
| fremdes System, Webhook | — | ja |
| mobile App | — | ja |
| Export als Datei | — | ja |
Im Zweifel Aktion — ein Endpunkt ist eine öffentliche Zusage, die versioniert und gepflegt werden will. Route Handlers aus Server Components aufzurufen ist ein Umweg über das Netz, von dem die Doku ausdrücklich abrät.
export async function GET(request: NextRequest) {
const sitzung = await verifySession()
if (!sitzung) return new Response(null, { status: 401 })
if (sitzung.rolle !== 'verwaltung') return new Response(null, { status: 403 })
return NextResponse.json(await getRechnungen())
}
Der Endpunkt hat eine eigene URL: Niemand muss dafür die Oberfläche geöffnet
haben. Nur exportierte Methoden existieren, alles andere antwortet 405; eine
route.ts und eine page.tsx im selben Ordner schließen sich aus.
Modul: Route Handlers und Proxy
Proxy statt Middleware
// proxy.ts — eine Datei je Projekt, neben app/, nicht darin
export function proxy(request: NextRequest) { /* … */ }
export const config = { matcher: '/dashboard/:path*' }
Der Name hat gewechselt, weil „Middleware” nach dem Ort für Anmeldung und Sitzungsverwaltung klang — genau dorthin gehört sie nicht.
| Aufgabe | Gehört in den Proxy |
|---|---|
| Nichtangemeldete auf die Anmeldung lenken | ja, optimistisch |
| Kopfzeilen setzen, A-B-Test umschreiben | ja |
| Sitzung in der Datenbank prüfen | nein, zu langsam |
| Rechte je Datensatz entscheiden | nein, falscher Ort |
| Daten laden | nein |
Der Proxy ist eine Weiche, keine Wache. Er läuft bei jeder Anfrage, auch bei jedem Prefetch — eine Datenbankabfrage darin vervielfacht die Last unbemerkt.
Modul: Route Handlers und Proxy
Datenbank und Zugriffs-Layer
| Prisma | Drizzle | |
|---|---|---|
| Schemaquelle | eigene Sprache | TypeScript |
| Nähe zu SQL | abstrahiert | nah |
| Generierungsschritt | ja | nein |
| Einstieg | leichter | direkter |
| Bundle-Größe | größer | kleiner |
Der Datenzugriffs-Layer führt Abfrage, Rechteprüfung und Zuschnitt an einem Ort zusammen — dort ist Sicherheit prüfbar statt über Seiten verstreut:
import 'server-only'
export async function getRechnung(id: string) {
const sitzung = await verifySession()
const r = await ladeRechnung(id)
if (r.gastId !== sitzung.userId && sitzung.rolle !== 'verwaltung') forbidden()
return { nummer: r.nummer, betrag: r.betrag } // schmal zurückgeben
}
| Abfrage | Cache | Form |
|---|---|---|
| Tarifliste | ja | geteilt |
| freie Liegeplätze | ja | geteilt, kurz |
| eigene Buchungen | nur privat | im Browser |
| personenbezogene Datensätze | nein | ungecacht |
Die Frage lautet immer: Sähen zwei Nutzer dasselbe Ergebnis?
| serverlos | eigener Node-Prozess | |
|---|---|---|
| Instanzen | viele, kurzlebig | wenige, langlebig |
| Verbindungen | je Aufruf neu | wiederverwendet |
| Pool | zwingend, extern | im Prozess |
Zwei Dinge gehören in die Datenbank, nicht in den Code: die Ausschlussbedingung gegen doppelte Belegung — zwei gleichzeitige Anfragen umgehen jede Prüfung, die nur in der Anwendung steht — und Geldbeträge als exakter Typ statt als Fließkommazahl.
Modul: Datenbankzugriff mit PostgreSQL
Anmeldung und Sitzungen
| Ebene | Frage | Mittel |
|---|---|---|
| Authentifizierung | wer bist du | Anmeldeformular |
| Sitzung | bist du noch da | Cookie |
| Autorisierung | was darfst du | Rolle und Besitz |
(await cookies()).set('sitzung', wert, {
httpOnly: true, secure: true, sameSite: 'lax', expires: bis, path: '/',
})
| Option | Verhindert |
|---|---|
httpOnly | Auslesen durch JavaScript im Browser |
secure | Übertragung über unverschlüsselte Verbindungen |
sameSite | Mitsenden bei fremden Seitenaufrufen |
expires | unbegrenzt gültige Sitzungen |
path | Geltung außerhalb des vorgesehenen Bereichs |
Fehlt eine davon, ist die Sitzung auf eine konkrete Weise angreifbar. Ins Cookie gehören keine Adressen, Telefonnummern oder Rechnungsdaten — es wird vom Nutzer transportiert und ist nie vertrauenswürdig.
| zustandslos | Sitzung in der Datenbank | |
|---|---|---|
| Aufwand | gering | höher |
| sofort widerrufbar | nein | ja |
| Abfrage je Prüfung | nein | ja |
| aktive Geräte sichtbar | nein | ja |
| Aufgabe | Selbst nötig |
|---|---|
| Soziale Anmeldung | erheblicher Aufwand |
| Mehrfaktor | erheblicher Aufwand |
| Passwort vergessen | mittlerer Aufwand |
| Sitzungsverwaltung | überschaubar |
Die unteren Zeilen sind machbar, die oberen selten selbst zu rechtfertigen — und ein Wechsel später ist teuer. Ein Dienst als Anbieter bedeutet dabei Auftragsverarbeitung und meist Drittlandtransfer.
Eine zustandslose Sitzung gilt bis zu ihrem Ablauf, auch nach einer Sperrung — für Verwaltungsrollen ist die rechte Spalte oft richtig. Die Anmeldeaktion gibt für „Nutzer unbekannt” und „Passwort falsch” dieselbe Meldung zurück, sonst verrät sie, welche Konten existieren.
Modul: Authentifizierung und Sessions
Autorisierung: wo eine Prüfung wirkt
| Ort | Wirkt | Reicht |
|---|---|---|
| Proxy | jede Anfrage | nein, optimistisch |
| Layout | einmal je Zweig | nein |
| Seite | je Aufruf | nur für die Ansicht |
| Zugriffs-Layer | je Abfrage | ja |
Nur die unterste Zeile deckt Seite, Aktion und Endpunkt gleichzeitig ab. Und Rolle allein genügt nicht — die zweite Prüfung, der Besitz, ist die, die am häufigsten fehlt:
if (!sitzung) unauthorized()
if (b.gastId !== sitzung.userId && sitzung.rolle !== 'verwaltung') forbidden()
Übertragungsobjekte statt Datensätze halten das über Umbauten hinweg: Was im DTO nicht steht, kann später nicht versehentlich sichtbar werden — ein neues Feld in der Datenbank landet sonst automatisch im Browser.
| Bei Server Actions eingebaut | Bleibt deine Aufgabe |
|---|---|
| verschlüsselte Aktions-Kennungen | Anmeldung prüfen |
| ungenutzte Aktionen entfernt | Berechtigung prüfen |
| Ursprung gegen Host geprüft | Eingaben validieren |
| nur POST erlaubt | Rückgabe zuschneiden |
Die linke Spalte senkt das Risiko, die rechte beseitigt es. Hinter einem Reverse Proxy braucht die Ursprungsprüfung eine Liste erlaubter Ursprünge; ab der zweiten Instanz muss der Schlüssel fest vorgegeben sein:
openssl rand -base64 32 # → NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Modul: Autorisierung und Datensicherheit
Testen
| Ebene | Werkzeug | Gegenstand |
|---|---|---|
| Einheit | Vitest | Zugriffs-Layer, Aktionen, Schemas |
| Komponente | Vitest | Client-Komponenten mit Zustand |
| Durchstich | Playwright | angemeldet buchen und sehen |
| Rechte | Playwright | positiv und negativ je Rolle |
Async Server Components fehlen in dieser Liste bewusst. Eine Server Action testet man, indem man sie als Funktion aufruft — nicht über ein Formular:
await expect(getBuchung('b-2', { userId: 'g-1', rolle: 'gast' })).rejects.toThrow()
Der negative Fall ist der wichtigere: Er prüft die Absicherung, und er
überlebt Umbauten der Oberfläche, weil er eine fachliche Zusage beschreibt.
Abgefragt wird über die Rolle (getByRole), nicht über CSS-Klassen.
import { instant } from '@next/playwright'
await instant(page, async () => { /* muss ohne Warten auf das Netz sichtbar sein */ })
Das macht aus dem Geschwindigkeitsversprechen der sofortigen Navigation eine prüfbare Zusage — eine verschobene Suspense-Grenze schlägt sonst nirgends fehl. Was den Merge nicht blockiert, wird nach zwei Wochen ignoriert.
Modul: Testen mit Vitest und Playwright
Produktionsreife und Selbsthosten
| Ort | Enthält |
|---|---|
app | Routen und Konventionsdateien |
data | Zugriffs-Layer, server-only, mit Rechteprüfung |
lib | Hilfsfunktionen ohne Fachbezug |
ui | geteilte Komponenten, Client-Grenzen |
e2e | Durchstiche und Rollentests |
Image, Font und Script sind Standardbausteine, keine Zusatzpakete — sie
übernehmen Bildgrößen und moderne Formate, Selbstauslieferung der Schriften
ohne Layoutsprung und das verzögerte Laden fremder Skripte.
| Was Vercel übernimmt | Bedeutet beim Selbsthosten |
|---|---|
| CDN vor der statischen Hülle | selbst einzurichten |
| Cache-Speicher für ISR | Cache-Handler nötig |
| Bildoptimierung | eigener Dienst oder Konfiguration |
| Skew-Schutz bei Rollouts | Auslieferungskennung setzen |
| Selbstbau-Aufgabe | Sonst passiert |
|---|---|
| Cache-Handler | jede Instanz zeigt anderen Stand |
| Verschlüsselungsschlüssel | Aktion nicht gefunden |
| Auslieferungskennung | Fehler bei rollenden Rollouts |
| Pufferung abschalten | Streaming kommt nicht an |
| Verbindungspool | Datenbankgrenze erreicht |
Alle fünf treten erst mit der zweiten Instanz auf, nie beim lokalen Test. Cache Components funktioniert dabei selbst gehostet — es ist keine Plattformfunktion.
Modul: Produktionsreife und Deployment
Agentische Entwicklung
Der Trainingsstand der Modelle liegt vor Next.js 16. Die vier häufigsten Vorschläge sind plausibel formuliert und alle vier falsch:
| Vorschlag des Agenten | Heute richtig |
|---|---|
Pages Router, getServerSideProps | App Router, await in der Komponente |
middleware.ts | proxy.ts |
fetch mit revalidate-Option | use cache mit cacheLife |
Datenladen im useEffect | Server Component |
Gegenmittel ist der von next dev gepflegte Block in AGENTS.md, der auf die
mitgelieferte Doku unter node_modules/next/dist/docs/ verweist. Eigene
Projektregeln gehören außerhalb der Markierungen — dort bleiben sie
erhalten.
| Typischer Fehler im generierten Code | Aus welchem Modul |
|---|---|
| Client-Grenze zu weit oben | Server und Client Components |
| Validierung in der Aktion fehlt | Formulare und Server Actions |
| Cache-Tag nicht invalidiert | Caching mit Cache Components |
| Besitzprüfung fehlt | Autorisierung und Datensicherheit |
| Aufgabe | Eignung | Grund |
|---|---|---|
| Migration, Refactoring | hoch | Sollzustand klar |
| Tests schreiben | hoch | sofort prüfbar |
| Dokumentation | hoch | leicht zu beurteilen |
| Architekturentwurf | mittel | Abwägung nötig |
| Fachliche Regeln erfinden | niedrig | Sollzustand unklar |
Die untere Zeile ist die, bei der Agenten am überzeugendsten danebenliegen.
Modul: Agentische Entwicklung mit Next.js
Typische Fallen
- Eine exportierte Server Action ist über einen direkten POST erreichbar, auch ohne Verlinkung. Anmeldung und Berechtigung gehören in die Aktion selbst — eine Prüfung auf der Seite deckt die dort definierte Aktion nicht mit ab.
- Ein zurückgegebener Datenbanksatz veröffentlicht interne Felder. Rückgabewerte werden serialisiert und sind im Browser lesbar.
redirectnach der Invalidierung aufrufen, nicht davor — danach läuft kein Code mehr. Ein Fehler in der Aktion ohne Rückgabe lässt das Formular stumm.- Wer den Proxy als einzige Absicherung nutzt, hat keine. Ein Cookie kann veraltet sein; die optimistische Prüfung ist kein Beweis.
- Beim Löschen und Ändern wird der Besitz öfter vergessen als beim Lesen. Eine Kennung aus der URL ist Benutzereingabe, kein Beweis für Zugehörigkeit.
- Eine Rolle im Cookie ist eine Behauptung, kein Nachweis — und eine gültige Sitzung sagt nichts über Berechtigungen aus.
- Cookies lassen sich nicht beim Rendern setzen. Anmelden und Abmelden sind Aktionen; bei Datenbanksitzungen genügt das Löschen des Cookies nicht.
- Der ORM ersetzt kein Verständnis der erzeugten Abfragen — naiv geladene Beziehungen erzeugen das klassische N-plus-1-Problem.
- Ein Pool im Anwendungsprozess hilft serverlos nicht, er muss davor liegen. Der Fehler tritt erst unter Last auf, nicht in der Entwicklung.
- Eine Vorschau-Auslieferung mit Produktionsdaten ist ein verbreiteter Fehler; Umgebungsvariablen je Umgebung trennen.
- Feste Wartezeiten in Playwright machen Tests langsam und trotzdem unzuverlässig; ohne zurückgesetzten Datenstand hängt der zweite Lauf vom ersten ab.
- Generierter Code, der läuft, ist damit nicht richtig. Agenten prüfen gern den positiven Fall und lassen den negativen weg.
Zum Seminar Next.js 16 Full-Stack-Entwicklung mit React und TypeScript