Mutations und Server Actions
Bisher haben wir Daten nur gelesen: Server Components laden sie beim Rendern, Prisma schreibt das Ergebnis in den JSX-Baum. Doch eine echte Applikation braucht auch Schreiboperationen – Notizen anlegen, bearbeiten, löschen. In Rails ist das eine Selbstverständlichkeit: Ein Controller-Action empfängt das POST-Formular, schreibt in die Datenbank und leitet weiter. In Next.js führt derselbe Gedanke zu Server Actions.
Das Problem: Mutations ohne REST-API
Die naheliegendste Lösung wäre ein eigener API-Endpunkt: POST /api/notes, PATCH /api/notes/:id, DELETE /api/notes/:id. Dann ruft der Client diese Endpunkte per fetch auf, verwaltet Ladezustände und Fehler selbst – das ist der klassische SPA-Ansatz. Er funktioniert, bringt aber viel Boilerplate mit sich.
Next.js bietet eine Alternative: Server Actions. Das sind asynchrone Funktionen, die zwar auf dem Server laufen, sich aber direkt in React-Formulare einbinden lassen. Kein separater API-Layer, keine manuelle fetch-Logik – das Framework kümmert sich um den Netzwerk-Transport.
# app/controllers/notes_controller.rb
def create
@note = current_user.notes.build(note_params)
if @note.save
redirect_to @note
else
render :new, status: :unprocessable_entity
end
end
private
def note_params
params.require(:note).permit(:title, :content)
end// app/notes/actions.ts (oder inline in der Komponente)
"use server";
export async function createNoteAction(fd: FormData) {
const user = await requireLogin();
const title = fd.get("title") as string;
await prisma.note.create({
data: { title, userId: user.id },
});
revalidatePath("/notes");
redirect("/notes");
}Der Kern der Analogie: Eine Server Action ist ein Rails Controller-Action – ohne separaten HTTP-Router und ohne manuell geschriebenen API-Client. Das Framework verdrahtet beides automatisch.
Die "use server"-Direktive
"use server" ist eine Compiler-Direktive, kein Laufzeit-Flag. Sie signalisiert dem Next.js-Bundler: „Diese Funktion gehört ausschließlich auf den Server. Exportiere sie niemals in Client-Bundle-Code."
Es gibt zwei Muster, wie man Server Actions anlegt:
Muster 1 – Separate Datei
"use server";
// Alle Exporte dieser Datei sind automatisch Server Actions.
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createNoteAction(fd: FormData) {
// Direkter Datenbankzugriff – kein fetch nötig
await prisma.note.create({ data: { title: fd.get("title") as string } });
revalidatePath("/notes");
redirect("/notes");
}Das ist das empfohlene Muster für Produktionscode: Alle Mutations-Funktionen liegen zentral, sind wiederverwendbar und lassen sich isoliert testen.
Muster 2 – Inline in einer Server Component
export default function NewNotePage() {
// Inline-Deklaration: "use server" direkt in der Funktion
async function createNote(fd: FormData) {
"use server";
await prisma.note.create({ data: { title: fd.get("title") as string } });
redirect("/notes");
}
return (
<form action={createNote}>
<input name="title" placeholder="Titel" />
<button type="submit">Anlegen</button>
</form>
);
}Inline-Actions sind praktisch für einfache Fälle – etwa wenn die Action nur einmal verwendet wird und eng an das Formular gebunden ist. Für komplexere Applikationen ist die separate Datei vorzuziehen, weil sie Übersicht und Testbarkeit verbessert.
Formular + Server Action
Das wichtigste Zusammenspiel: Ein HTML-<form>-Element nimmt als action-Prop direkt eine Server Action entgegen.
import { createNoteAction } from "@/app/notes/actions";
export default function NewNotePage() {
return (
<form action={createNoteAction} className="space-y-4">
<input
name="title"
required
placeholder="Titel der Notiz"
className="block w-full border rounded px-3 py-2"
/>
<textarea
name="content"
required
placeholder="Inhalt …"
className="block w-full border rounded px-3 py-2"
/>
<button type="submit" className="px-4 py-2 bg-indigo-600 text-white rounded">
Speichern
</button>
</form>
);
}Ein entscheidender Vorteil: Dieses Formular funktioniert ohne JavaScript – sogenanntes Progressive Enhancement. Der Browser sendet das Formular wie ein klassisches HTML-POST an den Server; Next.js leitet es an die Server Action weiter. Sobald JavaScript geladen ist, übernimmt React die Kontrolle und ermöglicht optimistische UI-Updates. Das war mit klassischen SPAs nicht ohne erheblichen Mehraufwand möglich.
<%# app/views/notes/new.html.erb %>
<%= form_with model: @note do |f| %>
<%= f.text_field :title %>
<%= f.text_area :content %>
<%= f.submit "Speichern" %>
<% end %>{/* app/notes/new/page.tsx */}
<form action={createNoteAction}>
<input name="title" />
<textarea name="content" />
<button type="submit">Speichern</button>
</form>Auch die FormData-API entspricht params.require(:note).permit(…) in Rails: Beide extrahieren gezielt Felder aus dem Request-Body. Der Unterschied ist nur die Syntax.
revalidatePath – Cache nach der Mutation invalidieren
Next.js cached Seiten aggressiv. Nach einer Mutation muss der Cache der betroffenen Route explizit ungültig gemacht werden, damit Nutzer beim nächsten Aufruf die aktuellen Daten sehen.
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createNoteAction(fd: FormData) {
await prisma.note.create({
data: { title: fd.get("title") as string, /* … */ },
});
// Seite /notes aus dem Cache werfen → nächster Request fetcht frisch
revalidatePath("/notes");
// Direkt weiterleiten, wie redirect_to in Rails
redirect("/notes");
}revalidatePath("/notes") invalidiert genau diese Route. Für feingranulareres Caching gibt es auch revalidateTag("notes"), das alle mit diesem Tag markierten Fetches ungültig macht – mehr dazu in Modul 7.
revalidatePath entspricht in Rails dem Zusammenspiel von redirect_to (Navigation) und dem manuellen Leeren eines Fragment- oder Action-Caches. In einer Standard-Rails-App ohne Caching ist redirect_to @note der vollständige Ablauf; in Next.js braucht man zusätzlich den Cache-Invalidierungsschritt, weil das Framework standardmäßig sehr aggressiv cached.
useActionState – Formular-State mit React 19
Wenn eine Server Action Fehler zurückgeben oder den Zustand einer Komponente steuern soll, kommt useActionState ins Spiel. Der Hook verbindet einen React-State mit einer Server Action und gibt den jeweils letzten Rückgabewert der Action als State-Wert zurück.
"use client";
import { useActionState } from "react";
import { createNoteAction } from "@/app/notes/actions";
type ActionState = { error?: string } | null;
export default function NewNotePage() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
createNoteAction,
null // Initialzustand
);
return (
<form action={formAction}>
{state?.error && (
<p className="text-red-600 text-sm">{state.error}</p>
)}
<input name="title" placeholder="Titel" />
<textarea name="content" placeholder="Inhalt" />
<button type="submit" disabled={isPending}>
{isPending ? "Speichern …" : "Speichern"}
</button>
</form>
);
}Die Server Action muss dafür ihren Rückgabewert anpassen: statt redirect() bei Fehlern nun return { error: "…" }. Das erlaubt die Anzeige von Validierungsfehlern direkt im Formular – analog zu Rails' render :new, status: :unprocessable_entity.
Die drei Rückgabewerte von useActionState:
state– der letzte von der Action zurückgegebene Wert (hier Fehlerobjekt odernull)formAction– die umwickelte Action, die dem<form action={…}>übergeben wirdisPending–true, solange die Action noch läuft (seit React 19)
useFormStatus – Pending-State im Submit-Button
useFormStatus ist ein spezialisierter Hook, der innerhalb einer Formular-Kindkomponente den Status des nächstliegenden übergeordneten Formulars liest. Er ist ideal für einen wiederverwendbaren Submit-Button, der während einer laufenden Action deaktiviert wird:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-indigo-600 disabled:bg-indigo-400 text-white rounded"
>
{pending ? "Läuft …" : label}
</button>
);
}Wichtig: useFormStatus muss in einer Kindkomponente des Formulars verwendet werden – nicht direkt im selben Komponenten-Baum wie das <form>-Element. Das ist der Grund, warum SubmitButton als eigene Komponente ausgelagert wird.
Validierung mit Zod
Bevor Formulardaten in die Datenbank geschrieben werden, müssen sie validiert werden. Zod ist die TypeScript-native Entsprechung zu Rails-Model-Validierungen.
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
// Schema definieren – ≈ validates :title, presence: true, length: { maximum: 255 }
const NoteSchema = z.object({
title: z.string().min(1, "Titel darf nicht leer sein").max(255, "Titel zu lang"),
content: z.string().min(1, "Inhalt darf nicht leer sein"),
});
type ActionState = { errors: Record<string, string[]> } | null;
export async function createNoteAction(
_prev: ActionState,
fd: FormData
): Promise<ActionState> {
// 1. FormData in ein plain Object umwandeln
const raw = {
title: fd.get("title") as string,
content: fd.get("content") as string,
};
// 2. Mit Zod validieren
const result = NoteSchema.safeParse(raw);
if (!result.success) {
// 3. Fehler als State zurückgeben – kein redirect, kein throw
return { errors: result.error.flatten().fieldErrors };
}
// 4. Datenbankschreibzugriff mit validierten Daten
await prisma.note.create({ data: result.data });
revalidatePath("/notes");
redirect("/notes");
}Das Muster hat vier klar abgegrenzte Schritte:
- Rohdaten extrahieren –
fd.get(…)liest aus demFormData-Objekt - Validieren –
safeParsegibt entweder{ success: true, data }oder{ success: false, error }zurück - Fehler zurückgeben – der Hook-State wird aktualisiert, das Formular bleibt sichtbar
- Persistieren – erst wenn die Validierung erfolgreich war, wird Prisma aufgerufen
Das entspricht genau dem Rails-Muster: @note.valid? → render :new oder @note.save → redirect_to.
Live-Demo: Server Action Counter
Der folgende Counter nutzt useActionState mit einer inline deklarierten Server Action. Jeder Klick löst eine echte Server-Roundtrip aus – der Zählerstand liegt auf dem Server, nicht im Client-State.
Öffne die DevTools → Network-Tab, bevor du klickst: Du siehst einen POST-Request an eine interne Next.js-URL. Das ist der transparente HTTP-Transport, den das Framework für Server Actions generiert.
Server Action Counter
0Beachte den isPending-State: Während die Action läuft (simulierte 300 ms Verzögerung), ist der Button deaktiviert und zeigt „Läuft…". Das ist useFormStatus in Aktion.
Öffne die Datei app/notes/actions.ts im Projekt. Sie enthält createNoteAction – identifiziere die vier Schritte aus dem Zod-Abschnitt:
- Wo werden die Rohdaten aus
FormDataextrahiert? (fd.get(…)) - Wo findet die Zod-Validierung statt? (
NoteSchema.safeParse(…)) - Was passiert im Fehlerfall? (Kein
redirect, keinthrow– was gibt die Funktion zurück?) - Wo passiert der Datenbankschreibzugriff? (
prisma.note.create(…))
Bonus: Die Funktion updateNoteAction validiert dieselben Felder, schreibt aber auf eine bestehende Note. Warum ruft sie revalidatePath zweimal auf?
Im Capstone: DevNotes
In der DevNotes-App stecken alle CRUD-Operationen als Server Actions in app/notes/actions.ts:
createNoteAction– liestFormData, validiert mit Zod, schreibt per Prisma, leitet nach/notesweiterupdateNoteAction– nimmt zusätzlich die Note-ID als Argument (viabind), validiert und aktualisiertdeleteNoteAction– prüft Eigentümerschaft, löscht und leitet weiterlogoutAction– zerstört die Session und leitet zur Login-Seite
Die Formulare in app/notes/new/page.tsx und app/notes/[id]/edit/page.tsx übergeben diese Actions direkt als action-Prop. Es gibt keinen einzigen fetch-Aufruf in der gesamten Mutations-Logik der App – das ist der Kernvorteil von Server Actions gegenüber dem klassischen REST-API-Ansatz.