Zum Inhalt springen

React Server Actions: Muster, Fallstricke und Praxiseinsatz

Server Actions holen Formulare und Mutationen in Next.js zurück auf den Server — richtig einsetzen, Fallstricke vermeiden, skalierbare Muster bauen.

4 Min. Lesezeit
Code für Next.js Server Actions, der Formularmutationen mit TypeScript-Typen verarbeitet

Server Actions kamen mit dem stabilen React-Modell und viel Versprechen: Mutationslogik direkt bei den Komponenten unterbringen, die lästige Boilerplate-API-Route überspringen, Progressive Enhancement quasi gratis bekommen. In der Praxis stoßen Teams auf echte Probleme — Lücken bei der Validierung, vermischter Zustand zwischen Client und Server und Actions, die zu unübersichtlichen Monolithen anwachsen. Hier ein nüchterner Blick darauf, wo sie glänzen und wo Leitplanken nötig sind.

Was Server Actions wirklich sind

Eine Server Action ist eine asynchrone Funktion, die mit "use server" markiert ist, auf dem Server läuft, aber vom Client aus aufgerufen werden kann — auch über die action-Prop eines Formulars. Im Hintergrund schickt der Browser eine POST-Anfrage; React kümmert sich um die Serialisierung.

tstypescript
"use server";
 
export async function createProject(formData: FormData) {
  const name = formData.get("name") as string;
  await db.project.create({ data: { name } });
  revalidatePath("/projects");
}

Diese Einfachheit ist echt. Aber genau sie kann dazu verleiten, die Muster zu überspringen, die Server-Code eigentlich absichern.

Validierung liegt in eurer Verantwortung

Die größte Falle: Server Actions validieren Eingaben nicht automatisch. Alles, was aus FormData kommt, hat den Typ string | File | null. Wer die Validierung auslässt, lässt beliebige Nutzereingaben direkt in die Datenbank.

tstypescript
// ❌ Trusting FormData directly — no schema, no guardrails
export async function createProject(formData: FormData) {
  const name = formData.get("name") as string;
  await db.project.create({ data: { name } }); // name could be empty or malicious
}
 
// ✅ Parse and validate with Zod before touching the database
import { z } from "zod";
 
const schema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
});
 
export async function createProject(formData: FormData) {
  const parsed = schema.safeParse({
    name: formData.get("name"),
    description: formData.get("description"),
  });
 
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
 
  await db.project.create({ data: parsed.data });
  revalidatePath("/projects");
  return { success: true };
}

Behandelt eine Server Action wie einen öffentlichen API-Endpunkt — denn genau das ist sie.

Autorisierung: Verlasst euch nicht auf die UI

Server Actions sind nicht automatisch geschützt, nur weil sie "innerhalb" einer Komponente liegen. Ein Client kann jeden Server-Action-Endpunkt direkt per POST ansprechen. Prüft die Autorisierung deshalb immer innerhalb der Action selbst.

tstypescript
"use server";
 
import { getSession } from "@/lib/auth";
 
export async function deleteProject(projectId: string) {
  const session = await getSession();
  if (!session?.user) {
    throw new Error("Unauthorized");
  }
 
  // Verify the user actually owns this project
  const project = await db.project.findUnique({ where: { id: projectId } });
  if (project?.ownerId !== session.user.id) {
    throw new Error("Forbidden");
  }
 
  await db.project.delete({ where: { id: projectId } });
  revalidatePath("/projects");
}

Es gilt dieselbe Regel wie bei API-Routen: Der Komponentenbaum ist keine Sicherheitsgrenze.

Rückgabetypen für Feedback an den Client

Server Actions können Werte zurückgeben. Nutzt typisierte Rückgaben, um dem Client Feedback zu geben — Validierungsfehler, den Erfolgsstatus oder die IDs neu erstellter Ressourcen.

tstypescript
type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };
 
export async function createProject(
  formData: FormData
): Promise<ActionResult<{ id: string }>> {
  const parsed = schema.safeParse(Object.fromEntries(formData));
 
  if (!parsed.success) {
    return {
      success: false,
      error: "Validation failed",
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }
 
  const project = await db.project.create({ data: parsed.data });
  return { success: true, data: { id: project.id } };
}

Auf der Client-Seite bindet ihr den Rückgabewert mit useActionState (React 19) oder useFormState (Next.js 14) an den Zustand der Komponente.

tstypescript
"use client";
 
import { useActionState } from "react";
import { createProject } from "./actions";
 
export function CreateProjectForm() {
  const [state, action, isPending] = useActionState(createProject, null);
 
  return (
    <form action={action}>
      <input name="name" required />
      {state?.fieldErrors?.name && (
        <span role="alert">{state.fieldErrors.name[0]}</span>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Project"}
      </button>
    </form>
  );
}

Actions in großem Maßstab organisieren

Der erste Instinkt ist Kolokation — die Action landet in derselben Datei wie die Komponente. Für einfache Fälle ist das völlig in Ordnung. Sobald eine Action jedoch Autorisierung oder komplexe Geschäftslogik berührt oder von mehreren Features gemeinsam genutzt wird, gehört sie in ein eigenes Modul.

MusterWann einsetzen
Kolokiert in page.tsx oder form.tsxEinfache Formulare, einmalig genutzte Mutationen
Feature-Modul features/projects/actions.tsActions, die innerhalb einer Domäne geteilt werden
Service-Schicht + schlanke ActionKomplexe Geschäftslogik, kontextübergreifend wiederverwendet

Das Service-Schicht-Muster ist besonders nützlich, wenn dieselbe Logik sowohl in einer Server Action als auch in einer API-Route laufen muss:

tstypescript
// services/projects.ts — pure business logic, no "use server"
export async function createProjectForUser(
  userId: string,
  data: CreateProjectInput
) {
  // validate, authorize, write to DB — framework-agnostic
}
 
// features/projects/actions.ts — thin action, just auth + delegate
"use server";
 
export async function createProjectAction(formData: FormData) {
  const session = await getSession();
  if (!session?.user) throw new Error("Unauthorized");
 
  const result = await createProjectForUser(
    session.user.id,
    parseFormData(formData)
  );
  revalidatePath("/projects");
  return result;
}

Steckt die Geschäftslogik in einer einfachen Service-Funktion, lässt sie sich per Unit-Test prüfen, ohne einen Next.js-Server hochzufahren oder React-Interna zu mocken.

Fehlerbehandlung: Exceptions mit Bedacht werfen

Unbehandelte Exceptions aus Server Actions landen als unbehandelte Fehler bei der nächsten Error Boundary. Das ist bei unerwarteten Fehlern durchaus gewollt — Datenbank-Timeouts, Netzwerkfehler, verletzte Invarianten. Bei erwarteten Fehlern solltet ihr aber einen typisierten Fehlerwert zurückgeben, statt eine Exception zu werfen.

tstypescript
// ❌ Throws for an expected condition — triggers error boundary unnecessarily
export async function getProject(id: string) {
  const project = await db.project.findUnique({ where: { id } });
  if (!project) throw new Error("Not found");
  return project;
}
 
// ✅ Return null for expected absence; throw only for unexpected failures
export async function getProject(id: string) {
  return db.project.findUnique({ where: { id } });
}

Diese Unterscheidung ist wichtig, denn Error Boundaries sind ein grobes Werkzeug. Ein "nicht gefunden" muss nicht gleich den ganzen Teilbaum zum Absturz bringen — die Komponente kann null auch elegant selbst behandeln.

Progressive Enhancement

Ein echter Gewinn von Server Actions: Formulare funktionieren auch ohne JavaScript. Weil die action-Prop eine Funktion akzeptiert, die auf eine HTTP-POST-Anfrage abgebildet wird, funktionieren eure Formulare auch dort, wo JS noch nicht geladen ist oder fehlgeschlagen ist.

tstypescript
// This form submits correctly with and without JavaScript
<form action={createProject}>
  <input name="name" required />
  <button type="submit">Create</button>
</form>

Verspielt das nicht, indem ihr eure Action in ein onClick einwickelt. Nutzt die action-Prop am <form>-Element und packt Progressive Enhancement obendrauf — optimistische Updates, Ladezustände — als zusätzliche Schicht, nicht als Ersatz.

~

Nutzt useOptimistic zusammen mit useActionState, um die UI sofort zu aktualisieren, während die Action im Hintergrund läuft. Bei einem Fehler wird automatisch zurückgerollt.

Die wichtigsten Punkte

  1. Validiert jede Eingabe mit einem Schema — FormData ist untypisierte Nutzereingabe; behandelt sie wie jeden Payload einer öffentlichen API
  2. Autorisiert innerhalb der Action — der Komponentenbaum ist keine Sicherheitsgrenze; prüft Session und Eigentümerschaft explizit
  3. Gebt für erwartete Fehler typisierte Ergebnisse zurück — Validierungsfehler und fehlende Datensätze sollten keine Error Boundary auslösen
  4. Haltet die Geschäftslogik in einer Service-Schicht — schlanke Actions delegieren an wiederverwendbare Funktionen, wodurch sich die Logik ohne Framework-Overhead testen lässt
  5. Bewahrt Progressive Enhancement — nutzt die action-Prop an <form>, nicht onClick, damit Formulare schon vor dem Laden von JS funktionieren
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX