Design wechseln

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

Easton editorial illustration: cache waterfall instrument

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:

  1. Server Component darf Client Component importieren
  2. Client Component darf Server Component nicht importieren
  3. Server Component darf als children an 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:

  1. Standard: Server Components – Client nur bei Interaktion
  2. Server darf Client importieren, nicht umgekehrt
  3. Daten über children oder props – Grenzen klar halten
  4. "use client" an Blattknoten – nicht im Root missbrauchen
  5. 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. 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. 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. 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. 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. 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?
shadcn/ui basiert auf Radix UI. Die meisten Komponenten nutzen intern React-Hooks (z. B. useState, useContext) für State und Events. Diese Hooks laufen nur im Browser – daher 'use client'.
Können Server und Client Components sich gegenseitig importieren?
Server Components dürfen Client Components importieren, Client Components nicht Server Components. Über children können Sie Server Components als Inhalt an Client Components übergeben – so arbeiten beide zusammen.
Wie vermeide ich doppelte Datenabrufe in mehreren Server Components?
Datenabruf mit React.cache() wrappen. Aufrufe mit gleichen Parametern werden innerhalb eines Render-Zyklus dedupliziert – keine wiederholten DB- oder API-Requests.
Wo sollten Context Provider stehen?
Provider müssen Client Components sein (React Context). Nicht im Root-Layout platzieren. Besser in layout.tsx einer konkreten Route – so bleibt der Client-Subtree klein und mehr Komponenten profitieren von Server Components.
Fehler „useEffect nur in Client Component“ – was tun?
Importkette des fehlerhaften Baums prüfen: Komponente mit Hooks oder Events finden und oben 'use client' setzen. Fehlt die Markierung bei Drittanbieter-Komponenten, einen Wrapper mit 'use client' anlegen und dann importieren.
Wann Server, wann Client Component?
Kurz: onClick, onChange usw. → Client; useState, useEffect → Client; localStorage, window → Client; sonst Standard Server Component.

8 Min. Lesezeit · Veröffentlicht am: 31. März 2026 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog