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.

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.
// 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.
// ❌ 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.
// ❌ 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.
// ✅ 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.
// 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.
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 ComponentsDie 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.


