Module 3 · Construire

Rendu & data fetching

Une page web, c'est du HTML. La seule vraie question, celle qui structure tout ce module, c'est : qui fabrique ce HTML, et à quel moment ? Le navigateur de l'utilisateur ? Le serveur, à l'instant de la requête ? Une machine de compilation, la semaine dernière ? Chaque réponse donne une expérience différente, un coût différent, un référencement différent. Et par-dessus vient la deuxième question, inséparable de la première : d'où viennent les données qui remplissent ce HTML, et combien de temps fait-on attendre l'utilisateur pour les obtenir.

Quatre façons de fabriquer une page

Avant Next.js, avant React, il n'y avait qu'une façon de faire : le serveur générait le HTML et l'envoyait. Puis les applications sont devenues interactives et le pendule est parti à l'autre extrême — page vide plus JavaScript, le navigateur construit tout. Aujourd'hui il est revenu au milieu, et le milieu a quatre positions, avec des acronymes que tu vas entendre en entrevue. Elles ne s'opposent pas : dans une vraie application, tu en emploies plusieurs, page par page — c'est tout l'intérêt de Next.js, te laisser choisir par route plutôt que pour l'application entière.

La première, le CSR (Client-Side Rendering : le navigateur assemble tout), a déjà été déroulée pas à pas dans le module Server et Client Components — page blanche, puis spinner, puis contenu, chaque étape coûtant un aller-retour. On ne la redéroule pas ici ; retiens qu'elle ne coûte presque rien au serveur, beaucoup au client, et qu'elle reste défendable derrière une authentification, là où le référencement n'importe pas et où l'utilisateur reste longtemps dans l'application (un tableau de bord d'administration, un éditeur). Les trois autres méritent qu'on s'y arrête.

SSR — Server-Side Rendering : le serveur rend à chaque requête

À chaque visite, le serveur exécute les composants, va chercher les données, produit le HTML complet et l'envoie. Le navigateur reçoit une page déjà remplie : le texte est lisible avant même que le moindre JavaScript ait été exécuté, celui-ci arrivant ensuite pour « brancher » l'interactivité par-dessus — l'hydratation du module sur les Server et Client Components.

Mille visiteurs = mille rendus, et autant de requêtes à ta base. Quand la choisir : quand le contenu dépend de l'instant présent ou de l'utilisateur — un panier, un tableau de bord personnalisé, des données qui changent d'une minute à l'autre.

SSG — Static Site Generation : rendu une fois, à la construction

Le HTML est fabriqué une seule fois, au déploiement (le build). Le résultat est un fichier sur disque, qu'on pose sur un CDN — un réseau de serveurs répartis dans le monde qui servent depuis la machine la plus proche de l'utilisateur. Plus aucun calcul à la visite : on lit un fichier et on l'envoie.

Indépassable en vitesse et gratuit à la visite ; en revanche le contenu est figé jusqu'au prochain déploiement, et avec cent mille pages produits le build peut durer très longtemps. Quand la choisir : pour tout ce qui ne change pas à la minute — une page « à propos », une documentation, un article de blogue, une liste de services.

ISR / régénération : statique, mais rafraîchi

L'Incremental Static Regeneration est le compromis malin : la page est servie comme du statique, mais elle porte une date de péremption. Passé ce délai, la prochaine visite déclenche en arrière-plan une re-fabrication ; le visiteur en cours reçoit encore l'ancienne version (donc instantanément), le suivant reçoit la neuve. Personne n'attend pour avoir de la fraîcheur — on accepte simplement quelques minutes de retard.

On paie un rendu occasionnel au lieu d'un rendu par visite, contre l'acceptation d'une fenêtre de données périmées. Quand la choisir : dès que la donnée change régulièrement mais pas continuellement et qu'un léger décalage est sans conséquence — un catalogue de prix, une liste d'articles, un classement.

Quand le HTML est fabriqué — la seule question qui les sépare
SSG une fois
ISR une fois et à nouveau
SSR à chaque visite
CSR après le JS
au build à la requête chez le client
L'axe n'est pas une durée mais un moment : plus une stratégie fabrique tôt, plus l'utilisateur reçoit vite. SSG fabrique une seule fois, au déploiement, et sert ensuite un fichier ; ISR fait pareil mais recommence de temps en temps ; SSR refait le travail à chaque visite ; CSR le repousse jusque dans le navigateur, après téléchargement du JavaScript — d'où la page blanche puis le spinner. Le tableau ci-dessous donne le coût et l'usage de chacune ; c'est ce déplacement vers la droite qu'il faut avoir en tête en le lisant.
Stratégie Le HTML est fabriqué… Ce que reçoit l'utilisateur Le coût Dans RendezVous
CSR dans le navigateur, après téléchargement du JS page blanche → spinner → contenu serveur : ~0 ; client : lourd l'espace admin de la clinique, derrière connexion
SSR sur le serveur, à chaque requête contenu complet, après un temps d'attente serveur un rendu + des requêtes API par visite « Mes rendez-vous » : personnel, jamais partageable
SSG une seule fois, au déploiement la page, quasi instantanément, depuis un CDN rien à la visite ; build plus long « Nos services » : 8 examens, ça bouge deux fois par an
ISR une fois, puis re-fabriquée périodiquement la vitesse du statique, avec un léger retard possible un rendu occasionnel, pas un par visite « Créneaux disponibles » : change souvent, mais pas à la seconde
📅 Dans RendezVous

La page « Nos services » liste huit examens, modifiés peut-être deux fois par année : la fabriquer à chaque visite serait absurde — mille visiteurs, mille requêtes identiques pour mille fois la même réponse. C'est du SSG pur, et la page part sur un CDN.

La page « Créneaux disponibles » change dès qu'un patient réserve, mais attention à ne pas surréagir : afficher un créneau pris il y a quarante secondes n'est pas un drame, le serveur revérifiera à la réservation et refusera avec un 409 Conflict si besoin (module HTTP & fetch). Donc ISR avec un rafraîchissement fréquent, ou du rendu à la requête avec streaming. La page « Mes rendez-vous » d'un patient connecté, elle, ne peut ni être figée ni être partagée entre utilisateurs : rendu à la requête, sans discussion.

💡 Le concept : déplacer le travail dans le temps

Ces quatre stratégies ne changent pas la quantité de travail à faire — il faut toujours aller chercher les données et produire du HTML. Elles changent quand ce travail a lieu, et donc qui attend. CSR : le travail a lieu chez l'utilisateur, l'utilisateur attend. SSR : le travail a lieu sur ton serveur pendant que l'utilisateur attend. SSG : le travail a eu lieu la semaine dernière, personne n'attend. La règle de décision tient en une question : « ce calcul donnerait-il le même résultat pour tout le monde ? » Si oui, il ne devrait pas être refait à chaque visite.

Ce choix se mesure, et le vocabulaire vaut de l'or en entrevue. Le TTFB (Time To First Byte, le délai avant le premier octet de réponse) est minuscule en SSG — le fichier existe déjà, sur un serveur proche — alors qu'en rendu à la requête il inclut tout ce que ton serveur doit faire avant de répondre, y compris cette requête à la base qui prend 400 ms un mauvais jour. Le coût serveur tranche souvent en entreprise : une page statique servie un million de fois coûte le prix de la bande passante, la même page rendue à la requête coûte un million d'exécutions et un million de requêtes à ta base — laquelle a une limite de connexions simultanées qu'un pic atteindra un jour. Et le référencement : un robot qui reçoit <div id="root"></div> reçoit une page vide, tout comme les aperçus de lien des messageries, qui n'exécutent jamais de JavaScript.

🧠 Quiz éclair

Ces deux pages sont écrites exactement pareil : un composant serveur async, un accès aux données, un rendu. L'une peut être fabriquée une fois au déploiement et servie depuis un CDN, l'autre doit être rendue à chaque requête. Laquelle, et sur quel critère tranche-t-on ?

// A — app/services/page.tsx
export default async function Page() {
  const services = await db.service.findMany({ where: { actif: true } });
  return <ListeServices services={services} />;
}

// B — app/mes-rendez-vous/page.tsx
export default async function Page() {
  const session = await lireSession();
  const rdv = await db.rdv.findMany({ where: { patientId: session.id } });
  return <ListeRendezVous rdv={rdv} />;
}

A est statique, B doit être rendue à la requête — et le critère n'est surtout pas la forme du code, puisqu'elle est identique. C'est : ce calcul donnerait-il le même résultat pour tout le monde ? A liste les huit examens de la clinique : même réponse pour les mille visiteurs du jour, donc la refaire mille fois, c'est payer mille fois le même calcul. B commence par lire une session : son résultat dépend de qui demande. La figer ne serait pas un défaut de performance mais une fuite de données — les rendez-vous de Patrick servis à Nadia. Le réflexe de lecture : cherche ce qui rend le résultat personnel ou daté — une session, un cookie, l'heure courante. Nuance utile pour A : comme la liste change deux fois par an, on lui donne une date de péremption (ISR) plutôt que d'exiger un redéploiement pour corriger une faute de frappe.

Charger des données dans un Server Component

Passons au code, et à la bonne surprise de l'App Router. Dans un Server Component (module sur les Server et Client Components), le composant lui-même peut être async. Tu peux donc awaiter directement dans le corps du composant. Le rendu attend, et la fonction ne retourne son JSX qu'une fois les données là.

app/services/page.tsx
// Server Component : ce fichier ne part JAMAIS dans le navigateur.
// Le mot-clé `async` sur un composant n'existe QUE côté serveur.
export default async function Page() {
  // On attend la réponse ici, dans le composant lui-même.
  const res = await fetch('https://api.exemple.ca/services');

  // Le fetch honnête : vérifier le verdict soi-même (module HTTP & fetch).
  if (!res.ok) throw new Error('Chargement impossible');

  const services = await res.json();

  // Ce JSX n'est produit qu'une fois `services` disponible.
  return (
    <ul>
      {services.map((s) => (
        <li key={s.id}>{s.name} — {s.durationMin} min</li>
      ))}
    </ul>
  );
}

Neuf lignes utiles. Aucun état, aucun effet, aucun booléen isLoading. Le composant est la fonction de chargement.

Compare avec ce que tu écris tous les jours dans ton app mobile : un useState pour les données, un autre pour le chargement, un troisième pour l'erreur, un useEffect pour déclencher la requête après le premier rendu, la gestion du composant démonté avant la fin, et trois branches de rendu. Une trentaine de lignes pour afficher une liste.

Mais le gain n'est pas d'abord une histoire de lignes économisées : il est structurel, et il porte un nom, la cascade client (waterfall). Avec useEffect, une requête ne peut partir qu'après le premier rendu du composant, et un enfant ne se rend qu'après son parent. Si la page charge l'utilisateur, puis un enfant charge ses rendez-vous, puis un petit-enfant les détails d'un service, tu obtiens trois requêtes strictement en série et trois spinners qui s'allument l'un après l'autre — cette interface qui se remplit par étages pendant deux secondes.

Côté serveur, le problème disparaît en grande partie : le serveur est à côté de la base — pas à l'autre bout d'un réseau mobile — donc chaque requête coûte des millisecondes, et Next.js rend les segments de route en parallèle, chacun commençant à chercher ses données sans attendre ses voisins. La cascade qui reste — deux await l'un après l'autre dans le même composant — se règle en section « Séquentiel ou parallèle : le piège des deux await ».

📖 La formule

La page qui n'existe qu'une fois servie

export default async function Page() {
  const res = await fetch('https://api.exemple.ca/services');
  if (!res.ok) throw new Error('Chargement impossible');
  const services = await res.json();
  return <ListeServices services={services} />;
}

Pas de useEffect, pas de useState, pas de spinner : la page n'existe qu'une fois les données là. Ce qui montre l'attente à l'utilisateur, c'est loading.js ou un <Suspense> (section « Le streaming : envoyer la page en morceaux ») — pas un état à toi.

🔗 Pont — c'est le fetch honnête, un cran plus haut

Regarde bien les trois premières lignes de la formule : await fetch, if (!res.ok) throw, await res.json(). C'est exactement le fetch honnête du module HTTP & fetch, mot pour mot. Rien n'a changé dans la mécanique HTTP — deux Promesses, un verdict à vérifier soi-même parce que fetch ne rejette que sur panne réseau.

Deux choses changent, en revanche, et elles valent d'être nommées. D'abord, il s'exécute : sur le serveur, pas dans le navigateur — donc pas de CORS (c'est une politique du navigateur), et les identifiants d'API peuvent voyager dans les en-têtes sans jamais être exposés. Ensuite, ce que devient le throw : dans le navigateur, il fallait l'attraper dans un catch et le transformer en état d'erreur. Ici, le throw remonte jusqu'à la frontière d'erreur la plus proche — le fichier error.tsx (section « Des états de chargement et d'erreur qui ne mentent pas ») — qui affiche l'interface de repli. Tu lances l'erreur ; le framework s'occupe de l'attraper.

🧭 Et sans API du tout ?

Puisque le composant s'exécute sur le serveur, rien ne t'oblige à passer par HTTP : tu peux interroger ta base directement dans le composant, avec un ORM (Prisma, Drizzle) ou un client SQL. Identifiants de connexion et logique de requête restent sur le serveur, jamais inclus dans le paquet envoyé au navigateur. Renversement complet du réflexe « le frontend appelle une API » — ici, le composant est déjà du backend. La documentation de Next.js le recommande, en rappelant qu'il reste de ta responsabilité de vérifier l'authentification et les permissions avant de lire.

Le cache, honnêtement

Voici la section la plus importante du module, et celle où tu dois te méfier de ce que tu liras ailleurs.

Dans Next.js aujourd'hui, fetch n'est pas caché par défaut. Une requête non cachée bloque le rendu de la page jusqu'à ce qu'elle se résolve. Si ton API met 800 ms, la page met 800 ms de plus à commencer à s'afficher. C'est le comportement par défaut, et il est volontairement prudent : mieux vaut une page lente et juste qu'une page rapide affichant des données périmées à l'insu du développeur.

Pourquoi insister ? Parce que ce n'était pas le cas avant. À l'époque de Next.js 14, la situation était inversée : fetch était caché par défaut, agressivement, et il fallait désactiver ce cache explicitement pour obtenir de la fraîcheur. Ce renversement a fait couler beaucoup d'encre, parce qu'un cache par défaut invisible produisait des bugs déconcertants — « pourquoi ma page affiche encore l'ancien prix ? ». Le web est aujourd'hui plein de tutoriels, de billets de blogue et de réponses sur les forums qui décrivent l'ancien comportement. Ils ne sont pas malhonnêtes : ils sont datés. Quand tu lis un article sur le cache de Next.js, cherche sa date et sa version avant de lire son contenu.

Ceci dit, il y a une chose que Next.js fait bel et bien tout seul, et qu'il ne faut pas confondre avec du cache : la mémoïsation par requête. Deux fetch identiques dans le même arbre de rendu, pendant le traitement de la même requête entrante, ne partent qu'une seule fois sur le réseau ; le second reçoit le résultat du premier. Détail crucial : cette déduplication ne dure que le temps d'une requête. Le visiteur suivant repart de zéro. Ce n'est donc pas du cache — c'est un anti-doublon.

La conséquence pratique est libératrice, et elle mérite d'être soulignée parce qu'elle va contre un réflexe très ancré : tu n'as plus besoin de faire descendre les données en props. Si l'entête et le pied de page ont tous les deux besoin des coordonnées de la clinique, chacun peut simplement les demander là où il en a besoin. Une seule requête réseau partira. Fini le prop drilling, ce cortège de props qu'on fait traverser cinq composants qui ne s'en servent pas, juste pour atteindre le sixième.

app/lib/clinique.ts
// `cache` de React mémoïse une fonction pour la durée d'UNE requête entrante.
// Utile quand ce n'est pas un fetch identique (ex. une requête base de données),
// que la mémoïsation automatique de fetch ne couvre pas.
import { cache } from 'react';

export const getClinique = cache(async () => {
  const res = await fetch('https://api.exemple.ca/clinique');
  if (!res.ok) throw new Error('Clinique introuvable');
  return res.json();
});

// Dix composants peuvent appeler getClinique() pendant le rendu :
// le travail n'est fait qu'une fois. Mais la requête suivante,
// venue d'un autre visiteur, repart avec un cache vide.

React.cache() est un anti-doublon intra-requête, comme la mémoïsation de fetch — pas un cache entre visiteurs.

Alors comment cache-t-on vraiment, c'est-à-dire d'une visite à l'autre ? Par un opt-in explicite — autrement dit, rien ne se cache tant que tu ne l'as pas demandé — avec la directive 'use cache'. On la pose en première ligne du corps d'une fonction asynchrone, ou d'un composant, et son résultat devient réutilisable. Elle s'accompagne d'un modèle plus large appelé Cache Components, activé par une option de configuration, et bâti sur le Partial Pre-Rendering : l'idée qu'une même page peut mélanger des morceaux pré-rendus servis instantanément et des morceaux calculés à la requête, dans le même document.

app/services/liste.tsx
import { cacheLife, cacheTag } from 'next/cache';

// Composant caché : tout le monde voit la même liste de services.
async function ListeServices() {
  'use cache';         // ← l'opt-in explicite : ce résultat est réutilisable
  cacheLife('hours');  // ← sa durée de vie
  cacheTag('services'); // ← une étiquette, pour l'invalider plus tard

  const res = await fetch('https://api.exemple.ca/services');
  const services = await res.json();

  return (
    <ul>
      {services.map((s) => <li key={s.id}>{s.name}</li>)}
    </ul>
  );
}

'use cache' est une directive : une chaîne de caractères en tête de fonction, comme 'use client'. Ce n'est pas un appel de fonction, et c'est pour ça qu'elle se place à cet endroit précis.

Un mot sur cacheTag('services'), parce que c'est la moitié d'un aller-retour et que la seconde moitié arrive deux modules plus loin. Poser une étiquette ne sert à rien en soi : elle prend son sens le jour où quelqu'un modifie les services et doit dire à Next.js « ce que tu as gardé sous cette étiquette est périmé, jette-le ». Cette phrase-là s'écrit revalidateTag('services'), et tu la verras dans le module Route Handlers & Server Actions. Retiens la boucle entière plutôt que ses deux moitiés : on étiquette à la lecture, on invalide par la même étiquette à l'écriture. Une étiquette qu'on ne révoque jamais est un cache qui ment ; une révocation sur une étiquette qu'on n'a jamais posée ne fait rien.

Retiens la ligne de partage, plus que la syntaxe exacte : ce que tout le monde voit pareil se cache ; ce qui dépend de l'utilisateur ou de l'instant présent ne se cache pas. La liste des services de la clinique : cachée. Les rendez-vous de Patrick : jamais. Et pour ce qui ne se cache pas mais met du temps, il existe une autre réponse que le cache — l'envoyer plus tard, sans bloquer le reste. C'est le streaming, et c'est la section suivante.

⚠️ Piège fréquent

Suivre un tutoriel écrit pour Next.js 13 ou 14 et raisonner comme s'il était encore valide. Ces tutoriels affirment que fetch est caché par défaut et te montrent comment désactiver ce cache. Tu vas donc écrire du code qui « désactive » un cache qui n'existe plus, en croyant l'avoir maîtrisé — et surtout, tu vas supposer qu'une page est rapide parce qu'elle serait cachée, alors qu'elle refait la requête à chaque visite et bloque le rendu à chaque fois. Le symptôme classique : une page dont on ne comprend pas pourquoi elle est lente, alors qu'« elle est cachée ». Non : elle ne l'est pas, parce que personne n'a écrit 'use cache'. Réflexe à prendre : sur ce sujet précis, vérifie la date et la version de tout ce que tu lis, y compris les réponses les mieux notées sur les forums.

✍️ Exercice de lecture

Un collègue justifie ainsi son architecture : « La page des services fait un fetch vers notre API. Comme Next met les fetch en cache automatiquement, la première visite paie le coût et toutes les suivantes sont servies depuis le cache. On est tranquilles, l'API ne verra qu'une requête par déploiement. »

export default async function Page() {
  const res = await fetch('https://api.exemple.ca/services');
  const services = await res.json();
  return <ListeServices services={services} />;
}

Questions : (1) Où le raisonnement est-il faux aujourd'hui ? (2) Que se passe-t-il réellement quand mille visiteurs ouvrent cette page ? (3) Que faudrait-il ajouter pour obtenir le comportement qu'il décrit ? (4) Ce code a-t-il un autre défaut, indépendant du cache ?

Voir le corrigé

(1) Sur la prémisse : fetch n'est plus caché par défaut. C'était vrai en Next 14, ça ne l'est plus. Le collègue raisonne sur une documentation périmée — probablement un tutoriel ou une réponse de forum qui n'a pas de date visible.

(2) Mille visiteurs déclenchent mille requêtes vers l'API, et chacune bloque le rendu de sa page jusqu'à sa résolution. Chaque visiteur attend donc la latence complète de l'API avant de voir quoi que ce soit. La seule déduplication qui existe est intra-requête : si plusieurs composants d'une même page faisaient ce même fetch, il ne partirait qu'une fois — mais ça ne mutualise rien entre visiteurs.

(3) L'opt-in explicite : la directive 'use cache' en tête de la fonction (accompagnée d'une durée de vie via cacheLife), dans le modèle Cache Components. C'est parfaitement légitime ici, puisque la liste des services est la même pour tout le monde.

(4) Oui : il manque if (!res.ok) throw .... C'est la deuxième ligne du fetch honnête. Si l'API renvoie un 500 avec une page HTML, res.json() échouera sur un message parlant de JSON invalide, et le vrai problème — l'API en panne — restera invisible dans le diagnostic.

🧠 Quiz éclair

Une employée corrige le prix d'un examen dans l'outil interne. Le site public continue d'afficher l'ancien prix pendant des heures. Aucune erreur, aucun avertissement, nulle part. Qu'est-ce qui manque ?

// LECTURE — app/services/liste.tsx
import { cacheLife, cacheTag } from 'next/cache';

async function ListeServices() {
  'use cache';
  cacheLife('hours');
  cacheTag('services');

  const res = await fetch('https://api.exemple.ca/services');
  return <Liste services={await res.json()} />;
}

// ÉCRITURE — la fonction serveur qu'appelle l'outil interne
export async function modifierPrix(id: string, prix: number) {
  await db.service.update({ where: { id }, data: { prix } });
  // ... et c'est tout
}

La seconde moitié de la boucle. cacheTag('services') pose une étiquette sur le résultat gardé ; une étiquette ne fait rien par elle-même — elle n'existe que pour qu'on puisse dire plus tard « ce que tu as gardé sous ce nom est périmé, jette-le ». Cette phrase-là s'écrit revalidateTag('services'), et il faut l'appeler après l'écriture, dans la fonction qui modifie (module Route Handlers & Server Actions). Ici personne ne la prononce : le seul mécanisme qui finira par libérer le cache est le cacheLife('hours') — d'où les heures de retard. Retiens la boucle entière : on étiquette à la lecture, on révoque par la même étiquette à l'écriture. Une étiquette qu'on ne révoque jamais est un cache qui ment ; et l'erreur symétrique — révoquer une étiquette qu'on n'a jamais posée — ne fait rien du tout, silencieusement elle aussi.

Le streaming : envoyer la page en morceaux

Le rendu serveur a un défaut structurel : il est tout ou rien. Le serveur assemble la page complète, puis l'envoie. Conséquence mécanique : la donnée la plus lente détermine le temps d'affichage de toute la page. Si l'entête, le menu et la liste des services sont prêts en 20 ms mais que les créneaux disponibles demandent 900 ms à un système de réservation externe, l'utilisateur regarde une page blanche pendant 900 ms. Alors qu'on avait de quoi remplir son écran presque tout de suite.

Le streaming casse cette règle. Au lieu d'attendre que tout soit prêt, le serveur découpe la page en morceaux et les envoie au fur et à mesure, sur la même réponse HTTP maintenue ouverte. Le navigateur affiche ce qu'il reçoit dès qu'il le reçoit. Concrètement : l'entête, le menu et les services arrivent immédiatement, avec un espace réservé — un squelette — à la place des créneaux ; puis, quand le système de réservation répond enfin, le morceau manquant arrive et vient remplacer le squelette. Une seule navigation, aucun appel réseau supplémentaire, et un écran utile dès la première centaine de millisecondes.

Ce que le lecteur a sous les yeux, seconde par seconde
sans streaming — tout ou rien
toute la page page blanche affichée
0 400 ms 800 ms
avec streaming — au fur et à mesure
haut de page affiché dès 20 ms
créneaux squelette affichés
0 400 ms 800 ms
Une colonne vaut 100 ms. Le système de réservation met 900 ms dans les deux cas — le streaming ne rend rien plus rapide. Ce qu'il change, c'est ce que le lecteur a devant les yeux pendant ces 900 ms : en haut, du blanc ; en bas, une page utilisable dès la première centaine de millisecondes, avec un squelette en pointillé à l'endroit qui attend encore. La ligne verte — l'entête, le menu et la liste des services — était prête en 20 ms : c'est ce que le rendu « tout ou rien » retenait sans raison.

Il y a deux façons de faire du streaming, et elles se distinguent par leur granularité.

loading.js — le segment entier

Tu crées un fichier loading.tsx à côté de ton page.tsx, dans le même dossier. Il exporte un composant qui décrit l'écran d'attente. Next.js s'occupe du reste : en coulisses, il enveloppe automatiquement ta page dans une frontière <Suspense> dont le fallback est ce composant. Zéro câblage à écrire — c'est une convention de nom, comme page et layout (module sur le routing).

app/creneaux/loading.tsx
// Ce composant s'affiche pendant que app/creneaux/page.tsx charge ses données.
// Next.js l'enveloppe tout seul dans un <Suspense> : rien à câbler.
export default function Loading() {
  return (
    <div className="squelette">
      {/* Des blocs gris à la forme des vraies cartes de créneaux */}
      <div className="squelette__ligne" />
      <div className="squelette__ligne" />
      <div className="squelette__ligne" />
    </div>
  );
}

Une convention de fichier, pas une API à mémoriser. Le layout parent, lui, reste affiché : seule la zone de la page est remplacée par cet écran d'attente.

<Suspense> — le grain fin

loading.js est grossier : il masque toute la page, même les parties qui étaient prêtes. Pour un contrôle plus fin, tu places toi-même des frontières <Suspense> autour des seuls composants qui attendent des données. Tout ce qui est hors de la frontière part immédiatement ; ce qui est dedans est remplacé par son fallback, puis streamé quand il est prêt.

app/creneaux/page.tsx
import { Suspense } from 'react';
import AvailableSlots from './slots-disponibles';
import SqueletteCreneaux from './squelette-slots';
import ListeServices from '@/components/liste-services';

// La page elle-même n'est PAS async : elle n'attend rien.
export default function Page() {
  return (
    <div>
      {/* Envoyé tout de suite : ces morceaux n'attendent aucune donnée lente */}
      <h1>Prendre rendez-vous</h1>
      <ListeServices />

      {/* Seul ce bloc attend. Le squelette part dans le premier envoi,
          le vrai contenu arrive plus tard sur la même réponse. */}
      <Suspense fallback={<SqueletteCreneaux />}>
        <AvailableSlots />
      </Suspense>
    </div>
  );
}

// Ce composant-là est async : c'est lui qui bloque, et lui seul.
async function AvailableSlots() {
  const res = await fetch('https://api.exemple.ca/slots');
  const slots = await res.json();
  return <SlotGrid slots={slots} />;
}

Le patron à reconnaître : la page devient synchrone, et l'await descend dans un composant enfant qu'on emballe dans <Suspense>. L'attente est ainsi confinée.

Ce déplacement de l'await vers le bas de l'arbre est le geste central à retenir. Plus l'attente est profonde dans l'arbre, plus la partie de la page qui peut partir immédiatement est grande. À l'inverse — et c'est le piège de la section « Des états de chargement et d'erreur qui ne mentent pas » — un await haut placé, dans un layout par exemple, bloque tout ce qui est en dessous.

🧭 Les robots ne reçoivent pas le flux

Une précision que peu de gens connaissent, et qui fait bonne impression en entrevue : les robots et les crawlers ne reçoivent pas la page en morceaux. Next.js les détecte à leur user-agent — la carte de visite que tout client envoie avec sa requête, et qui dit « je suis Googlebot » — et attend que le rendu soit complètement terminé avant d'envoyer le document entier, d'un bloc. C'est logique : un robot d'indexation veut un document HTML complet, pas une expérience progressive — il n'a pas d'yeux à occuper pendant le chargement. Le streaming est donc une optimisation de l'expérience humaine, et elle ne se paie pas en référencement.

📅 Dans RendezVous

La page de réservation est l'exemple d'école. Elle contient trois zones dont les vitesses n'ont rien à voir : le bandeau de la clinique (aucune donnée — instantané), la liste des services (donnée cachée, quasi instantanée) et la grille des créneaux disponibles (interrogation d'un système de réservation externe, 600 à 900 ms les mauvais jours).

Sans streaming, le patient regarde du blanc pendant 900 ms, puis tout apparaît d'un coup. Avec un <Suspense> autour de la seule grille de créneaux, il voit en moins de 100 ms le nom de la clinique, le titre, la liste des examens — il peut déjà choisir son service — pendant qu'un squelette de grille indique qu'on cherche les disponibilités. Le temps total n'a pas bougé d'une milliseconde. Le temps perçu a été divisé par cinq, parce que l'utilisateur a passé ces 900 ms à lire au lieu d'attendre.

🧠 Quiz éclair

Le développeur a bien emballé la grille lente dans un <Suspense>. Le squelette ne s'affiche pourtant jamais, et la page reste blanche 900 ms — exactement comme avant. Pourquoi la frontière ne sert-elle à rien ici ?

import { Suspense } from 'react';

export default async function Page() {
  const creneaux = await getCreneaux();   // 900 ms

  return (
    <div>
      <h1>Prendre rendez-vous</h1>
      <ListeServices />

      <Suspense fallback={<SqueletteCreneaux />}>
        <SlotGrid creneaux={creneaux} />
      </Suspense>
    </div>
  );
}

Parce qu'un <Suspense> ne crée pas l'attente : il attrape une attente qui se produit à l'intérieur de lui. Ici l'await est dans la page, au-dessus de la frontière. Tant qu'il n'a pas rendu la main, la fonction n'a rien retourné du tout : ni le titre, ni la liste des services, ni même la balise <Suspense>. On ne peut pas afficher le repli d'un JSX qui n'existe pas encore.

Le correctif est le geste central de la section : faire descendre l'await. SlotGrid devient async et va chercher les créneaux elle-même, dans la frontière ; la page redevient synchrone et part immédiatement. Variante équivalente : garder l'appel dans la page mais sans await, passer la promesse en prop et la lire avec use() côté enfant — la requête démarre au plus tôt, l'attente reste confinée.

Séquentiel ou parallèle : le piège des deux await

Voici un bug de performance que tu écriras — tout le monde l'écrit — parce que le code fautif est parfaitement lisible et parfaitement naturel :

export default async function Page() {
  // ⚠️ Ces deux requêtes s'exécutent l'une APRÈS l'autre.
  const services = await getServices();   // 400 ms
  const cliniques = await getCliniques(); // 300 ms
  // Total : 700 ms — alors qu'elles ne dépendent pas l'une de l'autre.

  return <Reservation services={services} cliniques={cliniques} />;
}

Le mot await signifie littéralement « arrête-toi ici et attends ». La deuxième requête ne part même pas avant que la première soit revenue.

await met la fonction en pause : tant que getServices() n'a pas répondu, la ligne suivante n'existe pas. La requête vers les cliniques ne part donc qu'à la 401e milliseconde, alors que rien ne l'obligeait à attendre — on additionne deux latences qui auraient pu se recouvrir.

La correction repose sur un point de JavaScript à avoir bien en tête : une fonction asynchrone démarre son travail dès qu'on l'appelle, pas au moment où on l'await. Appeler sans await, c'est allumer le feu sous la casserole et passer à autre chose ; on obtient une Promesse. On lance donc les deux, puis on attend les deux ensemble avec Promise.all :

export default async function Page() {
  // On APPELLE sans await : les deux requêtes partent tout de suite,
  // on récupère deux Promesses (deux « accusés de réception »).
  const promesseServices = getServices();
  const promesseCliniques = getCliniques();

  // Puis on attend les deux ensemble. Le tableau destructuré donne
  // les résultats dans le MÊME ordre que les promesses fournies.
  const [services, cliniques] = await Promise.all([
    promesseServices,
    promesseCliniques,
  ]);
  // Total : ~400 ms — la durée de la PLUS LENTE, pas la somme.

  return <Reservation services={services} cliniques={cliniques} />;
}

Deux caractères déplacés, 300 ms gagnées. La règle visuelle : deux await consécutifs qui ne s'utilisent pas l'un l'autre = un drapeau rouge.

700 ms contre 400 — les mêmes requêtes
deux await consécutifs
services 400 ms
cliniques 300 ms
0 400 ms 800 ms
total : 700 ms
Promise.all
services 400 ms
cliniques 300 ms
0 400 ms 800 ms
total : 400 ms
Une colonne vaut 100 ms, et les barres occupent de vraies colonnes de grille : leur longueur est donc proportionnelle par construction. En haut, le deuxième await ne peut pas commencer avant que le premier ait fini — les barres se suivent, et les durées s'additionnent. En bas, les deux requêtes partent ensemble : cliniques se glisse sous services au lieu de la suivre, et le total tombe à la durée de la plus lente. Les 300 ms gagnées sont l'espace vide à droite.

Un détail de comportement à connaître : Promise.all est tout ou rien. Si une seule promesse échoue, l'ensemble échoue et les autres résultats sont perdus — souvent ce qu'on veut (sans les services, la page n'a pas de sens). Mais quand un échec doit rester local — les créneaux sont indispensables, les avis des patients sont un bonus — on utilise Promise.allSettled, qui attend tout le monde et rend pour chaque promesse un objet décrivant sa réussite ou son échec.

Quand le séquentiel est inévitable

Il existe un cas où l'on ne peut pas paralléliser : quand la deuxième requête a besoin du résultat de la première — pour charger les créneaux d'un service, il faut d'abord connaître son identifiant. C'est une dépendance de données, pas une maladresse d'écriture. Mais si on ne peut pas supprimer l'attente, on peut la rendre supportable : au lieu de bloquer la page entière, on affiche ce que la première requête a rapporté et on streame le reste.

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;

  // 1re requête : indispensable pour connaître le service.
  const service = await getService(slug);

  return (
    <>
      {/* Le titre et la description s'affichent DÈS la 1re requête revenue. */}
      <h1>{service.name}</h1>
      <p>{service.description}</p>

      {/* La 2e requête a besoin de service.id : elle ne peut pas
          démarrer avant. Mais elle n'a plus à bloquer le titre. */}
      <Suspense fallback={<SqueletteCreneaux />}>
        <Creneaux serviceId={service.id} />
      </Suspense>
    </>
  );
}

On ne supprime pas la dépendance, on la découpe : ce qui est prêt part, ce qui dépend d'autre chose attend derrière sa propre frontière.

Et pour que la première attente elle-même ne montre pas de page blanche, on ajoute un loading.tsx au segment : écran d'attente immédiat, puis le titre, puis les créneaux. Le principe se résume en une phrase : fais partir tout ce qui peut partir, et mets une frontière devant tout ce qui doit attendre.

✍️ Exercice de lecture

Voici la page d'accueil de RendezVous. Les temps de réponse mesurés sont notés en commentaire. La page n'a pas de loading.tsx et ne contient aucun <Suspense>.

export default async function Page() {
  const clinique = await getClinique();   // 120 ms
  const services = await getServices();   // 350 ms
  const avis     = await getAvis();       // 800 ms (service externe, capricieux)

  return (
    <>
      <Entete clinique={clinique} />
      <ListeServices services={services} />
      <Avis avis={avis} />
    </>
  );
}

Questions : (1) Combien de temps l'utilisateur regarde-t-il une page blanche ? (2) Comment descendre ce chiffre sans rien changer aux trois API ? (3) Quel est le meilleur découpage possible, sachant que les avis sont un contenu secondaire ? (4) Que se passe-t-il si le service d'avis tombe en panne ?

Voir le corrigé

(1) 1270 ms — la somme, puisque les trois await sont strictement en série alors qu'aucune requête ne dépend des autres. Ces 1270 ms sont dues à l'écriture du code, pas aux API.

(2) Geste gratuit : lancer les trois appels sans await, puis await Promise.all([...]). Le total tombe à ~800 ms, la durée de la plus lente — 470 ms gagnées sans toucher à l'architecture.

(3) Mieux : reconnaître que les avis sont secondaires. On sort getAvis() de la page, on le déplace dans le composant <Avis />, et on emballe celui-ci dans un <Suspense fallback={...}>. La page ne bloque plus que sur clinique + services en parallèle, soit ~350 ms pour un écran déjà utile, et les avis se streament ensuite ; avec un loading.tsx, plus de page blanche du tout.

