Next.js App Router + shadcn/ui: Server- und Client-Komponenten richtig mischen

Auf dem Bildschirm erscheint eine Fehlermeldung: Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client".
In layout.tsx steht bereits "use client" – trotzdem der Fehler?
Nach stundenlangem Durchforsten der Docs liegt das Problem an der Importgrenze. Die Trennlinie zwischen Server Components und Client Components im App Router ist komplexer, als viele erwarten.
Genau das erleben viele beim Umstieg auf den App Router: Standard sind Server Components, UI-Bibliotheken wie shadcn/ui brauchen aber meist Client Components. Wo ziehen Sie die Grenze? Wie fließen Daten? Wie optimieren Sie Performance?
Dieser Artikel beantwortet diese Fragen Schritt für Schritt.
Server Components vs. Client Components: der grundlegende Unterschied
Im App Router gilt: Alle Komponenten sind standardmäßig Server Components.
page.tsx und layout.tsx rendern also auf dem Server – ohne JavaScript an den Browser zu senden.
Was Server Components können
Der Kernvorteil: Sie sind näher an den Daten:
// app/products/page.tsx - Server Component (Standard)
async function ProductsPage() {
// Daten direkt in der Komponente awaiten
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // 1 Stunde Cache
}).then(res => res.json())
return (
<div>
{products.map(p => (
<div key={p.id}>{p.name} - ${p.price}</div>
))}
</div>
)
}
Kein useEffect, kein useState – einfach await und fertig. Das ist das „async component“-Feature von Server Components.
Typische Einsatzfälle:
- Datenabruf (fetch, Datenbank)
- Server-only APIs (
headers(),cookies()) - Große Bibliotheken (z. B. Markdown-Parser 100 KB+) – nicht ins Browser-Bundle
- Sensible Daten (API-Keys bleiben auf dem Server)
Was Client Components können
Client Components sind die klassischen React-Komponenten. Oben in der Datei "use client":
// components/like-button.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const [count, setCount] = useState(0)
const handleClick = () => {
setLiked(!liked)
setCount(prev => liked ? prev - 1 : prev + 1)
}
return (
<button onClick={handleClick}>
{liked ? '❤️' : '🤍'} {count}
</button>
)
}
Typische Einsatzfälle:
- Event-Handler (
onClick,onChange,onSubmit) - React-Hooks (
useState,useEffect,useRef,useContext) - Browser-APIs (
localStorage,window,document) - Context Provider
Überraschend: Client Components werden ebenfalls auf dem Server vorgerendert (HTML). Im Browser folgt Hydration für Interaktivität. Beim ersten Besuch sehen Nutzer also Inhalt – kein leeres Warten auf JS.
Kernregeln: wer darf wen importieren
Hier passieren die meisten Fehler.
Die Regeln sind einfach – leicht verwechselt:
- Server Component darf Client Component importieren ✅
- Client Component darf Server Component nicht importieren ❌
- Server Component darf als
childrenan Client Component übergeben werden ✅
Punkt 3 klingt umständlich – im Code wird es klar:
// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'
export default function Page() {
return (
<ClientContainer>
{/* ServerData als children */}
<ServerData />
</ClientContainer>
)
}
// client-container.tsx
'use client'
export function ClientContainer({ children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
)
}
// server-data.tsx - Server Component
async function ServerData() {
const data = await fetch('/api/data').then(r => r.json())
return <div>{data.title}</div>
}
Häufiges Muster: Client Container für Interaktion, Server Data für Daten – getrennt über children, ohne direkten Server-Import im Client-Baum.
shadcn/ui: warum es etwas „umständlich“ wirkt
shadcn/ui ist eine starke UI-Bibliothek – im App Router braucht sie aber Disziplin bei den Grenzen.
Grund: shadcn/ui baut auf Radix UI auf; die meisten Komponenten nutzen React-Hooks.
Button, Dialog, Dropdown Menu usw. enthalten useState oder useEffect – sie müssen Client Components sein.
Falsch: shadcn/ui direkt in der Server Component
// ❌ Server Component importiert Client Component mit Events
import { Button } from '@/components/ui/button'
async function ProductPage() {
const product = await fetchProduct()
return (
<div>
<h1>{product.name}</h1>
{/* Fehler: Button braucht "use client" */}
<Button onClick={() => addToCart(product.id)}>
Add to Cart
</Button>
</div>
)
}
Meldung in der Art: Button nutzt useState – "use client" erforderlich.
Richtig 1: Interaktion als Blattknoten auslagern
Der häufigste und sauberste Weg:
// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'
async function ProductPage({ params }) {
const product = await fetchProduct(params.id)
return (
<div>
{/* Server: Anzeige */}
<ProductInfo product={product} />
{/* Client: Interaktion */}
<AddToCartButton productId={product.id} />
</div>
)
}
// product-info.tsx - Server Component
export function ProductInfo({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
)
}
// add-to-cart-button.tsx - Client Component
'use client'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
export function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
const handleAdd = async () => {
setLoading(true)
await addToCart(productId)
setLoading(false)
}
return (
<Button onClick={handleAdd} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</Button>
)
}
Prinzip: Nur der interaktive Teil wird Client Component – der Rest bleibt Server.
Richtig 2: Kombination (Server liefert Daten an Client)
Braucht die Client Component Startdaten:
// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'
async function DashboardPage() {
const users = await fetchUsers() // Server holt Daten
return <DataTable data={users} /> // an Client übergeben
}
// data-table.tsx - Client Component
'use client'
import { Table } from '@/components/ui/table'
import { useState } from 'react'
export function DataTable({ data }) {
const [selectedRows, setSelectedRows] = useState([])
return (
<Table>
<TableBody>
{data.map(user => (
<TableRow
key={user.id}
selected={selectedRows.includes(user.id)}
onClick={() => toggleSelection(user.id)}
>
<TableCell>{user.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
Server-Vorteile beim Datenabruf, Client-Vorteile bei Interaktion – in einem Baum.
Context Provider: wo platzieren?
Häufige Frage: Wo kommen globale Provider wie ThemeProvider oder AuthProvider hin?
Antwort: Client Component – aber möglichst tief im Layout-Baum.
// app/layout.tsx - Server Component (Root)
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Provider nicht hier */}
{children}
</body>
</html>
)
}
// app/providers.tsx - Client Component
'use client'
import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'
export default function DashboardLayout({ children }) {
return (
<Providers>
{children}
</Providers>
)
}
Warum tief? Ein Provider macht den gesamten umschlossenen Baum zum Client-Subtree. Im Root-Layout würde die ganze App clientseitig mitlaufen.
In einem Route-spezifischen layout.tsx bleibt der Einfluss klein – mehr Server Components behalten ihre Vorteile.
Datenfluss: vom Server zum Client
props sind der einfachste und robusteste Weg:
// Server holt Daten
const data = await fetchData()
// an Client übergeben
<ClientComponent initialData={data} />
Performance: React.cache() – wenn mehrere Server Components dieselben Daten brauchen:
// lib/get-user.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.query('SELECT * FROM users WHERE id = ?', [id])
})
// app/layout.tsx
async function Layout() {
const user = await getUser('123') // erster Aufruf
return <header>{user.name}</header>
}
// app/page.tsx
async function Page() {
const user = await getUser('123') // gleiche Parameter: kein zweiter Request
return <main>Welcome {user.name}</main>
}
cache dedupliziert gleiche Aufrufe innerhalb eines Render-Zyklus.
Vier typische Fehler
Fehler 1: "use client" zu weit oben
// ❌ app/layout.tsx mit "use client"
'use client'
export default function Layout({ children }) {
return <div>{children}</div>
}
Der gesamte Unterbaum wird Client – Server-Vorteile gehen verloren.
Fix: "use client" nur dort, wo wirklich Interaktion nötig ist – ideal an Blattknoten.
Fehler 2: Hooks in Server Components
// ❌ useState in Server Component
async function Page() {
const [count, setCount] = useState(0) // Fehler
return <div>{count}</div>
}
Fix: Hook-Logik in eigene Client Component auslagern.
Fehler 3: headers() / cookies() im Client
// ❌ Server-API im Client
'use client'
import { headers } from 'next/headers'
function UserProfile() {
const headersList = headers() // nur in Server Components
return <div>...</div>
}
Fix: Server holt Daten, Client bekommt props:
// Server
async function Page() {
const userAgent = headers().get('user-agent')
return <UserProfile userAgent={userAgent} />
}
// Client
'use client'
function UserProfile({ userAgent }) {
return <div>Browser: {userAgent}</div>
}
Fehler 4: Drittanbieter ohne "use client"
// ❌ Server importiert unmarkierte Bibliothek
import { AcmeCarousel } from 'acme-carousel'
async function Page() {
return <AcmeCarousel /> // intern Hooks → Fehler
}
Fix: Wrapper anlegen:
// components/carousel-wrapper.tsx
'use client'
import { AcmeCarousel } from 'acme-carousel'
export function CarouselWrapper(props) {
return <AcmeCarousel {...props} />
}
// page.tsx - Server
import { CarouselWrapper } from './carousel-wrapper'
async function Page() {
return <CarouselWrapper />
}
Performance-Tipps
1. Client Components an Blattknoten
Diese Regel kann bis zu 70 % Client-JavaScript einsparen.
Beispiel Produktliste:
- Grid: Server Component
- Produktkarte: Server Component
- Mengenauswahl auf der Karte: einziger Client-Blattknoten
2. Streaming mit Suspense
// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'
export default function Page() {
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
<Suspense fallback={<RecSkeleton />}>
<Recommendations />
</Suspense>
</div>
)
}
Nutzer sehen zuerst das Gerüst, Daten füllen sich nach – besser als auf alles zu warten.
3. fetch-Cache-Strategie
// Statisch (Build-Zeit)
await fetch(url, { cache: 'force-cache' })
// ISR: stündlich revalidieren
await fetch(url, { next: { revalidate: 3600 } })
// Dynamisch (jeder Request)
await fetch(url, { cache: 'no-store' })
Passende Strategie wählen – nicht alles unnötig dynamisch rendern.
Zusammenfassung
Die Kernpunkte:
- Standard: Server Components – Client nur bei Interaktion
- Server darf Client importieren, nicht umgekehrt
- Daten über
childrenoder props – Grenzen klar halten "use client"an Blattknoten – nicht im Root missbrauchen- shadcn/ui auslagern – nicht direkt in Server Components mit Events mischen
Die Server/Client-Trennung soll Sie näher an Daten, weiter vom Browser-Bundle bringen. Mit diesem Bild lösen sich die meisten Migrationsfragen.
Starten Sie mit einer einfachen Seite: Server holt Daten, Interaktion kommt schrittweise als Client-Blätter dazu. Bei Fehlern zuerst die Importkette prüfen – meist ist es eine Grenzverletzung.
Serie: Dieser Artikel gehört zur Serie Next.js – Komplettguide (Teil 46). Mehr zum App Router finden Sie in den anderen Teilen. Für shadcn/ui empfehlen wir die Serie Tailwind & shadcn/ui – Praxisguide.
Server und Client Components korrekt mischen
Best Practices für shadcn/ui in Next.js App Router-Projekten
⏱️ Estimated time: 30 min
- 1
Step 1: Komponententyp ermitteln
Prüfen, ob eine Komponente Interaktivität braucht:
• Event-Handler (onClick, onChange) → Client Component
• React-Hooks (useState, useEffect) → Client Component
• Browser-APIs (localStorage, window) → Client Component
• Nur Datenanzeige, keine Interaktion → Server Component (Standard) - 2
Step 2: Interaktive Teile als Blattknoten auslagern
Interaktive Abschnitte in eigene Client Components auslagern:
• Neue Datei anlegen, oben 'use client' setzen
• shadcn/ui-Komponenten importieren (Button, Dialog usw.)
• Client Component in der Server Component importieren
• Daten per props übergeben - 3
Step 3: Datenfluss entwerfen
Server Component holt Daten, Client Component rendert Interaktion:
• Server Component nutzt async/await für Daten
• Übergabe an Client Component per props
• Bei mehrfachem Bedarf an denselben Daten React.cache() verwenden
• headers()/cookies() nicht direkt in Client Components nutzen - 4
Step 4: Context Provider platzieren
Provider müssen Client Components sein, aber möglichst tief im Layout:
• providers.tsx anlegen und 'use client' setzen
• ThemeProvider, AuthProvider usw. wrappen
• In layout.tsx der jeweiligen Route importieren (nicht im Root-Layout)
• Client-Subtree möglichst klein halten - 5
Step 5: Prüfen und optimieren
Komponentengrenzen kontrollieren:
• 'use client' nur an Blattknoten
• Kein Import von Server Components aus Client Components
• Asynchrone Komponenten mit Suspense umschließen
• Sinnvolle fetch-Cache-Strategie konfigurieren
FAQ
Warum müssen shadcn/ui-Komponenten Client Components sein?
Können Server und Client Components sich gegenseitig importieren?
Wie vermeide ich doppelte Datenabrufe in mehreren Server Components?
Wo sollten Context Provider stehen?
Fehler „useEffect nur in Client Component“ – was tun?
Wann Server, wann Client Component?
8 Min. Lesezeit · Veröffentlicht am: 31. März 2026 · Aktualisiert am: 14. Juli 2026
Next.js Komplettleitfaden
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Next.js + Tailwind CSS Best Practices: Vollständiger Leitfaden von Konfiguration bis Dark Mode (2025)
Aktueller Next.js + Tailwind CSS v4 Praxisleitfaden 2025: zu lange Klassennamen, Custom Theme, Dark Mode und Performance – von 500 KB auf 50 KB mit echten Fallbeispielen und vollständigen Codebeispielen.
Teil 45 von 51
Nächster
React Server Components Performance-Optimierung: Datenabruf und Caching in der Praxis
React Server Components Performance-Optimierung in der Praxis: vom Waterfall-Problem zur Streaming-Architektur – Datenabruf und Caching-Strategien im Detail. Optimierungspfad von TTFB 450 ms auf 45 ms, mit 4 Lösungsvergleichen und 5 Cache-API-Anleitungen
Teil 47 von 51
Ähnliche Beiträge
Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick

Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick
Next.js 15 in der Praxis: Wie ich an einem Wochenende ein produktionsreifes Blog-System aufgebaut habe


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen