React Server Components und Server Actions: Die neue Grenze
Server Components laufen außerhalb des Client-Bundles und liefern ihr Ergebnis statt ihres Codes an den Browser. Server Functions holen Schreibvorgänge auf den Server – umgesetzt wird beides durch ein passendes Framework oder einen Bundler.
Error: You're importing a component that needs `useState`.
This React Hook only works in a Client Component.
To fix, mark the file (or its parent) with the "use client" directive.
Diese Meldung ist für viele der erste Kontakt mit React Server Components, und der übliche Reflex darauf lautet: "use client" in die erste Zeile, Fehler weg, weiter im Text. Der Build ist damit wieder grün und die eigentliche Frage übersprungen. Die Meldung ist nämlich kein Konfigurationsfehler. Sie fragt, auf welche Seite einer Grenze dieses Stück Oberfläche gehört – und die Antwort reicht weit über die Datei hinaus, in der die Direktive am Ende steht.
Denn wer eine Architekturgrenze verschiebt, verschiebt immer mehr als Code: auch, wo getestet wird, was deployt werden muss und welches Team für welchen Teil der Oberfläche zuständig ist. Genau das ist der Trade-off, um den es hier eigentlich geht. Die schnellere Auslieferung und das kleinere Bundle sind der sichtbare Gewinn. Die eigentliche Wirkung liegt darunter: kürzere Wege vom Datenzugriff zur Anzeige, weniger Reibung an der Naht zwischen den beiden Welten – erkauft mit einer Grenze, die man von nun an bewusst ziehen und verteidigen muss.
Redaktionelle Einordnung (Juli 2026): React 19 hat Server Components als Komponentenmodell stabilisiert. Die Schnittstellen, mit denen Frameworks und Bundler sie umsetzen, folgen innerhalb React 19.x jedoch weiterhin nicht der üblichen Semver-Stabilität. React allein liefert daher noch keine vollständige Produktionsarchitektur dafür; die Beispiele dieses Beitrags verwenden den Next.js App Router. Server Components können außerdem beim Build oder pro Anfrage laufen. Nur die dynamische Variante braucht dafür einen Laufzeitserver.
Daran bleiben viele zunächst hängen: Man sucht nach einem neuen Hook und stößt stattdessen auf eine Grenze, die sich verschoben hat – mit Folgen für Test, Betrieb und Teamzuschnitt. Welcher Teil der Oberfläche auf dem Server bleibt und welcher in den Browser geht, nimmt einem das Framework von jetzt an nicht mehr ab. Darum geht es in diesem Beitrag.
Was eine Server Component wirklich ist
Eine Server Component läuft in einer Umgebung außerhalb des Client-Bundles: beim Build oder auf einem Server pro Anfrage. Dort kann sie mit direktem Zugriff auf Datenbank, Dateisystem oder interne Services rendern und anschließend nur ihr Ergebnis an den Browser schicken. Nicht ihren Code. Das ist der entscheidende Satz. Für eine Komponente, die bloß etwas anzeigt, landet am Ende kein eigener Komponenten-Code im Client-Bundle. Der Browser bekommt eine Beschreibung dessen, was er darstellen soll, und nicht die Komponente, die diese Beschreibung erzeugt hat.
Das ist ein anderer Umgang mit dem Wort „rendern“, als man ihn gewohnt ist. Deshalb lohnt sich der Rückgriff auf einen früheren Beitrag: In Wie React den Baum rendert habe ich gezeigt, dass Rendern bei React ohnehin nur eine Berechnung ist – eine Funktion, die aus Eingaben eine Beschreibung des Baums macht, die React dann mit dem letzten Stand abgleicht. Wenn Rendern aber nur eine Berechnung ist, dann stellt sich sofort die Frage: Wo läuft diese Berechnung? Genau hier setzen Server Components an. Sie verlagern einen Teil dieser Berechnung dauerhaft auf den Server, statt ihn wie bisher im Browser zu erledigen.
Der Unterschied zu SSR, den fast alle verwechseln
Ein Punkt gehört hier geradegerückt, weil er zuverlässig für Verwirrung sorgt: Server Components sind nicht dasselbe wie Server-Side Rendering, und der Unterschied ist nicht kosmetisch.
Klassisches SSR nimmt ganz normale Client-Komponenten und rendert sie einmal auf dem Server zu HTML, damit der Browser sofort etwas sieht. Danach schickt der Server aber trotzdem den Komponenten-Code hinterher, und im Browser läuft die Hydratisierung: React hängt sich an das gelieferte HTML, spielt dieselbe Komponente noch einmal durch und macht sie interaktiv. Der Code geht also mit – SSR ist ein früher erster Eindruck, kein Sparen von JavaScript. Die Komponente existiert am Ende doppelt: einmal als HTML vom Server, einmal als lauffähiger Code im Browser.
Eine Server Component existiert dagegen nie im Client-Bundle. Es gibt nichts zu hydratisieren, weil es im Browser keine zweite Ausführung gibt. Der Server hat gerechnet, das Ergebnis ist da, und damit endet die Geschichte für diesen Teil des Baums. SSR beantwortet die Frage „wann sieht der Nutzer zum ersten Mal etwas“. Server Components beantworten die andere Frage: „muss dieser Code überhaupt jemals in den Browser“. Für reine Anzeige ist die Antwort jetzt nein.
flowchart TB
subgraph Server
SC["Server Component<br/>liest Daten direkt"]
end
subgraph Browser
UI["fertiges Ergebnis<br/>wird angezeigt"]
Island["Client-Insel<br/>(Hooks, Events)"]
end
DB[("Datenbank / Service")]
SC -->|await| DB
SC -->|nur Ergebnis,<br/>kein Code| UI
SC -.->|bettet ein| Island
Die Grenze heißt "use client"
Wenn Server Components der Normalfall sind, braucht man eine Markierung für die Ausnahme – für die Stelle, an der Interaktivität beginnt. Diese Markierung ist die Direktive "use client" am Anfang einer Datei. Alles oberhalb dieser Grenze läuft auf dem Server: Daten laden, anzeigen, Struktur bauen, kein State. Ab der Direktive ist man im Browser-Land mit allem, was dazugehört – useState, useEffect, Event-Handler, Zugriff auf window.
Wichtig ist die Richtung, in der diese beiden Welten zusammenpassen. Eine Server Component darf Client Components als Kinder enthalten – man streut Inseln der Interaktivität in eine ansonsten statische, serverseitig gerenderte Seite. Der umgekehrte Weg ist eingeschränkt: Eine Client Component kann eine Server Component nicht einfach importieren, weil der Server-Code dann ja doch im Browser landen müsste. Sie kann eine Server Component nur als übergebenen children-Slot einbetten – der Server rendert das Kind, die Client-Komponente bekommt das fertige Ergebnis gereicht und packt ihr interaktives Drumherum darum. Diese eine Regel erklärt die meisten Fehlermeldungen, über die Leute am Anfang stolpern.
flowchart TB Page["Server Component (Seite)<br/>lädt Daten, zeigt an"] Page --> List["Server Component<br/>Rechnungsliste"] Page --> Button["Client-Insel<br/>"use client": Button mit onClick"] Button -->|darf als children| Slot["Server-Inhalt<br/>als Slot"]
Server Components konkret
Weil eine Server Component nur auf dem Server läuft, darf sie etwas, das im Browser nie ging: Sie darf async sein und im Body direkt await nutzen. Das Laden von Daten passiert dann ohne useEffect, ohne Lade-Flag, ohne den ganzen Apparat – man schreibt es hin, als wäre es eine normale Funktion, die eben auf eine Datenbank zugreifen kann.
// invoices-page.jsx — a Server Component, no "use client" here
import { db } from "@/lib/db";
import { PayButton } from "./pay-button";
export default async function InvoicesPage() {
const invoices = await db.invoice.findMany({ where: { open: true } });
return (
<section>
<h1>Open invoices: {invoices.length}</h1>
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.title} — {invoice.amount} €
<PayButton invoiceId={invoice.id} />
</li>
))}
</ul>
</section>
);
}
Zwei Dinge stehen hier bewusst nebeneinander. Der await db.invoice.findMany(...) greift direkt auf die Datenbank zu – dieser Code verlässt den Server nie, es gibt keine API-Route dazwischen und keinen Ladezustand im Browser. Und PayButton ist eine eingebettete Client-Insel: alles, was einen Klick braucht, wandert in eine eigene Datei mit "use client".
// pay-button.jsx
"use client";
import { useState } from "react";
export function PayButton({ invoiceId }) {
const [busy, setBusy] = useState(false);
return (
<button disabled={busy} onClick={() => setBusy(true)}>
{busy ? "Paying…" : "Pay"}
</button>
);
}
Was in einer Server Component nicht geht, ist ebenso lehrreich: kein useState, kein useEffect. In dieser Umgebung gibt es keinen lokalen Client-Zustand, den man über Klicks hinweg hält. Eine Server Component kann bei einem späteren Request oder einer erneuten Server-Ausführung wieder rendern; direkte Browser-Interaktion verarbeitet sie aber nicht. Alles, was auf einen Klick unmittelbar im Browser reagieren soll, gehört in eine Client-Insel jenseits der Grenze.
Server Actions: das Schreiben zurück auf den Server
Server Components lösen das Lesen und Anzeigen. Für das Schreiben gibt es Server Functions, oft Server Actions genannt, markiert mit der Direktive "use server". Das sind Funktionen, die auf dem Server laufen, aber über die Integration des Frameworks vom Client aus aufgerufen werden – am direktesten als action eines <form>. Man baut in der Anwendung keine eigene API-Route und kein eigenes Request-Format für diesen Aufruf. Den Transport, die Serialisierung und die Sicherheitsgrenze übernimmt aber nicht React als Laufzeit allein, sondern das eingesetzte Framework.
// invoice-actions.js
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function payInvoice(formData) {
const id = formData.get("invoiceId");
await db.invoice.update({ where: { id }, data: { open: false } });
revalidatePath("/invoices");
}
Aufgerufen wird das direkt aus dem Formular. Damit der Nutzer während der laufenden Aktion Rückmeldung bekommt, gibt es zwei Hooks, die genau dafür da sind: useActionState bündelt den Aufruf mit seinem Ergebnis und einem Pending-Flag, useFormStatus liest den Pending-Zustand des umgebenden Formulars in einem Kind-Element aus.
// pay-form.jsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { payInvoice } from "./invoice-actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Paying…" : "Pay invoice"}
</button>
);
}
export function PayForm({ invoiceId }) {
const [state, formAction] = useActionState(payInvoice, null);
return (
<form action={formAction}>
<input type="hidden" name="invoiceId" value={invoiceId} />
<SubmitButton />
</form>
);
}
Der Reiz liegt darin, wie wenig hier steht. Kein fetch, kein manuelles POST, kein selbstgebauter Endpunkt, keine Serialisierung. Man ruft eine Funktion auf, die zufällig auf dem Server läuft, und der Pending-Zustand für die Oberfläche fällt nebenbei ab.
flowchart LR
Form["Formular im Browser<br/>action = payInvoice"]
Form -->|Absenden| Action["Server Action<br/>"use server""]
Action -->|schreibt| DB[("Datenbank")]
Action -->|revalidatePath| Fresh["Server rendert<br/>betroffene Ansicht neu"]
Fresh -->|neues Ergebnis| Form
Was das für die Serie bedeutet
Diese neue Grenze verschiebt eine andere, über die ich schon geschrieben habe. In Client-Zustand vs. Server-Cache war der Kernpunkt: Das meiste, was wie Zustand aussieht, ist in Wahrheit geliehener Server-Cache, den man im Browser nur zwischenspeichert. Server Components verschieben diese Grenze noch einmal. Ein großer Teil des Anzeige-Datenladens, für den man bisher eine Cache-Bibliothek im Browser gebraucht hat, wandert jetzt auf den Server – dort liest die Server Component direkt und schickt nur das Ergebnis. Der Bedarf, dieselben Daten im Client nachzuhalten, schrumpft entsprechend.
Das heißt nicht, dass Server-State-Bibliotheken überflüssig werden. Sobald es interaktiv wird – Daten, die sich nach einem Klick ändern, ohne die ganze Seite neu zu laden – bleibt der Cache im Browser sinnvoll. Aber die Aufteilung verschiebt sich: weniger Anzeige im Client, mehr auf dem Server. Für das gestaffelte Ausliefern sorgt dabei Suspense, das die serverseitig gerenderten Teile stückweise zum Browser streamt.
Was die verschobene Grenze mit Test, Betrieb und Team macht
Bis hierher klingt das nach einer sauberen technischen Umschichtung. Interessant wird es aber dort, wo diese Grenze auf die Art trifft, wie ein Team arbeitet, testet und ausliefert. Denn eine Architekturgrenze ist nie nur eine Zeile Code. Sie entscheidet, an welcher Stelle die Komplexität anfällt – und Komplexität hat immer eine Rechnung.
Fangen wir bei der Testbarkeit an, weil sich dort am deutlichsten zeigt, was die Verschiebung kostet und was sie einspart. Eine reine Anzeige-Komponente, die vorher im Browser Daten geladen hat, war überraschend aufwendig zu testen: fetch mocken, Ladezustand durchspielen, Fehlerpfad nachstellen, das erneute Rendern nach dem Eintreffen der Daten abwarten. Als Server Component fällt dieser ganze Apparat weg. Die Komponente ist eine async-Funktion, die Daten hereinbekommt und eine Beschreibung zurückgibt – testbar wie eine Funktion, ohne Browser-Umgebung, ohne Timing. Der Preis steht auf der anderen Seite der Grenze: Der direkte await db.invoice.findMany(...) im Komponenten-Body verwebt Datenzugriff und Darstellung, und die Komponente will für einen Test jetzt eine Datenbank sehen. Das Testen verlagert sich also, statt zu verschwinden: Server Components prüft man eher über Integrationstests gegen eine echte oder in-memory Datenquelle, die interaktiven Inseln weiterhin als isolierte Client-Tests. Wer diese Zweiteilung bewusst zieht, gewinnt; wer sie ignoriert, baut sich schwer testbare Zwitter.
Beim Deployment verschiebt sich der Charakter der Anwendung, sobald Server Components pro Anfrage laufen oder Server Functions schreiben. Client-only React konnte am Ende ein Bündel statischer Dateien hinter einem CDN sein; reine, beim Build ausgeführte Server Components können ebenfalls statisch ausgeliefert werden. Die dynamische Variante braucht dagegen einen laufenden Prozess mit Zugriff auf Datenbank und interne Dienste. Das bringt Nähe – der Datenzugriff sitzt direkt neben der Komponente, ohne eigene API-Schicht dazwischen – und Betriebsverantwortung: Serverkapazität, Laufzeitfehler jenseits des Browsers und eine Grenze zwischen öffentlichem und internem Code, die zur Sicherheitsgrenze wird. Der Gewinn ist eine kürzere Kette vom Klick bis zur Persistenz. Die Kosten sind eine Betriebsfläche, die vorher nicht da war.
Und dann ist da der Teamzuschnitt, der Punkt, den man am leichtesten übersieht und am teuersten bezahlt. In der klassischen Aufteilung gab es eine harte Naht: Frontend baut die Oberfläche, Backend liefert die API, dazwischen ein Vertrag aus Endpunkten und Formaten. Diese Naht war Reibung, aber auch eine klare Zuständigkeitsgrenze. Server Components und Server Actions lösen sie auf – der Datenzugriff wandert in die Komponente, das Schreiben in eine Funktion daneben, der Vertrag aus API-Routen verschwindet. Für ein Team, das über die ganze Tiefe arbeitet, ist das ein spürbarer Reibungsverlust: kürzere Wege, weniger Abstimmung über Endpunkte, weniger Code, der nur Daten hin- und herträgt. Für zwei getrennte Teams mit sauberer Frontend-Backend-Trennung ist es das Gegenteil: Die Grenze, an der sie ihre Verantwortung übergeben haben, ist weg, und die neue verläuft mitten durch die Oberfläche. Ob Server Components ein Gewinn oder ein Konfliktherd sind, hängt damit weniger an der Technik als an der Organisation.
Daraus ergeben sich die Entscheidungskriterien, die ich in Projekten anlege. Server Components zahlen sich aus, wenn das Team über die volle Tiefe arbeitet und die Frontend-Backend-Naht bisher eher Ballast als Schutz war, wenn ein laufender Server ohnehin zum Betrieb gehört, und wenn die Oberfläche datenlastig und wenig interaktiv ist. Sie kosten mehr, als sie bringen, wenn zwei Teams entlang einer bewusst gezogenen API-Grenze liefern, wenn der Betrieb rein statisch bleiben soll, oder wenn die Anwendung fast nur aus interaktiven Inseln besteht. Die Frage ist also nicht „ist die Technik gut“, sondern „passt die verschobene Grenze zu dem, wie wir bauen, testen und ausliefern“.
Ehrlich eingeordnet: neu und mit Reibung
Ich will das nicht schönreden. Server Components sind neu, und sie bringen Reibung mit sich. Man muss die Grenze zwischen Server und Client wirklich verstanden haben, sonst produziert man genau die Fehler, die ich oben beschrieben habe – einen Import über die Grenze, einen Hook auf der falschen Seite, einen Datenzugriff, der plötzlich im Browser landen soll. Das Ökosystem ist noch im Fluss: Die verbreitete Umsetzung ist heute der Next.js App Router, andere Frameworks ziehen nach, und manche Bibliothek muss erst lernen, wo sie in dieser Welt hingehört.
Und nicht jede Anwendung braucht das. Ein hochinteraktives Dashboard, das ohnehin fast vollständig im Browser läuft, gewinnt wenig, wenn man es durch die Server-Client-Grenze zwängt. Wo sich Server Components dagegen deutlich lohnen: bei datenlastigen, weitgehend statischen Oberflächen, wo viel angezeigt und wenig geklickt wird; wenn das Client-Bundle spürbar drückt und man Anzeige-Code loswerden will, der im Browser nichts zu suchen hat; und dort, wo serverseitig geliefertes HTML für Suchmaschinen und den ersten Eindruck zählt. Das ist eine ehrliche Abwägung, keine Pflicht.
Die eigentliche Entscheidung
Wenn ein Gedanke aus diesem Beitrag hängen bleiben soll, dann dieser: Server Components sind keine schnellere Art zu rendern. Sie sind eine Ortsentscheidung. Bei jedem Teil der Oberfläche fragt man jetzt zuerst: Bleibt das auf dem Server – weil es nur liest und anzeigt – oder gehört es in den Browser, weil ein Mensch damit interagiert? Die Direktiven "use client" und "use server" sind nichts anderes als die sichtbaren Markierungen dieser einen Entscheidung.
Wer die Grenze bewusst zieht, erntet die Wirkung, die unter der Technik liegt: kürzere Wege vom Datenzugriff zur Anzeige und damit schnellere Zyklen, weniger Reibung an der aufgelösten Frontend-Backend-Naht, und eine Testbarkeit, die sich sauber in serverseitige Integrationstests und Client-Inseln teilt. Wer die Grenze dagegen verwischt, bekommt das Gegenteil – schwer testbare Zwitter, unklare Zuständigkeiten, Betriebslast ohne Gegenwert. Der Unterschied liegt nicht im Framework. Er liegt darin, ob die verschobene Grenze zu dem passt, wie ein Team baut, testet und ausliefert – und ob man sie danach klar genug hält, um sie zu verteidigen. Diese Frage ist das Wesentliche – lange bevor man den ersten Hook nachschlägt.
Weiterführende Quellen
- Repository introduction-react (Kapitel server-components, server-actions; App-Router-Beispiele): https://github.com/mikebild/introduction-react
- React, „Server Components“: https://react.dev/reference/rsc/server-components
- React, „Server Functions“: https://react.dev/reference/rsc/server-functions
- Next.js, App Router: https://nextjs.org/docs/app
Wie fandest du diesen Beitrag?
Kommentare