(4) Dans le code actuel, la panne fait échouer le rendu de toute la page : l'utilisateur perd l'entête et les services à cause d'un contenu secondaire. Avec la version 3, l'échec est confiné à la frontière — la plus proche (un error.tsx, section « Des états de chargement et d'erreur qui ne mentent pas ») affiche « Avis indisponibles » et le reste tient debout. Et si on tenait à Promise.all, Promise.allSettled permettrait au moins de survivre à l'échec du troisième appel.

🧠 Quiz éclair

Trois await à la file, 1000 ms de page blanche. Lesquels peux-tu réellement lancer en parallèle, et à combien tombe le total ? (Les temps mesurés sont en commentaire.)

export default async function Page({
  params,
}: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;

  const service  = await getService(slug);          // 200 ms
  const avis     = await getAvis(slug);             // 500 ms
  const creneaux = await getCreneaux(service.id);   // 300 ms

  return <Fiche service={service} avis={avis} creneaux={creneaux} />;
}

Deux sur trois, pas trois. getService(slug) et getAvis(slug) n'ont besoin que de slug : rien ne les oblige à se suivre. getCreneaux(service.id), lui, a besoin d'une valeur que seule la première requête peut fournir — c'est une vraie dépendance de données, pas une maladresse d'écriture, et aucun Promise.all ne la supprimera.

Version naïve : Promise.all([getService(slug), getAvis(slug)]) → 500 ms, puis les créneaux → 800 ms. Version fine : lancer les avis tout en haut sans await (const pAvis = getAvis(slug);), enchaîner service (200) puis créneaux (300), et faire await pAvis à la fin — il aura fini depuis longtemps : ~500 ms. La règle de lecture n'est donc pas « combien d'await » mais « lequel utilise vraiment le résultat du précédent ». Et la meilleure réponse va un cran plus loin : les avis sont un contenu secondaire — sors-les derrière un <Suspense> et la fiche ne les attend plus du tout.

Charger côté client quand il le faut

Tout ce qui précède se passe sur le serveur. Mais certaines données ne peuvent pas venir de là : celles qui apparaissent après une interaction — le patient tape dans un champ de recherche, coche un filtre, clique sur « page suivante », ou tu rafraîchis les créneaux toutes les trente secondes. Il n'y a plus de « rendu initial » pour les porter : elles arrivent en cours de vie de l'écran. Deux voies pour ça.

Le hook use(promise) : le serveur commence, le client termine

Le module « Server et Client Components » a annoncé ce patron du point de vue de la frontière. Voici sa démonstration, sous l'angle du chargement : un Server Component appelle sa fonction de chargement sans l'awaiter, passe la Promesse obtenue en prop, et un Client Component la lit avec le hook use() — en se suspendant, donc en déclenchant le fallback du <Suspense> parent, tant qu'elle n'est pas résolue.

app/creneaux/page.tsx (serveur)
import { Suspense } from 'react';
import SlotGrid from './grille-slots';

export default function Page() {
  // PAS de await : on veut la Promesse elle-même, pas son résultat.
  // La requête part quand même immédiatement (une fonction async
  // démarre à l'appel, pas à l'await).
  const promesseCreneaux = getCreneaux();

  return (
    <Suspense fallback={<SqueletteCreneaux />}>
      <SlotGrid slots={promesseCreneaux} />
    </Suspense>
  );
}
app/creneaux/grille-creneaux.tsx (client)
'use client';
import { use, useState } from 'react';

export default function SlotGrid({
  slots,
}: {
  slots: Promise<Creneau[]>; // ← le type dit bien : une Promesse, pas des données
}) {
  // `use` lit la Promesse. Le composant se SUSPEND tant qu'elle n'est
  // pas résolue → c'est le fallback du <Suspense> parent qui s'affiche.
  const liste = use(slots);

  // Et comme on est côté client, on peut être interactif :
  const [selection, setSelection] = useState<string | null>(null);

  return (
    <div>
      {liste.map((c) => (
        <button key={c.id} onClick={() => setSelection(c.id)}>
          {c.heure}
        </button>
      ))}
    </div>
  );
}

Le meilleur des deux mondes : la requête part du serveur, au plus tôt, mais le composant qui l'affiche est interactif. Aucune cascade client.

Sans ce patron, tu aurais deux mauvaises options : awaiter côté serveur et perdre l'interactivité, ou faire un useEffect côté client — et alors la requête ne partirait qu'après le téléchargement du JavaScript, son exécution et le premier rendu. Ici, la requête part au plus tôt (serveur) et le rendu arrive au plus tôt (client). React précise qu'on peut aussi passer cette Promesse par un contexte, si plusieurs Client Components ont besoin du même résultat.

SWR et TanStack Query : quand la donnée vit après le chargement

Le hook use résout un chargement initial ; dès que la donnée doit être rechargée en réponse à une interaction, il faut autre chose. C'est le terrain de deux bibliothèques que tu croiseras dans à peu près toutes les offres d'emploi frontend : SWR (par l'équipe de Vercel) et TanStack Query (anciennement React Query, plus riche et indépendante du framework).

'use client';
import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export default function RechercheCreneaux({ date }: { date: string }) {
  // La clé (l'URL) contient `date` : quand la date change, SWR
  // refait la requête tout seul et sert d'abord la donnée en cache.
  const { data, error, isLoading } = useSWR(
    `/api/slots?date=${date}`,
    fetcher,
  );

  if (isLoading) return <SqueletteCreneaux />;
  if (error) return <p>Impossible de charger les créneaux.</p>;

  return <SlotGrid slots={data} />;
}

Une ligne de hook remplace trois useState et un useEffect — et apporte en prime tout ce que tu n'aurais pas écrit à la main.

Ce que ces bibliothèques apportent réellement, au-delà de la concision, tient en quatre points qu'il vaut la peine de savoir énumérer en entrevue :

  • Un cache côté client, indexé par clé. Deux composants qui demandent la même clé partagent une requête et une copie ; revenir sur une date déjà consultée affiche la donnée instantanément.
  • La revalidation en arrière-plan — le sens du sigle SWR, stale-while-revalidate : on affiche immédiatement la donnée en cache, même un peu vieille, et on va en chercher une fraîche en silence ; si elle a changé, l'écran se met à jour. L'utilisateur ne voit jamais de spinner sur une donnée déjà vue.
  • Les états de chargement et d'erreur, gratuitsisLoading, error, et le suivi du composant démonté avant la fin : tout le code ennuyeux du useEffect manuel est déjà écrit, testé, correct.
  • La déduplication et le rafraîchissement automatiques. Les requêtes identiques rapprochées sont fusionnées ; un rafraîchissement peut se déclencher quand la fenêtre reprend le focus ou quand la connexion revient — exactement ce que fait ton app mobile quand elle resynchronise au retour au premier plan.
🔗 Pont — useEffect + fetch ↔ le Server Component async

Voici la comparaison à avoir en tête, parce qu'elle explique pourquoi presque tout ce que tu trouveras en ligne ressemble à ton code React Native. Pendant dix ans, la seule façon de charger des données en React a été : trois useState (données, chargement, erreur), un useEffect qui lance le fetch après le premier rendu, un try/catch/finally, et un nettoyage pour ignorer la réponse si le composant a été démonté. C'est ce que tu écris dans ton app mobile, et c'est toujours la bonne façon de faire là-bas : il n'y a pas de serveur de rendu dans une app React Native, tout est « client ».

Sur le web avec l'App Router, ce patron s'effondre en une fonction async. Ce n'est pas de la magie : la différence, c'est l'existence d'un moment serveur avant l'envoi du HTML. Le serveur peut attendre les données sans faire attendre un écran déjà affiché — il n'y a pas encore d'écran. Un composant qui « attend », côté client, ça n'existe pas ; côté serveur, c'est juste une fonction lente.

Le corollaire est important pour ta lecture : quand tu croiseras useEffect + fetch dans un tutoriel Next.js, demande-toi si l'auteur écrit pour l'App Router ou s'il transpose un réflexe d'une autre époque. Il reste des cas légitimes — la donnée qui change après une interaction (section « Charger côté client quand il le faut ») — mais ce n'est plus le patron par défaut du chargement initial.

Des états de chargement et d'erreur qui ne mentent pas

Une requête a trois moments — ça charge, ça a marché, ça a raté — et une interface honnête affiche les trois (module HTTP & fetch). Ce qui change ici, c'est qui les affiche : plus toi avec des états, mais des conventions de fichiers.

Pour le chargement, un conseil que la documentation de Next.js formule elle-même : préfère les squelettes aux spinners. Un spinner dit « quelque chose se passe », rien d'autre ; un squelette — des blocs gris à la forme et à la place du contenu à venir — dit « voici ce qui arrive, et où ». Trois bénéfices : la structure se comprend avant d'être remplie, la page ne saute pas quand le contenu arrive (la place était réservée), et l'attente paraît plus courte parce que l'œil a de quoi parcourir. Un bon écran d'attente est un avant-goût fidèle, pas un cache-misère — on peut y mettre les parties déjà connues, image d'entête ou titre, plutôt que de tout masquer.

Pour l'erreur, la convention est le fichier error.tsx, qui définit une frontière d'erreur (error boundary) : si un composant du segment lance une exception pendant le rendu, elle remonte jusqu'à cette frontière, qui affiche son interface de repli au lieu de faire s'effondrer la page. C'est la destination du throw du fetch honnête — le if (!res.ok) throw ... de la section « Charger des données dans un Server Component » finit ici.

app/creneaux/error.tsx
'use client'; // une frontière d'erreur est TOUJOURS un Client Component :
              // elle doit pouvoir réagir au clic sur « Réessayer ».

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void; // fourni par Next.js : retente le rendu du segment
}) {
  return (
    <div role="alert">
      <h2>Les créneaux n'ont pas pu être chargés.</h2>
      <p>
        Le service de réservation est momentanément indisponible.
        Tu peux réessayer, ou nous joindre au 555-0199.
      </p>
      <button onClick={reset}>Réessayer</button>
    </div>
  );
}

Remarque le message : il dit ce qui a échoué, il propose une action, il offre une porte de sortie. Il n'affiche pas la error.message brute à l'utilisateur.

Un point de conception, plus important que le code : ne montre jamais le message d'erreur technique à l'utilisateur. « TypeError: Cannot read properties of undefined » ne lui apprend rien, l'inquiète, et peut divulguer des détails d'infrastructure. Le message technique va dans les journaux du serveur ; l'utilisateur, lui, reçoit une phrase en français qui dit ce qui a échoué, ce qu'il peut faire et par où passer autrement. « Une erreur est survenue » n'est pas un message, c'est un aveu qu'on n'a pas réfléchi au cas.

Le placement de ces frontières compte autant que leur contenu. Un error.tsx placé à la racine transforme n'importe quel échec en page d'erreur pleine — l'utilisateur perd la navigation, l'entête, tout. Placé au niveau du segment des créneaux, il ne remplace que cette zone. Même logique que pour <Suspense> : plus la frontière est proche du problème, moins l'utilisateur perd.

✍️ Exercice de lecture

Un développeur a ajouté un loading.tsx dans app/creneaux/, mais il jure qu'il ne s'affiche jamais : quand on navigue vers la page depuis l'accueil, l'application reste figée sur l'ancienne page pendant une seconde, puis la nouvelle page apparaît d'un coup, complète. Voici le layout du segment :

// app/creneaux/layout.tsx
export default async function Layout({ children }: { children: React.ReactNode }) {
  // Donnée fraîche à chaque requête, non cachée, et lente.
  const clinique = await getCliniqueEnDirect(); // ~900 ms

  return (
    <section>
      <BandeauClinique clinique={clinique} />
      {children}
    </section>
  );
}

Questions : (1) Pourquoi le loading.tsx ne s'affiche-t-il jamais ? (2) Où le loading.tsx se situe-t-il par rapport à ce layout ? (3) Quelles sont les deux corrections possibles ?

Voir le corrigé

(1) Parce que le layout lit une donnée non cachée, et qu'un layout qui fait ça bloque la navigation jusqu'à la fin de son rendu. Or l'écran d'attente est à l'intérieur de ce layout : tant qu'il n'est pas rendu, rien de ce qu'il englobe ne peut partir. L'utilisateur reste sur l'ancienne page pendant les 900 ms, puis reçoit la nouvelle d'un bloc.

(2) C'est la clé : loading.js est imbriqué à l'intérieur de layout.js et enveloppe automatiquement page.js et ses enfants dans un <Suspense>. Il couvre la page, jamais le layout qui le contient — un layout ne peut pas se replier sur l'écran d'attente d'un segment qu'il englobe, il est en amont.

(3) Deux options, toutes deux recommandées par la documentation. Soit on emballe l'accès à la donnée dans sa propre frontière <Suspense fallback={...}> à l'intérieur du layout — on extrait <BandeauClinique /> en composant async et on l'enveloppe, ce qui rend le layout synchrone et non bloquant. Soit on déplace ce chargement dans page.tsx, là où le loading.tsx peut le couvrir. D'où le conseil de placer un <Suspense> au plus près de l'accès aux données plutôt que de compter sur loading.js. À noter : le modèle Cache Components signale ce genre de situation par une erreur au build.

🧠 Quiz éclair

Ce fichier compile, s'affiche au bon endroit, et un relecteur le refuse quand même. Deux reproches — lesquels ?

// app/creneaux/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: { error: Error; reset: () => void }) {
  return (
    <div role="alert">
      <h2>Une erreur est survenue</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Réessayer</button>
    </div>
  );
}

Un : {error.message} à l'écran. « TypeError: Cannot read properties of undefined » n'apprend rien au patient, l'inquiète, et peut divulguer des détails d'infrastructure — un nom d'hôte, une requête. Le message technique va dans les journaux du serveur. Ironie du sort : en production, Next.js masque de toute façon le message d'origine d'une erreur serveur et ne transmet qu'un texte générique accompagné d'un digest pour la retrouver dans les logs — cette ligne n'affichera donc souvent rien d'utile.

Deux : le titre ne dit rien non plus. « Une erreur est survenue » est un aveu qu'on n'a pas réfléchi au cas. Un message honnête nomme ce qui a échoué, ce que la personne peut faire, et une porte de sortie : « Les créneaux n'ont pas pu être chargés. Le service de réservation est momentanément indisponible : réessaie, ou joins-nous au 555-0199. » Le reste du fichier est juste, lui : le 'use client', le role="alert" et le bouton branché sur reset.

Le pont offline-first : deux stratégies inverses, un même objectif

Terminons par le lien qui va faire le plus pour ta compréhension, parce qu'il relie ce module à ce que tu fais déjà tous les jours. Dans ton app mobile, l'architecture est local-first : la donnée vit d'abord dans une base SQLite sur l'appareil (WatermelonDB & offline-first, guide Halterofit). Quand un écran s'ouvre, il lit la base locale — quelques millisecondes, pas de réseau — et s'affiche plein immédiatement ; la synchronisation se fait après, en arrière-plan. La source de vérité de l'affichage est locale, le réseau est un rattrapage.

Sur le web, l'architecture est exactement inverse : il n'y a pas de base locale, le premier chargement arrive sur une machine qui ne sait rien de ton application. La donnée vient donc du serveur, et c'est ensuite qu'on la garde sous la main — cache serveur ('use cache'), cache d'un CDN, cache client de SWR ou TanStack Query, cache du routeur pour les navigations suivantes. La source de vérité est distante, le cache est un rattrapage.

 Mobile (local-first)Web (server-first)
Source de vérité de l'affichagebase SQLite localeserveur / base distante
Premier affichagelecture locale, ~instantanéeHTML pré-rendu ou streamé
Le rattrapagesynchronisation en arrière-plancache serveur, CDN, cache client
Le pire casdonnée locale périméepage blanche en attendant le réseau
La paradesync au retour au premier planstreaming + squelettes + revalidation

Deux stratégies opposées, un seul objectif : que l'écran soit rempli vite. Et deux façons opposées de payer le même prix, celui de la fraîcheur — en local-first tu affiches vite une donnée qui peut être périmée et tu la corriges après ; en server-first avec cache, tu affiches vite une donnée qui peut être périmée et tu la revalides après. Le même compromis, vitesse contre exactitude, résolu depuis deux points de départ. En entrevue, cette mise en perspective vaut de l'or : peu de candidats juniors peuvent expliquer le cache d'une application web en le comparant à une architecture offline-first qu'ils ont réellement mise en œuvre.

🎤 En entrevue

« SSR, SSG, CSR : explique-moi la différence, et comment tu choisis. »

« La question, c'est qui fabrique le HTML et quand. En CSR, le navigateur : le serveur envoie une page vide plus du JavaScript, et l'écran ne se remplit qu'après téléchargement, exécution, puis souvent une requête de données — donc page blanche au début, et un robot d'indexation ne voit rien. En SSR, le serveur rend à chaque requête : l'utilisateur reçoit du HTML déjà rempli, mais on paie un rendu et des requêtes à chaque visite. En SSG, le HTML est fabriqué une fois au build et servi depuis un CDN : c'est imbattable en vitesse et en coût, mais le contenu est figé jusqu'au prochain déploiement — d'où l'ISR, qui garde la vitesse du statique en régénérant la page périodiquement.

Ma règle de décision tient en une question : est-ce que cette page serait identique pour tout le monde ? Si oui, statique — le refaire à chaque visite, c'est payer mille fois le même calcul. Si le contenu dépend de l'utilisateur ou de l'instant, rendu à la requête. Et entre les deux, du cache avec une durée de vie. Le vrai point, c'est que dans Next.js ce choix se fait par route, pas pour l'application entière : dans une app de prise de rendez-vous, la page “nos services” est statique et la page “mes rendez-vous” est rendue à la requête, dans le même projet. »

« Comment évites-tu une cascade de requêtes ? »

« Je distingue deux cascades. La cascade client classique vient du patron useEffect + fetch : une requête ne peut partir qu'après le rendu du composant, et un enfant ne se rend qu'après son parent, donc les requêtes s'empilent en série. Charger dans des Server Components la supprime en grande partie, parce que la donnée est cherchée pendant le rendu serveur, à côté de la base.

La cascade dans un même composant, c'est deux await consécutifs qui ne dépendent pas l'un de l'autre : on additionne deux latences pour rien. Je lance les appels sans await — une fonction async démarre à l'appel — et j'attends avec Promise.all, ce qui ramène le total à la durée de la plus lente. Si un échec ne doit pas tout emporter, j'utilise Promise.allSettled.

Enfin, quand la séquence est inévitable — la deuxième requête a besoin de l'identifiant renvoyé par la première — je ne cherche pas à la supprimer, je la découpe : j'affiche ce que la première a rapporté et je mets la seconde derrière un <Suspense>, pour qu'elle se streame sans bloquer le reste. »

« Comment gères-tu le chargement et l'erreur ? »

« Avec l'App Router, ce ne sont plus des états que je gère à la main, ce sont des conventions. Pour le chargement, un fichier loading.tsx enveloppe automatiquement le segment dans un <Suspense> ; et quand je veux du grain plus fin, je place moi-même des <Suspense> autour des seuls composants lents, pour que le reste de la page parte tout de suite. Je fais attention à placer la frontière au plus près de l'accès aux données — un layout qui lit une donnée non cachée bloque la navigation et son propre loading.js ne le couvre pas.

Pour l'erreur, un fichier error.tsx définit une frontière : le throw que je fais quand res.ok est faux y atterrit, et la page affiche une interface de repli avec un bouton “Réessayer” au lieu de s'effondrer. Je la place au niveau du segment concerné, pas à la racine, pour que l'échec d'un bloc secondaire ne coûte pas toute la page.

Côté conception, deux règles : des squelettes plutôt que des spinners, parce qu'ils montrent la structure à venir et évitent que la page saute quand le contenu arrive ; et jamais le message d'erreur technique à l'écran — il va dans les journaux, l'utilisateur reçoit une phrase claire avec une action possible. »

À retenir

Quatre façons de fabriquer une page : CSR (le navigateur assemble, page blanche au début, mauvais pour le SEO), SSR (le serveur rend à chaque requête, du contenu tout de suite mais un coût par visite), SSG (rendu une fois au build, imbattable mais figé) et ISR (statique, régénéré périodiquement). La question de décision : ce calcul donnerait-il le même résultat pour tout le monde ?

Dans un Server Component, on charge avec async + await directement dans le composant — c'est le fetch honnête, un cran plus haut : await fetch, if (!res.ok) throw, await res.json(). Pas de useEffect, pas de useState, pas de cascade client. Le throw atterrit dans error.tsx.

fetch n'est PAS caché par défaut (les vieux tutoriels disent l'inverse — vérifie toujours la date). Ce qui est automatique, c'est la mémoïsation par requête des fetch identiques, ce qui supprime le besoin de faire descendre les données en props ; React.cache() fait pareil pour une fonction quelconque. Le vrai cache est un opt-in explicite : la directive 'use cache' et le modèle Cache Components, bâti sur le Partial Pre-Rendering.

Le streaming envoie la page en morceaux : loading.js pour le segment entier, <Suspense> pour le grain fin — et on place la frontière au plus près de l'accès aux données, parce qu'un layout qui lit une donnée non cachée bloque la navigation. Deux await indépendants à la suite = drapeau rouge : lancer sans await, puis Promise.all (allSettled si un échec ne doit pas tout casser). Côté client : use(promise) pour une promesse venue du serveur, SWR ou TanStack Query pour ce qui change après interaction. Enfin : squelettes plutôt que spinners, et jamais le message technique à l'écran.

Et ailleurs : le compromis fraîcheur contre vitesse est universel — la même décision que tu prends dans ton app mobile en choisissant à quelle fréquence resynchroniser, qu'un ingénieur backend prend en plaçant un Redis devant sa base, qu'un CDN applique à des images. Partout : « cette réponse est-elle la même pour tout le monde, et pendant combien de temps ? »

Le raisonnement séquentiel contre parallèle est du JavaScript pur : Promise.all t'évitera les mêmes latences empilées dans un script Node, une fonction Edge, ou ton app mobile quand tu charges deux collections indépendantes — la règle « lancer avant d'attendre » vaut dans tous les langages qui ont des promesses. Et l'idée de frontière<Suspense> pour l'attente, error.tsx pour l'échec — est un principe de conception qui dépasse le frontend : isoler ce qui peut mal tourner pour que sa défaillance ne se propage pas, comme les circuit breakers côté serveur, ou la mise en quarantaine d'une synchronisation en échec. Plus la frontière est proche du problème, moins on perd — garde cette phrase, tu t'en resserviras longtemps après avoir oublié le nom de cacheLife.

🗂️ L'aide-mémoire
La question qui tranche entre statique et rendu à la requête
ce calcul donnerait-il le même résultat pour tout le monde ? Si oui, statique. Ce qui dépend d'une session, d'un cookie ou de l'heure : jamais
Ce que fetch fait par défaut dans Next.js aujourd'hui
rien — il n'est pas caché, et il bloque le rendu jusqu'à sa réponse. C'était l'inverse en Next 14, d'où des tutoriels entiers à contretemps
Ce qui, lui, est automatique — et n'est pas du cache
la mémoïsation par requête : deux fetch identiques dans le même rendu ne partent qu'une fois. Le visiteur suivant repart de zéro
La directive qui cache vraiment, et où elle se pose
'use cache', en première ligne du corps de la fonction — c'est une directive comme 'use client', pas un appel
La seconde moitié de cacheTag
revalidateTag, appelé après l'écriture. On étiquette à la lecture, on révoque par la même étiquette à l'écriture ; une étiquette qu'on ne révoque jamais est un cache qui ment
Ce qu'un <Suspense> ne peut pas attraper
une attente située au-dessus de lui. Il n'ouvre rien : il intercepte ce qui se suspend à l'intérieur. D'où le geste — faire descendre l'await
Le moment où une fonction async commence son travail
à l'appel, pas à l'await. C'est ce qui permet de lancer d'abord et d'attendre ensuite
Le drapeau rouge de deux await à la suite
quand le second n'utilise pas le résultat du premier : les latences s'additionnent pour rien. Promise.all ramène le total à la plus lente
Ce qu'un loading.tsx ne couvre jamais
le layout qui le contient — il est imbriqué dedans. Un layout qui lit une donnée non cachée bloque donc la navigation, et son propre écran d'attente ne s'affiche pas
Pourquoi error.tsx porte toujours 'use client'
parce qu'il doit réagir au clic sur « Réessayer » : le reset que Next.js lui passe est un gestionnaire d'événement