Développement Web

Server vs Client Components : ou placer la frontiere en React 19

11 دقائق للقراءة

Le concept le plus déroutant pour qui découvre React 19 et l’App Router de Next.js est la frontière Server / Client. Tout composant est par défaut rendu côté serveur, sans JavaScript envoyé au navigateur. Pour devenir interactif, il doit explicitement franchir la frontière via la directive "use client". Mal placée, cette frontière fait soit ballonner le bundle JS, soit casser l’interactivité avec des erreurs cryptiques. Bien placée, elle donne des pages qui se chargent en quelques kilo-octets de JS pour des UX très riches.

Ce tutoriel suit la construction d’une page produit d’un e-commerce — un cas concret qui mélange affichage statique, interactivité locale, et appels serveur. À la fin, vous saurez tracer la frontière correctement sur n’importe quelle page, éviter les erreurs de sérialisation, et appliquer le pattern des client islands qui est devenu le standard de fait dans les applications Next.js modernes.

Prérequis

  • Node.js 20 LTS ou plus récent (Bun 1.2+ accepté également)
  • Une connaissance de base de React (hooks, JSX)
  • Un projet Next.js 15 initialisé avec App Router activé
  • Niveau : intermédiaire — au moins un projet React simple déjà écrit
  • Temps estimé : 60 à 90 minutes en suivant les étapes

Si vous n’avez pas encore d’app Next.js sous la main, créez-en une en une commande avant de continuer.

pnpm create next-app@latest produit-demo \
  --typescript --tailwind --app --turbopack --src-dir --no-linter

Après quelques secondes, vous obtenez un projet vide mais fonctionnel. pnpm dev le lance sur http://localhost:3000. C’est notre point de départ pour le reste du tutoriel.

Étape 1 — Comprendre la règle par défaut

Avant d’écrire la moindre ligne de code, ancrons la règle. Dans un projet App Router, chaque fichier dans app/ est traité comme un Server Component, sauf indication contraire. Cela inclut les page.tsx, les layout.tsx, et tous les composants importés depuis ces fichiers — à moins que le composant importé n’ait lui-même "use client" en tête.

Un Server Component ne peut pas utiliser : useState, useEffect, useContext, useRef, ni aucun gestionnaire d’événement DOM (onClick, onChange, onSubmit). En revanche il peut faire await directement, accéder au système de fichiers, ouvrir des connexions base de données, lire des variables d’environnement secrètes, et importer des bibliothèques Node.js comme fs ou crypto.

Créons un fichier src/app/produits/[id]/page.tsx qui affiche une fiche produit factice — entièrement côté serveur.

// src/app/produits/[id]/page.tsx
type Produit = { id: string; nom: string; prix: number; description: string };

async function getProduit(id: string): Promise<Produit> {
  // En vrai : await db.produit.findUnique({ where: { id } }) ou un fetch
  await new Promise((r) => setTimeout(r, 200)); // simule la latence DB
  return {
    id,
    nom: 'Casque audio Bluetooth Aria X',
    prix: 89.90,
    description: 'Autonomie 35 h, reduction de bruit active, USB-C.',
  };
}

export default async function PageProduit({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const produit = await getProduit(id);

  return (
    <article className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold">{produit.nom}</h1>
      <p className="text-lg mt-2">{produit.prix.toFixed(2)} €</p>
      <p className="mt-4 text-gray-700">{produit.description}</p>
    </article>
  );
}

Visitez http://localhost:3000/produits/123. La page s’affiche au bout de 200 ms (le délai simulé). Ouvrez l’onglet Network de DevTools et regardez la requête : le HTML arrive déjà rempli avec le nom, le prix, la description. Aucun JavaScript spécifique à cette page n’a été chargé — uniquement le runtime React minimum partagé entre toutes les pages. C’est ça, un Server Component en action.

Notez deux détails importants : params est désormais une Promise en Next.js 15 (changement majeur depuis la 14), d’où le await params. Le composant lui-même est marqué async — c’est seulement possible côté serveur, jamais côté client.

Étape 2 — Ajouter un bouton interactif sans casser le serveur

Notre page produit a besoin d’un bouton « Ajouter au panier » qui mémorise un état local et déclenche une action. La tentation naturelle : ajouter useState et onClick directement dans page.tsx. Mauvaise idée — cela transformerait toute la page en Client Component, et même le composant qui charge le produit en base devrait migrer. On perdrait l’intérêt principal du serveur.

La bonne approche consiste à isoler la zone interactive dans un composant enfant marqué "use client", puis l’importer dans la page serveur. C’est le pattern dit des client islands — des îlots d’interactivité dans une mer de HTML statique.

// src/components/AjouterAuPanier.tsx
'use client';

import { useState } from 'react';

export function AjouterAuPanier({ produitId }: { produitId: string }) {
  const [quantite, setQuantite] = useState(1);
  const [ajoute, setAjoute] = useState(false);

  const handleClick = () => {
    // Logique panier : localStorage, Zustand, ou Server Action plus tard
    console.log('Ajout produit', produitId, 'x', quantite);
    setAjoute(true);
    setTimeout(() => setAjoute(false), 2000);
  };

  return (
    <div className="flex items-center gap-3 mt-6">
      <input type="number" min={1} value={quantite}
        onChange={(e) => setQuantite(Number(e.target.value))}
        className="border rounded px-2 py-1 w-20" />
      <button onClick={handleClick}
        className="bg-black text-white px-4 py-2 rounded">
        {ajoute ? 'Ajoute' : 'Ajouter au panier'}
      </button>
    </div>
  );
}

On l’utilise dans la page serveur sans rien changer d’autre, en l’important comme un composant React normal.

// src/app/produits/[id]/page.tsx — modifications uniquement
import { AjouterAuPanier } from '@/components/AjouterAuPanier';

// ...dans le JSX, apres la description :
<AjouterAuPanier produitId={produit.id} />

Rechargez la page. L’interactivité fonctionne : le compteur réagit, le bouton change de texte. Côté Network, vous voyez désormais un petit chunk JS chargé — uniquement le code du composant AjouterAuPanier et ses dépendances. Le composant PageProduit, lui, reste serveur et ne contribue à aucun bundle. C’est exactement l’objectif recherché.

Étape 3 — Le piège des props non sérialisables

Quand on passe des props d’un Server Component vers un Client Component (comme produitId ci-dessus), Next.js les sérialise en JSON pour les transmettre. Cela impose une contrainte : on ne peut passer que des valeurs sérialisables.

Sont autorisés : chaînes, nombres, booléens, null, undefined, tableaux et objets simples composés de ces types, plus quelques cas spéciaux gérés par React (Promises résolvables, ReactElements). Sont interdits : fonctions, instances de classes, Dates brutes, Map, Set, fonctions fléchées qui capturent du contexte serveur, références JSX qui appellent du code serveur arbitraire.

// Ne fonctionne pas
const formaterPrix = (n: number) => n.toFixed(2) + ' €';

return <AjouterAuPanier produitId={produit.id} formater={formaterPrix} />;
// Error: Functions cannot be passed directly to Client Components.

La solution : soit appeler la fonction côté serveur et passer le résultat (une chaîne, sérialisable), soit définir la fonction dans un fichier client et l’importer depuis AjouterAuPanier. Pour les Dates, on passe une string ISO via date.toISOString() et on parse côté client avec new Date(str). Pour les Map/Set, on convertit en tableau d’entrées avant transmission.

Astuce de debug : si vous voyez en console une erreur « Only plain objects can be passed to Client Components », c’est exactement ce piège. Inspectez les props passées au composant client incriminé et identifiez celle qui n’est pas sérialisable. L’IDE TypeScript moderne flag aussi ces erreurs au moment de l’écriture si on configure le plugin Next correctement.

Étape 4 — Composer Server et Client en arbre profond

Une question revient souvent : peut-on imbriquer un Server Component à l’intérieur d’un Client Component ? Réponse courte : pas par import direct, mais oui par composition via children. C’est un pattern puissant qui débloque énormément de cas concrets.

Disons qu’on veut un système d’onglets qui affiche soit la description produit, soit les avis clients chargés depuis la base. L’onglet lui-même est interactif (clic, état actif) donc client. Comment garder le contenu des avis côté serveur ?

// src/components/Onglets.tsx
'use client';

import { useState, ReactNode } from 'react';

export function Onglets({
  description,
  avis,
}: {
  description: ReactNode;
  avis: ReactNode;
}) {
  const [actif, setActif] = useState<'desc' | 'avis'>('desc');

  return (
    <div className="mt-8">
      <div className="flex gap-2 border-b">
        <button onClick={() => setActif('desc')}>Description</button>
        <button onClick={() => setActif('avis')}>Avis</button>
      </div>
      <div className="pt-4">
        {actif === 'desc' ? description : avis}
      </div>
    </div>
  );
}
// src/app/produits/[id]/page.tsx
import { Onglets } from '@/components/Onglets';
import { ListeAvis } from '@/components/ListeAvis'; // Server Component async

// ...dans le JSX :
<Onglets
  description={<p className="text-gray-700">{produit.description}</p>}
  avis={<ListeAvis produitId={produit.id} />}
/>

Magique : Onglets est client et gère l’état des onglets, mais ListeAvis reste un Server Component qui peut faire await db.avis.findMany(...). Les deux contenus sont rendus côté serveur en HTML, puis le client se contente d’afficher l’un ou l’autre selon l’état local. Aucun JS supplémentaire pour la donnée, juste pour la bascule entre onglets. C’est la clé pour garder des bundles minimaux même sur des pages riches.

Étape 5 — La grille de décision pour chaque composant

Pour ne plus hésiter, voici la grille mentale à appliquer pour chaque nouveau composant que vous créez. Posez-vous les questions dans l’ordre et arrêtez-vous à la première réponse positive.

Question Réponse positive → Réponse négative →
Utilise useState, useReducer, useEffect ou autre hook React stateful ? Client obligatoire Continuer
A un gestionnaire d’événement DOM (onClick, onChange, onSubmit) ? Client obligatoire Continuer
Utilise une API navigateur (localStorage, window, navigator) ? Client obligatoire Continuer
Consomme un contexte React via useContext ? Client obligatoire Continuer
Doit accéder à une base, lire un fichier, ou utiliser une variable d’environnement secrète ? Serveur obligatoire Continuer
Importe une bibliothèque Node.js (fs, crypto natif) ? Serveur obligatoire Continuer
Aucune des questions ci-dessus ne s’applique Serveur (défaut)

Règle d’or : commencez toujours par serveur, ne basculez en client que si la grille l’exige. Et quand vous basculez, isolez le plus profondément possible dans l’arbre — pas tout un layout entier juste pour un bouton de menu. Un composant client peut englober une dizaine d’éléments présentationnels, mais il est presque toujours préférable d’extraire un sous-composant client minuscule qui gère la partie interactive seule.

Étape 6 — Vérifier la taille du bundle

Pour mesurer concrètement les gains, lancez un build de production et regardez le rapport généré par Next.js.

pnpm build

Dans la sortie, Next.js affiche un tableau par route avec deux colonnes : Size (la taille du JS spécifique à cette route) et First Load JS (le total chargé au premier accès, incluant le runtime React et les chunks partagés). Pour notre page produit avec les îlots clients bien placés, on devrait voir Size autour de 1 à 3 kB. Si vous voyez 50 kB ou plus pour une page simple, c’est qu’un import lourd s’est faufilé côté client — typiquement une lib de graphiques, un éditeur Markdown, ou un date-picker importé par mégarde dans un composant "use client".

Pour creuser, installez @next/bundle-analyzer et activez-le selon la procédure de la documentation officielle. Il génère un treemap interactif qui montre exactement quel module pèse quoi dans chaque bundle. C’est l’outil le plus rapide pour traquer une dépendance qui aurait migré côté client par accident — d’expérience, c’est le premier endroit à regarder dès qu’un build sort des chiffres anormaux.

Erreurs fréquentes

Erreur Cause Solution
« You’re importing a component that needs useState » Composant client importé depuis un Server Component sans isolation Ajouter "use client" en tête du composant, ou l’isoler dans un fichier dédié
« Functions cannot be passed directly to Client Components » Tentative de passer une fonction en prop d’un serveur à un client Soit définir la fonction côté client, soit la marquer "use server" pour en faire une Server Action
« Hydration failed because the initial UI does not match » Rendu serveur et client divergent (date, random, formatage locale) Calculer ces valeurs uniquement client via useEffect ou les figer côté serveur
Composant client qui ne reçoit jamais les nouvelles props après revalidation Mauvaise clé React, ou cache navigateur agressif Ajouter key={produit.id} sur le composant client, vérifier les en-têtes de cache
Bundle JS énorme malgré peu de composants client Import indirect d’une grosse lib (lodash, moment, ICU) Tree-shake avec imports précis (lodash-es/debounce), remplacer moment par date-fns ou dayjs

Pour aller plus loin

Cet article fait partie d’une série sur React 19 et Next.js 15. Pour la vue d’ensemble et le parcours conseillé, consultez React 19 + Next.js 15 : architecture full-stack moderne.

Tutoriels frères qui s’appuient sur les concepts vus ici :

Ressources officielles :

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité