Zum Inhalt springen
·17 Min. Lesezeit

React Server Components: Mentale Modelle, Patterns und Stolperfallen

Der praktische Guide zu React Server Components, den ich mir gewünscht hätte. Mentale Modelle, echte Patterns, das Boundary-Problem und Fehler, die ich gemacht habe, damit du sie nicht machen musst.

Teilen:X / TwitterLinkedIn

Ich habe die ersten drei Monate mit React Server Components verbracht und gedacht, ich hätte sie verstanden. Ich hatte die RFCs gelesen, die Konferenzvorträge geschaut, ein paar Demo-Apps gebaut. Ich war selbstbewusst falsch in fast allem.

Das Problem ist nicht, dass RSC kompliziert ist. Es ist, dass das mentale Modell sich wirklich von allem unterscheidet, was wir bisher in React gemacht haben, und jeder — einschließlich mir — versucht, es in die alte Schublade zu stecken. „Es ist wie SSR." Ist es nicht. „Es ist wie PHP." Näher dran, aber nein. „Es sind einfach Komponenten, die auf dem Server laufen." Technisch korrekt, praktisch nutzlos.

Was folgt, ist alles, was ich tatsächlich wissen musste, geschrieben so, wie ich mir gewünscht hätte, dass es mir jemand erklärt. Nicht die theoretische Version. Die, bei der du um 23 Uhr auf einen Serialisierungsfehler starrst und verstehen musst, warum.

Das mentale Modell, das tatsächlich funktioniert#

Vergiss für einen Moment alles, was du über React-Rendering weißt. Hier ist das neue Bild.

Im traditionellen React (Client-seitig) wird dein gesamter Komponentenbaum als JavaScript an den Browser geschickt. Der Browser lädt es herunter, parst es, führt es aus und rendert das Ergebnis. Jede Komponente — ob ein 200 Zeilen langes interaktives Formular oder ein statischer Textabsatz — durchläuft dieselbe Pipeline.

React Server Components teilen das in zwei Welten auf:

Server Components laufen auf dem Server. Sie werden einmal ausgeführt, produzieren ihr Ergebnis und senden das Resultat an den Client — nicht den Code. Der Browser sieht nie die Komponentenfunktion, lädt nie ihre Abhängigkeiten herunter, rendert sie nie neu.

Client Components funktionieren wie traditionelles React. Sie werden an den Browser geschickt, hydriert, verwalten State, behandeln Events. Es ist das React, das du bereits kennst.

Die Schlüsselerkenntnis, für die ich peinlich lange gebraucht habe, sie zu verinnerlichen: Server Components sind der Standard. Im Next.js App Router ist jede Komponente eine Server Component, es sei denn, du optierst sie explizit mit "use client" für den Client. Das ist das Gegenteil von dem, was wir gewohnt sind, und es ändert, wie du über Komposition nachdenkst.

Der Rendering-Wasserfall#

Hier ist, was tatsächlich passiert, wenn ein Benutzer eine Seite anfordert:

1. Request trifft auf dem Server ein
2. Server führt Server Components von oben nach unten aus
3. Wenn eine Server Component auf eine "use client"-Grenze trifft,
   stoppt sie — dieser Unterbaum wird auf dem Client rendern
4. Server Components erzeugen RSC Payload (ein spezielles Format)
5. RSC Payload wird an den Client gestreamt
6. Client rendert Client Components und fügt sie in den
   server-gerenderten Baum ein
7. Hydration macht Client Components interaktiv

Schritt 4 ist, wo die meiste Verwirrung lebt. Der RSC Payload ist kein HTML. Es ist ein spezielles Streaming-Format, das den Komponentenbaum beschreibt — was der Server gerendert hat, wo der Client übernehmen muss und welche Props über die Grenze übergeben werden.

Er sieht ungefähr so aus (vereinfacht):

M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]

Du musst dir dieses Format nicht merken. Aber zu verstehen, dass es existiert — dass es eine Serialisierungsschicht zwischen Server und Client gibt — wird dir Stunden des Debuggings ersparen. Jedes Mal, wenn du einen „Props must be serializable"-Fehler bekommst, liegt es daran, dass etwas, das du übergibst, diese Übersetzung nicht überleben kann.

Was „läuft auf dem Server" wirklich bedeutet#

Wenn ich sage, eine Server Component „läuft auf dem Server", meine ich das wörtlich. Die Komponentenfunktion wird in Node.js (oder der Edge Runtime) ausgeführt. Das bedeutet, du kannst:

tsx
// app/dashboard/page.tsx — dies ist standardmäßig eine Server Component
import { db } from "@/lib/database";
import { headers } from "next/headers";
 
export default async function DashboardPage() {
  const headerList = await headers();
  const userId = headerList.get("x-user-id");
 
  // Direkte Datenbankabfrage. Kein API-Route nötig.
  const user = await db.user.findUnique({
    where: { id: userId },
  });
 
  const recentOrders = await db.order.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <OrderList orders={recentOrders} />
    </div>
  );
}

Kein useEffect. Kein Loading-State-Management. Kein API-Route, um alles zusammenzukleben. Die Komponente ist die Datenschicht. Das ist der größte Gewinn von RSC, und es fühlte sich anfangs am unangenehmsten an, weil ich ständig dachte: „Aber wo ist die Trennung?"

Die Trennung ist die "use client"-Grenze. Alles darüber ist Server. Alles darunter ist Client. Das ist deine Architektur.

Die Server/Client-Grenze#

Hier bricht das Verständnis der meisten Leute zusammen, und hier habe ich in den ersten Monaten die meiste Debugging-Zeit verbracht.

Die "use client"-Direktive#

Die "use client"-Direktive am Anfang einer Datei markiert alles, was aus dieser Datei exportiert wird, als Client Component. Es ist eine Annotation auf Modulebene, nicht auf Komponentenebene.

tsx
// src/components/Counter.tsx
"use client";
 
import { useState } from "react";
 
// Diese gesamte Datei ist jetzt "Client-Territorium"
export function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
 
// Das ist EBENFALLS eine Client Component, weil sie in derselben Datei ist
export function ResetButton({ onReset }: { onReset: () => void }) {
  return <button onClick={onReset}>Reset</button>;
}

Häufiger Fehler: "use client" in eine Barrel-Datei (index.ts) zu setzen, die alles re-exportiert. Herzlichen Glückwunsch, du hast gerade deine gesamte Komponentenbibliothek clientseitig gemacht. Ich habe Teams gesehen, die auf diese Weise versehentlich 200 KB JavaScript ausgeliefert haben.

Was die Grenze überquert#

Hier ist die Regel, die dich retten wird: Alles, was die Server-Client-Grenze überquert, muss zu JSON serialisierbar sein.

Was serialisierbar ist:

  • Strings, Zahlen, Booleans, null, undefined
  • Arrays und einfache Objekte (die serialisierbare Werte enthalten)
  • Dates (als ISO-Strings serialisiert)
  • Server Components (als JSX — dazu kommen wir noch)
  • FormData
  • Typed Arrays, ArrayBuffer

Was NICHT serialisierbar ist:

  • Funktionen (einschließlich Event-Handler)
  • Klassen (Instanzen benutzerdefinierter Klassen)
  • Symbols
  • DOM-Knoten
  • Streams (in den meisten Kontexten)

Das bedeutet, du kannst das nicht tun:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
 
export default async function Page() {
  const items = await getItems();
 
  return (
    <ItemList
      items={items}
      // FEHLER: Funktionen sind nicht serialisierbar
      onItemClick={(id) => console.log(id)}
      // FEHLER: Klasseninstanzen sind nicht serialisierbar
      formatter={new Intl.NumberFormat("en-US")}
    />
  );
}

Der Fix ist nicht, die Seite zu einer Client Component zu machen. Der Fix ist, Interaktivität nach unten und Datenabruf nach oben zu schieben:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // Nur serialisierbare Daten übergeben
  return <ItemList items={items} locale="en-US" />;
}
tsx
// src/components/ItemList.tsx (Client Component)
"use client";
 
import { useState, useMemo } from "react";
 
interface Item {
  id: string;
  name: string;
  price: number;
}
 
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
  // Formatter auf der Client-Seite erstellen
  const formatter = useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
    [locale]
  );
 
  return (
    <ul>
      {items.map((item) => (
        <li
          key={item.id}
          onClick={() => setSelectedId(item.id)}
          className={selectedId === item.id ? "selected" : ""}
        >
          {item.name} — {formatter.format(item.price)}
        </li>
      ))}
    </ul>
  );
}

Das „Insel"-Missverständnis#

Anfangs dachte ich an Client Components als „Inseln" — kleine interaktive Teile in einem Meer von server-gerendertem Inhalt. Das ist teilweise richtig, verfehlt aber ein entscheidendes Detail: eine Client Component kann Server Components rendern, wenn sie als Children oder Props übergeben werden.

Das bedeutet, die Grenze ist keine harte Wand. Sie ist eher wie eine Membran. Server-gerenderte Inhalte können durch Client Components fließen, über das Children-Pattern. Wir gehen darauf im Kompositions-Abschnitt noch genauer ein.

Datenabruf-Patterns#

RSC verändert den Datenabruf grundlegend. Kein useEffect + useState + Loading-States mehr für Daten, die zur Renderzeit bekannt sind. Aber die neuen Patterns haben ihre eigenen Tücken.

Einfacher Fetch mit Caching#

In einer Server Component fetchst du einfach. Next.js erweitert das globale fetch, um Caching hinzuzufügen:

