Warum Server Actions das Fundament moderner Next.js-Anwendungen sind
Wer mit dem Next.js App Router arbeitet, kennt das Dilemma: Man will ein Formular bauen, Daten in eine Datenbank schreiben oder eine Mutation auslösen — und plötzlich steht man vor der Frage, ob man einen API-Route-Handler erstellen, einen externen Endpunkt anbinden oder doch lieber alles client-seitig lösen soll. Ganz ehrlich, das war lange Zeit nervig.
Server Actions räumen mit dieser Unsicherheit auf. Sie sind asynchrone Funktionen, die ausschließlich auf dem Server laufen, aber direkt aus React-Komponenten heraus aufgerufen werden können.
Klingt simpel, oder? Ist es im Grunde auch — aber es verändert grundlegend, wie wir Full-Stack-Anwendungen in Next.js bauen. Statt für jede Mutation einen eigenen API-Endpunkt zu erstellen, definiert man eine Funktion mit der 'use server'-Direktive, und Next.js kümmert sich um den HTTP-Request, die Serialisierung und die Antwort. Ein einzelner Server-Roundtrip, und das Framework liefert sowohl die aktualisierten Daten als auch die neue UI zurück.
In diesem Artikel gehen wir tief in die Praxis: Von der Grundstruktur über React-19-Hooks wie useActionState und useFormStatus, über Validierung mit Zod, bis hin zu Sicherheitsarchitekturen mit dem Data Access Layer. Wer die vorherigen Artikel zu Cache Components und Middleware-Sicherheit gelesen hat, findet hier die natürliche Ergänzung — Caching betrifft das Lesen von Daten, Server Actions das Schreiben.
Server Actions: Die Grundlagen
Was passiert hinter den Kulissen?
Wenn man eine Funktion mit 'use server' markiert, erstellt Next.js beim Build einen HTTP-POST-Endpunkt dafür. Der Client bekommt eine verschlüsselte, nicht-deterministische Action-ID, mit der er diese Funktion aufrufen kann. Das bedeutet: Keine manuellen Route-Handler, kein Fetch-Boilerplate, keine API-Pfade, die man synchron halten muss.
Es gibt zwei Wege, Server Actions zu definieren.
1. In einer dedizierten Datei
Die sauberste Variante (und meiner Meinung nach die, die man fast immer wählen sollte): Man erstellt eine Datei mit der 'use server'-Direktive ganz oben. Alle exportierten Funktionen werden dann automatisch zu Server Actions:
// app/actions/benutzer.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
export async function benutzerErstellen(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.benutzer.create({
data: { name, email },
})
revalidatePath('/benutzer')
redirect('/benutzer')
}
2. Inline in einer Server Component
Für einfache, einmalige Aktionen kann man die 'use server'-Direktive direkt innerhalb einer Funktion verwenden:
// app/feedback/page.tsx
export default function FeedbackSeite() {
async function feedbackSenden(formData: FormData) {
'use server'
const nachricht = formData.get('nachricht') as string
await db.feedback.create({ data: { nachricht } })
}
return (
<form action={feedbackSenden}>
<textarea name="nachricht" required />
<button type="submit">Absenden</button>
</form>
)
}
Für Projekte mit mehr als einer Handvoll Aktionen empfiehlt sich die dedizierte Datei-Variante. Warum? Weil man so die Actions zentral organisieren, wiederverwenden und — ganz entscheidend — sicherheitstechnisch auditieren kann. Dazu später mehr.
Formulare mit React 19: useActionState und useFormStatus
React 19 hat mit useActionState und useFormStatus zwei Hooks eingeführt, die die Arbeit mit Server Actions erheblich vereinfachen. Kurzer Hinweis: Wer noch mit dem alten useFormState aus react-dom arbeitet — das ist veraltet. useActionState ist der offizielle Nachfolger und lebt jetzt im react-Paket.
useActionState: Der zentrale Form-Hook
Konzeptionell ist useActionState vergleichbar mit useReducer, nur dass man Seiteneffekte im Reducer ausführen kann. Der Hook gibt drei Werte zurück: den aktuellen State, eine Action-Dispatcher-Funktion und einen Pending-Status.
Hier ein vollständiges Beispiel für ein Registrierungsformular:
// app/actions/auth.ts
'use server'
import { z } from 'zod'
const RegistrierungSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen haben'),
email: z.string().email('Ungültige E-Mail-Adresse'),
passwort: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
})
export type RegistrierungState = {
fehler?: {
name?: string[]
email?: string[]
passwort?: string[]
}
nachricht?: string
erfolg?: boolean
}
export async function registrieren(
vorherigState: RegistrierungState,
formData: FormData
): Promise<RegistrierungState> {
const validierung = RegistrierungSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
passwort: formData.get('passwort'),
})
if (!validierung.success) {
return {
fehler: validierung.error.flatten().fieldErrors,
nachricht: 'Bitte korrigiere die markierten Felder.',
}
}
try {
// Benutzer in der Datenbank erstellen
await db.benutzer.create({
data: validierung.data,
})
return { erfolg: true, nachricht: 'Registrierung erfolgreich!' }
} catch (error) {
return { nachricht: 'Ein unerwarteter Fehler ist aufgetreten.' }
}
}
Und die dazugehörige Client-Komponente:
// app/registrieren/formular.tsx
'use client'
import { useActionState } from 'react'
import { registrieren, type RegistrierungState } from '@/app/actions/auth'
import { AbsendenButton } from './absenden-button'
const initialState: RegistrierungState = {}
export function RegistrierungFormular() {
const [state, formAction, pending] = useActionState(
registrieren,
initialState
)
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
{state.fehler?.name && (
<p className="fehler">{state.fehler.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email">E-Mail</label>
<input id="email" name="email" type="email" required />
{state.fehler?.email && (
<p className="fehler">{state.fehler.email[0]}</p>
)}
</div>
<div>
<label htmlFor="passwort">Passwort</label>
<input id="passwort" name="passwort" type="password" required />
{state.fehler?.passwort && (
<p className="fehler">{state.fehler.passwort[0]}</p>
)}
</div>
<AbsendenButton />
{state.nachricht && (
<p className={state.erfolg ? 'erfolg' : 'fehler'}>
{state.nachricht}
</p>
)}
</form>
)
}
useFormStatus: Loading-Indikator für den Submit-Button
Der Hook useFormStatus aus react-dom liefert den Pending-Status eines Formulars. Wichtig (und das hat mich anfangs auch verwirrt): Er muss in einer separaten Komponente verwendet werden, die innerhalb eines <form>-Elements gerendert wird:
// app/registrieren/absenden-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function AbsendenButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Wird gesendet...' : 'Registrieren'}
</button>
)
}
Warum eine separate Komponente? Weil useFormStatus den Status des nächsten übergeordneten <form>-Elements liest. Verwendet man ihn in derselben Komponente wie das Formular, findet er kein übergeordnetes Formular — und funktioniert schlicht nicht.
Wann welchen Hook verwenden?
Die Unterscheidung ist eigentlich ganz klar:
- useActionState (aus
react): Für den gesamten Formular-State — Validierungsfehler, Erfolgsmeldungen, Pending-Status auf Formular-Ebene - useFormStatus (aus
react-dom): Ausschließlich für den Loading-Indikator des Submit-Buttons
In den meisten Fällen reicht useActionState völlig aus, da er bereits einen pending-Wert zurückgibt. useFormStatus wird dann interessant, wenn man den Button in eine eigene, wiederverwendbare Komponente auslagern möchte.
Validierung: Warum TypeScript allein nicht reicht
Das ist ein Punkt, den man nicht oft genug betonen kann. TypeScript-Typen existieren nur zur Compile-Zeit. Zur Laufzeit? Komplett verschwunden. Wenn jemand einen POST-Request direkt an die Server-Action-URL schickt — und das kann er, denn Server Actions sind öffentliche HTTP-Endpunkte — dann gibt es keine TypeScript-Typen, die ihn aufhalten.
Das muss man sich mal auf der Zunge zergehen lassen.
Zod als Laufzeit-Schutzschild
Zod ist die De-facto-Standardbibliothek für Laufzeit-Validierung in Next.js-Projekten. Man definiert ein Schema, und safeParse gibt entweder ein Erfolgs- oder ein Fehlerobjekt zurück — ohne Exceptions zu werfen:
import { z } from 'zod'
const ArtikelSchema = z.object({
titel: z
.string()
.min(5, 'Titel muss mindestens 5 Zeichen lang sein')
.max(200, 'Titel darf maximal 200 Zeichen lang sein'),
inhalt: z
.string()
.min(50, 'Inhalt muss mindestens 50 Zeichen lang sein'),
kategorieId: z
.number()
.int()
.positive('Ungültige Kategorie'),
veroeffentlicht: z.boolean().default(false),
})
type ArtikelEingabe = z.infer<typeof ArtikelSchema>
Und in der Server Action:
'use server'
export async function artikelErstellen(formData: FormData) {
const rohdaten = {
titel: formData.get('titel'),
inhalt: formData.get('inhalt'),
kategorieId: Number(formData.get('kategorieId')),
veroeffentlicht: formData.get('veroeffentlicht') === 'true',
}
const validierung = ArtikelSchema.safeParse(rohdaten)
if (!validierung.success) {
return {
fehler: validierung.error.flatten().fieldErrors,
}
}
// Ab hier sind die Daten garantiert valide
const artikel = await db.artikel.create({
data: validierung.data,
})
revalidatePath('/artikel')
redirect(`/artikel/${artikel.id}`)
}
Die Methode flatten() von Zod ist übrigens besonders praktisch für Formulare, weil sie die Fehler nach Feldnamen gruppiert — genau das Format, das man für die UI braucht.
Sicherheit: Server Actions als öffentliche Endpunkte behandeln
Okay, jetzt wird es ernst. Dieser Abschnitt ist meiner Meinung nach der wichtigste im gesamten Artikel. Denn hier liegt der häufigste Fehler, den Entwickler mit Server Actions machen: Sie behandeln sie wie interne Funktionen, obwohl sie öffentliche HTTP-Endpunkte sind.
Das Problem
Jede Funktion, die mit 'use server' markiert ist, erstellt einen POST-Endpunkt. Selbst wenn die Funktion nirgendwo importiert oder referenziert wird — der Endpunkt existiert trotzdem (sofern die Funktion nicht durch Dead Code Elimination entfernt wird). Ein Angreifer kann diesen Endpunkt direkt aufrufen, ohne jemals das Formular in der UI zu sehen.
Alle Sicherheitsmechanismen auf Seiten- oder Komponenten-Ebene — Authentifizierungsprüfungen in Layouts, Middleware-Redirects, rollenbasierte UI-Einschränkungen — werden dabei komplett umgangen. Einfach so.
Die Lösung: Auth-Check in jeder Action
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
export async function bestellungStornieren(bestellungId: string) {
// 1. Authentifizierung prüfen
const session = await auth()
if (!session?.user) {
throw new Error('Nicht authentifiziert')
}
// 2. Eingabe validieren
const validierteId = z.string().uuid().safeParse(bestellungId)
if (!validierteId.success) {
throw new Error('Ungültige Bestell-ID')
}
// 3. Autorisierung prüfen (gehört die Bestellung dem Benutzer?)
const bestellung = await db.bestellung.findUnique({
where: { id: validierteId.data },
})
if (!bestellung || bestellung.benutzerId !== session.user.id) {
throw new Error('Keine Berechtigung')
}
// 4. Erst jetzt die Mutation ausführen
await db.bestellung.update({
where: { id: validierteId.data },
data: { status: 'storniert' },
})
revalidatePath('/bestellungen')
}
Jede Server Action muss diese vier Schritte durchlaufen: Authentifizierung, Input-Validierung, Autorisierung, dann erst die Mutation. Ohne Ausnahme. Wirklich ohne Ausnahme.
CSRF-Schutz: Was Next.js automatisch macht
Die gute Nachricht: Next.js bietet eingebauten CSRF-Schutz für Server Actions. Das Framework vergleicht den Origin-Header mit dem Host-Header (bzw. X-Forwarded-Host). Stimmen diese nicht überein, wird die Anfrage abgelehnt. Zusammen mit der Tatsache, dass Server Actions ausschließlich POST-Requests akzeptieren und moderne Browser SameSite-Cookies als Standard verwenden, ist der CSRF-Schutz solide — aber eben nicht unfehlbar.
Fehlerbehandlung in der Produktion
Ein subtiler, aber wichtiger Punkt: Im Produktionsmodus ersetzt React Fehlermeldungen durch generische Hashes. Das ist gut für die Sicherheit — sensible Informationen wie Stack Traces oder Datenbankfehler gelangen nicht zum Client. Trotzdem sollte man in Server Actions keine detaillierten Fehler werfen, sondern strukturierte Fehler-Objekte zurückgeben:
// Schlecht: Exception werfen
throw new Error(`Benutzer ${userId} nicht gefunden in Tabelle ${tabelle}`)
// Gut: Strukturiertes Fehler-Objekt zurückgeben
return {
erfolg: false,
nachricht: 'Der angeforderte Benutzer wurde nicht gefunden.',
}
Der Data Access Layer: Sicherheit durch Architektur
Der Data Access Layer (DAL) ist ein Architekturmuster, das Next.js offiziell empfiehlt — und ehrlich gesagt, nachdem ich es in mehreren Projekten eingesetzt habe, möchte ich nicht mehr darauf verzichten. Die Idee: Statt Datenbankzugriffe direkt in Server Actions, Server Components oder Route Handlers zu verstreuen, zentralisiert man sie in einer eigenen Schicht. Jede Funktion im DAL erhält den aktuellen Benutzer als Parameter und prüft dessen Berechtigung, bevor Daten gelesen oder geschrieben werden.
// lib/dal/bestellungen.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { cache } from 'react'
export const getBestellungen = cache(async () => {
const session = await auth()
if (!session?.user) {
throw new Error('Nicht authentifiziert')
}
return db.bestellung.findMany({
where: { benutzerId: session.user.id },
orderBy: { erstelltAm: 'desc' },
})
})
export async function bestellungErstellen(
daten: ValidierteBestellDaten
) {
const session = await auth()
if (!session?.user) {
throw new Error('Nicht authentifiziert')
}
return db.bestellung.create({
data: {
...daten,
benutzerId: session.user.id,
},
})
}
Der Vorteil ist doppelt: Erstens hat man die Authentifizierung und Autorisierung an genau einer Stelle. Wenn sich die Auth-Logik ändert, muss man nur den DAL anpassen — nicht dutzende Server Actions. Zweitens kann man den DAL sowohl aus Server Actions als auch aus Server Components heraus verwenden, ohne die Sicherheitschecks zu duplizieren.
Die Server Action wird damit richtig schön schlank:
'use server'
import { bestellungErstellen } from '@/lib/dal/bestellungen'
import { BestellSchema } from '@/lib/schemas'
import { revalidatePath } from 'next/cache'
export async function neueBestellungAction(formData: FormData) {
const validierung = BestellSchema.safeParse(
Object.fromEntries(formData)
)
if (!validierung.success) {
return { fehler: validierung.error.flatten().fieldErrors }
}
await bestellungErstellen(validierung.data)
revalidatePath('/bestellungen')
return { erfolg: true }
}
Cache-Revalidierung und Redirects nach Mutationen
Nachdem eine Server Action erfolgreich eine Mutation durchgeführt hat, muss die UI natürlich die neuen Daten widerspiegeln. Next.js bietet dafür zwei Mechanismen.
revalidatePath und revalidateTag
revalidatePath invalidiert den Cache für einen bestimmten Pfad. revalidateTag ist granularer und invalidiert alle Cache-Einträge, die mit einem bestimmten Tag versehen wurden. Wer den vorherigen Artikel über Cache Components gelesen hat, erkennt hier die Verbindung zur use cache-Direktive und den cacheTag-Funktionen:
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function produktAktualisieren(
produktId: string,
formData: FormData
) {
// ... Validierung und Mutation ...
// Spezifische Produktseite revalidieren
revalidatePath(`/produkte/${produktId}`)
// Alle Seiten revalidieren, die den Tag 'produkte' verwenden
revalidateTag('produkte')
// Nach erfolgreicher Mutation umleiten
redirect(`/produkte/${produktId}`)
}
Kleiner Tipp am Rande: revalidatePath und revalidateTag sollten vor dem redirect-Aufruf stehen. Das stellt sicher, dass die Zielseite mit frischen Daten gerendert wird.
Optimistische Updates mit useOptimistic
Für eine reaktive Benutzeroberfläche kann man mit dem React-Hook useOptimistic die UI sofort aktualisieren, bevor die Server Action abgeschlossen ist. Das fühlt sich für den Benutzer deutlich schneller an — und mal ehrlich, genau das erwarten Nutzer heutzutage auch.
'use client'
import { useOptimistic } from 'react'
import { nachrichtSenden } from '@/app/actions/chat'
type Nachricht = {
id: string
text: string
status: 'gesendet' | 'ausstehend'
}
export function ChatFormular({
nachrichten,
}: {
nachrichten: Nachricht[]
}) {
const [optimistischeNachrichten, optimistischHinzufuegen] =
useOptimistic(
nachrichten,
(state: Nachricht[], neuerText: string) => [
...state,
{
id: crypto.randomUUID(),
text: neuerText,
status: 'ausstehend' as const,
},
]
)
async function formAction(formData: FormData) {
const text = formData.get('text') as string
optimistischHinzufuegen(text)
await nachrichtSenden(text)
}
return (
<div>
{optimistischeNachrichten.map((n) => (
<div key={n.id} className={n.status}>
{n.text}
{n.status === 'ausstehend' && ' ⏳'}
</div>
))}
<form action={formAction}>
<input name="text" placeholder="Nachricht..." />
<button type="submit">Senden</button>
</form>
</div>
)
}
Der optimistische State wird automatisch zurückgesetzt, wenn die Server Action abgeschlossen ist und die echten Daten vom Server kommen. Falls die Action fehlschlägt, sieht der Benutzer wieder den vorherigen korrekten State. Ziemlich elegant gelöst, muss man sagen.
next-safe-action: Typsichere Actions für die Produktion
Für produktionsreife Anwendungen lohnt sich ein Blick auf next-safe-action. Die Bibliothek bietet eine typsichere Abstraktion über Server Actions mit eingebautem Middleware-System, Validierung und strukturierter Fehlerbehandlung.
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'
export const actionClient = createSafeActionClient()
export const authActionClient = createSafeActionClient({
async middleware() {
const session = await auth()
if (!session?.user) {
throw new Error('Nicht authentifiziert')
}
return { benutzer: session.user }
},
})
Damit lassen sich Actions definieren, bei denen Authentifizierung und Validierung automatisch ablaufen:
// app/actions/profil.ts
'use server'
import { authActionClient } from '@/lib/safe-action'
import { z } from 'zod'
import { flattenValidationErrors } from 'next-safe-action'
export const profilAktualisieren = authActionClient
.schema(
z.object({
name: z.string().min(2),
bio: z.string().max(500).optional(),
}),
{
handleValidationErrorsShape: (ve) =>
flattenValidationErrors(ve).fieldErrors,
}
)
.action(async ({ parsedInput, ctx }) => {
// ctx.benutzer ist garantiert vorhanden (durch Middleware)
await db.profil.update({
where: { benutzerId: ctx.benutzer.id },
data: parsedInput,
})
revalidatePath('/profil')
return { erfolg: true }
})
Der Vorteil gegenüber reinen Server Actions: Man bekommt typsichere Validierungsfehler, ein komponierbares Middleware-System für Authentifizierung und Autorisierung, und eine konsistente Fehlerstruktur in der gesamten Anwendung. Seit Anfang 2026 unterstützt next-safe-action neben Zod auch jede Bibliothek, die die Standard-Schema-Spezifikation implementiert — das gibt einem zusätzliche Flexibilität.
Progressive Enhancement: Formulare ohne JavaScript
Ein oft unterschätzter Vorteil von Server Actions: Progressive Enhancement funktioniert automatisch. Wenn man ein <form>-Element mit einer Server Action als action-Prop verwendet, funktioniert das Formular auch ohne JavaScript — als ganz gewöhnlicher HTML-POST-Request.
// Das funktioniert auch mit deaktiviertem JavaScript
export default function SucheSeite() {
async function suchen(formData: FormData) {
'use server'
const query = formData.get('q') as string
redirect(`/ergebnisse?q=${encodeURIComponent(query)}`)
}
return (
<form action={suchen}>
<input name="q" type="search" placeholder="Suchen..." />
<button type="submit">Suchen</button>
</form>
)
}
In Server Components funktioniert das sofort als Standard-HTML. In Client Components werden Formularübermittlungen in eine Warteschlange gestellt, bis die Hydration abgeschlossen ist. Danach wird das Formular per JavaScript ohne Seitenneuladen gesendet.
Kurz gesagt: Man bekommt die beste UX mit JavaScript, aber die Grundfunktionalität ist auch ohne gegeben. Das ist besonders für SEO und Barrierefreiheit ein großer Pluspunkt.
Zusätzliche Argumente mit bind
Manchmal braucht man neben den Formulardaten noch zusätzliche Parameter. Die JavaScript-bind-Methode funktioniert sowohl in Server als auch in Client Components und unterstützt Progressive Enhancement:
'use server'
export async function aufgabeAktualisieren(
aufgabeId: string,
formData: FormData
) {
const titel = formData.get('titel') as string
await db.aufgabe.update({
where: { id: aufgabeId },
data: { titel },
})
revalidatePath('/aufgaben')
}
// In der Komponente
const aktualisierenMitId = aufgabeAktualisieren.bind(
null,
aufgabe.id
)
return (
<form action={aktualisierenMitId}>
<input name="titel" defaultValue={aufgabe.titel} />
<button type="submit">Speichern</button>
</form>
)
Häufige Fehler und Anti-Patterns
Nach mittlerweile über zwei Jahren produktivem Einsatz von Server Actions hat sich eine klare Liste von Fallstricken herauskristallisiert. Hier sind die, die ich am häufigsten sehe:
1. Server Actions für Datenabrufe verwenden
Server Actions nutzen POST-Requests und können nicht gecacht werden. Wer Daten lesen will, sollte Server Components verwenden — dort laufen Datenabfragen automatisch auf dem Server, können gecacht werden und sind Teil des initialen HTML:
// Falsch: Server Action zum Datenabruf
'use server'
export async function getProdukte() {
return db.produkt.findMany()
}
// Richtig: Datenabruf in einer Server Component
export default async function ProdukteSeite() {
const produkte = await db.produkt.findMany()
return <ProduktListe produkte={produkte} />
}
2. Mutationen im Render-Prozess
Niemals Cookies setzen, Cache invalidieren oder Daten schreiben innerhalb des Render-Prozesses einer Komponente. Next.js verhindert das aktiv, weil es zu unvorhersehbaren Seiteneffekten führen würde. Alle Mutationen gehören in Server Actions — Punkt.
3. Sensible Daten über Closures weitergeben
Das hier ist tückisch. Wenn man Inline-Server-Actions in Server Components definiert, können Variablen aus dem umgebenden Scope über Closures in die Action gelangen. Diese Werte werden serialisiert und über den Client zurück zum Server geschickt — potenziell sichtbar für den Benutzer:
// Gefährlich: Das Geheimnis wird serialisiert
export default async function SeiteComponent() {
const geheimnis = process.env.API_SECRET
async function action() {
'use server'
// geheimnis wird über den Client hin- und hergeschickt!
await fetch(apiUrl, {
headers: { Authorization: geheimnis },
})
}
return <form action={action}>...</form>
}
// Sicher: Geheimnis direkt auf dem Server lesen
async function action() {
'use server'
const geheimnis = process.env.API_SECRET
await fetch(apiUrl, {
headers: { Authorization: geheimnis },
})
}
4. Benutzer-IDs vom Client vertrauen
Niemals eine userId als verstecktes Formularfeld oder als Argument vom Client akzeptieren. Stattdessen immer die Session auf dem Server auslesen. Klingt offensichtlich, passiert aber erschreckend oft:
// Falsch: userId kommt vom Client
export async function profilLoeschen(userId: string) {
'use server'
await db.benutzer.delete({ where: { id: userId } })
}
// Richtig: userId aus der Session
export async function profilLoeschen() {
'use server'
const session = await auth()
if (!session?.user) throw new Error('Nicht authentifiziert')
await db.benutzer.delete({ where: { id: session.user.id } })
}
Server Actions vs. Route Handlers: Wann was?
Die Faustregel ist simpel: Server Actions für alle Mutationen innerhalb der eigenen Anwendung. Route Handlers nur dann, wenn man eine öffentliche API bereitstellen muss — etwa für externe Webhooks, Mobile-Apps oder Drittanbieter-Integrationen.
| Anwendungsfall | Server Action | Route Handler |
|---|---|---|
| Formular absenden | ✅ | ❌ |
| Daten in der DB ändern | ✅ | ❌ |
| Webhook empfangen | ❌ | ✅ |
| Öffentliche REST-API | ❌ | ✅ |
| Datei-Upload verarbeiten | ✅ | ✅ |
| Stripe-Callback | ❌ | ✅ |
Sicherheits-Audit-Checkliste
Zum Abschluss noch eine praktische Checkliste, die man bei jeder Server Action durchgehen sollte. Am besten durchsucht ihr euer Projekt nach 'use server'-Direktiven und prüft für jede Action:
- Input-Validierung: Werden alle Eingaben mit einem Zod-Schema (oder vergleichbar) validiert?
- Authentifizierung: Wird die Session geprüft, bevor auf Daten zugegriffen wird?
- Autorisierung: Darf der aktuelle Benutzer diese spezifische Operation ausführen? Gehört die Ressource ihm?
- Fehlerbehandlung: Werden strukturierte Fehler-Objekte zurückgegeben statt Exceptions mit internen Details?
- Keine Closures mit Geheimnissen: Werden sensible Daten (API-Keys, Secrets) erst innerhalb der Action gelesen?
- Rate Limiting: Ist ein Schutz gegen massenhafte Aufrufe vorhanden (z.B. über Middleware oder Bibliotheken wie Arcjet)?
Server Actions sind mächtig, aber sie erfordern die gleiche Sorgfalt wie jeder andere öffentliche API-Endpunkt. Wer das einmal verinnerlicht hat, baut damit sichere, wartbare und performante Full-Stack-Anwendungen — ganz ohne das Boilerplate klassischer REST-APIs. Und das fühlt sich verdammt gut an.