Server Actions in Next.js: das Formular, das funktioniert, bevor JavaScript lädt
Server Actions in Next.js: das Formular, das funktioniert, bevor JavaScript lädt
Das klassische Formularmuster in React: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState zur Behandlung des Ladezustands. Das funktioniert – unter einer Voraussetzung, die man leicht vergisst, weil sie sich auf dem eigenen Laptop mit schneller Verbindung nie zeigt: JavaScript muss geladen, geparst und ausgeführt worden sein, bevor das Formular überhaupt beginnt, auf einen Klick zu reagieren. Bei einer langsamen mobilen Verbindung, während der Hydration einer großen Seite, oder wenn ein Skript durch eine Browsererweiterung blockiert wird – klickt der Nutzer auf „Absenden", und es passiert buchstäblich nichts. Kein Fehler, kein Spinner – onClick existiert schlicht noch nicht.
Server Actions in Next.js werden manchmal als bequemere Syntax für genau denselben Aufruf dargestellt – „fetch, nur dass man keine API-Route schreiben muss". Das ist eine Verflachung, die genau das übersieht, was an diesem Mechanismus tatsächlich neu ist: <form action={meineAktion}> baut auf dem nativen, seit jeher existierenden Mechanismus des Browsers zum Absenden von Formularen auf, nicht auf JavaScript als notwendiger Voraussetzung. Das ändert sowohl, wie es unter der Haube funktioniert, als auch, welche Fehler Teams in der Praxis machen, die "use server" wie den Aufruf einer gewöhnlichen lokalen Funktion behandeln.
Was die Direktive "use server" wirklich tut
Wenn du eine Funktion mit "use server" markierst, lässt der Next.js-Compiler ihren Body nicht im Paket, das der Browser bekommt. Er schneidet ihn heraus und ersetzt ihn durch eine Referenz – einen verschlüsselten Bezeichner, der angibt, welche Funktion auf dem Server aufgerufen werden soll. Was tatsächlich beim Client ankommt, ist ein kleiner Stub: „Wenn du mich aufrufst, sende ein POST an einen speziellen RSC-Endpunkt mit dieser ID und den serialisierten Argumenten." Die eigentliche Logik – eine Datenbankabfrage, Validierung, was auch immer die Funktion tut – verlässt den Server physisch nie.
tsx
// app/actions.ts"use server";import { revalidatePath } from "next/cache";import { db } from "@/lib/db";export async function addTodoAction(prevState: unknown, formData: FormData) {const title = formData.get("title");if (typeof title !== "string" || title.trim().length === 0) {return { error: "Der Titel darf nicht leer sein." };}await db.todo.create({ data: { title: title.trim() } });revalidatePath("/todos");return { error: null };}
Wenn du diese Funktion an <form action={addTodoAction}> anhängst, passiert etwas, das ein gewöhnlicher fetch nie kostenlos geliefert hat: es funktioniert, bevor überhaupt JavaScript ausgeführt wird. Der Browser konnte schon immer ein Formular als natives POST absenden, wenn das action-Attribut auf eine URL zeigt – React 19 und Next.js erweitern das so, dass action auch eine Server-Funktion sein kann, und der Browser geht trotzdem seinen nativen Weg, falls JavaScript noch nicht bereit ist. Läuft JavaScript bereits, fängt Next.js dieses Ereignis ab und macht im Hintergrund dasselbe per fetch, ohne Neuladen der Seite, mit einer nahtlosen UI-Aktualisierung. Das ist Progressive Enhancement, eingebaut ins Framework, nicht etwas, das du selbst programmieren musst – exakt derselbe Mechanismus, über den wir im Artikel zur Barrierefreiheit im Zusammenhang mit dem RouteAnnouncer geschrieben haben, nur hier angewandt auf Datenmutationen statt auf Navigation.
Praktisches Beispiel: ein Formular mit vollständiger Statusbehandlung, ohne manuellen Fetch
React 19 hat speziell für dieses Muster useActionState eingeführt – es verbindet den Aufruf einer Server-Action mit dem von ihrer letzten Ausführung zurückgegebenen Zustand, ohne manuelles useState zur Speicherung von Fehler oder Ladestatus.
tsx
"use client";import { useActionState } from "react";import { addTodoAction } from "./actions";const initialState = { error: null };const TodoForm = () => {const [state, formAction, isPending] = useActionState(addTodoAction,initialState,);return (<form action={formAction}><input type="text" name="title" placeholder="Neue Aufgabe" required /><button type="submit" disabled={isPending}>{isPending ? "Wird hinzugefügt…" : "Hinzufügen"}</button>{state.error && <p className="form-error">{state.error}</p>}</form>);};export default TodoForm;
Hier passiert einiges gleichzeitig, ohne eine Zeile Code für die manuelle Verwaltung des Requests. isPending spiegelt den tatsächlichen Zustand der Formularübermittlung wider – einschließlich dieses nativen, vor-hydratationszeitlichen POSTs, nicht nur clientseitiger Aufrufe. state.error ist direkt das, was die Server-Funktion zurückgegeben hat – kein Parsen einer JSON-Antwort, kein try/catch um einen fetch. revalidatePath("/todos") in der Aktion weist Next.js an, den Cache für diese Route zu erneuern, sodass die Aufgabenliste auf der Seite den neuen Eintrag zeigt, ohne den Client-State manuell zu aktualisieren – das Framework entscheidet, nicht du, wann und wie ein neues RSC-Payload nachgezogen wird.
Fallstricke und Best Practices
- Jede
"use server"-Funktion ist ein öffentlicher HTTP-Endpunkt – keine private Funktion. Das ist der häufigste Sicherheitsirrtum bei Server Actions: Da nichts in der UI direkt darauf verlinkt, wirkt sie „versteckt". Ist sie nicht – der Compiler erzeugt für sie einen dauerhaften, von außen aufrufbaren Aktions-Bezeichner, sodass jeder, der ihn kennt (z. B. durch Beobachten des Netzwerkverkehrs in den DevTools), sie direkt aufrufen kann, unter Umgehung deiner UI und ihrer Annahmen. Die Validierung der Eingabedaten und die Berechtigungsprüfung (Session, Nutzerrolle) müssen innerhalb der Aktion selbst erfolgen, genau wie bei einem klassischen API-Endpunkt – verlass dich nie darauf, dass „der Button für nicht angemeldete Nutzer unsichtbar ist". - Argumente und Rückgabewert müssen serialisierbar sein. Server Actions übertragen Daten über das Netzwerk im React-eigenen Serialisierungsformat (reicher als JSON – es unterstützt u. a.
Date,Map, direkt aus dem Formular übergebeneFormData), lassen aber weiterhin keine Funktionen, Klasseninstanzen oder DOM-Referenzen durch. Nutzt du.bind(), um beim Aufruf vom Client aus ein zusätzliches Argument festzulegen (addTodoAction.bind(null, listId)), muss auchlistIdserialisierbar sein. - Es ist weiterhin ein echter Netzwerk-Request, kein lokaler Funktionsaufruf. Das vergisst man leicht, weil
await meineAktion(daten)syntaktisch genauso aussieht wie der Aufruf einer gewöhnlichen JS-Funktion. Unter der Haube ist es immer ein vollständiger Roundtrip: Serialisierung, HTTP, Deserialisierung auf der anderen Seite. Eine Server-Action in einer Schleife bei jedem im Textfeld eingegebenen Zeichen aufzurufen (z. B. für eine Live-Validierung) verwandelt dein Formular in einen Request-Generator, nicht in einen reaktionsschnellen Input – für solche Fälle ist Debounce genauso notwendig wie bei jedem anderen Netzwerkaufruf. - Fehlendes
revalidatePath/revalidateTaglässt die UI mit veraltetem Cache zurück. Next.js cacht Seitendaten standardmäßig – eine Aktion, die etwas in die Datenbank schreibt, dem Framework aber nicht mitteilt, welche Route zu erneuern ist, führt häufig zur Meldung „es wurde gespeichert, ist aber nicht sichtbar" – die Daten in der Datenbank sind korrekt, aber die UI zeigt weiterhin die alte, zwischengespeicherte Version der Seite.
Fazit
Server Actions sind kein „fetch mit hübscherer Syntax" – es sind zwei unterschiedliche Dinge, aufgebaut auf demselben Mechanismus. Erstens beruhen sie auf dem nativen Absenden von Formularen durch den Browser, sodass das Formular funktioniert, selbst bevor JavaScript geladen ist – du musst keine einzige Zeile Code für dieses Verhalten schreiben, du bekommst es allein durch die Architektur von <form action={...}>. Zweitens, und aus Sicherheitssicht wichtiger: Jede mit "use server" markierte Funktion wird zu einem realen, von außen aufrufbaren Endpunkt im Netz, unabhängig davon, ob irgendein sichtbares UI-Element sie überhaupt aufruft. Sie wie eine lokale, vertrauenswürdige Funktion zu behandeln – ohne Validierung und Autorisierung im Inneren – ist der einfachste Weg, wie eine bequeme Syntax zu einem ungesicherten API-Endpunkt wird.