tsx
// app/products/page.tsx
export default async function ProductsPage() {
  // Standardmäßig gecacht — dieselbe URL liefert gecachtes Ergebnis
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
 
  return (
    <div>
      {products.map((product: Product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Das Caching-Verhalten explizit steuern:

tsx
// Alle 60 Sekunden revalidieren (ISR-ähnliches Verhalten)
const res = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
});
 
// Kein Caching — immer frische Daten
const res = await fetch("https://api.example.com/user/profile", {
  cache: "no-store",
});
 
// Cache mit Tags für On-Demand-Revalidierung
const res = await fetch("https://api.example.com/products", {
  next: { tags: ["products"] },
});

Dann kannst du per Tag aus einer Server Action revalidieren:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

Paralleler Datenabruf#

Der häufigste Performance-Fehler, den ich sehe: sequenzieller Datenabruf, wenn parallel problemlos funktionieren würde.

Schlecht — sequenziell (Wasserfälle):

tsx
// app/dashboard/page.tsx — MACH DAS NICHT
export default async function Dashboard() {
  const user = await getUser();           // 200ms
  const orders = await getOrders();       // 300ms
  const notifications = await getNotifications(); // 150ms
  // Gesamt: 650ms — jeder wartet auf den vorherigen
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Gut — parallel:

tsx
// app/dashboard/page.tsx — MACH DAS
export default async function Dashboard() {
  // Alle drei starten gleichzeitig
  const [user, orders, notifications] = await Promise.all([
    getUser(),        // 200ms
    getOrders(),      // 300ms (läuft parallel)
    getNotifications(), // 150ms (läuft parallel)
  ]);
  // Gesamt: ~300ms — begrenzt durch die langsamste Anfrage
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Noch besser — parallel mit unabhängigen Suspense-Boundaries:

tsx
// app/dashboard/page.tsx — AM BESTEN
import { Suspense } from "react";
 
export default function Dashboard() {
  // Hinweis: diese Komponente ist NICHT async — sie delegiert an die Kinder
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </div>
  );
}
 
// Jede Komponente ruft ihre eigenen Daten ab
async function UserInfo() {
  const user = await getUser();
  return <div>{user.name}</div>;
}
 
async function OrderList() {
  const orders = await getOrders();
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
 
async function Notifications() {
  const notifications = await getNotifications();
  return <span>({notifications.length})</span>;
}

Dieses letzte Pattern ist am mächtigsten, weil jeder Abschnitt unabhängig lädt. Der Benutzer sieht Inhalte, sobald sie verfügbar sind, nicht alles-oder-nichts. Die schnellen Abschnitte warten nicht auf die langsamen.

Request-Deduplizierung#

Next.js dedupliziert automatisch fetch-Aufrufe mit derselben URL und denselben Optionen während eines einzelnen Renderdurchlaufs. Das bedeutet, du musst den Datenabruf nicht nach oben ziehen, um redundante Anfragen zu vermeiden:

tsx
// Beide Komponenten können dieselbe URL fetchen
// und Next.js macht nur EINE tatsächliche HTTP-Anfrage
 
async function Header() {
  const user = await fetch("/api/user").then(r => r.json());
  return <nav>Welcome, {user.name}</nav>;
}
 
async function Sidebar() {
  // Dieselbe URL — automatisch dedupliziert, keine zweite Anfrage
  const user = await fetch("/api/user").then(r => r.json());
  return <aside>Role: {user.role}</aside>;
}

Wichtiger Vorbehalt: Das funktioniert nur mit fetch. Wenn du ein ORM oder einen Datenbank-Client direkt verwendest, musst du Reacts cache()-Funktion nutzen:

tsx
import { cache } from "react";
import { db } from "@/lib/database";
 
// Wickle deine Datenfunktion in cache()
// Jetzt bedeuten mehrere Aufrufe im selben Render = eine tatsächliche Abfrage
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

cache() dedupliziert für die Lebenszeit einer einzelnen Serveranfrage. Es ist kein persistenter Cache — es ist eine Pro-Request-Memoisierung. Nachdem die Anfrage abgeschlossen ist, werden die gecachten Werte vom Garbage Collector aufgeräumt.

Komponentenkompositions-Patterns#

Hier wird RSC wirklich elegant, sobald du die Patterns verstehst. Und wirklich verwirrend, bis du es tust.

Das „Children-als-Lücke"-Pattern#

Das ist das wichtigste Kompositions-Pattern in RSC, und ich habe Wochen gebraucht, um es voll zu schätzen. Hier ist das Problem: Du hast eine Client Component, die Layout oder Interaktivität bereitstellt, und du möchtest Server Components darin rendern.

Du kannst eine Server Component nicht in eine Client-Component-Datei importieren. In dem Moment, wo du "use client" hinzufügst, ist alles in diesem Modul clientseitig. Aber du kannst Server Components als children übergeben:

tsx
// src/components/Sidebar.tsx — Client Component
"use client";
 
import { useState } from "react";
 
export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <aside className={isOpen ? "w-64" : "w-0"}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? "Close" : "Open"}
      </button>
      {isOpen && (
        <div className="sidebar-content">
          {/* Diese Children können Server Components sein! */}
          {children}
        </div>
      )}
    </aside>
  );
}
tsx
// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar>
        {/* Das sind Server Components, die durch eine Client Component durchgereicht werden */}
        <UserProfile />
        <NavigationLinks />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}

Warum funktioniert das? Weil die Server Components (UserProfile, NavigationLinks) zuerst auf dem Server gerendert werden, dann ihr Output (der RSC Payload) als children an die Client Component übergeben wird. Die Client Component muss nie wissen, dass sie Server Components waren — sie empfängt einfach vorgerenderte React-Knoten.

Stell dir children als ein „Loch" in der Client Component vor, durch das server-gerenderter Inhalt fließen kann.

Server Components als Props übergeben#

Das Children-Pattern verallgemeinert sich auf jede Prop, die React.ReactNode akzeptiert:

tsx
// src/components/TabLayout.tsx — Client Component
"use client";
 
import { useState } from "react";
 
interface TabLayoutProps {
  tabs: { label: string; content: React.ReactNode }[];
}
 
export function TabLayout({ tabs }: TabLayoutProps) {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      <div className="tab-bar" role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={i === activeTab}
            onClick={() => setActiveTab(i)}
            className={i === activeTab ? "active" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">{tabs[activeTab]?.content}</div>
    </div>
  );
}
tsx
// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "@/components/ProfileSettings";
import { SecuritySettings } from "@/components/SecuritySettings";
 
export default async function SettingsPage() {
  return (
    <TabLayout
      tabs={[
        {
          label: "Profil",
          content: <ProfileSettings />,
        },
        {
          label: "Sicherheit",
          content: <SecuritySettings />,
        },
      ]}
    />
  );
}

ProfileSettings und SecuritySettings sind Server Components. Sie werden auf dem Server gerendert, und ihre Ausgabe wird als serialisierte React-Knoten an das content-Prop übergeben. Die Client Component TabLayout schaltet zwischen ihnen um, ohne zu wissen oder sich darum zu kümmern, dass sie auf dem Server gerendert wurden.

Dieses Pattern ist unglaublich mächtig für jede Art von Container-Komponente: Tabs, Akkordeons, Modals, Karussells — überall, wo Client-seitige Interaktivität bestimmt, welcher Inhalt sichtbar ist, aber der Inhalt selbst keine Interaktivität braucht.

Die Provider-Falle#

Context Providers sind Client Components (sie nutzen createContext, was ein Client-API ist). Aber du brauchst Providers oft hoch im Baum — für Theme, Auth, Toasts usw.

Das Antimuster ist, alles in eine Client Component einzuwickeln:

tsx
// MACH DAS NICHT — macht alles clientseitig
"use client";
 
export default function RootLayout({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <ToastProvider>
          {children}
        </ToastProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}

Erstelle stattdessen eine dedizierte Providers-Wrapper-Komponente:

tsx
// src/components/Providers.tsx
"use client";
 
import { ThemeProvider } from "./ThemeProvider";
import { ToastProvider } from "./ToastProvider";
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <ToastProvider>{children}</ToastProvider>
    </ThemeProvider>
  );
}
tsx
// app/layout.tsx — Server Component bleibt Server Component
import { Providers } from "@/components/Providers";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="de">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

