Module 1 · Le modèle

Server et Client Components

Voici le module du bloc Next.js. Pas le plus long, pas le plus technique : le plus structurant. Tout ce qui suit — le routing, le rendu, les Server Actions — n'est que la conséquence d'une seule idée, et cette idée tient en une question que tu devras te poser devant chaque fichier d'un projet Next.js : « où est-ce que ce code s'exécute ? » Prends ton temps ici, relis-le. Beaucoup de texte et peu de code, délibérément : le code n'est que l'illustration d'un déplacement mental.

💡 Le concept en une phrase

Jusqu'à maintenant, dans tout ce que tu as écrit — React Native au quotidien, React web dans le module « React web ↔ React Native » — il n'existait qu'un seul lieu où ton code tournait : l'appareil sur mobile, le navigateur sur le web classique. Un seul lieu, donc jamais de question à se poser. Next.js casse ça : dans l'App Router, ton code React vit dans deux lieux, et c'est toi qui décides lequel, fichier par fichier.

Attention à la formulation exacte, parce que c'est là que tout le monde se trompe. La bonne opposition n'est pas « serveur ou client », c'est « serveur seulement » contre « serveur PUIS client » : un Server Component s'exécute une fois sur le serveur et n'envoie jamais son code au navigateur ; un Client Component est lui aussi exécuté sur le serveur pour produire le HTML initial, puis reprend vie dans le navigateur pour y devenir interactif. Tant que tu ne sais pas où tourne le fichier que tu lis, la moitié des erreurs de Next.js te semblera arbitraire ; une fois que tu le sais, elles deviennent prévisibles avant même de lancer le projet.

🧭 Où on en est

Le module « La carte de Next.js » t'a donné le plan du territoire. Celui-ci zoome sur le mécanisme central ; les trois suivants — « Routing », « Rendu & data fetching » et « Route Handlers & Server Actions » — supposent tous que la distinction serveur / client est acquise. C'est ici que ça se joue.

Le problème que ça résout : quand tout partait dans le navigateur

Pour comprendre pourquoi les Server Components existent, il faut d'abord ressentir la douleur qu'ils soignent. Une application React « classique » — celle que tu construirais avec Vite, ou celle que tu connais déjà sous forme d'app React Native — fonctionne selon un schéma unique : tout le JavaScript est téléchargé par le navigateur, puis exécuté par le navigateur. Le serveur n'est qu'un distributeur de fichiers : il envoie une page HTML quasiment vide, un gros paquet de JavaScript, et il se retire.

Prenons notre fil rouge, RendezVous : une application de prise de rendez-vous en clinique, avec une page qui affiche les services offerts et un formulaire pour réserver un créneau. Voici ce qui se passe, seconde par seconde, quand un patient ouvre cette page dans la version « tout client ».

  1. Le navigateur demande la page. Le serveur répond avec un fichier HTML qui contient, en gros, <div id="root"></div> et une balise <script>. L'utilisateur voit… une page blanche.
  2. Le navigateur télécharge le JavaScript. Pas seulement ton code : React, le routeur, la bibliothèque de formulaires, celle de dates, les utilitaires… Sur un réseau mobile moyen, ça se compte en secondes. Toujours la page blanche.
  3. Le navigateur exécute le JavaScript. React démarre, monte l'arbre, et la page affiche enfin… un squelette vide, faute de données. Un spinner à la place de la liste des services.
  4. Maintenant seulement, un useEffect lance un fetch vers l'API pour récupérer les services : un aller-retour réseau de plus. Puis un deuxième fetch pour les disponibilités du praticien, qui ne peut partir qu'après le premier puisqu'il a besoin de l'identifiant du service. Deux allers-retours en file indienne.
  5. Enfin, les données arrivent, l'état change, React re-render, et la liste s'affiche.

Les étapes 1 et 3 devraient te dire quelque chose : ce <div id="root"></div> vide, ce <script>, puis React qui « monte l'arbre » — c'est Le clou que Next.js enfonce à ta place, du module React web ↔ React Native : les ponts, autrement dit createRoot(document.getElementById('root')).render(<App />). Dans le schéma tout-client, c'est toi qui plantes ce clou, et rien ne peut s'afficher avant qu'il ne soit planté. Tout ce module raconte ce qui change quand le framework le plante avant toi, sur le serveur.

Fais le compte : entre l'adresse tapée et le premier contenu utile, quatre attentes successives — le HTML, le JS, l'exécution, les données — dont chacune ne peut commencer que quand la précédente est finie. C'est une cascade (en anglais request waterfall) : chaque palier doit se remplir avant que l'eau ne coule au suivant.

📅 Dans RendezVous

En version tout-client, la page d'accueil de RendezVous ressemblait à ça : un useState pour la liste des services, un pour chargement, un pour erreur, un useEffect qui lance le fetch, et un rendu qui commence par trois if (« si ça charge, un spinner ; s'il y a une erreur, un message ; sinon, la liste »). Ce trio état / effet / triple-if, tu l'as écrit cent fois en React Native : c'est le tribut normal de la vie « côté client », où l'on n'a pas les données au premier rendu et où il faut donc coder l'attente.

Ajoute deux ennuis moins visibles. Le poids du bundle d'abord — le bundle, c'est le gros paquet de JavaScript que le navigateur doit télécharger : tout ton code part chez l'utilisateur, y compris ce qui n'a aucune raison d'y être. Une bibliothèque de dates pèse quelques dizaines de kilo-octets, un client de base de données bien plus, et le téléphone télécharge, décompresse et compile tout ça avant d'afficher un seul mot.

Les secrets ensuite : puisque le code tourne dans le navigateur, il ne peut jamais contenir de clé d'API ni de mot de passe de base de données. Ce n'est pas une question de discipline, c'est physique — tout ce que le navigateur exécute, l'utilisateur peut le lire. Il faut donc obligatoirement une couche d'API intermédiaire, un serveur à écrire, déployer et sécuriser, dont le seul rôle est souvent de recopier une base de données vers du JSON.

Récapitulons les quatre douleurs, parce que les Server Components répondent exactement, une par une, à ces quatre-là : (1) un bundle JavaScript trop gros, (2) des données qui n'arrivent qu'après coup, (3) une cascade de spinners qui s'enchaînent, (4) l'impossibilité de toucher directement aux données sensibles.

🔗 Pont — tu connais déjà cette douleur

Le module « HTTP & fetch » a posé le vocabulaire de l'aller-retour réseau : une requête, une réponse, un temps de latence incompressible. La cascade décrite ci-dessus n'est rien d'autre que plusieurs de ces allers-retours mis bout à bout, alors qu'ils auraient pu être faits en parallèle — ou, mieux, faits par une machine bien plus proche de la base de données. Retiens cette intuition : un aller-retour depuis le téléphone de l'utilisateur coûte cher ; un aller-retour depuis le serveur, qui est dans le même centre de données que la base, coûte presque rien. Déplacer le travail vers le serveur, ce n'est pas un caprice d'architecte, c'est de la géographie.

La révélation : « où ce code s'exécute-t-il ? »

Voici l'affirmation à graver, plus radicale qu'elle n'en a l'air : dans l'App Router de Next.js, un composant s'exécute par défaut sur le serveur. Pas « peut s'exécuter », pas « si tu configures quelque chose ». Tu crées app/page.tsx, tu y écris un composant React tout ce qu'il y a de plus normal, et il tournera sur le serveur ; son code ne sera jamais téléchargé par le navigateur.

C'est le retournement complet de ce que tu connais : en React Native, tu ne t'es jamais demandé où tournait WorkoutCard, il n'y avait pas d'autre endroit. Le nom officiel de ces composants qui tournent sur le serveur, c'est Server Components (ou « RSC », React Server Components) ; ceux qui tournent — aussi — dans le navigateur sont les Client Components.

💡 Le serveur cuisine, le navigateur dresse l'assiette

Un restaurant, une cuisine et une salle. En cuisine (le serveur), on a le garde-manger, les fournisseurs, le coffre-fort aux recettes secrètes, et on fait tout le travail lourd : chercher, cuire, assembler. Ce qui en sort n'est ni la recette ni les ingrédients bruts, c'est un plat fini. En salle (le navigateur), on ne cuisine pas : on reçoit le plat, on le pose sur la table, et on gère ce qui est vivant — le client qui appelle, change d'avis, demande du sel. La salle réagit ; la cuisine produit.

La limite de l'analogie, tu la connais déjà : les composants de la salle passent quand même par la cuisine avant le service — « serveur seulement » contre « serveur puis client ». La section « 'use client' : ce que la directive fait vraiment » y revient en détail.

Concrètement : quand une requête arrive, Next.js exécute tes Server Components sur le serveur. Ils vont chercher leurs données, ils rendent leur JSX, et le résultat est sérialisé — converti en une suite d'octets qui peut voyager sur le réseau (voir la section « La frontière et les props sérialisables ») — dans un format compact appelé le RSC Payload. Ce payload n'est ni du HTML ni ton code : c'est une description de l'arbre rendu — le texte, la structure, et des marqueurs qui disent « ici, un Client Component, voici le fichier JavaScript à charger pour lui ». En parallèle, Next.js produit du HTML classique pour que le navigateur ait quelque chose à afficher immédiatement.

Reprends la page RendezVous. Le serveur reçoit la requête, exécute la page, qui interroge directement la base pour obtenir les services — quelques millisecondes, la base est à côté — rend le JSX avec les vraies données dedans, et envoie du HTML déjà rempli. L'utilisateur voit la liste dès la première image : pas de page blanche, pas de spinner, pas de cascade. Et le code qui interroge la base n'a jamais quitté le serveur : il ne pèse rien dans le bundle et ne révèle aucun secret.

La cascade, et ce qu'il en reste
tout client — chaque palier attend le précédent
HTML
bundle JS
exécution
services
créneaux
page blanche squelette et spinner les données, l'une après l'autre
avec des Server Components — le serveur rend, données comprises
avec RSC la liste est à l'écran
Une colonne vaut une unité d'attente, pas une milliseconde : la figure montre l'enchaînement et les proportions que raconte la section, pas un chronomètre. Ce qu'il faut lire, c'est la forme — en haut un escalier, celui-là même que l'onglet Réseau te montrera : cinq paliers dont aucun ne peut commencer avant que le précédent soit fini, et rien d'utile à l'écran avant le dernier. En bas, l'attente n'a pas disparu : elle s'est déplacée là où elle est courte, entre le serveur et une base qui est à côté de lui.
app/page.tsx — un Server Component
// Aucune directive en haut du fichier : dans l'App Router,
// ce composant est donc un SERVER COMPONENT (le défaut).

import { db } from '@/lib/db';

// Remarque le mot-clé `async` : un composant serveur a le droit
// d'être asynchrone, parce qu'il n'est rendu qu'UNE fois, côté serveur.
export default async function PageAccueil() {
  // Requête directe à la base. Les identifiants de connexion
  // vivent dans les variables d'environnement du serveur et
  // ne partiront JAMAIS vers le navigateur.
  const services = await db.service.findMany({ where: { actif: true } });

  // Quand ce JSX est produit, les données sont DÉJÀ dedans.
  return (
    <section>
      <h1>Nos services</h1>
      <ul>
        {services.map((s) => (
          <li key={s.id}>{s.nom} — {s.duree} min</li>
        ))}
      </ul>
    </section>
  );
}

Compare avec la version « tout client » : plus de useState, de useEffect, de spinner, de if (chargement). L'attente n'a pas disparu — elle a été déplacée là où elle est courte et où l'utilisateur ne la subit pas sous forme d'écran vide.

🧭 « async » dans un composant, vraiment ?

Oui, et ça surprend tout le monde. En React classique, un composant ne peut pas être async : il est rappelé sans arrêt (module « Le modèle mental de React » du guide Halterofit), et une fonction qui rend une promesse n'aurait aucun sens dans ce cycle. Un Server Component, lui, n'est appelé qu'une seule fois, sur le serveur, pour une requête donnée — pas de re-render, pas d'état qui évolue, pas de cycle de vie. Dans ces conditions async redevient raisonnable : juste une fonction qui met un peu de temps à retourner sa description.

🧠 Quiz éclair

La page affiche bien la liste des services. Mais tu ouvres la console du navigateur pour vérifier le nombre chargé : rien, pas une ligne. Le console.log s'est-il exécuté ?

// app/page.tsx — aucune directive
import { db } from '@/lib/db';

export default async function Page() {
  const services = await db.service.findMany();
  console.log('services chargés :', services.length);

  return <ListeServices services={services} />;
}

Oui, il s'est exécuté — mais pas là où tu regardes. Ce composant tourne sur le serveur : son console.log est parti dans la sortie du terminal où tourne next dev, pas dans la console du navigateur. Le navigateur, lui, n'a jamais reçu ce code ; il a reçu le HTML qui en est sorti. C'est la conséquence la plus quotidienne du « où est-ce que ça tourne ? » : sur un Server Component, on débogue dans le terminal, et un composant marqué 'use client' loguera, lui, aux deux endroits — une fois côté serveur pour le HTML initial, une fois dans le navigateur à l'hydratation.

Ce qu'un Server Component peut et ne peut pas faire

Voici la partie où tout devient intuitif, à condition de ne jamais apprendre la liste par cœur. Chaque capacité et chaque interdiction découle d'un seul fait : le code tourne sur le serveur, avant que l'utilisateur n'ait vu quoi que ce soit. Si tu retiens ça, tu peux régénérer la liste toi-même à tout moment.

Un Server Component…Peut / Ne peut pasPourquoi, logiquement
Être async et faire await ✅ Peut Il est rendu une seule fois, côté serveur. Rien ne l'empêche de prendre son temps.
Interroger directement une base de données ✅ Peut Le serveur est un environnement de confiance, et il est physiquement proche de la base.
Lire une clé d'API secrète ✅ Peut Ce code n'est jamais envoyé au navigateur : le secret reste secret.
Rendre d'autres Server Components ✅ Peut On reste du même côté de la frontière ; rien à traverser.
Rendre un Client Component ✅ Peut Il laisse un marqueur « ici, un composant client » que le navigateur remplira.
Utiliser useState ❌ Ne peut pas L'état sert à changer dans le temps. Le composant a fini son travail et n'existe plus quand l'utilisateur regarde la page. Changer quoi, et pour qui ?
Utiliser useEffect ❌ Ne peut pas Un effet se déclenche « après l'affichage ». Côté serveur, il n'y a pas d'affichage : il n'y a qu'une chaîne de caractères à produire.
Poser un onClick / onChange ❌ Ne peut pas Un gestionnaire d'événement est une fonction. On ne peut pas envoyer une fonction dans du HTML ; et de toute façon, le serveur ne sera plus là pour l'exécuter.
Toucher window, localStorage, navigator ❌ Ne peut pas Ces objets appartiennent au navigateur (module « Le navigateur »). Sur le serveur, ils n'existent tout simplement pas.
Utiliser un contexte React (useContext) ❌ Ne peut pas Le contexte est un mécanisme de l'arbre React vivant, côté client. Un provider doit donc être un Client Component.
Utiliser un hook personnalisé qui utilise l'un des ci-dessus ❌ Ne peut pas Un hook n'est qu'une fonction : il hérite des contraintes de ce qu'il appelle.

Regarde la colonne de droite : pas une seule interdiction arbitraire, que des impossibilités physiques. Demander window sur un serveur, c'est comme demander le numéro de plaque d'un vélo — ce n'est pas interdit, c'est vide de sens.

Fais-toi l'exercice mental sur le cas le plus parlant, l'état. useState promet deux choses : « garde-moi cette valeur d'un rendu à l'autre » et « rappelle ma fonction quand elle change ». Or un Server Component n'a qu'un seul rendu — il produit son HTML, il est jeté, la requête est terminée. Pas de « rendu suivant » où garder la valeur, personne pour rappeler la fonction : le serveur est passé à la requête d'un autre utilisateur. Les deux promesses sont vides ici ; c'est interdit non parce que quelqu'un l'a décidé, mais parce que ça ne veut rien dire.

Même raisonnement pour onClick. Le HTML que le serveur envoie est du texte : on n'écrit pas une fonction JavaScript dans du texte en espérant qu'elle s'exécute — et même si on le pouvait, elle devrait s'exécuter deux secondes plus tard, dans le navigateur, alors que le serveur a déjà oublié cette requête. La seule façon d'avoir un vrai onClick, c'est que du JavaScript soit présent dans le navigateur. D'où la section suivante.

✍️ Exercice de lecture

Voici un fichier de RendezVous. Il ne fonctionne pas. Lis-le et dis pourquoi, en une phrase, sans regarder le corrigé.

'use client';

import { db } from '@/lib/db';

export default async function ListeServices() {
  const services = await db.service.findMany();

  return (
    <ul>
      {services.map((s) => <li key={s.id}>{s.nom}</li>)}
    </ul>
  );
}
Voir le corrigé

Le fichier se contredit lui-même. La première ligne, 'use client', annonce « ce composant vivra dans le navigateur ». Mais le corps du composant fait deux choses réservées au serveur : il est async et il interroge la base de données.

Sur le async : un composant client est rappelé sans arrêt par React (props, état, parent — le trio du module « Le modèle mental de React »), et React ne sait pas quoi faire d'une fonction-composant qui retourne une promesse dans ce cycle. D'où l'erreur « async/await is not yet supported in Client Components, only Server Components », dont le message souffle souvent la bonne piste : un 'use client' ajouté par erreur en haut d'un module écrit pour le serveur.

Sur la base de données : le problème le plus grave, même s'il est plus discret. En marquant le fichier 'use client', tu demandes au bundler d'embarquer ce fichier et tout ce qu'il importe dans le paquet envoyé au navigateur : @/lib/db, son pilote, et potentiellement la chaîne de connexion. Énorme et dangereux. En pratique Next.js t'arrêtera avant (et le paquet server-only existe pour transformer ce genre d'erreur en échec de compilation clair), mais l'intention du code est fausse dès le départ.

Le correctif tient en une touche : supprimer la ligne 'use client'. Ce composant n'a aucune interactivité — il affiche une liste. Il n'a rien à faire côté client.

🧠 Quiz éclair

Le site refuse de démarrer : « createContext only works in Client Components. Add the "use client" directive at the top of the file to use it. » Pourquoi un contexte n'a-t-il aucun sens ici — et quel est le correctif qui ne fait pas basculer tout le site dans le navigateur ?

// app/layout.tsx — aucune directive
import { createContext } from 'react';

export const ThemeContext = createContext('clair');

export default function RootLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <html lang="fr">
      <body>
        <ThemeContext.Provider value="sombre">
          {children}
        </ThemeContext.Provider>
      </body>
    </html>
  );
}

Un contexte est un mécanisme de l'arbre React vivant : un fournisseur dépose une valeur, des consommateurs s'y abonnent, et quand elle change tout le monde est re-rendu. Or un Server Component n'a qu'un seul rendu — il produit sa description, la requête se termine, il est jeté : il n'y a plus d'arbre auquel s'abonner, ni de re-rendu à propager. L'abonnement n'est pas interdit, il est vide de sens, exactement comme useState. Le correctif n'est pas d'ajouter 'use client' en tête du layout racine : la directive est virale, et tout ce que le layout importe partirait avec lui. On extrait le fournisseur dans son propre petit fichier 'use client', qui ne fait que rendre {children}, et le layout serveur s'en sert comme d'un composant ordinaire.

'use client' : ce que la directive fait vraiment

Passons à l'autre côté. Puisque le défaut est « serveur », il faut un moyen de dire « celui-là, non ». Ce moyen, c'est une ligne de texte placée tout en haut d'un fichier, avant même les import :

'use client';

Une chaîne de caractères seule sur une ligne. Pas un import, pas un appel de fonction : une directive, un marqueur que l'outil de compilation lit avant tout le reste.

Voici la nuance la plus mal comprise de tout l'écosystème : 'use client' ne veut PAS dire « ce composant ne s'exécute que dans le navigateur ». Sa première exécution a lieu côté serveur, pour produire le HTML initial que l'utilisateur verra avant même que le JavaScript n'ait fini de se charger ; ensuite seulement il est ré-exécuté dans le navigateur pour devenir interactif — c'est l'hydratation (React « rebranche » les gestionnaires d'événements sur le HTML déjà présent). Ce que la directive fait vraiment, c'est marquer une frontière : « à partir d'ici, embarque ce fichier dans le bundle client ». Le vocabulaire officiel dit qu'elle déclare une boundary entre deux graphes de modules.

💡 Le bon découpage : « serveur seulement » vs « serveur ET client »

Reprends l'opposition posée en ouverture, elle se décline ainsi :

  • Server Component = s'exécute uniquement sur le serveur. Son code n'est jamais envoyé au navigateur. Il ne peut donc pas être interactif.
  • Client Component = s'exécute une fois sur le serveur (pour le HTML initial), puis dans le navigateur (pour l'interactivité). Son code voyage jusqu'à l'utilisateur.

Vu comme ça, le mot « client » dans 'use client' se lit correctement : il ne dit pas où ça tourne, il dit où ça finit. C'est une étiquette d'expédition, pas une adresse d'exécution.

