Zum Inhalt springen

Server oder Client Components: die Wahl in Next.js

Das mentale Modell für die Wahl zwischen Server und Client Components in Next.js: Rendering-Grenze, Serialisierung und Kompositionsmuster.

4 Min. Lesezeit
Ein Komponentenbaum, aufgeteilt zwischen Server- und Client-Grenzen, der den Datenfluss und die Rendering-Orte zeigt

Die Rendering-Grenze

Im App Router von Next.js ist jede Komponente standardmäßig eine Server Component. Sie läuft auf dem Server, hat direkten Zugriff auf Datenbanken und das Dateisystem und liefert kein JavaScript an den Client aus. Sobald du "use client" hinzufügst, werden diese Komponente und alles, was sie importiert, zu einer Client Component – sie wird im Browser hydriert und kann State, Effekte und Event-Handler verwenden.

Server Components: Daten und Layout

Server Components eignen sich für das Laden von Daten, das Layout und Inhalte, die keine Interaktivität benötigen. Sie laufen einmal auf dem Server, senden HTML an den Client und werden im Browser nie neu gerendert.

tsxtsx
// app/blog/[slug]/page.tsx — Server Component (default)
import { db } from "@/lib/database";
import { formatDate } from "@/utils/dates";
import { CommentSection } from "./CommentSection"; // Client Component
 
interface Props {
  params: Promise<{ slug: string }>;
}
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
 
  // Direct database access — no API route needed
  const post = await db.posts.findUnique({
    where: { slug },
    include: { author: true },
  });
 
  if (!post) notFound();
 
  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <time dateTime={post.publishedAt.toISOString()}>
          {formatDate(post.publishedAt)}
        </time>
        <span>By {post.author.name}</span>
      </header>
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
      {/* Client boundary starts here */}
      <CommentSection postId={post.id} />
    </article>
  );
}

Client Components: Interaktivität

Client Components sind für alles gedacht, was Browser-APIs, State, Effekte oder Event-Handler benötigt. Kennzeichne sie mit "use client" am Anfang der Datei.

tsxtsx
// ❌ Making an entire page a Client Component for one button
"use client";
export default function ProductPage({ product }) {
  const [added, setAdded] = useState(false);
  // Now ALL data fetching must happen client-side
  // Page ships unnecessary JavaScript
  // No direct database access
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <button onClick={() => setAdded(true)}>Add to Cart</button>
    </div>
  );
}
 
// ✅ Only the interactive part is a Client Component
// app/products/[id]/page.tsx — Server Component
export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  const product = await db.products.findUnique({ where: { id } });
  if (!product) notFound();
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}
 
// components/AddToCartButton.tsx — Client Component
"use client";
import { useState } from "react";
 
export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
 
  async function handleAdd() {
    await fetch("/api/cart", {
      method: "POST",
      body: JSON.stringify({ productId }),
    });
    setAdded(true);
  }
 
  return (
    <button onClick={handleAdd} disabled={added}>
      {added ? "Added ✓" : "Add to Cart"}
    </button>
  );
}

Die Serialisierungsgrenze

Props, die von einer Server Component an eine Client Component übergeben werden, müssen serialisierbar sein – ausschließlich JSON-kompatible Werte. Funktionen, Klassen, Date- und Map-Objekte können diese Grenze nicht überqueren.

tsxtsx
// ❌ Passing non-serializable props
// Server Component
export default async function Dashboard() {
  const data = await getMetrics();
  return (
    <MetricsChart
      data={data}
      onRefresh={async () => { await refreshMetrics(); }} // Functions can't serialize
      formatter={new Intl.NumberFormat("en-US")} // Classes can't serialize
    />
  );
}
 
// ✅ Pass serializable data, let client create its own functions
export default async function Dashboard() {
  const data = await getMetrics();
  return (
    <MetricsChart
      data={data.map((d) => ({
        label: d.name,
        value: d.value,
        timestamp: d.timestamp.toISOString(), // Date → string
      }))}
      locale="en-US" // String, not Intl object
    />
  );
}