Das Layout bleibt eine Server Component. Die Children (deine Seiten) sind Server Components. Nur die Providers selbst sind clientseitig. Die Children fließen durch die Providers, ohne zum Client gezogen zu werden.

Server Actions#

Server Actions sind die zweite Hälfte von RSC. Wenn Server Components Daten an den Client fließen lassen (Server → Client), lassen Server Actions Mutationen vom Client zurück zum Server fließen (Client → Server).

Die Grundlagen#

tsx
// app/posts/new/page.tsx
export default function NewPostPage() {
  async function createPost(formData: FormData) {
    "use server";
 
    const title = formData.get("title") as string;
    const content = formData.get("content") as string;
 
    await db.post.create({
      data: { title, content, authorId: getCurrentUserId() },
    });
 
    revalidatePath("/posts");
    redirect("/posts");
  }
 
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Veröffentlichen</button>
    </form>
  );
}

Die "use server"-Direktive innerhalb der Funktion markiert sie als Server Action. Wenn das Formular abgeschickt wird, serialisiert React die FormData, sendet sie an den Server, und der Server führt die Funktion aus. Kein API-Route. Kein Fetch-Aufruf. Kein Request-Handler.

Server Actions in separaten Dateien#

Für Wiederverwendbarkeit definiere Actions in einer separaten Datei:

tsx
// src/actions/posts.ts
"use server";
 
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
 
  await db.post.create({
    data: { title, content },
  });
 
  revalidatePath("/posts");
  redirect("/posts");
}
 
export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

Die Datei-Level-"use server"-Direktive markiert alle Exporte als Server Actions. Diese können sowohl aus Server- als auch aus Client Components importiert werden.

Server Actions mit Validierung#

Vertraue niemals Client-Daten. Validiere auf dem Server:

tsx
"use server";
 
import { z } from "zod";
 
const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(50000),
});
 
export async function createPost(formData: FormData) {
  const parsed = PostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });
 
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
 
  await db.post.create({ data: parsed.data });
  revalidatePath("/posts");
  redirect("/posts");
}

Server Actions mit useActionState#

React 19 führte useActionState (ehemals useFormState) für progressiv verbesserte Formulare ein:

tsx
"use client";
 
import { useActionState } from "react";
import { createPost } from "@/actions/posts";
 
export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);
 
  return (
    <form action={formAction}>
      <input name="title" required />
      {state?.error?.title && (
        <p className="text-red-500">{state.error.title}</p>
      )}
      <textarea name="content" required />
      {state?.error?.content && (
        <p className="text-red-500">{state.error.content}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Wird gespeichert..." : "Veröffentlichen"}
      </button>
    </form>
  );
}

Dieses Formular funktioniert sogar ohne JavaScript (progressive Verbesserung). Das Formular wird abgeschickt, der Server verarbeitet es, und die Seite wird mit den neuen Daten aktualisiert. Wenn JavaScript verfügbar ist, passiert alles ohne vollständigen Seitenneuladen.

Häufige Fehler, die ich gemacht habe#

Lass mich dir etwas Debugging-Zeit ersparen, indem ich die Fehler teile, die mich am meisten gekostet haben.

Fehler 1: Alles zur Client Component machen#

Mein erster Instinkt war, "use client" hinzuzufügen, wann immer etwas nicht funktionierte. Event-Handler? Client. Conditional Rendering? Client. Styling, das vom State abhängt? Client.

Das Ergebnis: Ich hatte im Grunde eine traditionelle React-App mit zusätzlichen Schritten gebaut. Null Nutzen von Server Components.

Die Regel: Fang serverseits an und schiebe nur das zum Client, was den Client tatsächlich braucht. Ein onClick muss auf dem Client sein. Die Daten, die den Button antreiben, müssen es nicht.

Fehler 2: Nicht-serialisierbare Props#

Ich habe Stunden mit Fehlern wie diesen verbracht:

Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".

Der Fix war fast immer einer von:

  1. Die Funktion in der Client Component erstellen, nicht sie übergeben
  2. Die Funktion als Server Action markieren
  3. Serialisierbare Daten statt der Funktion übergeben

Fehler 3: async Client Components#

tsx
// DAS FUNKTIONIERT NICHT
"use client";
 
export default async function SearchResults() {
  // Client Components können nicht async sein!
  const data = await fetch("/api/search");
  // ...
}

Client Components können nicht async sein. Wenn du Daten in einer Client Component abrufen musst, verwende useEffect oder eine Datenabruf-Library (SWR, React Query). Oder besser noch: rufe die Daten in einer Server-Component ab und übergib sie als Props.

Fehler 4: Zu viel auf der Client-Grenze importieren#

Jeder Import in einer "use client"-Datei wird Teil des Client-Bundles. Wenn du ein großes Utility-Modul importierst, das du nur für eine Funktion brauchst, geht das gesamte Modul an den Client.

tsx
// SCHLECHT — importiert alles von lodash in den Client
"use client";
import { debounce } from "lodash";
 
// BESSER — importiere nur was du brauchst
"use client";
import debounce from "lodash/debounce";
 
// AM BESTEN — schreib dein eigenes (es sind 10 Zeilen)
"use client";
function debounce(fn: Function, ms: number) { /* ... */ }

Performance-Überlegungen#

Bundle-Größe#

Der größte Performance-Gewinn von RSC ist die Bundle-Größenreduktion. Server Components senden null JavaScript an den Client. Das bedeutet:

  • Dein Markdown-Parser (der in Blog-Posts verwendet wird)? Null KB im Client-Bundle.
  • Dein Datenbankdriver? Null KB.
  • Diese Syntax-Highlighting-Library? Null KB.
  • Die 50 Utility-Funktionen, die du auf dem Server verwendest? Null KB.

Auf dieser Seite sind die Blog-Post-Seiten Server Components. Die MDX-Verarbeitung, Syntax-Highlighting und Content-Transformation — nichts davon wird an den Browser geschickt. Das Ergebnis ist, dass Blog-Post-Seiten unter 50 KB JavaScript laden (Frameworks-Overhead eingeschlossen).

Streaming und Suspense#

Streaming ist standardmäßig aktiviert im App Router. Wenn du Suspense-Boundaries verwendest, streamt Next.js HTML, sobald jeder Suspense-Boundary aufgelöst wird. Das bedeutet:

  1. Die Navigations-Shell rendert sofort
  2. Schnelle Datenabschnitte füllen sich als Nächstes
  3. Langsame Datenabschnitte werden nachgestreamt

Der Benutzer sieht progressive Inhalte statt eines Ladebildschirms gefolgt von einem vollständigen Seitenflash.

Wann solltest du zum Client verschieben#

Nicht alles sollte eine Server Component sein. Hier ist meine Heuristik:

Server Component verwenden wenn:

  • Die Komponente Daten abruft
  • Die Komponente auf serverseitige APIs zugreift (Dateisystem, Datenbank)
  • Die Komponente keinen State oder Events braucht
  • Die Komponente große Abhängigkeiten verwendet, die nicht an den Client gesendet werden sollten

Client Component verwenden wenn:

  • Die Komponente useState, useEffect, useRef oder andere Hooks braucht
  • Die Komponente Event-Listener hat (onClick, onChange usw.)
  • Die Komponente Browser-APIs verwendet (localStorage, Geolocation usw.)
  • Die Komponente benutzerdefinierte Hooks verwendet, die auf Client-State angewiesen sind

TL;DR#

  • Server Components sind der Standard. Alles läuft auf dem Server, es sei denn, du markierst es mit "use client".
  • Die Grenze ist eine Serialisierungsschicht. Alles, was sie überquert, muss JSON-serialisierbar sein.
  • Children fließen durch. Übergib Server Components als children oder React.ReactNode-Props an Client Components.
  • Daten nach oben, Interaktivität nach unten. Rufe Daten in Server Components ab, übergib sie an Client Components.
  • Verwende Suspense für paralleles Streaming. Jeder Suspense-Boundary streamt unabhängig.
  • Server Actions ersetzen API-Routes für Mutationen. "use server"-Funktionen werden direkt aus Formularen und Event-Handlern aufgerufen.
  • Mach nicht alles zum Client. Fang serverseitig an und schiebe nur das Nötigste zum Client.
  • Überprüfe deine Imports. Alles, was in eine "use client"-Datei importiert wird, geht in das Client-Bundle.

RSC ist nicht kompliziert. Es ist anders. Die meisten Frustration entsteht, weil wir das alte Modell auf das neue anwenden. Sobald du die Dualität Server/Client akzeptierst und die Kompositions-Patterns lernst, wird es die natürlichste Art, React-Anwendungen zu bauen.

Die Kompromisse sind es wert. Kleinere Bundles, schnelleres Laden, direkte Datenzugriffe ohne API-Glue-Code. Der anfängliche Lernaufwand zahlt sich aus — und zwar schnell.

Ähnliche Beiträge