Next.js App Router + shadcn/ui : guide pour mélanger Server et Client Components

Un message d’erreur s’affiche à l’écran : 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".
Vous avez déjà ajouté "use client" dans layout.tsx, pourtant l’erreur persiste.
Après avoir parcouru la documentation, vous réalisez que le problème vient de la frontière entre composants. La ligne de démarcation entre Server Components et Client Components dans App Router est bien plus subtile qu’on ne l’imagine.
C’est une situation que beaucoup de développeurs rencontrent en migrant vers App Router. Le framework considère par défaut tous les composants comme Server Components, alors que la plupart des bibliothèques UI (comme shadcn/ui) exigent des Client Components. Comment tracer la frontière ? Comment faire circuler les données ? Comment optimiser les performances ?
Cet article répond à toutes ces questions.
Server Components vs Client Components : la différence fondamentale
Commençons par l’essentiel : avec App Router, tous les composants sont par défaut des Server Components.
Qu’est-ce que cela implique ? Vos page.tsx et layout.tsx sont rendus côté serveur par défaut, sans envoyer de JavaScript au navigateur.
Ce que les Server Components peuvent faire
L’avantage principal des Server Components est d’être « plus proches des données » :
// app/products/page.tsx - Server Component (par défaut)
async function ProductsPage() {
// Récupération directe des données dans le composant
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // cache 1 heure
}).then(res => res.json())
return (
<div>
{products.map(p => (
<div key={p.id}>{p.name} - ${p.price}</div>
))}
</div>
)
}
Pas de useEffect, pas de useState : un simple await suffit. C’est la caractéristique des composants asynchrones des Server Components.
Cas d’usage :
- Récupération de données (fetch, requêtes base de données)
- Accès aux APIs serveur exclusives (headers(), cookies())
- Bibliothèques lourdes (parseur markdown 100 Ko+, sans les inclure dans le bundle navigateur)
- Traitement d’informations sensibles (clés API jamais exposées au frontend)
Ce que les Client Components peuvent faire
Les Client Components sont les composants React « classiques ». Il suffit d’ajouter "use client" en tête de fichier :
// 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>
)
}
Cas d’usage :
- Gestion d’événements (onClick, onChange, onSubmit)
- Hooks React (useState, useEffect, useRef, useContext)
- APIs navigateur (localStorage, window, document)
- Context Provider
Point contre-intuitif : les Client Components sont aussi pré-rendus en HTML côté serveur. Ils s’hydratent ensuite dans le navigateur pour retrouver l’interactivité. L’utilisateur voit donc le contenu complet dès la première visite, sans écran blanc en attendant le chargement du JS.
Règle centrale : qui peut importer qui
C’est la partie où l’on piège le plus facilement.
La règle est simple, mais souvent inversée :
- Un Server Component peut importer un Client Component ✅
- Un Client Component ne peut pas importer un Server Component ❌
- Un Server Component peut être passé comme children à un Client Component ✅
Le troisième point peut sembler abstrait ; le code le rend clair :
// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'
export default function Page() {
return (
<ClientContainer>
{/* ServerData passé comme 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>
}
Ce pattern est très courant : le Client Container gère l’interactivité, le Server Data récupère les données. Ils sont isolés via children, sans import direct.
Intégration shadcn/ui : pourquoi c’est « compliqué »
shadcn/ui est ma bibliothèque UI préférée, mais dans App Router elle demande un peu de technique.
La raison : shadcn/ui repose sur Radix UI, et la plupart de ses composants utilisent des hooks React.
Button, Dialog, Dropdown Menu, etc. contiennent en interne useState ou useEffect. Ils doivent donc être des Client Components.
Mauvais exemple : utiliser shadcn/ui directement dans un Server Component
// ❌ Erreur : Server Component importe un Client Component
import { Button } from '@/components/ui/button'
async function ProductPage() {
const product = await fetchProduct()
return (
<div>
<h1>{product.name}</h1>
{/* Erreur : Button nécessite "use client" */}
<Button onClick={() => addToCart(product.id)}>
Add to Cart
</Button>
</div>
)
}
Message d’erreur : Button utilise useState, il doit être marqué "use client".
Bonne approche 1 : extraire la partie interactive en Client Component
La solution la plus courante et la plus simple :
// 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 Component : affichage des données */}
<ProductInfo product={product} />
{/* Client Component : interactivité */}
<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>
)
}
L’idée centrale : extraire les parties interactives en nœuds feuilles, le reste restant en Server Component.
Bonne approche 2 : pattern de composition (Server transmet les données au Client)
Si le Client Component a besoin de données initiales :
// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'
async function DashboardPage() {
const users = await fetchUsers() // récupération côté Server Component
return <DataTable data={users} /> // transmission au Client Component
}
// 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>
{/* composant Table shadcn/ui */}
<TableBody>
{data.map(user => (
<TableRow
key={user.id}
selected={selectedRows.includes(user.id)}
onClick={() => toggleSelection(user.id)}
>
<TableCell>{user.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
Vous profitez ainsi de la récupération de données côté serveur tout en conservant l’interactivité côté client.
Où placer les Context Providers
Autre source de confusion : où mettre les Context Providers globaux (ThemeProvider, AuthProvider, etc.) ?
Réponse : dans un Client Component, mais « le plus profondément possible ».
// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Ne placez pas le Provider ici */}
{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>
)
}
Pourquoi « profond » ? Un Provider transforme tout son sous-arbre en Client Component. Placé dans le root layout, toute l’application basculerait en rendu côté client.
En le plaçant plus bas (layout d’une route spécifique), vous limitez la portée du Provider.
Flux de données : de Server à Client
Les props sont le moyen le plus simple et le plus fiable :
// Server Component récupère les données
const data = await fetchData()
// Transmission au Client Component
<ClientComponent initialData={data} />
Optimisation importante : la fonction React.cache().
Si plusieurs Server Components ont besoin des mêmes données, cache évite les requêtes dupliquées :
// 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') // première requête
return <header>{user.name}</header>
}
// app/page.tsx
async function Page() {
const user = await getUser('123') // mêmes paramètres, pas de requête dupliquée
return <main>Welcome {user.name}</main>
}
cache déduplique automatiquement les appels avec les mêmes paramètres pendant un cycle de rendu.
Quatre erreurs les plus fréquentes
Erreur 1 : abuser de “use client” en haut de l’arborescence
// ❌ app/layout.tsx avec "use client"
'use client'
export default function Layout({ children }) {
return <div>{children}</div>
}
Tout le sous-arbre de l’application devient Client Component, perdant les avantages des Server Components.
Correction : n’ajoutez "use client" que là où l’interactivité est réellement nécessaire, en nœuds feuilles.
Erreur 2 : utiliser des hooks dans un Server Component
// ❌ Server Component avec useState
async function Page() {
const [count, setCount] = useState(0) // erreur !
return <div>{count}</div>
}
Correction : extrayez la partie nécessitant des hooks en Client Component.
Erreur 3 : utiliser headers()/cookies() dans un Client Component
// ❌ Client Component avec API serveur
'use client'
import { headers } from 'next/headers'
function UserProfile() {
const headersList = headers() // erreur ! réservé aux Server Components
return <div>...</div>
}
Correction : récupérez les données dans un Server Component, puis transmettez-les :
// Server Component récupère headers
async function Page() {
const userAgent = headers().get('user-agent')
return <UserProfile userAgent={userAgent} />
}
// Client Component reçoit les données
'use client'
function UserProfile({ userAgent }) {
return <div>Browser: {userAgent}</div>
}
Erreur 4 : composant tiers sans “use client”
// ❌ Server Component importe un composant tiers non marqué
import { AcmeCarousel } from 'acme-carousel'
async function Page() {
return <AcmeCarousel /> // erreur ! AcmeCarousel utilise des hooks en interne
}
Correction : créez un wrapper :
// components/carousel-wrapper.tsx
'use client'
import { AcmeCarousel } from 'acme-carousel'
export function CarouselWrapper(props) {
return <AcmeCarousel {...props} />
}
// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'
async function Page() {
return <CarouselWrapper /> // fonctionne correctement
}
Conseils d’optimisation des performances
Quelques astuces pratiques :
1. Placer les Client Components en nœuds feuilles
Cette règle peut réduire le JavaScript côté client d’environ 70 %.
Exemple pour une page liste de produits :
- Grille de produits : Server Component
- Chaque carte produit : Server Component
- Sélecteur de quantité sur la carte : Client Component (seule partie interactive)
2. Utiliser Suspense pour le rendu en streaming
// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'
export default function Page() {
return (
<div>
{/* Skeleton d'abord, remplacé quand les données arrivent */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
{/* Contenu secondaire rendu en streaming indépendamment */}
<Suspense fallback={<RecSkeleton />}>
<Recommendations />
</Suspense>
</div>
)
}
L’utilisateur voit d’abord la structure de la page, puis les données s’affichent progressivement. Bien meilleure expérience qu’attendre le chargement complet.
3. Stratégie de cache fetch
// Données statiques (récupérées au build)
await fetch(url, { cache: 'force-cache' })
// ISR : revalidation toutes les heures
await fetch(url, { next: { revalidate: 3600 } })
// Données dynamiques (récupérées à chaque requête)
await fetch(url, { cache: 'no-store' })
Choisissez la stratégie de cache adaptée pour éviter un rendu trop dynamique.
Résumé
En résumé, retenez ces points :
- Privilégiez les Server Components ; n’utilisez les Client Components que pour l’interactivité
- Server peut importer Client, mais pas l’inverse
- Transmettez les données via children ou props pour garder des frontières claires
- Placez “use client” en nœuds feuilles, sans abus en haut de l’arborescence
- Extrayez les composants shadcn/ui ; ne les mélangez pas dans un Server Component
La frontière Server/Client d’App Router vise à rapprocher les développeurs des données et à les éloigner du navigateur. Une fois ce principe compris, beaucoup de doutes disparaissent.
Commencez par des pages simples : Server Component pour les données, puis ajoutez progressivement l’interactivité. En cas d’erreur, vérifiez la frontière entre composants — c’est souvent un problème d’import, rapidement identifiable.
Série : Cet article fait partie du Guide complet Next.js (article n° 46). Si vous apprenez Next.js App Router, poursuivez avec les pièges courants de l’App Router. Pour approfondir shadcn/ui, consultez les modèles de composition shadcn/ui.
Mélanger correctement Server et Client Components
Bonnes pratiques pour intégrer shadcn/ui dans un projet Next.js App Router
⏱️ Estimated time: 30 min
- 1
Step 1: Identifier le type de composant requis
Déterminez si chaque composant a besoin d'interactivité :
• Gestion d'événements (onClick, onChange) → Client Component
• Hooks React (useState, useEffect) → Client Component
• APIs navigateur (localStorage, window) → Client Component
• Affichage de données uniquement, sans interaction → Server Component (par défaut) - 2
Step 2: Extraire les parties interactives en nœuds feuilles
Isolez les parties interactives dans des Client Components :
• Créez un nouveau fichier avec 'use client' en tête
• Importez les composants shadcn/ui (Button, Dialog, etc.)
• Importez ce Client Component dans le Server Component
• Transmettez les données via props - 3
Step 3: Concevoir le flux de données
Le Server Component récupère les données et les transmet au Client Component :
• Le Server Component utilise async/await pour récupérer les données
• Transmettez-les au Client Component via props
• Si plusieurs endroits ont besoin des mêmes données, utilisez React.cache()
• Évitez d'utiliser headers()/cookies() directement dans un Client Component - 4
Step 4: Placer les Context Providers
Les Providers doivent être des Client Components, mais placés profondément dans l'arborescence :
• Créez providers.tsx avec 'use client'
• Enveloppez ThemeProvider, AuthProvider, etc.
• Importez dans le layout.tsx d'une route spécifique (pas le root layout)
• Minimisez la portée du sous-arbre Client Component - 5
Step 5: Vérifier et optimiser
Contrôlez que les frontières entre composants sont correctes :
• Assurez-vous que 'use client' n'est présent qu'en nœuds feuilles
• Vérifiez qu'aucun Client Component n'importe de Server Component
• Enveloppez les composants asynchrones avec Suspense
• Configurez une stratégie de cache fetch appropriée
FAQ
Pourquoi les composants shadcn/ui doivent-ils être des Client Components ?
Les Server et Client Components peuvent-ils s'importer mutuellement ?
Comment éviter les requêtes dupliquées dans plusieurs Server Components ?
Où placer les Context Providers ?
Que faire face à l'erreur « useEffect ne fonctionne que dans un Client Component » ?
Comment savoir si un composant doit être Server ou Client Component ?
10 min de lecture · Publié le: 31 mars 2026 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Next.js + Tailwind CSS : bonnes pratiques — guide complet de la config au mode sombre (2025)
Guide pratique Next.js + Tailwind CSS v4 (2025) : classes trop longues, thème personnalisé, mode sombre et performance. Cas réel 500 Ko → 50 Ko avec exemples de code complets.
Partie 45 sur 51
Suivant
Optimisation des performances React Server Components : récupération de données et cache en pratique
Guide pratique d'optimisation RSC : du problème de waterfall au streaming, stratégies de récupération de données et de cache. Parcours TTFB 450 ms→45 ms, 4 solutions comparées et 5 API de cache.
Partie 47 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire