React Server Components : modèles mentaux, patterns et pièges
Le guide pratique des React Server Components que j'aurais voulu avoir en commençant. Modèles mentaux, vrais patterns, le problème des frontières, et les erreurs que j'ai faites pour que tu n'aies pas à les faire.
J'ai passé les trois premiers mois avec les React Server Components en pensant que je les comprenais. J'avais lu les RFCs, regardé les conférences, construit quelques applis démo. J'avais tort sur presque tout, avec une belle assurance.
Le problème, ce n'est pas que les RSC soient compliqués. C'est que le modèle mental est véritablement différent de tout ce qu'on a fait en React avant, et tout le monde — moi compris — essaie de le faire rentrer dans l'ancien moule. « C'est comme le SSR. » Non. « C'est comme PHP. » Plus proche, mais non. « Ce sont juste des composants qui tournent sur le serveur. » Techniquement vrai, pratiquement inutile.
Ce qui suit est tout ce que j'avais vraiment besoin de savoir, écrit comme j'aurais aimé qu'on me l'explique. Pas la version théorique. Celle où tu fixes une erreur de sérialisation à 23h et où tu as besoin de comprendre pourquoi.
Le modèle mental qui fonctionne vraiment#
Oublie tout ce que tu sais sur le rendu React pendant un instant. Voici le nouveau schéma.
En React traditionnel (côté client), l'intégralité de ton arbre de composants est envoyée au navigateur sous forme de JavaScript. Le navigateur le télécharge, le parse, l'exécute et affiche le résultat. Chaque composant — que ce soit un formulaire interactif de 200 lignes ou un paragraphe de texte statique — passe par le même pipeline.
Les React Server Components scindent ça en deux mondes :
Les Server Components tournent sur le serveur. Ils s'exécutent une fois, produisent leur sortie, et envoient le résultat au client — pas le code. Le navigateur ne voit jamais la fonction du composant, ne télécharge jamais ses dépendances, ne le re-rend jamais.
Les Client Components fonctionnent comme le React traditionnel. Ils sont envoyés au navigateur, s'hydratent, maintiennent un état, gèrent les événements. C'est le React que tu connais déjà.
L'insight clé qui m'a pris un temps embarrassant à intérioriser : les Server Components sont le comportement par défaut. Dans le App Router de Next.js, chaque composant est un Server Component sauf si tu l'optes explicitement côté client avec "use client". C'est l'inverse de ce à quoi on est habitué, et ça change ta façon de penser la composition.
La cascade de rendu#
Voici ce qui se passe réellement quand un utilisateur demande une page :
1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
the server-rendered tree
7. Hydration makes Client Components interactive
L'étape 4 est celle qui génère le plus de confusion. Le RSC Payload n'est pas du HTML. C'est un format de streaming spécial qui décrit l'arbre de composants — ce que le serveur a rendu, où le client doit prendre le relais, et quelles props passer à travers la frontière.
Ça ressemble à peu près à ceci (simplifié) :
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
Tu n'as pas besoin de mémoriser ce format. Mais comprendre qu'il existe — qu'il y a une couche de sérialisation entre le serveur et le client — te fera gagner des heures de débogage. Chaque fois que tu obtiens une erreur « Props must be serializable », c'est parce que quelque chose que tu passes ne peut pas survivre à cette traduction.
Ce que « tourne sur le serveur » signifie vraiment#
Quand je dis qu'un Server Component « tourne sur le serveur », je le dis littéralement. La fonction du composant s'exécute dans Node.js (ou le runtime Edge). Ça veut dire que tu peux faire :
// app/dashboard/page.tsx — this is a Server Component by default
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// Direct database query. No API route needed.
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welcome back, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}Pas de useEffect. Pas de gestion d'état de chargement. Pas de route API pour faire le lien. Le composant est la couche de données. C'est le plus grand gain des RSC et c'est la chose qui m'a le plus déstabilisé au début, parce que je n'arrêtais pas de penser « mais où est la séparation ? »
La séparation, c'est la frontière "use client". Tout ce qui est au-dessus, c'est le serveur. Tout ce qui est en dessous, c'est le client. Voilà ton architecture.
La frontière serveur/client#
C'est là que la compréhension de la plupart des gens s'effondre, et là où j'ai passé le plus de temps à déboguer pendant les premiers mois.
La directive "use client"#
La directive "use client" en haut d'un fichier marque tout ce qui est exporté depuis ce fichier comme un Client Component. C'est une annotation au niveau du module, pas au niveau du composant.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// This entire file is now "client territory"
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}Erreur classique : mettre "use client" dans un fichier barrel (index.ts) qui ré-exporte tout. Félicitations, tu viens de rendre toute ta bibliothèque de composants côté client. J'ai vu des équipes envoyer accidentellement 200 Ko de JavaScript de cette manière.
Ce qui traverse la frontière#
Voici la règle qui va te sauver : tout ce qui traverse la frontière serveur-client doit être sérialisable en JSON.
Ce qui est sérialisable :
- Strings, nombres, booléens, null, undefined
- Tableaux et objets simples (contenant des valeurs sérialisables)
- Dates (sérialisées en chaînes ISO)
- Server Components (en tant que JSX — on y reviendra)
- FormData
- Typed arrays, ArrayBuffer
Ce qui n'est PAS sérialisable :
- Fonctions (y compris les gestionnaires d'événements)
- Classes (instances de classes personnalisées)
- Symbols
- Nœuds DOM
- Streams (dans la plupart des contextes)
Ça veut dire que tu ne peux pas faire ça :
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// ERROR: Functions are not serializable
onItemClick={(id) => console.log(id)}
// ERROR: Class instances are not serializable
formatter={new Intl.NumberFormat("en-US")}
/>
);
}La solution n'est pas de transformer la page en Client Component. La solution est de pousser l'interactivité vers le bas et le fetch de données vers le haut :
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Only pass serializable data
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx (Client Component)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create the formatter on the client side
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}L'idée fausse de l'« îlot »#
J'ai d'abord pensé aux Client Components comme des « îlots » — de petits bouts interactifs dans une mer de contenu rendu côté serveur. C'est partiellement correct mais ça oublie un détail crucial : un Client Component peut rendre des Server Components s'ils sont passés comme children ou props.
Ça signifie que la frontière n'est pas un mur solide. C'est plutôt une membrane. Le contenu rendu côté serveur peut traverser les Client Components via le pattern children. On va creuser ça dans la section composition.
Patterns de fetch de données#
Les RSC changent fondamentalement le fetch de données. Plus de useEffect + useState + états de chargement pour les données connues au moment du rendu. Mais les nouveaux patterns ont leurs propres pièges.
Fetch basique avec mise en cache#
Dans un Server Component, tu fais simplement un fetch. Next.js étend le fetch global pour ajouter la mise en cache :
// app/products/page.tsx
export default async function ProductsPage() {
// Cached by default — same URL returns cached result
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Contrôle le comportement de la mise en cache explicitement :
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});Ensuite tu peux revalider par tag depuis une Server Action :
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Fetch de données en parallèle#
L'erreur de performance la plus courante que je vois : du fetch séquentiel quand le parallèle fonctionnerait très bien.
Mauvais — séquentiel (cascade) :
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — each waits for the previous one
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Bien — parallèle :
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
// All three fire simultaneously
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (runs in parallel)
getNotifications(), // 150ms (runs in parallel)
]);
// Total: ~300ms — limited by the slowest request
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Encore mieux — parallèle avec des frontières Suspense indépendantes :
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
export default function Dashboard() {
// Note: this component is NOT async — it delegates to children
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}Ce dernier pattern est le plus puissant parce que chaque section se charge indépendamment. L'utilisateur voit le contenu au fur et à mesure qu'il devient disponible, pas en tout-ou-rien. Les sections rapides n'attendent pas les lentes.
Déduplication des requêtes#
Next.js déduplique automatiquement les appels fetch avec la même URL et les mêmes options lors d'un même passage de rendu. Ça veut dire que tu n'as pas besoin de remonter le fetch de données pour éviter les requêtes redondantes :
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// Same URL — automatically deduped, not a second request
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}Attention importante : ça ne fonctionne qu'avec fetch. Si tu utilises un ORM ou un client de base de données directement, tu dois utiliser la fonction cache() de React :
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() déduplique pour la durée d'une seule requête serveur. Ce n'est pas un cache persistant — c'est une mémoïsation par requête. Une fois la requête terminée, les valeurs cachées sont collectées par le ramasse-miettes.
Patterns de composition de composants#
C'est là que les RSC deviennent véritablement élégants, une fois que tu comprends les patterns. Et véritablement déroutants, tant que ce n'est pas le cas.
Le pattern « children comme un trou »#
C'est le pattern de composition le plus important dans les RSC et il m'a fallu des semaines pour l'apprécier pleinement. Voici le problème : tu as un Client Component qui fournit une mise en page ou de l'interactivité, et tu veux rendre des Server Components à l'intérieur.
Tu ne peux pas importer un Server Component dans un fichier de Client Component. Dès que tu ajoutes "use client", tout dans ce module est côté client. Mais tu peux passer des Server Components comme children :
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* These children can be Server Components! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* These are Server Components, passed through a Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}Pourquoi ça fonctionne ? Parce que les Server Components (UserProfile, NavigationLinks) sont rendus côté serveur d'abord, puis leur sortie (le RSC payload) est passée comme children au Client Component. Le Client Component n'a jamais besoin de savoir que c'étaient des Server Components — il reçoit simplement des nœuds React pré-rendus.
Pense aux children comme un « trou » dans le Client Component par lequel le contenu rendu côté serveur peut passer.
Passer des Server Components comme props#
Le pattern children se généralise à n'importe quelle prop qui accepte React.ReactNode :
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings"; // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings"; // Server Component — can fetch data
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}Chaque composant de paramètres peut être un Server Component async qui récupère ses propres données. Le Client Component (TabLayout) gère juste le changement d'onglet. C'est un pattern incroyablement puissant.
Les Server Components async#
Les Server Components peuvent être async. C'est un changement majeur parce que ça signifie que le fetch de données se fait pendant le rendu, pas comme un effet de bord :
// This is valid and beautiful
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Les Client Components ne peuvent pas être async. Si tu essaies de rendre un composant "use client" async, React lèvera une erreur. C'est une contrainte stricte.
Les frontières Suspense : la primitive de streaming#
Suspense est ce qui te permet d'obtenir le streaming dans les RSC. Sans frontières Suspense, la page entière attend le composant async le plus lent. Avec elles, chaque section streame indépendamment :
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Static — renders immediately */}
<HeroSection />
{/* Fast data — shows quickly */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Medium speed — shows when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Slow (ML-powered) — shows last, doesn't block the rest */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}L'utilisateur voit HeroSection instantanément, puis ProductGrid streame, puis les avis, puis les recommandations. Chaque frontière Suspense est un point de streaming indépendant.
Imbriquer les frontières Suspense est aussi valide et utile :
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Si Dashboard est rapide mais que RevenueChart est lent, le Suspense externe se résout d'abord (montrant le squelette du tableau de bord), et le Suspense interne pour le graphique se résout après.
Les Error Boundaries avec Suspense#
Associe Suspense avec error.tsx pour des interfaces résilientes :
// app/dashboard/error.tsx — Client Component (must be)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Something went wrong loading the dashboard</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Try again
</button>
</div>
);
}Le fichier error.tsx enveloppe automatiquement le segment de route correspondant dans un React Error Boundary. Si un Server Component dans ce segment lève une erreur, l'interface d'erreur s'affiche au lieu de planter la page entière.
Quand utiliser quoi : l'arbre de décision#
Après avoir construit plusieurs applications en production avec les RSC, j'ai défini un cadre de décision clair. Voici le processus de réflexion que je suis réellement pour chaque composant :
Commencer avec les Server Components (le défaut)#
Chaque composant devrait être un Server Component sauf s'il y a une raison spécifique pour qu'il ne le soit pas. C'est la règle la plus importante.
En faire un Client Component quand :#
1. Il utilise des APIs exclusives au navigateur
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}2. Il utilise des hooks React nécessitant un état ou des effets
useState, useEffect, useReducer, useRef (pour les refs mutables), useContext — n'importe lequel de ces hooks nécessite "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// This component MUST be a Client Component because it
// uses useState and manages user input
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}3. Il attache des gestionnaires d'événements
onClick, onChange, onSubmit, onMouseEnter — tout comportement interactif signifie côté client.
4. Il utilise des bibliothèques côté client
Framer Motion, React Hook Form, Zustand, React Query (pour le fetch côté client), toute bibliothèque de graphiques qui rend du canvas ou du SVG interactif.
Le garder comme Server Component quand :#
- Il affiche uniquement des données (pas d'interaction utilisateur)
- Il récupère des données depuis une base de données ou une API
- Il accède à des ressources backend (système de fichiers, variables d'environnement avec des secrets)
- Il importe de grosses dépendances dont le client n'a pas besoin (parseurs markdown, coloration syntaxique, bibliothèques de formatage de dates)
- Il rend du contenu statique ou semi-statique
La décision concrète en pratique#
Voici un exemple concret. Je construis une page produit :
ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — just text
│ ├── ProductPrice (Server) — formatted number, no interaction
│ └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — form with validation
Remarque le pattern : la coquille de la page et les parties riches en données sont des Server Components. Les îlots interactifs (ImageGallery, AddToCartButton, ReviewForm) sont des Client Components. Les sections lentes (RelatedProducts, ProductReviews) sont enveloppées dans Suspense.
Ce n'est pas théorique. C'est à quoi ressemblent réellement mes arbres de composants.
Erreurs courantes (je les ai toutes faites)#
Erreur 1 : tout transformer en Client Component#
Le chemin de moindre résistance quand on migre depuis le Pages Router ou Create React App, c'est de coller "use client" partout. Ça marche ! Rien ne casse ! Tu es aussi en train d'envoyer l'intégralité de ton arbre de composants en JavaScript et tu n'obtiens aucun bénéfice des RSC.
J'ai vu des codebases où le layout racine a "use client" dessus. À ce stade, tu fais littéralement tourner une appli React côté client avec des étapes en plus.
La correction : commence avec des Server Components. N'ajoute "use client" que quand le compilateur te dit que c'est nécessaire (parce que tu as utilisé un hook ou un gestionnaire d'événements). Pousse "use client" aussi bas que possible dans l'arbre.
Erreur 2 : le prop drilling à travers la frontière#
// BAD: fetching data in a Server Component, then passing it through
// multiple Client Components
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}Chaque donnée que tu passes à travers la frontière est sérialisée dans le RSC payload. Tu passes le même objet cinq fois ? Il est dans le payload cinq fois. J'ai vu des RSC payloads gonfler à plusieurs mégaoctets à cause de ça.
La correction : utilise la composition. Passe des Server Components comme children au lieu de passer des données comme props :
// GOOD: Server Components fetch their own data, pass through as children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetches its own data */}
<Settings /> {/* Server Component — fetches its own data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetches its own data */}
</ClientWidget>
</ClientShell>
);
}Erreur 3 : ne pas utiliser Suspense#
Sans Suspense, le Time to First Byte (TTFB) de ta page est limité par ton fetch de données le plus lent. J'avais une page de tableau de bord qui prenait 4 secondes à charger parce qu'une requête d'analytique était lente, alors que le reste des données de la page était prêt en 200 ms.
// BAD: everything waits for everything
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← blocks everything
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// GOOD: analytics loads independently
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Takes 4s but doesn't block the rest */}
</Suspense>
</div>
);
}Erreur 4 : les erreurs de sérialisation au runtime#
Celle-ci est particulièrement douloureuse parce que tu ne la détectes souvent qu'en production. Tu passes quelque chose de non sérialisable à travers la frontière et tu obtiens une erreur cryptique :
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
Les coupables habituels :
- Passer des objets Date (utilise
.toISOString()à la place) - Passer des Map ou Set (convertis en tableaux/objets)
- Passer des instances de classes depuis des ORM (utilise
.toJSON()ou spread dans des objets simples) - Passer des fonctions (déplace la logique dans le Client Component ou utilise des Server Actions)
- Passer des résultats de modèle Prisma avec des champs
Decimal(convertis ennumberoustring)
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// GOOD
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;Erreur 5 : utiliser le Context pour tout#
useContext ne fonctionne que dans les Client Components. Si tu essaies d'utiliser un React context dans un Server Component, ça ne marchera pas. J'ai vu des gens transformer toute leur application en Client Component juste pour utiliser un contexte de thème.
La correction : pour les thèmes et autres états globaux, utilise des variables CSS définies côté serveur, ou utilise les fonctions cookies() / headers() :
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}Pour de l'état véritablement côté client (tokens d'authentification, paniers d'achat, données en temps réel), crée un fin Client Component provider au niveau approprié — pas à la racine :
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider is a Client Component, but children flow through as server content
return <CartProvider>{children}</CartProvider>;
}Erreur 6 : ignorer l'impact sur la taille du bundle#
L'un des plus grands gains des RSC est que le code des Server Components n'est jamais envoyé au client. Mais tu dois y penser activement. Si tu as un composant qui utilise un parseur markdown de 50 Ko et qui ne fait qu'afficher du contenu rendu — ça devrait être un Server Component. Le parseur reste sur le serveur, et seul le HTML généré part vers le client.
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Si tu en faisais un Client Component, marked serait envoyé au navigateur. Pour rien. L'utilisateur téléchargerait 50 Ko de JavaScript juste pour rendre du contenu qui aurait pu être du HTML dès le départ.
Vérifie ton bundle avec @next/bundle-analyzer. Les résultats pourraient te surprendre.
Stratégie de cache#
Le cache dans Next.js 15+ a été considérablement simplifié par rapport aux versions antérieures, mais il y a encore des couches distinctes à comprendre.
La fonction cache() (React)#
La fonction cache() de React sert à la déduplication par requête, pas au cache persistant :
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.C'est limité à une seule requête serveur. Quand la requête est terminée, la valeur cachée disparaît. C'est de la mémoïsation, pas du cache.
unstable_cache (Next.js)#
Pour un cache persistant entre les requêtes, utilise unstable_cache (le nom est « unstable » depuis toujours, mais ça fonctionne très bien en production) :
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // cache key prefix
{
revalidate: 3600, // revalidate every hour
tags: ["products"], // for on-demand revalidation
}
);
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}Pour invalider :
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Rendu statique vs dynamique#
Next.js décide si une route est statique ou dynamique en fonction de ce que tu utilises dedans :
Statique (rendu au moment du build, mis en cache) :
- Pas de fonctions dynamiques (
cookies(),headers(),searchParams) - Tous les appels
fetchont le cache activé - Pas de
export const dynamic = "force-dynamic"
Dynamique (rendu par requête) :
- Utilise
cookies(),headers(), ousearchParams - Utilise
fetchaveccache: "no-store" - A
export const dynamic = "force-dynamic" - Utilise
connection()ouafter()denext/server
Tu peux vérifier quelles routes sont statiques vs dynamiques en lançant next build — ça affiche une légende en bas :
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
La hiérarchie de cache#
Pense au cache en couches :
1. React cache() — per-request, in-memory, automatic dedup
2. fetch() cache — cross-request, automatic for GET requests
3. unstable_cache() — cross-request, for non-fetch operations
4. Full Route Cache — rendered HTML cached at build/revalidation time
5. Router Cache (client) — in-browser cache of visited routes
Chaque couche sert un but différent. Tu n'as pas toujours besoin de toutes, mais comprendre laquelle est active t'aide à déboguer les problèmes de type « pourquoi mes données ne se mettent pas à jour ? ».
Une stratégie de cache concrète#
Voici ce que je fais réellement en production :
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
// cache() here is only for per-request dedup, not persistence
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});La règle d'or : les données publiques qui changent rarement obtiennent unstable_cache. Les données spécifiques à l'utilisateur obtiennent cache() pour la déduplication uniquement. Les données en temps réel n'obtiennent aucun cache.
Server Actions : le pont retour#
Les Server Actions méritent leur propre section parce qu'elles complètent l'histoire des RSC. C'est la façon dont les Client Components communiquent avec le serveur sans routes API.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Invalid email address" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Already subscribed" };
}
return { error: "Something went wrong" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="you@example.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Subscribed!</p>}
</form>
);
}Les Server Actions sont la réponse à « comment je modifie des données ? » dans le monde RSC. Elles remplacent la plupart des routes API pour les soumissions de formulaires, les mutations et les effets de bord.
Règles clés pour les Server Actions :
- Toujours valider les entrées (la fonction est appelable depuis le client — traite-la comme un endpoint API)
- Toujours retourner des données sérialisables
- Elles tournent sur le serveur, donc tu peux accéder aux bases de données, systèmes de fichiers, secrets
- Elles peuvent appeler
revalidatePath()ourevalidateTag()pour mettre à jour les données en cache après les mutations
Patterns de migration#
Si tu as une application React existante (Pages Router, Create React App, Vite), passer aux RSC ne doit pas forcément être une réécriture. Voici comment j'aborde le sujet.
Étape 1 : cartographier tes composants#
Parcours ton arbre de composants et classe tout :
Component State? Effects? Events? → Decision
─────────────────────────────────────────────────────────
Header No No No → Server
NavigationMenu No No Yes → Client (mobile toggle)
Footer No No No → Server
BlogPost No No No → Server
SearchBar Yes Yes Yes → Client
ProductCard No No Yes → Client (onClick) or split
UserAvatar No No No → Server
CommentForm Yes Yes Yes → Client
Sidebar Yes No Yes → Client (collapse toggle)
MarkdownRenderer No No No → Server (big dependency win)
DataTable Yes Yes Yes → Client (sorting, filtering)
Étape 2 : remonter le fetch de données#
Le plus grand changement architectural est de déplacer le fetch de données des useEffect dans les composants vers les Server Components async. C'est là que se trouve le vrai effort de migration.
Avant :
// Old pattern — data fetching in a Client Component
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}Après :
// New pattern — Server Component fetches, Client Component interacts
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}Plus de gestion d'état de chargement. Plus d'état d'erreur. Plus de useEffect. Le framework gère tout ça via Suspense et les error boundaries.
Étape 3 : découper les composants aux frontières d'interaction#
Beaucoup de composants sont principalement statiques avec une petite partie interactive. Découpe-les :
Avant (un gros Client Component) :
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}Après (Server Component avec un petit îlot Client) :
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}L'image, le titre, la description et le prix sont maintenant rendus côté serveur. Seul le petit bouton favori est un Client Component. Moins de JavaScript, des chargements de page plus rapides.
Étape 4 : convertir les routes API en Server Actions#
Si tu as des routes API qui existent uniquement pour servir ton propre frontend (pas des consommateurs externes), la plupart d'entre elles peuvent devenir des Server Actions :
Avant :
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, save to DB, send email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});Après :
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validate, save to DB, send email
return { success: true };
}
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";Garde les routes API pour : les webhooks, les consommateurs d'API externes, tout ce qui a besoin d'en-têtes HTTP personnalisés ou de codes de statut, les uploads de fichiers avec streaming.
Étape 5 : tester les composants RSC#
Tester les Server Components nécessite une approche légèrement différente puisqu'ils peuvent être async :
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock the database
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("renders product details", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Test Product",
description: "A great product",
price: 29.99,
});
// Server Components are async — await the JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});La différence clé : tu fais await sur la fonction du composant parce qu'elle est async. Ensuite tu rends le JSX résultant. Tout le reste fonctionne comme avec la Testing Library React traditionnelle.
Structure de fichiers pour les projets RSC#
Voici la structure sur laquelle j'ai convergé après plusieurs projets. C'est opinioné, mais ça fonctionne :
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Home page (Server Component)
│ ├── (marketing)/ ← Route group for marketing pages
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Route group for authenticated app
│ │ ├── layout.tsx ← App shell with auth check
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback for this route
│ │ │ └── error.tsx ← Error boundary for this route
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Shared UI primitives (mostly Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (just styling)
│ └── features/ ← Feature-specific components
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, fetches data)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutation)
│ └── blog/
│ ├── PostList.tsx ← Server (async, fetches data)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (form, real-time)
├── lib/
│ ├── data/ ← Data access layer
│ │ ├── products.ts ← cache() wrapped DB queries
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — wraps parts that need theme
└── CartProvider.tsx ← "use client" — wraps shop section only
Les principes clés :
- Les Server Components n'ont pas de directive — c'est le comportement par défaut
- Les Client Components sont explicitement marqués — tu peux le voir d'un coup d'œil
- Le fetch de données vit dans
lib/data/— enveloppé aveccache()ouunstable_cache - Les Server Actions vivent dans
app/actions/— co-localisées avec l'app, clairement séparées - Les providers enveloppent le minimum nécessaire — pas l'application entière
Le mot de la fin#
Les React Server Components ne sont pas juste une nouvelle API. C'est une façon différente de penser où le code s'exécute, où les données vivent, et comment les pièces se connectent. Le changement de modèle mental est réel et ça prend du temps.
Mais une fois que ça fait tilt — une fois que tu arrêtes de te battre contre la frontière et que tu commences à concevoir autour — tu te retrouves avec des applications qui sont plus rapides, plus simples et plus maintenables que ce qu'on construisait avant. Moins de JavaScript est envoyé au client. Le fetch de données ne nécessite pas de cérémonie. L'arbre de composants devient l'architecture.
La transition en vaut la peine. Sache juste que les premiers projets seront inconfortables, et c'est normal. Tu ne galères pas parce que les RSC sont mauvais. Tu galères parce que c'est véritablement nouveau.
Commence avec des Server Components partout. Pousse "use client" vers les feuilles. Enveloppe les trucs lents dans Suspense. Récupère les données là où elles sont rendues. Compose à travers les children.
Voilà tout le plan de jeu. Tout le reste, ce sont des détails.