Kompositionsmuster: Server innerhalb von Client

Eine Client Component kann eine Server Component nicht direkt importieren. Sie kann sie aber als children oder als Prop entgegennehmen – das ist das Slot-Pattern.

tsxtsx
// ✅ Server Component passed as children to Client Component
// layout.tsx (Server Component)
import { Sidebar } from "./Sidebar"; // Client Component
import { UserProfile } from "./UserProfile"; // Server Component
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      <Sidebar>
        {/* Server Component rendered as children of Client Component */}
        <UserProfile />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}
 
// Sidebar.tsx (Client Component)
"use client";
import { useState } from "react";
 
export function Sidebar({ children }: { children: React.ReactNode }) {
  const [collapsed, setCollapsed] = useState(false);
 
  return (
    <aside className={collapsed ? "w-16" : "w-64"}>
      <button onClick={() => setCollapsed(!collapsed)}>
        {collapsed ? "→" : "←"}
      </button>
      {!collapsed && children}
    </aside>
  );
}

Server Actions für Mutationen

Mit Server Actions können Client Components direkt serverseitige Funktionen aufrufen. Sie ersetzen API-Routen für Formularübermittlungen und Mutationen.

tsxtsx
// actions/cart.ts
"use server";
 
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
 
export async function addToCart(productId: string): Promise<{
  success: boolean;
  error?: string;
}> {
  const session = await getSession(cookies());
  if (!session) {
    return { success: false, error: "Not authenticated" };
  }
 
  await db.cartItems.create({
    data: {
      userId: session.userId,
      productId,
      quantity: 1,
    },
  });
 
  revalidatePath("/cart");
  return { success: true };
}
 
// Client Component using the Server Action
"use client";
import { addToCart } from "@/actions/cart";
import { useTransition } from "react";
 
export function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition();
 
  function handleClick() {
    startTransition(async () => {
      const result = await addToCart(productId);
      if (!result.success) {
        console.error(result.error);
      }
    });
  }
 
  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "Adding..." : "Add to Cart"}
    </button>
  );
}

Der Entscheidungsrahmen

Nutze diese Checkliste, um zu entscheiden, wo eine Komponente gerendert werden soll.

tstypescript
interface ComponentDecision {
  needsState: boolean;          // useState, useReducer
  needsEffects: boolean;        // useEffect, useLayoutEffect
  needsEventHandlers: boolean;  // onClick, onChange, onSubmit
  needsBrowserAPIs: boolean;    // window, document, localStorage
  fetchesData: boolean;         // Database queries, file reads
  displaysStaticContent: boolean;
  hasExpensiveImports: boolean;  // Heavy libraries (charts, editors)
}
 
function shouldBeClientComponent(decision: ComponentDecision): boolean {
  return (
    decision.needsState ||
    decision.needsEffects ||
    decision.needsEventHandlers ||
    decision.needsBrowserAPIs
  );
}
 
// If shouldBeClientComponent is false → Server Component
// If true → make the SMALLEST possible component a Client Component
// Keep data fetching and layout in Server Components

Die wichtigsten Erkenntnisse

Verwende standardmäßig Server Components. Sie laden Daten direkt, liefern kein JavaScript aus und rendern schneller. Füge "use client" nur hinzu, wenn du State, Effekte, Event-Handler oder Browser-APIs benötigst – und halte die Client Component so klein wie möglich.

Props, die die Grenze zwischen Server und Client überqueren, müssen serialisierbar sein. Nutze das children/slot-Pattern, um Server Components innerhalb von Client Components zu verschachteln. Server Actions ersetzen API-Routen bei Mutationen und halten die serverseitige Logik direkt bei der Komponente, die sie auslöst. Das Ziel ist es, die Client-Grenze so weit wie möglich nach unten im Komponentenbaum zu verschieben, das aufwendige Rendering auf dem Server zu belassen und nur das interaktive JavaScript auszuliefern, das der Nutzer tatsächlich braucht.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX