Formulare und Mutationen ohne separate API-Route
Server Actions verschieben Mutationen direkt in Server-Funktionen, die aus Client-Komponenten heraus aufrufbar sind. Ohne saubere Typisierung von Eingaben, Rückgaben und Fehlern wird aus dieser Bequemlichkeit jedoch schnell eine Quelle unsichtbarer Laufzeitfehler.
Inhaltsverzeichnis
- 1. Was eine Server Action tatsächlich ist
- 2. FormData typsicher validieren statt blind vertrauen
- 3. Typisierte Rückgaben statt geworfener Fehler
- 4. useActionState: Formularzustand ohne Client-Bibliothek
- 5. Revalidierung nach der Mutation typsicher steuern
- 6. Sicherheit: Jede Server Action ist ein öffentlicher Endpunkt
- 7. Progressive Enhancement ohne JavaScript im Client
- 8. Optimistische Updates mit useOptimistic typsicher koppeln
- 9. Wiederverwendbare, typsichere Action-Wrapper
- 10. Zusammenfassung
- 11. FAQ
1. Was eine Server Action tatsächlich ist
Eine Server Action ist eine asynchrone Funktion, die mit der Direktive 'use server' markiert ist und dadurch garantiert ausschließlich auf dem Server ausgeführt wird, selbst wenn sie aus einer Client-Komponente heraus wie eine normale Funktion aufgerufen wird. Das Framework generiert dafür im Hintergrund einen eigenen Netzwerk-Endpunkt, serialisiert Argumente und Rückgabewert und kümmert sich um die Zustellung.
Wichtig für die Typsicherheit ist, dass diese Serialisierung nicht jeden beliebigen TypeScript-Typ transportieren kann. Funktionen, Klassen-Instanzen mit Methoden oder zyklische Strukturen überstehen die Grenze zwischen Server und Client nicht unverändert, weshalb Signaturen bewusst auf serialisierbare Werte wie primitive Typen, Arrays, Plain Objects und FormData beschränkt bleiben sollten.
// app/actions/create-post.ts
'use server';
export async function createPost(formData: FormData): Promise<{ id: string }> {
const title = formData.get('title');
if (typeof title !== 'string' || title.length === 0) {
throw new Error('Titel darf nicht leer sein');
}
const post = await db.post.create({ data: { title } });
return { id: post.id };
}
2. FormData typsicher validieren statt blind vertrauen
FormData liefert für jedes Feld FormDataEntryValue | null zurück, also niemals einen fertigen, spezifischen Typ. Wer an dieser Stelle mit as string arbeitet, verschiebt das Risiko lediglich in die Laufzeit und verliert jede Garantie, dass ein Feld tatsächlich vorhanden und korrekt formatiert war.
Der robuste Weg führt über ein Schema, das die rohen Formulardaten in ein typisiertes Objekt überführt und dabei alle Validierungsregeln an einer Stelle bündelt. Mit Zod lässt sich FormData über Object.fromEntries() in ein Plain Object umwandeln und anschließend gegen ein Schema parsen, das sowohl Struktur als auch Geschäftsregeln wie Mindestlängen prüft.
'use server';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(3).max(120),
content: z.string().min(1),
});
export async function createPost(formData: FormData) {
const raw = Object.fromEntries(formData);
const result = CreatePostSchema.safeParse(raw);
if (!result.success) {
return { success: false as const, errors: result.error.flatten().fieldErrors };
}
const post = await db.post.create({ data: result.data });
return { success: true as const, id: post.id };
}
3. Typisierte Rückgaben statt geworfener Fehler
Ein geworfener Fehler in einer Server Action landet im Fehlerpfad des Frameworks und zeigt in der Regel eine generische Fehlerseite, was für Validierungsfehler in Formularen selten das gewünschte Verhalten ist. Robuster ist ein diskriminiertes Union-Ergebnis, das Erfolg und Fehler als reguläre Rückgabewerte kodiert, sodass die aufrufende Komponente beide Fälle explizit behandeln muss.
Dieses Muster lässt sich als wiederverwendbaren Typ formulieren, der von mehreren Actions geteilt wird. Der entscheidende Vorteil gegenüber try-catch im Client ist, dass der TypeScript-Compiler erzwingt, den Fehlerzweig tatsächlich zu prüfen, bevor auf Erfolgsdaten zugegriffen werden darf.
type ActionResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string[]> };
'use server';
export async function createPost(formData: FormData): Promise<ActionResult<{ id: string }>> {
const result = CreatePostSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors };
}
const post = await db.post.create({ data: result.data });
return { success: true, data: { id: post.id } };
}
4. useActionState: Formularzustand ohne Client-Bibliothek
Der Hook useActionState koppelt eine Server Action direkt an den lokalen Rendering-Zustand einer Client-Komponente. Er nimmt die Action sowie einen initialen Zustand entgegen und liefert den aktuellen Zustand, eine an form action übergebbare Funktion und einen Pending-Status zurück, ganz ohne zusätzliche State-Management-Bibliothek.
Damit dieser Mechanismus typsicher bleibt, muss die Signatur der Action zum erwarteten Zustandstyp passen: Der erste Parameter ist immer der vorherige Zustand, der zweite die Formulardaten. TypeScript prüft diese Signatur gegen den generischen Typparameter des Hooks und meldet eine Abweichung sofort als Kompilierfehler.
'use client';
import { useActionState } from 'react';
import { createPost, type ActionResult } from '../actions/create-post';
const initialState: ActionResult<{ id: string }> | null = null;
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" required />
{state && !state.success && (
<p>{state.errors.title?.[0]}</p>
)}
<button disabled={isPending}>Speichern</button>
</form>
);
}
5. Revalidierung nach der Mutation typsicher steuern
Nach einer erfolgreichen Server Action muss der zwischengespeicherte Seiteninhalt aktualisiert werden, damit Nutzer die Änderung sehen. Funktionen wie revalidatePath und revalidateTag nehmen dafür einen String-Pfad beziehungsweise einen Tag-Namen entgegen, was auf den ersten Blick untypisiert wirkt, aber bewusst so gestaltet ist, weil Pfade und Tags zur Laufzeit dynamisch aus Routendaten zusammengesetzt werden.
Um Tippfehler bei häufig verwendeten Tags zu vermeiden, lohnt sich ein zentrales Modul mit benannten Konstanten statt verstreuter String-Literale. So bleibt die Revalidierung an einer Stelle wartbar, auch wenn der zugrunde liegende Aufruf selbst keine strikte Typsicherheit für den Pfad bietet.
// app/cache-tags.ts
export const CacheTags = {
posts: 'posts',
post: (id: string) => `post:${id}` as const,
} as const;
'use server';
import { revalidateTag } from 'next/cache';
import { CacheTags } from '../cache-tags';
export async function updatePost(id: string, formData: FormData) {
await db.post.update({ where: { id }, data: { /* ... */ } });
revalidateTag(CacheTags.post(id));
revalidateTag(CacheTags.posts);
}
6. Sicherheit: Jede Server Action ist ein öffentlicher Endpunkt
Eine häufig unterschätzte Eigenschaft von Server Actions ist, dass jede exportierte Action einen eigenen, direkt aufrufbaren HTTP-Endpunkt erzeugt, unabhängig davon, ob sie in der Benutzeroberfläche tatsächlich verlinkt ist. Autorisierungsprüfungen gehören deshalb zwingend in die Action selbst und dürfen sich niemals darauf verlassen, dass ein Button in der UI ausgeblendet wurde.
Typsicherheit hilft hier indirekt: Ein zentraler Wrapper, der eine Session lädt, deren Typ prüft und erst danach die eigentliche Action-Logik ausführt, erzwingt über die Typsignatur, dass jede geschützte Action tatsächlich mit einer validierten Session-Variable arbeitet, statt versehentlich eine ungeprüfte Anfrage durchzulassen.
async function withAuth<T>(
action: (session: Session, formData: FormData) => Promise<T>,
) {
return async (formData: FormData) => {
const session = await getSession();
if (!session) {
throw new Error('Nicht authentifiziert');
}
return action(session, formData);
};
}
export const deletePost = withAuth(async (session, formData) => {
const id = String(formData.get('id'));
await db.post.delete({ where: { id, authorId: session.userId } });
});
7. Progressive Enhancement ohne JavaScript im Client
Weil Server Actions über das native form action-Attribut ausgelöst werden können, funktioniert das Formular grundsätzlich auch, bevor JavaScript im Browser geladen ist, solange die Action tatsächlich eine FormData-Instanz statt beliebiger Argumente entgegennimmt. Diese Eigenschaft geht verloren, sobald eine Action stattdessen mehrere einzelne, nicht-serialisierbare Argumente erwartet, die nur über einen Client-seitigen Event-Handler zusammengestellt werden können.
Für Formulare, bei denen Progressive Enhancement wichtig ist, sollte die Signatur deshalb bewusst bei (formData: FormData) => Promise bleiben, während komplexere, rein client-getriebene Interaktionen wie Drag-and-drop-Reihenfolgen eher über bind() vorbelegte Argumente oder dedizierte Client-Aufrufe gelöst werden.
8. Optimistische Updates mit useOptimistic typsicher koppeln
Der Hook useOptimistic erlaubt es, den lokalen Zustand sofort zu aktualisieren, während die zugehörige Server Action im Hintergrund noch läuft, und automatisch auf den echten Zustand zurückzufallen, sobald die Server-Antwort eintrifft oder fehlschlägt. Der generische Typparameter des Hooks beschreibt sowohl den Basiszustand als auch die Reducer-Funktion, die eine optimistische Aktualisierung berechnet.
Damit dieses Muster typsicher bleibt, muss die Struktur der optimistischen Aktualisierung exakt zum Typ des tatsächlichen Serverzustands passen. TypeScript prüft, dass der Reducer aus aktuellem Zustand und Aktion wieder denselben Zustandstyp erzeugt, wodurch inkonsistente Zwischenzustände frühzeitig auffallen.
'use client';
import { useOptimistic } from 'react';
type Post = { id: string; title: string; pending?: boolean };
export function PostList({ posts }: { posts: Post[] }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(state: Post[], newTitle: string) => [
...state,
{ id: 'temp', title: newTitle, pending: true },
],
);
return (
<ul>
{optimisticPosts.map((p) => (
<li key={p.id}>{p.title}{p.pending ? ' (wird gespeichert...)' : ''}</li>
))}
</ul>
);
}
9. Wiederverwendbare, typsichere Action-Wrapper
In größeren Projekten lohnt sich ein zentraler Wrapper, der Validierung, Autorisierung und Fehlerformat für alle Server Actions vereinheitlicht, statt diesen Code in jeder einzelnen Action zu wiederholen. Bibliotheken wie next-safe-action bieten dafür generische Builder, die ein Zod-Schema, eine optionale Autorisierungsprüfung und die eigentliche Logik zu einer vollständig typisierten Action zusammensetzen.
Der Effekt eines solchen Wrappers ist, dass Validierungsfehler, Serverfehler und Erfolgsfälle konsistent über dieselbe diskriminierte Union-Struktur an den Client zurückgegeben werden, unabhängig davon, welcher Entwickler die konkrete Action geschrieben hat. Das reduziert Boilerplate und verhindert, dass einzelne Actions versehentlich rohe Fehler ungeprüft nach außen durchreichen.
| Aspekt | Geworfener Fehler | Typisierte Rückgabe (ActionResult) | Empfehlung |
|---|---|---|---|
| Client-Behandlung | Landet in Error Boundary | Muss explizit geprüft werden | Typisierte Rückgabe für Formularfehler |
| Typsicherheit | Fehlertyp meist unknown | Diskriminierte Union mit Feldern | Rückgabe erzwingt Prüfung im Compiler |
| UX bei Validierung | Generische Fehlerseite | Feldbezogene Fehlermeldung möglich | Rückgabe für Formulare |
| Geeignet für | Unerwartete Serverfehler | Erwartbare Validierungsfehler | Beide Mechanismen kombinieren |
Mironsoft
TypeScript-Migration, Typsicherheit und Team-Onboarding
JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?
Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.
Migrations-Fahrplan
Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.
Strict-Mode-Einführung
tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.
Team-Onboarding
Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.
10. Zusammenfassung
Server Actions typsicher nutzen
Direktive
'use server' markiert eine Funktion als serverseitig ausgeführten Endpunkt
Validierung
FormData über Zod-Schema in ein typisiertes Objekt überführen
Fehlermuster
Diskriminierte ActionResult-Union statt geworfener Fehler
Sicherheit
Autorisierung in der Action selbst, nicht nur im UI verstecken