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.

Rails-Analogie
Ruby on Rails
# 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
Next.js
// 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.

Rails-Analogie
Ruby on Rails
<%# app/views/notes/new.html.erb %>
<%= form_with model: @note do |f| %>
<%= f.text_field :title %>
<%= f.text_area :content %>
<%= f.submit "Speichern" %>
<% end %>
Next.js
{/* 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.

Rails-Analogie

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 oder null)
  • formAction – die umwickelte Action, die dem <form action={…}> übergeben wird
  • isPendingtrue, 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:

  1. Rohdaten extrahierenfd.get(…) liest aus dem FormData-Objekt
  2. ValidierensafeParse gibt entweder { success: true, data } oder { success: false, error } zurück
  3. Fehler zurückgeben – der Hook-State wird aktualisiert, das Formular bleibt sichtbar
  4. Persistieren – erst wenn die Validierung erfolgreich war, wird Prisma aufgerufen

Das entspricht genau dem Rails-Muster: @note.valid?render :new oder @note.saveredirect_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 CounterZähler-State liegt auf dem Server

Server Action Counter

0
Klick → Network Tab beobachten

Beachte 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.

actions.ts erkunden

Öffne die Datei app/notes/actions.ts im Projekt. Sie enthält createNoteAction – identifiziere die vier Schritte aus dem Zod-Abschnitt:

  1. Wo werden die Rohdaten aus FormData extrahiert? (fd.get(…))
  2. Wo findet die Zod-Validierung statt? (NoteSchema.safeParse(…))
  3. Was passiert im Fehlerfall? (Kein redirect, kein throw – was gibt die Funktion zurück?)
  4. 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 – liest FormData, validiert mit Zod, schreibt per Prisma, leitet nach /notes weiter
  • updateNoteAction – nimmt zusätzlich die Note-ID als Argument (via bind), validiert und aktualisiert
  • deleteNoteAction – prüft Eigentümerschaft, löscht und leitet weiter
  • logoutAction – 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.