⚠️ Piège fréquent — l'erreur d'hydratation

« Un Client Component s'exécute deux fois » n'est pas une subtilité théorique : c'est la cause de l'erreur que tu rencontreras en premier sur un vrai projet Next.js. Elle ressemble à ça :

Hydration failed because the server rendered HTML
didn't match the client.

Traduction : le HTML fabriqué par le serveur et celui que le navigateur vient de recalculer ne sont pas identiques, et React refuse de rebrancher ses gestionnaires sur un arbre qu'il ne reconnaît pas. La cause est presque toujours la même — une valeur qui ne peut pas donner le même résultat aux deux exécutions :

'use client';

export function Header() {
  // ⛔ Exécuté sur le serveur à 14:03:07, puis dans le navigateur à 14:03:09.
  //    Deux HTML différents → erreur d'hydratation.
  const now = new Date().toLocaleTimeString();
  return <p>Il est {now}</p>;
}

Même mécanisme avec Math.random(), un identifiant tiré au hasard, ou une valeur lue dans localStorage (qui n'existe pas côté serveur — c'est le piège vu dans le module Le navigateur : DOM, événements, rendu). La parade est toujours l'une des deux : soit la valeur variable est calculée uniquement dans le navigateur, dans un useEffect qui ne s'exécute jamais sur le serveur ; soit elle est décidée par le serveur et descendue en prop, et alors les deux exécutions reçoivent la même.

🧠 Quiz éclair

Le développeur a lu la parade et a bien déplacé la mise à jour de l'heure dans un useEffect. Pourtant « Hydration failed because the server rendered HTML didn't match the client » revient à chaque chargement. Où reste l'écart ?

'use client';
import { useState, useEffect } from 'react';

export function Horloge() {
  const [heure, setHeure] = useState(new Date().toLocaleTimeString());

  useEffect(() => {
    const id = setInterval(
      () => setHeure(new Date().toLocaleTimeString()),
      1000,
    );
    return () => clearInterval(id);
  }, []);

  return <p>Il est {heure}</p>;
}

L'effet corrige le battement de l'horloge, pas son premier affichage. La valeur initiale de useState est calculée pendant le rendu — et ce rendu a lieu deux fois : une fois sur le serveur pour produire le HTML, une fois dans le navigateur à l'hydratation, à deux instants différents. Les deux HTML diffèrent d'une seconde ou deux, et React refuse de rebrancher. Un useEffect ne s'exécute jamais sur le serveur, donc il ne peut rien corriger avant la comparaison : il ne s'exécute qu'après. Correctif : partir d'une valeur neutre que les deux exécutions produisent à l'identique — useState(null), on n'affiche rien (ou un tiret) tant qu'elle est nulle — et laisser l'effet poser la vraie heure juste après. Règle générale : tout ce qui est calculé pendant le rendu d'un Client Component doit être reproductible à l'identique aux deux endroits.

Deuxième propriété capitale : la directive est virale vers le bas. Une fois qu'un fichier est marqué 'use client', tous les modules qu'il importe rejoignent le bundle client avec lui, ainsi que les composants qu'il rend directement ; inutile de répéter la directive dans chaque enfant, elle se propage le long des import. Très pratique (une seule ligne, à l'entrée de la zone interactive) et très dangereux si tu la places trop haut : marque-la en haut de ta page principale et tout ce que cette page importe — carte de service, pied de page, formatteur de dates, bibliothèque de validation — bascule dans le navigateur. Tu as réinventé l'application « tout client » de la section « Le problème que ça résout : quand tout partait dans le navigateur », avec en prime la complexité de Next.js.

⚠️ Piège fréquent — la directive qu'on remonte pour faire taire l'erreur

Le symptôme est innocent : un composant refuse un onClick, tu ajoutes 'use client' en haut du fichier, l'erreur disparaît. Puis tu recommences un cran plus haut, et un jour la directive est en tête de la page racine.

Le signe qui doit t'arrêter : tu écris la directive en tête d'un fichier de plus de cent cinquante lignes. À ce moment-là, ce n'est jamais de basculer qu'il s'agit, c'est de découper — et la section « Le patron de composition : les Client Components aux feuilles » dit comment.

🧭 Une exception importante : children

La viralité suit les imports, pas le rendu visuel. Si un Client Component reçoit un Server Component via children (ou n'importe quelle autre prop), ce Server Component ne rejoint pas le bundle client : il n'a jamais été importé par le fichier client, il a été rendu sur le serveur et passé déjà cuit. C'est exactement ce qui rend possible le patron de composition de la section « Le patron de composition : les Client Components aux feuilles ». Garde cette phrase : la frontière suit les imports, pas l'imbrication visuelle.

📖 La formule

La frontière client

'use client';
import { useState } from 'react';

export default function AppointmentForm({ services }) {
  const [service, setService] = useState('');
  // … interactivité ici
}

La directive marque le début du monde navigateur : à partir de cette ligne, on a le droit d'utiliser l'état, les événements et les API du navigateur. Et tout ce que ce fichier importe le rejoint automatiquement dans le bundle client — d'où la règle : place-la le plus bas possible dans l'arbre.

🔗 Pont — le moteur est le même, seul le lieu change

Rien de ce que tu as appris sur React ne devient faux ici. Relis le module Le modèle mental de React du guide Halterofit : UI = fonction(état), le JSX qui n'est qu'une description en mémoire, les deux phases render puis commit, la réconciliation qui compare l'ancien arbre au nouveau. Tout ça fonctionne exactement pareil dans un Server Component. Un Server Component est toujours une fonction qui reçoit des données et retourne une description d'écran ; c'est simplement que le calcul se fait dans une autre machine, et que la description est expédiée par le réseau (le RSC Payload) au lieu de rester en mémoire. Le moteur n'a pas changé. Il a juste déménagé une partie de l'atelier.

La frontière et les props sérialisables

Dès qu'il y a deux machines, il y a un réseau entre elles, et dès qu'il y a un réseau, il faut convertir les données en quelque chose qui voyage. C'est ce qu'on appelle la sérialisation : transformer une valeur vivante en mémoire en une suite d'octets, puis la reconstruire de l'autre côté. Tu connais déjà le principe par JSON.stringify et le module « HTTP & fetch ».

D'où la règle : toute prop qui traverse la frontière serveur → client doit être sérialisable. Ce n'est pas une préférence stylistique, c'est une contrainte de transport. Une valeur qui ne peut pas être décrite en octets ne peut pas être remise au navigateur, point.

Ce qui traverse ✅Ce qui ne traverse pas ❌
Chaînes, nombres, booléens, null, undefined, bigint Les fonctions ordinaires (une fonction n'est pas de la donnée : c'est du code, et le serveur ne sera plus là pour l'exécuter)
Objets simples (créés avec { }) et tableaux, s'ils ne contiennent eux-mêmes que des valeurs sérialisables Les instances de classes que tu as écrites toi-même — l'objet perdrait ses méthodes en route
Les Date — oui, vraiment : React sait les transporter et les reconstruire de l'autre côté Les objets à prototype null
Map, Set, TypedArray, ArrayBuffer Les Symbol locaux, créés avec Symbol('x')
Du JSX — un élément React est une valeur, donc il voyage (c'est la clé de la section « Le patron de composition : les Client Components aux feuilles ») Tout ce qui contient, même en profondeur, un des cas ci-dessus
Les promesses — on verra ce cas particulier à la section « Passer une promesse au client : use() et Suspense »  
Les Server Actions, c'est-à-dire les fonctions marquées 'use server' — la seule exception à l'interdiction des fonctions (module « Route Handlers & Server Actions »)
Cas particulier : les Symbol enregistrés globalement (Symbol.for('x')) traversent, parce que le nom suffit à les retrouver de l'autre côté.

La ligne la plus utile du tableau est la première de la colonne de droite — les fonctions — parce que c'est celle sur laquelle tu vas te cogner. Tu voudras écrire ce que tu as toujours écrit en React : passer un callback du parent vers l'enfant (les données descendent, les événements remontent — module « Le modèle mental de React »). Mais si le parent est un Server Component et l'enfant un Client Component, ce callback devrait littéralement traverser Internet. Impossible : on ne met pas du code exécutable dans un flux de données, et de toute façon le serveur qui a créé cette fonction a terminé son travail depuis longtemps.

app/page.tsx — la faute classique
// PAGE SERVEUR (pas de 'use client' ici)
export default async function Page() {
  const services = await db.service.findMany();

  // ❌ `onSelect` est une FONCTION. Elle ne peut pas
  //    franchir la frontière serveur → client.
  return <ServiceSelector
           services={services}
           onSelect={(id) => console.log(id)}
         />;
}

services passe sans problème (c'est un tableau d'objets simples). onSelect, lui, bloque tout.

Le message d'erreur que tu verras alors ressemble à ceci — et maintenant tu peux le lire en entier au lieu de le subir :

Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".

Traduction : « une fonction ne traverse pas, sauf si c'est une Server Action. »

Décortique-le mot à mot, c'est un excellent entraînement. « Functions cannot be passed directly to Client Components » : on te dit quoi (une fonction) et (vers un Client Component). « unless you explicitly expose it by marking it with "use server" » : on te donne la seule échappatoire, les Server Actions, que le module « Route Handlers & Server Actions » détaille. Ce que le navigateur reçoit alors n'est pas la fonction mais une référence, une adresse à rappeler ; le code reste au chaud sur le serveur. Il existe une variante encore plus fréquente de ce message, quand tu écris directement un onClick dans du JSX de Server Component :

Error: Event handlers cannot be passed to Client Component props.
  <button onClick={function onClick}>
If you need interactivity, consider converting part of this
to a Client Component.

Même cause, autre habillage. La dernière phrase te souffle même la solution : découpe, et rends cette partie-là cliente.

Et la bonne nouvelle du tableau : les Date traversent très bien. Beaucoup de tutoriels datés racontent qu'il faut les convertir en chaînes avant de les passer, parce qu'ils raisonnent avec JSON.stringify en tête ; or le format de sérialisation de React est plus riche que JSON — il gère nativement Date, Map, Set, les bigint et les tableaux typés. Ce qui casse vraiment, ce sont les instances de classes maison, un objet d'ORM avec des méthodes dessus par exemple : les données passeraient, les méthodes disparaîtraient, et React refuse plutôt que de te livrer un objet mutilé.

✍️ Exercice de lecture

Voici quatre composants de RendezVous et la façon dont ils s'imbriquent. Un seul fichier porte la directive. Question : lesquels de ces quatre composants finiront dans le bundle JavaScript téléchargé par le navigateur ?

// app/page.tsx  — pas de directive
import EnTete from './en-tete';                 // pas de directive
import PanneauReservation from './panneau';     // 'use client'
import ResumeClinique from './resume';          // pas de directive

export default async function Page() {
  return (
    <>
      <EnTete />
      <PanneauReservation>
        <ResumeClinique />
      </PanneauReservation>
    </>
  );
}
Voir le corrigé

Un seul : PanneauReservation. Chacun des quatre illustre une règle différente.

Page et EnTete — pas de directive, donc Server Components ; ils s'exécutent sur le serveur, produisent leur description, et leur code ne part jamais (Page a même le droit d'être async). Bundle client : non. PanneauReservation porte 'use client' : il part dans le bundle, ainsi que tous les modules qu'il importe. Bundle client : oui.

ResumeClinique — c'est le piège, et la question à laquelle il faut savoir répondre en entrevue. Visuellement, il est à l'intérieur d'un composant client. Mais il n'est pas importé par le fichier client : c'est page.tsx, fichier serveur, qui l'importe et le passe via children. Il est donc rendu sur le serveur, et seul son résultat est glissé dans le trou laissé par le panneau. Bundle client : non. La règle : la frontière suit la ligne import, pas l'imbrication visuelle du JSX — c'est ce qui rend possible le patron de la section suivante.

🧠 Quiz éclair

Lesquelles de ces props peuvent traverser la frontière serveur → client : un tableau d'objets, une Date, une fonction fléchée, une instance de classe ?

Le tableau d'objets simples ✅ et la Date ✅ (le format de sérialisation de React est plus riche que JSON : il gère aussi Map, Set, bigint, les tableaux typés, le JSX et les promesses). La fonction fléchée ❌ — sauf si c'est une Server Action marquée 'use server', seule exception. L'instance de classe ❌ : ses données passeraient mais ses méthodes disparaîtraient, donc React refuse plutôt que de livrer un objet mutilé.

Le patron de composition : les Client Components aux feuilles

On arrive au patron que tu dois savoir dessiner au tableau sans hésiter. Il tient en une image : un arbre majoritairement serveur, avec de petites îles clientes tout en bas, aux feuilles. Pas une grosse zone cliente en haut. Des petits îlots interactifs, aussi bas et aussi petits que possible.

Pourquoi « en bas » ? Parce que la viralité de 'use client' descend : plus tu poses la frontière haut, plus la zone qu'elle capture est grande et plus tu expédies de JavaScript inutile au navigateur. La question à te poser devant chaque composant est donc simple — « celui-ci a-t-il vraiment besoin de mémoire ou de réagir à un clic ? ». Si la réponse est non, et c'est non la plupart du temps, laisse-le serveur. Appliquons à RendezVous : la page affiche la liste des services (données, aucune interactivité) et un formulaire de réservation (état, validation, clics). Le découpage s'impose de lui-même.

La même page, la frontière posée à deux hauteurs
la directive en haut — le réflexe à éviter
PageRendezVous 'use client'à partir d'ici, tout part dans le bundle
ListeServicesaucune interactivité, et pourtant il voyage
AppointmentFormétat, clics — celui-là, légitimement
lib/dble client de base de données, dans le navigateur
la directive aux feuilles — le patron
PageRendezVousserveur : interroge la base, distribue
ListeServicesserveur : son code ne quitte jamais la machine
AppointmentForm 'use client'le seul à voyager
Les deux arbres rendent exactement le même écran. Ce qui change, c'est la hauteur de la ligne violette : la directive est virale vers le bas, donc tout ce qu'elle surplombe rejoint le bundle du navigateur — y compris une liste qui n'a rien à faire d'interactif et le client de base de données qu'elle traîne derrière elle. D'où la question à poser devant chaque composant : celui-ci a-t-il besoin de mémoire ou de réagir à un clic ? La plupart du temps, non.
app/rendez-vous/page.tsx — la page (serveur)
// Aucune directive : SERVER COMPONENT.
import { db } from '@/lib/db';
import AppointmentForm from './formulaire-rendez-vous';

export default async function PageRendezVous() {
  // Les données sont récupérées AVANT le premier affichage,
  // directement depuis la base, sans passer par une API.
  const services = await db.service.findMany({ where: { actif: true } });

  return (
    <main>
      <h1>Prendre rendez-vous</h1>

      {/* Cette liste n'a aucune interactivité : elle reste
          côté serveur, et son code ne part pas au navigateur. */}
      <ul>
        {services.map((s) => (
          <li key={s.id}>{s.nom} — {s.duree} min</li>
        ))}
      </ul>

      {/* Les données traversent la frontière en tant que
          props sérialisables (tableau d'objets simples). */}
      <AppointmentForm services={services} />
    </main>
  );
}

La page fait une chose : aller chercher les données et les distribuer. Elle ne gère aucun état.

app/rendez-vous/formulaire-rendez-vous.tsx — l'île cliente
'use client';  // ← ici commence le monde du navigateur

import { useState } from 'react';

// Le formulaire reçoit les services DÉJÀ chargés : il n'a
// aucun fetch à faire, aucun spinner à afficher au démarrage.
export default function AppointmentForm({ services }) {
  const [serviceId, setServiceId] = useState('');
  const [nom, setNom] = useState('');

  return (
    <form>
      <select value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
        <option value="">Choisir un service</option>
        {services.map((s) => (
          <option key={s.id} value={s.id}>{s.nom}</option>
        ))}
      </select>

      <input value={nom} onChange={(e) => setNom(e.target.value)} />
      <button type="submit">Réserver</button>
    </form>
  );
}

Un champ contrôlé, exactement comme dans le module « Formulaires 1 — contrôlé ou non ». Rien de nouveau côté React : ce qui est nouveau, c'est que ce fichier est le seul des deux à voyager jusqu'au navigateur.

📅 Dans RendezVous

Compte ce qu'on a gagné par rapport à la version « tout client » de la section « Le problème que ça résout : quand tout partait dans le navigateur ». (1) La liste des services est dans le HTML de la première réponse : plus de page blanche ni de spinner initial. (2) Le client de base de données ne pèse rien dans le bundle, il n'est jamais parti. (3) Plus d'API intermédiaire à écrire juste pour recopier la table service en JSON. (4) Le formulaire garde exactement son interactivité ; ce qu'il a perdu, c'est du code d'attente — plus de useEffect de chargement, plus de if (chargement) return <Spinner />. Il naît avec ses données.

Quand l'îlot doit contenir du contenu serveur : le trou children

Un cas revient sans arrêt et déroute au début. RendezVous affiche les détails d'un service dans une fenêtre modale : la fenêtre a besoin d'état (ouverte / fermée, animation, fermeture au clic extérieur), c'est forcément un Client Component ; mais son contenu — la fiche tirée de la base — n'a besoin d'aucune interactivité et gagnerait à rester serveur. Comment mettre du serveur dans du client ?

Réponse : on ne l'importe pas, on le fait descendre. Le composant client ouvre un trou avec children, et c'est le parent serveur qui le remplit. Tu as déjà vu ce mécanisme dans le module « Le modèle mental de React » avec le composant Card : un contenant qui ne sait rien de son contenu. Ici, la même idée devient une technique d'architecture.

// modale.tsx — CLIENT : il gère l'état d'ouverture,
// mais ne sait absolument RIEN de son contenu.
'use client';
import { useState } from 'react';

export default function Modal({ children }) {
  const [ouverte, setOuverte] = useState(false);
  return (
    <>
      <button onClick={() => setOuverte(true)}>Détails</button>
      {ouverte && <div className="modale">{children}</div>}
    </>
  );
}
// page.tsx — SERVEUR : il remplit le trou de la modale
// avec un composant serveur qui, lui, touche la base.
import Modal from './modale';
import FicheService from './fiche-service';   // serveur, async

export default function Page() {
  return (
    <Modal>
      <FicheService id="physio" />
    </Modal>
  );
}

FicheService est rendu sur le serveur, puis son résultat déjà calculé est glissé dans children. Son code ne rejoint jamais le bundle client — parce que le fichier modale.tsx ne l'importe pas.

Le même écran, deux graphes d'imports opposés
le trou children — qui importe quoi
page.tsxserveur — il importe les deux, et écrit le JSX des deux
modale.tsx 'use client'part dans le bundle — et il n'emporte que lui-même
fiche-service.tsxrendu sur le serveur, glissé tout cuit dans children
lib/dbne bouge pas
l'import direct — qui importe quoi
page.tsxserveur — il n'importe plus que la modale
modale.tsx 'use client'et c'est lui, maintenant, qui importe la fiche
fiche-service.tsxentraîné dans le bundle par l'import
lib/dbentraîné à son tour, derrière elle
À l'écran, les deux versions donnent le même arbre : une modale cliente qui contient une fiche serveur. Ce sont ces deux dessins-ci qui diffèrent, et ce ne sont pas des arbres de rendu — ce sont les imports. En haut, page.tsx est le propriétaire des deux : c'est lui qui écrit leur JSX, donc c'est lui qui les fait naître, chacun du bon côté. En bas, la modale n'est plus seulement le parent visuel de la fiche, elle en devient le propriétaire — et la viralité de la directive l'entraîne, avec le client de base de données derrière. D'où la phrase à retenir : la frontière suit les imports, pas l'imbrication visuelle.

C'est le patron d'entrevue. À la question « comment imbriquer un Server Component dans un Client Component ? », la réponse tient en une phrase : on ne l'importe pas depuis le client, on le passe en children (ou en prop) depuis un parent serveur — parce que la frontière suit les imports et que le JSX est une valeur sérialisable, donc il traverse. Le même patron résout le cas des providers de contexte : le contexte étant un mécanisme client, un provider doit être un Client Component qui rend {children}, et on l'utilise dans le layout serveur en enveloppant les enfants. Les enfants restent serveur, le provider reste client.

🔗 Pont — « où ça tourne ? », tu t'es déjà posé la question

Cette question n'est pas nouvelle pour toi, même si tu ne l'avais jamais formulée ainsi. Relis le module Supabase : backend et synchronisation du guide Halterofit. Il y a du code qui tourne sur l'appareil (les composants React Native, la base locale, la logique d'affichage) et du code qui tourne sur l'infrastructure Supabase (les Edge Functions, les politiques RLS, les triggers SQL). Et tu sais déjà, d'instinct, ce qui ne se mélange pas : on ne met pas une clé de service dans l'app mobile, parce qu'on peut décompiler un APK ; on ne manipule pas l'écran depuis une Edge Function, parce qu'elle n'a pas d'écran.

Server et Client Components appliquent exactement ce raisonnement, avec une différence de forme qui est aussi ce qui le rend déroutant : les deux mondes sont maintenant écrits dans le même langage, avec la même syntaxe, dans des fichiers voisins. Une Edge Function, tu ne peux pas la confondre avec un composant : le dossier est différent, le fichier est différent, tout crie « je suis ailleurs ». Ici, deux fichiers .tsx côte à côte peuvent tourner dans deux machines à des milliers de kilomètres l'une de l'autre. D'où la nécessité d'un marqueur explicite — 'use client' — et d'une habitude de lecture, celle de la section « Comment lire un fichier Next.js et savoir où il tourne ».

🧠 Quiz éclair

À l'écran, cette modale rend exactement le même arbre que la version en children vue plus haut : un composant client qui contient une fiche serveur. Pourtant celle-ci ne compile pas, et embarquerait @/lib/db dans le navigateur. Qu'est-ce qui change ?

// modale.tsx
'use client';
import { useState } from 'react';
import FicheService from './fiche-service';   // serveur, async, touche la base

export default function Modal({ id }: { id: string }) {
  const [ouverte, setOuverte] = useState(false);
  return (
    <>
      <button onClick={() => setOuverte(true)}>Détails</button>
      {ouverte && <div className="modale"><FicheService id={id} /></div>}
    </>
  );
}

La ligne import FicheService from './fiche-service'. Ici, c'est le fichier client qui importe la fiche : la viralité de 'use client' suit les import, donc FicheService — et @/lib/db derrière lui — rejoint le bundle du navigateur. Et comme un composant client ne peut pas être async, la fiche ne peut de toute façon pas s'exécuter là. Dans la version qui marche, le fichier client ne connaît que children : c'est le parent serveur qui importe les deux, rend la fiche sur le serveur, et glisse son résultat déjà cuit dans le trou. Deux arbres identiques à l'œil, deux graphes d'imports opposés — d'où la phrase à retenir : la frontière suit les imports, pas l'imbrication visuelle.

Passer une promesse au client : use() et Suspense

Un dernier scénario, pour que tu saches qu'il existe. Sur la page RendezVous, la liste des services se charge en 20 ms, mais les créneaux disponibles demandent 800 ms à un système de réservation externe : faire await sur les deux dans la page prendrait le contenu rapide en otage. React 19 offre une sortie élégante — un Server Component a le droit de lancer une requête sans l'attendre et de passer la promesse elle-même à un Client Component (elles sont sérialisables, revois le tableau de la section « La frontière et les props sérialisables »). Le composant client la déballe avec le hook use(), pendant qu'une frontière <Suspense> affiche un état d'attente à sa place.

La démonstration complète — le code des deux fichiers, le streaming qui va avec, et le cousin cache() de React qui évite de recharger la même donnée dans trois composants serveur — vit dans son contexte naturel : le module Rendu & data fetching.

Comment lire un fichier Next.js et savoir où il tourne

Voici l'outil pratique du module — celui que tu utiliseras vraiment, en entrevue technique comme au premier jour d'un stage. Quatre coups d'œil, dans cet ordre, et tu sais où tourne le fichier.

#Ce que tu cherchesCe que ça t'apprend
1 La toute première ligne : y a-t-il 'use client' ? C'est la réponse la plus rapide et la plus fiable. Si oui → composant client, il part dans le bundle. Si non → serveur par défaut. Ce seul réflexe répond à 90 % des cas.
2 Le mot-clé async devant le composant Un composant async ne peut être que serveur. C'est une confirmation, et un signal fort : ce fichier va chercher des données.
3 useState, useEffect, onClick, window N'importe lequel de ces mots impose le côté client. Si tu les vois sans 'use client' en tête, tu viens de trouver un bug (ou une directive héritée d'un fichier parent — vérifie qui importe qui).
4 Les import en haut : importe-t-il un composant client ? Le fichier peut rester serveur tout en rendant des îlots clients. C'est le cas normal et sain d'une page. L'inverse — un fichier client qui importe un module serveur — est un problème.

Une précision sur le point 3, parce que c'est là qu'on se fait avoir : un fichier sans 'use client' n'est pas forcément un Server Component — si le seul fichier qui l'importe est marqué client, il hérite de la frontière par viralité. Quand un doute subsiste, la question n'est plus « qu'y a-t-il dans ce fichier ? » mais « qui importe ce fichier ? » : remonte la chaîne d'imports jusqu'à une directive, ou jusqu'à un fichier de app/.

Applique cette grille à un vrai projet et tu verras toujours la même forme : une majorité de fichiers sans directive, qui chargent et affichent ; une poignée de fichiers 'use client' aux noms révélateurs — formulaire, modale, menu-deroulant, carrousel. Un projet qui a 'use client' partout n'est pas une fatalité technique, c'est un signe de découpage bâclé — et tu peux le dire, poliment, en entrevue.

✍️ Exercice de lecture

Applique la checklist à ce fichier de RendezVous. Où tourne-t-il, quelle erreur va-t-il produire, et pourquoi ?

import { db } from '@/lib/db';

export default async function CarteService({ id }) {
  const service = await db.service.findUnique({ where: { id } });

  return (
    <article>
      <h2>{service.nom}</h2>
      <button onClick={() => alert(service.description)}>
        En savoir plus
      </button>
    </article>
  );
}
Voir le corrigé

Coup d'œil 1 : pas de 'use client' → Server Component. Coup d'œil 2 : async + requête base de données → ça confirme, et c'est parfaitement légitime jusqu'ici. Coup d'œil 3 : un onClick. Contradiction.

L'erreur ressemblera à : « Event handlers cannot be passed to Client Component props », avec la ligne fautive et la suggestion « If you need interactivity, consider converting part of this to a Client Component ».

Pourquoi — et c'est ça qu'il faut savoir expliquer, pas juste reconnaître. Ce composant s'exécute sur le serveur et n'y produit qu'une description à sérialiser pour traverser le réseau ; une fonction fléchée n'est pas de la donnée, c'est du code. Et même en supposant qu'on puisse l'expédier, elle devrait s'exécuter plus tard, dans le navigateur, alors que le serveur a terminé sa requête et oublié son contexte : la variable service qu'elle capture n'existerait plus nulle part. L'interdiction est une conséquence, pas une règle de Next.js.

Le correctif idiomatique n'est pas d'ajouter 'use client' en haut du fichier — ça casserait la requête et ferait voyager @/lib/db. C'est de découper : CarteService reste serveur pour la donnée et le titre, et le bouton part dans un tout petit fichier client à qui on passe la description en prop (une chaîne : parfaitement sérialisable). Le patron « Client Components aux feuilles », appliqué à la lettre.

Ce que ça change pour ta tête de développeur mobile

Terminons par le déplacement mental, puisque c'est l'objet réel de ce module. En React Native, tu vis dans un monde mono-lieu : tout ton JavaScript tourne sur l'appareil — composants, hooks, base locale, logique métier, calcul de l'e1RM. Le serveur, quand il y en a un, est une entité extérieure à qui tu parles par HTTP, jamais quelque chose qui exécute ton code React. Confortable : aucune question à se poser, faute d'alternative.

Next.js te fait passer à un monde bi-lieu, et le coût d'entrée est exactement là. Ce n'est pas de la syntaxe nouvelle — tout le code de ce module est du React que tu sais déjà lire — c'est une dimension supplémentaire à garder en tête en permanence. Comme le passage du synchrone à l'asynchrone : la syntaxe de await s'apprend en dix minutes, mais raisonner en « ce code ne s'exécute pas maintenant » prend des semaines. Ici, c'est « ce code ne s'exécute pas ici ».

RéflexeReact Native (mono-lieu)Next.js App Router (bi-lieu)
Première question devant un fichier« Qu'est-ce que ça affiche ? »« Où est-ce que ça tourne ? » puis « qu'est-ce que ça affiche ? »
Aller chercher des donnéesuseEffect + fetch + état de chargementawait directement dans le composant serveur
Secrets et clés d'APIJamais dans l'app — un backend est obligatoireDans un Server Component, sans intermédiaire
État et interactivitéPartout, sans y penserUniquement dans les fichiers 'use client'
Passer un callback à un enfantToujours possibleImpossible à travers la frontière serveur → client
Coût d'un composant en plusUn peu de bundle, toujoursZéro bundle s'il reste serveur

Une chose ne change pas, et c'est rassurant : ton React reste ton React. Les props descendent, les événements remontent, le JSX est une description, la réconciliation compare deux arbres, les key suivent l'identité des éléments — tout ton investissement dans le modèle mental de React est intégralement transférable. Tu ajoutes simplement une carte à deux territoires par-dessus le moteur que tu connais.

Et il y a un bénéfice inattendu pour toi, parce que tu viens du mobile : l'obsession pour la taille du bundle et le temps avant le premier affichage utile, tu la comprends déjà d'instinct — tu sais ce que coûte un téléphone bas de gamme sur un réseau moyen. Beaucoup de développeurs web de bureau, avec leur fibre, ne le sentent pas. C'est un angle à assumer en entrevue.

🎤 En entrevue

« C'est quoi, un Server Component ? »

« C'est un composant React qui s'exécute uniquement sur le serveur. Dans l'App Router de Next.js, c'est le comportement par défaut : tout composant est serveur tant qu'on n'a pas écrit 'use client'. Concrètement, il peut être async, faire un await fetch ou interroger une base de données directement, et utiliser des clés secrètes — puisque son code n'est jamais envoyé au navigateur. En contrepartie, il ne peut pas avoir d'état, d'effets, de gestionnaires d'événements ni toucher aux API du navigateur, parce qu'il a fini son travail avant même que l'utilisateur ne voie la page. Le gain principal, c'est double : les données sont là dès le premier affichage, et ce code pèse zéro octet dans le bundle client. »

« Que fait exactement la directive 'use client' ? »

« Elle marque une frontière, pas un lieu d'exécution — et c'est la nuance que beaucoup ratent. Un Client Component est quand même pré-rendu sur le serveur pour produire le HTML initial ; il est ensuite hydraté dans le navigateur pour devenir interactif. Donc 'use client' ne veut pas dire "seulement dans le navigateur", ça veut dire "ce fichier part aussi dans le navigateur".

Et elle est virale vers le bas : tous les modules que ce fichier importe rejoignent le bundle client avec lui. D'où la règle pratique : on la place le plus bas possible dans l'arbre, sur les petits composants vraiment interactifs, pas en haut d'une page. Une exception importante : ce qu'on reçoit en children n'est pas concerné, parce que la viralité suit les imports, pas l'imbrication visuelle. »

« Comment mets-tu de l'interactivité dans une page rendue au serveur ? »

« J'extrais la partie interactive dans un composant séparé marqué 'use client', et je le garde le plus près possible des feuilles de l'arbre. La page reste un Server Component : elle va chercher les données, puis elle les passe en props à l'îlot client — en s'assurant que ces props sont sérialisables : des chaînes, des nombres, des objets simples, des dates ; jamais des fonctions ni des instances de classes.

Si l'îlot client doit contenir du contenu serveur — typiquement une modale cliente autour d'une fiche qui vient de la base — je ne l'importe pas depuis le fichier client : je le passe en children depuis le parent serveur. Le contenu est alors rendu sur le serveur et seul son résultat est glissé dans le trou. Et si j'ai besoin de renvoyer une action au serveur au clic, je passe une Server Action, la seule catégorie de fonctions autorisée à traverser la frontière. »

À retenir

Dans l'App Router, un composant est Server Component par défaut : il s'exécute sur le serveur, peut être async, interroger une base de données et lire des secrets, et son code ne part jamais au navigateur. En échange, il n'a ni useState, ni useEffect, ni onClick, ni window — non par décret, mais parce que ces choses n'ont pas de sens dans un lieu qui a fini son travail avant que l'utilisateur ne regarde. 'use client' marque une frontière, pas un lieu : le composant est aussi pré-rendu sur le serveur puis hydraté, et la directive est virale vers le bas le long des imports. Les props qui traversent doivent être sérialisables (chaînes, nombres, objets simples, dates, Map/Set, JSX, promesses — mais pas les fonctions, sauf Server Actions, ni les instances de classes). Le patron canonique : garder les Client Components aux feuilles, charger les données en haut côté serveur, et faire descendre le contenu serveur dans les îlots clients via children. Et devant chaque fichier, une seule question à se poser en premier : « où est-ce que ça tourne ? »

Et ailleurs : le raisonnement « où ce code s'exécute-t-il, et qu'est-ce que ce lieu me permet ? » dépasse largement Next.js — c'est la question d'architecture des systèmes distribués, et tu la retrouveras dans les Edge Functions Supabase de ton projet mobile, les Cloud Functions, les workers Cloudflare, ou la question « est-ce que je calcule ça sur l'appareil ou dans le nuage ? ». Le modèle « un défaut serveur, une frontière explicite, des îlots interactifs » n'est pas propre à Next.js non plus : Remix, Astro (ses islands et sa directive client:load), SvelteKit et Nuxt reposent sur des variantes de la même idée. Comprendre les Server Components une fois, en profondeur, te rend lisible tout ce pan de l'écosystème frontend moderne.

🗂️ L'aide-mémoire
Le défaut de l'App Router
Server Component, sans rien écrire. C'est 'use client' qui fait exception, jamais l'inverse
Les quatre ❌ d'un Server Component
useState, useEffect, les gestionnaires (onClick…), et window / localStorageuseContext avec eux
async
autorisé sur un Server Component (rendu une seule fois) ; interdit sur un Client Component
Le console.log d'un composant serveur
sort dans le terminal de next dev, pas dans la console du navigateur
Ce qui traverse la frontière
chaînes, nombres, booléens, objets simples, tableaux, Date, Map, Set, JSX, promesses, Server Actions
Ce qui ne traverse pas
les fonctions ordinaires et les instances de classes — et tout objet qui en contient, même en profondeur
Où se met la directive
tout en haut du fichier, avant les import — c'est une directive, pas une instruction
'use server'
ne déclare pas un Server Component — c'est l'absence de directive qui le fait. Il marque une Server Action