Server Actions typsicher nutzen: Formulare ohne API-Route
AI generated
type
TypeScript · React Server Components
Server Actions typsicher nutzen
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.

10 Min. Lesezeit Server Actions React Server Components Formulare

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

11. FAQ: Server Actions typsicher nutzen

1Was macht 'use server' technisch?
Die Direktive markiert eine Funktion so, dass Build-Tooling einen eigenen Netzwerk-Endpunkt dafür generiert. Argumente und Rückgabewert werden serialisiert zwischen Client und Server übertragen, die eigentliche Ausführung findet ausschließlich auf dem Server statt.
2Warum sollte ich FormData nicht direkt mit as string casten?
Ein Cast verschiebt lediglich das Risiko in die Laufzeit, ohne tatsächlich zu prüfen, ob das Feld vorhanden und korrekt formatiert ist. Ein Validierungsschema wie Zod prüft Struktur und Inhalt gleichzeitig und liefert bei Fehlern strukturierte Fehlermeldungen.
3Wann sollte eine Server Action einen Fehler werfen statt ihn zurückzugeben?
Für unerwartete Serverfehler, etwa einen Datenbankausfall, ist ein geworfener Fehler sinnvoll, da er in die generische Fehlerbehandlung des Frameworks läuft. Für erwartbare Validierungsfehler in Formularen ist eine typisierte Rückgabe die bessere Wahl, weil sie feldbezogene Meldungen erlaubt.
4Wie hängt useActionState mit der Signatur einer Server Action zusammen?
useActionState erwartet, dass der erste Parameter der Action der vorherige Zustand ist und der zweite die Formulardaten. TypeScript prüft diese Signatur gegen den generischen Typparameter des Hooks und meldet Abweichungen als Kompilierfehler.
5Warum ist jede Server Action sicherheitstechnisch wie ein öffentlicher Endpunkt zu behandeln?
Jede exportierte Action erzeugt einen eigenen, direkt aufrufbaren HTTP-Endpunkt, unabhängig davon, ob ein Button dafür in der Benutzeroberfläche sichtbar ist. Autorisierungsprüfungen müssen deshalb innerhalb der Action selbst erfolgen.
6Was ist der Unterschied zwischen revalidatePath und revalidateTag?
revalidatePath invalidiert den Cache für eine konkrete Route anhand ihres Pfads, revalidateTag invalidiert alle Daten, die mit einem bestimmten Tag markiert wurden, unabhängig davon, auf welchen Routen sie erscheinen.
7Bleibt Progressive Enhancement bei jeder Server Action erhalten?
Nur solange die Action tatsächlich eine FormData-Instanz als Argument entgegennimmt und über das native form action Attribut ausgelöst wird. Signaturen mit mehreren einzelnen Argumenten benötigen meist einen Client-seitigen Event-Handler und funktionieren ohne JavaScript nicht.
8Wofür wird useOptimistic eingesetzt?
Der Hook aktualisiert den lokalen Zustand sofort, während die zugehörige Server Action im Hintergrund läuft, und fällt automatisch auf den echten Serverzustand zurück, sobald die Antwort eintrifft oder die Aktion fehlschlägt.
9Was bietet ein Wrapper wie next-safe-action gegenüber handgeschriebenen Actions?
Ein solcher Wrapper vereinheitlicht Validierung, Autorisierung und Fehlerformat über einen generischen Builder, sodass alle Actions im Projekt konsistente, vollständig typisierte Ergebnisse zurückgeben, statt diesen Code in jeder Action zu wiederholen.
10Kann eine Server Action beliebige TypeScript-Objekte als Argument entgegennehmen?
Nur serialisierbare Werte wie primitive Typen, Arrays, Plain Objects und FormData überstehen die Grenze zwischen Client und Server unverändert. Funktionen, Klassen-Instanzen mit Methoden oder zyklische Strukturen sind dafür ungeeignet.