Module 2 · Construire

Routing : segments, layouts, navigation

Tu connais déjà le principe : l'arborescence des dossiers est la carte de l'application. Expo Router te l'a appris sur mobile, et l'App Router de Next.js en est l'ancêtre direct. Ce module ne va donc pas te réapprendre le routage par fichiers, mais te montrer ce que le web ajoute par-dessus : une adresse visible, copiable, partageable. Tout le reste — segments, layouts, fichiers d'état, navigation — découle de ça.

🔗 Pont — Expo Router ↔ App Router, la table de conversion

Tu n'apprends pas un nouveau système : tu apprends l'original. Le routage par fichiers est né sur le web, et Expo Router l'a porté sur mobile — c'est pour ça que les conventions se ressemblent tant. Voici la traduction terme à terme, à garder sous la main. Le module Navigation (Expo Router) du guide Halterofit couvre la colonne de gauche en détail.

Expo Router (mobile)App Router (web)Ce qui diffère
_layout.tsx layout.tsx Sans tiret bas. Le layout racine du web doit porter <html> et <body>.
index.tsx page.tsx Le mot « index » n'existe plus ; un dossier sans page.tsx n'est pas une route.
[id].tsx [id]/page.tsx Un dossier entre crochets, qui contient la page.
(auth)/, (app)/ (public)/, (admin)/ Identique. Le web permet en plus plusieurs layouts racines.
useLocalSearchParams() await params (serveur), useParams() / useSearchParams() (client) Le web sépare segments et query string, et rend la version serveur asynchrone.
router.push / replace useRouter().push / replace Même sémantique de pile. Sur le web, c'est un hook, donc réservé aux Client Components.
<Link href> (expo-router) <Link href> (next/link) Même nom, même rôle. Sur le web, il produit un vrai <a> et précharge.
<Redirect href> dans un layout redirect() côté serveur Une fonction qu'on appelle, plutôt qu'un composant qu'on retourne.
+not-found.tsx not-found.tsx + notFound() Le web y ajoute un vrai code de statut HTTP 404.

La seule vraie nouveauté conceptuelle de la colonne de droite, c'est la scission serveur/client — et c'est le sujet du module « Server et Client Components ». Tout le reste, tu l'as déjà dans les doigts.

L'URL comme état partageable

Commençons par la différence qui change tout, et qui n'est pas technique mais philosophique. Dans une app mobile, quand l'utilisateur regarde la fiche de l'exercice 42, cette information vit uniquement dans la mémoire de l'application : il y a bien une « route » interne, mais personne ne la voit, elle n'est écrite nulle part, et pour montrer cet écran à quelqu'un il faut une capture d'écran.

Sur le web, c'est l'inverse : l'adresse est affichée en permanence, en haut de l'écran. https://rendez-vous.example/rendez-vous/physiotherapie?date=2026-09-01 n'est pas un détail d'implémentation, c'est une donnée que l'utilisateur possède — copiable, collable dans un message, mettable en favori, rechargeable demain dans un autre navigateur, indexable par Google. L'URL est un état sérialisé, public et durable.

Mesure la portée de cette phrase : une partie de l'état de ton application n'est plus en mémoire, elle est dans la barre d'adresse. Tu as donc un choix de conception à faire, écran par écran, morceau d'état par morceau d'état — beaucoup de juniors ne se le posent jamais, mettent tout dans useState par réflexe, et se retrouvent avec des pages impossibles à partager. La règle de tri est simple : « si l'utilisateur rechargeait la page maintenant, serait-il fâché de perdre cette information ? » Si oui, elle va dans l'URL ; sinon, elle reste locale.

Ça mérite d'être dans l'URLÇa reste en état local
Le service choisi (/rendez-vous/physiotherapie) — c'est le sujet de la page.Le fait qu'un menu déroulant soit ouvert ou fermé.
La date filtrée (?date=2026-09-01) — on veut pouvoir envoyer « voilà les dispos de ce jour-là ».Le texte en cours de frappe dans un champ, avant validation.
La page courante d'une liste paginée (?page=3).L'animation en cours, la position d'un survol.
L'onglet actif d'une fiche patient, si on veut pouvoir pointer un collègue dessus.Un état d'accordéon purement décoratif.
Le tri et les filtres d'un tableau de bord.Le contenu d'un brouillon non enregistré.

La colonne de gauche décrit ce qu'on regarde, celle de droite comment on interagit. Bonne heuristique : le quoi va dans l'URL, le comment reste en mémoire.

📅 Dans RendezVous

Dans RendezVous, la question de l'URL se pose dès le premier écran : quand la réceptionniste veut envoyer à un patient « voici les disponibilités en physio pour le 1er septembre », elle doit pouvoir copier un lien. Le service devient donc un segment de chemin, et la date un paramètre de requête : /rendez-vous/physiotherapie?date=2026-09-01. En revanche, le fait que le petit calendrier soit déplié ou non ne concerne que la personne devant l'écran — ça reste un useState.

💡 Le vrai coût d'un mauvais tri

Mettre un état dans l'URL n'est pas gratuit : il faut l'y écrire, le lire, gérer les valeurs absurdes qu'un utilisateur peut y taper (?date=bonjour), alors qu'un état local est toujours d'un type connu. Le compromis : l'URL t'offre le partage et le rechargement contre un peu de validation. Mais l'erreur inverse coûte bien plus cher — une page dont le lien ne ramène jamais l'utilisateur là où il était ne peut être ni partagée, ni mise en favori, ni retrouvée dans l'historique. Sur le web, c'est un défaut fonctionnel, pas un détail.

Dossiers = segments : la carte se lit dans l'arborescence

Le mécanisme de base, tu le connais : dans l'App Router, tout vit dans app/ et chaque dossier imbriqué ajoute un segment à l'URL. Le chemin du dossier et le chemin de l'URL sont la même phrase, écrite deux fois. Deux écarts seulement avec Expo Router méritent qu'on s'arrête.

Un — un dossier, à lui seul, ne rend rien public. Sur mobile, poser settings.tsx suffit à créer l'écran ; ici, il faut un dossier settings/ qui contient un page.tsx. Sans ce fichier, /settings renvoie une 404, même si le dossier existe et contient dix autres fichiers :

  • page.tsx rend le segment public en tant que page (du HTML pour un humain) ; route.ts le rend public en tant qu'endpoint (des données pour une machine — module « Route Handlers & Server Actions »). Jamais les deux dans le même segment.
  • Tout le reste — composants, helpers, styles, tests — est invisible pour le routeur : c'est la colocalisation, présentée dans le module « La carte de Next.js », qui détaille aussi les neuf noms de fichiers réservés et leur rôle.
Arborescence de RendezVous (extrait)
app/
├── layout.tsx                    ← layout RACINE (obligatoire, porte html/body)
├── page.tsx                      → /
├── a-propos/
│   └── page.tsx                  → /a-propos
├── rendez-vous/
│   ├── page.tsx                  → /rendez-vous        (liste des services)
│   ├── ServiceCard.tsx           ← PAS une route : simple composant colocalisé
│   ├── formatHeure.ts            ← PAS une route : helper colocalisé
│   └── [service]/
│       ├── page.tsx              → /rendez-vous/physiotherapie, /rendez-vous/massage…
│       ├── loading.tsx           ← état de chargement de CE segment
│       └── error.tsx             ← filet à errors de CE segment
└── confirmation/
    └── page.tsx                  → /confirmation

Trois fichiers dans rendez-vous/, une seule route : seul page.tsx est public. ServiceCard.tsx et formatHeure.ts sont là parce que c'est qu'ils servent ; le routeur les ignore.

Deux — il n'y a pas d'index. Ce que tu appelles index.tsx sur mobile s'appelle ici page.tsx, et la « racine » d'un dossier, c'est simplement le page.tsx de ce dossier. Le mot index appartient à l'ancien Pages Router — si tu le croises dans un tutoriel, tu sais que le tutoriel est périmé. (Les noms réservés s'écrivent sans extension dans la documentation parce que .js, .jsx et .tsx marchent tous ; en TypeScript ce sera page.tsx pour un composant et route.ts pour un endpoint.)

Les routes dynamiques : un fichier, une infinité de pages

Une clinique a une dizaine de services, et cette liste changera. Écrire une page par service serait absurde : dix fichiers quasi identiques à maintenir en parallèle, et un onzième à chaque embauche. La solution est la même que sur mobile — le segment dynamique, entre crochets : le dossier [service] attrape n'importe quelle valeur à cette position de l'URL et la passe à la page.

Là où il faut ralentir, c'est sur la manière de lire cette valeur, parce qu'elle a changé récemment et que la moitié d'internet raconte encore l'ancienne version. Voici le code réel, correct pour Next.js 16 :

app/rendez-vous/[service]/page.tsx
// Server Component par défaut (voir le module « Server et Client Components ») :
// donc on peut le déclarer `async` et attendre des choses dedans.
export default async function PageService({
  params,
}: {
  // ⚠️ params est une PROMESSE, pas un objet simple.
  params: Promise<{ service: string }>;
}) {
  // On l'attend pour en extraire le segment. Le nom `service` correspond
  // EXACTEMENT au nom du dossier [service].
  const { service } = await params;

  // À partir d'ici, `service` vaut "physiotherapie" pour /rendez-vous/physiotherapie.
  const fiche = await chargerService(service);

  if (!fiche) notFound(); // 404 propre — on y revient plus bas.

  return <h1>{fiche.nom}</h1>;
}

La fonction est async : c'est ce qui autorise la ligne suivante.

⚠️ Piège fréquent — l'await oublié devant params

Le symptôme n'est pas une exception, c'est un trou silencieux : params.service lit une propriété qui n'existe pas sur une promesse, obtient undefined, et cet undefined voyage jusqu'à un appel de données qui ne trouve rien. Tu débogues alors la couche données, alors que la faute est dans la signature. TypeScript t'arrête si le type est écrit ; en JavaScript pur, rien.

Le réflexe : la première ligne du corps d'une page est un await. Et méfie-toi de ce que tu copies — tous les tutoriels antérieurs à 2025 montrent l'ancienne écriture synchrone, qui se lit comme du code correct. Même chose pour searchParams, promesse elle aussi.

Pourquoi diable une promesse ? Le framework connaît pourtant l'URL depuis le début de la requête… mais pas forcément au moment où ce composant-là commence à s'exécuter. Next.js sait désormais rendre une page en morceaux et les envoyer au fur et à mesure (le streaming, détaillé dans le module « Rendu & data fetching ») : il peut produire la coquille statique — en-tête, mise en page, squelette — avant de savoir quelle valeur prendra le segment dynamique, puis remplir le trou. Pour ça, il faut que le composant puisse dire « je m'arrête et j'attends », et le mot du langage pour l'exprimer, c'est await sur une promesse. Autrement dit, la signature de ta fonction porte la trace d'une capacité nouvelle : le rendu est devenu différable. C'est une idée qu'on retrouve partout en informatique — quand un système gagne en paresse, ses interfaces deviennent asynchrones.

Et si tu as besoin du segment dans un Client Component ? Un composant client ne peut pas être async — React ne l'autorise pas, parce qu'il doit pouvoir se re-rendre encore et encore, instantanément. On déballe alors avec le hook use() de React, ou plus simplement avec useParams() de next/navigation :

'use client';
import { use } from 'react';
import { useParams } from 'next/navigation';

// Option A — la page reste dans app/, on reçoit la promesse et on la « déballe ».
export default function PageCliente({ params }: { params: Promise<{ service: string }> }) {
  const { service } = use(params); // use() déballe une promesse côté client
  return <p>Service : {service}</p>;
}

// Option B — un composant enfant quelconque, qui ne reçoit rien en props :
function BackButton() {
  const { service } = useParams<{ service: string }>(); // lit l'URL courante
  // ...
}

use() est le pendant client de await. useParams(), lui, va directement chercher les segments de l'URL courante — c'est le jumeau web de ton useLocalSearchParams d'Expo Router.

Attraper plusieurs segments d'un coup

Un segment dynamique n'attrape qu'un seul morceau d'URL : [service] matche /rendez-vous/physio mais pas /rendez-vous/physio/genou. Pour avaler une profondeur variable, deux variantes existent :

ÉcritureNomCe qu'elle attrape
[service] dynamique Exactement un segment. /rendez-vous/physio ✅ — /rendez-vous/physio/genou ❌ — /rendez-vous
[...chemin] catch-all Un segment ou plus. /docs/a ✅ (chemin = ['a']) — /docs/a/b/c ✅ (['a','b','c']) — /docs
[[...chemin]] catch-all optionnel Zéro segment ou plus. Comme au-dessus, plus /docs ✅ (chemin = undefined)

La différence entre les deux dernières lignes tient au cas « rien du tout » : le double crochet dit « ce segment peut aussi être absent », ce qui permet de servir la page d'index et toutes ses sous-pages avec un seul fichier — documentation, catalogue à profondeur libre, pages pilotées par un CMS. Dans ces cas-là, la valeur reçue est un tableau de chaînes : { chemin: string[] | undefined }.

searchParams : ce qui vient après le point d'interrogation

Un segment n'est pas le seul endroit où loger de l'information dans une URL. Après le ? viennent les paramètres de requête (query string) : ?date=2026-09-01&duree=60. C'est le mécanisme le plus vieux du web — il date des formulaires HTML des années 90, où le navigateur encodait les champs saisis directement dans l'adresse — et il n'a jamais cessé d'être utile, parce qu'il attache des modificateurs à une page sans changer son identité.

Côté serveur, la lecture ressemble beaucoup à celle de params — et pour exactement la même raison, c'est aussi une promesse :

app/rendez-vous/[service]/page.tsx
export default async function PageService({
  params,
  searchParams,
}: {
  params: Promise<{ service: string }>;
  // Chaque valeur peut être absente, unique, ou répétée (?tag=a&tag=b) :
  searchParams: Promise<{ [cle: string]: string | string[] | undefined }>;
}) {
  // On peut attendre les deux d'un coup.
  const [{ service }, { date }] = await Promise.all([params, searchParams]);

  // ⚠️ `date` vient de l'utilisateur : il peut valoir n'importe quoi,
  // y compris "bonjour". On se rabat sur aujourd'hui si c'est illisible.
  const jour = analyserDate(date) ?? new Date();

  const slots = await chargerCreneaux(service, jour);
  return <ListeCreneaux slots={slots} />;
}

Le type dit tout : une clé de query string peut être absente ou apparaître plusieurs fois — d'où le string | string[] | undefined, et d'où la nécessité de valider.

Côté client, on lit la même chose avec useSearchParams(), qui retourne un objet URLSearchParams — l'API standard du navigateur vue au module « HTTP & fetch » — ici en lecture seule : pour changer un paramètre, on navigue vers la nouvelle URL, on ne mute pas l'objet.

'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';

export function DateSelector() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname(); // ex. "/rendez-vous/physiotherapie"

  const dateCourante = searchParams.get('date') ?? '';

  function changerDate(nouvelle: string) {
    // On copie les paramètres existants pour ne pas perdre les autres filtres,
    // on modifie celui qui nous intéresse, puis on navigue vers l'URL résultante.
    const suivants = new URLSearchParams(searchParams);
    suivants.set('date', nouvelle);
    router.replace(`${pathname}?${suivants}`); // replace : on n'empile pas 40 entrées d'historique
  }

  return <input type="date" value={dateCourante}
                onChange={(e) => changerDate(e.target.value)} />;
}

« Copier, modifier, naviguer » est le geste standard pour toucher à un filtre. Note le replace plutôt que push : changer un filtre dix fois ne doit pas obliger à appuyer dix fois sur « retour ».

🧠 Quiz éclair

Cette page fonctionne pour /rendez-vous/physiotherapie?tag=matin. Avec ?tag=matin&tag=soir, elle plante : « tag.split is not a function ». Le type te prévenait pourtant déjà — où ?

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ [cle: string]: string | string[] | undefined }>;
}) {
  const { tag } = await searchParams;
  const filtres = tag.split(',');   // 💥 seulement quand il y a deux tags
  // ...
}

Dans le type string | string[] | undefined. Ces trois branches ne sont pas de la coquetterie : elles décrivent exactement les trois choses qu'un visiteur peut envoyer. Absente (undefined), une fois (une chaîne — .split marche, d'où l'illusion que le code est bon), ou répétée : ?tag=matin&tag=soir est une URL parfaitement légale, et la valeur devient alors un tableau, qui n'a pas de méthode .split. Correctif : normaliser avant de s'en servir — const tags = tag === undefined ? [] : Array.isArray(tag) ? tag : [tag]; — et travailler ensuite sur un tableau, toujours. C'est le même réflexe que pour ?date=bonjour : tout ce qui vient de l'URL vient de l'extérieur, donc se valide avant usage.

Segment ou paramètre de requête ?

Question de conception qu'on te posera peut-être en entrevue, et la bonne réponse tient en une phrase : le segment dit ce qu'est la page, le paramètre dit comment on la regarde.

  • Segment quand la valeur identifie la ressource : un autre service, c'est une autre page, avec son propre titre, sa propre description, sa propre entrée dans Google.
  • Paramètre quand la valeur filtre, trie, pagine ou configure la même page (la physio au 1er et au 2 septembre, c'est une page vue sous deux angles), ou quand les valeurs se combinent librement — trois filtres indépendants donneraient une explosion combinatoire de segments, alors qu'ils s'empilent sans effort dans une query string.

Le critère décisif, quand tu hésites : est-ce que Google devrait avoir une entrée distincte pour cette valeur ? Si oui, segment ; sinon, paramètre. Les moteurs traitent les segments comme des pages à part entière et se méfient des query strings — ce qui correspond presque toujours à ce que tu veux.

🔗 Pont — l'historique du navigateur ↔ la pile de navigation mobile

Tu connais déjà ce mécanisme sous un autre nom. Sur mobile, tu manipules une pile d'écrans : router.push empile, le bouton retour dépile, router.replace échange l'écran du dessus sans laisser de trace. Le navigateur a exactement la même structure, elle s'appelle l'historique de session, et elle est pilotée par les mêmes deux verbes — push et replace. La flèche « retour » du navigateur, c'est ton bouton retour ; le geste de balayage vers la droite sur téléphone, c'est encore lui.

Deux différences valent le détour. D'abord, l'historique web est visible et manipulable par l'utilisateur : il peut maintenir le bouton retour enfoncé pour sauter cinq pages en arrière, ou ouvrir un lien dans un nouvel onglet — donc démarrer une deuxième pile en parallèle. Tu ne contrôles pas son parcours autant que sur mobile. Ensuite, revenir en arrière sur le web ne restaure pas forcément l'état des composants comme le fait une pile mobile : la page peut être re-rendue. D'où l'importance de la section précédente — ce qui est dans l'URL survit au retour arrière, ce qui est dans useState peut ne pas survivre. C'est le même raisonnement que le tien sur push vs replace après une inscription, appliqué à un environnement plus ouvert.

🧠 Quiz éclair

Le service choisi va dans le chemin (/rendez-vous/physiotherapie) et la date en paramètre (?date=…). Pourquoi pas l'inverse ?

Parce que le segment dit ce qu'est la page et le paramètre dit comment on la regarde. Chaque service est une ressource distincte, avec son titre, sa description, sa propre entrée dans les moteurs de recherche. La date, elle, filtre la même page — et on ne veut pas 365 pages indexées par service. Le critère décisif : « Google devrait-il avoir une entrée distincte pour cette valeur ? » Si oui, segment ; sinon, paramètre.

Les layouts : l'emballage qui ne se démonte pas

Un layout.tsx enveloppe tout ce qui se trouve dans son segment et en dessous : il reçoit une prop children et décide de ce qu'il met autour. Jumeau exact de ton _layout.tsx d'Expo Router, au tiret bas près.

Les layouts s'imbriquent : une page à /rendez-vous/physiotherapie est enveloppée par le layout de [service], lui-même par celui de rendez-vous/, lui-même par celui de la racine. Des poupées russes qui reflètent l'arborescence, chaque niveau apportant ce qui est commun à son sous-arbre : en-tête et pied de page du site à la racine, fil d'Ariane et barre latérale des services dans rendez-vous/.

Le layout racine, un cas à part

Rappel du module « La carte de Next.js » : le app/layout.tsx de la racine est obligatoire et c'est lui qui écrit les balises <html> et <body>. Le voici en entier pour RendezVous, il est court :

app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

// Le titre et la description par défaut de TOUT le site.
// Chaque page pourra les remplacer (voir « Rendu & data fetching »).
export const metadata: Metadata = {
  title: 'RendezVous — Clinique Saint-Laurent',
  description: 'Prenez rendez-vous en ligne, 24 h sur 24.',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // lang="fr" : indispensable pour les lecteurs d'écran et le navigateur
    // (voir le module « HTML sémantique & accessibilité »).
    <html lang="fr">
      <body>
        <EnTeteClinique />
        {/* children = la page courante, déjà emballée par les layouts inférieurs */}
        <main>{children}</main>
        <PiedDePage />
      </body>
    </html>
  );
}

Le seul endroit où tu écris <html> et <body> : littéralement toutes les pages du site passent par ce composant.

Ce fichier et le page.tsx qu'il enveloppe forment Le duo layout + page du module La carte de Next.js : l'un emballe et persiste, l'autre occupe et change. Les deux marques sont bien là — l'export default, et le paramètre children côté layout. Retiens surtout ceci pour la suite du module : tout ce qu'on va empiler par-dessus — segments dynamiques, groupes de routes, routes parallèles — n'est qu'une répétition de ce même duo, un étage de dossier plus bas.

La propriété qui change tout : ils persistent

Voici le point le plus important du module, et celui qui explique le plus de comportements « bizarres » quand on débute. Un layout ne se remonte pas quand on navigue à l'intérieur de son sous-arbre. En passant de /rendez-vous/physiotherapie à /rendez-vous/massage, le layout de rendez-vous/ et le layout racine ne sont pas détruits puis recréés : ils restent en place, et seule la partie children est échangée. Les conséquences sont concrètes et toutes désirables :

  • L'état des composants du layout survit — une barre latérale dépliée reste dépliée, un champ de recherche garde son texte — et la position de défilement est conservée.
  • Un lecteur vidéo ou audio dans un layout continue de jouer pendant qu'on navigue : c'est le mécanisme qui permet à un site de musique de garder sa lecture en cours.
  • Pas de re-rendu inutile, donc pas de scintillement de l'en-tête à chaque clic.
Un clic vers un autre service, et ce qui bouge vraiment
/rendez-vous/physiotherapie — avant le clic
RootLayouten-tête, police, providers
Layout rendez-vousbarre latérale dépliée, page défilée à mi-hauteur
Page physiotherapie
/rendez-vous/massage — après le clic
RootLayouten-tête, police, providers
Layout rendez-vousbarre latérale dépliée, page défilée à mi-hauteur
Page massagela seule chose échangée
Les deux premiers niveaux sont écrits deux fois avec exactement les mêmes mots, et c'est à prendre au pied de la lettre : ce ne sont pas deux layouts qui se ressemblent, c'est le même, jamais démonté — d'où le fait que son état, son défilement et sa lecture en cours survivent au clic. Seul le children du bas est remplacé.

Tu reconnais cette propriété : c'est la persistance des onglets sur mobile, celle qui fait que l'onglet Exercices retrouve son défilement quand tu y reviens. Même idée, autre décor.

template.tsx : quand tu veux au contraire tout remonter

Cette persistance est presque toujours ce qu'on veut… mais pas toujours. Parfois, tu as besoin qu'un composant reparte de zéro à chaque navigation. C'est là qu'intervient template.tsx : il se place exactement comme un layout, mais il est recréé à chaque changement de route. Nouvelle instance, état réinitialisé, effets rejoués.

layout.tsxtemplate.tsx
À la navigation interneReste montéDémonté puis remonté
État local (useState)ConservéRemis à zéro
useEffect de montageJoué une seule foisRejoué à chaque route
Défilement, focusConservésRéinitialisés
Quand l'utiliserPar défaut, dans 95 % des casAnimation d'entrée à rejouer, journalisation par page, formulaire qui doit se vider

Un exemple parlant : une animation de fondu à l'ouverture de chaque page de service. Dans un layout, elle se jouerait une seule fois, à la première visite, puis plus jamais — le composant ne se remonte plus. Dans un template.tsx, elle se rejoue à chaque changement de service. Même chose pour un compteur de consultation qu'on veut envoyer une fois par page vue. Si les deux fichiers coexistent dans un segment, ils s'emboîtent : le layout est le plus extérieur, le template à l'intérieur. Le layout est stable, le template clignote.

📅 Dans RendezVous

Le layout de app/rendez-vous/ contient la barre latérale des services et le fil d'Ariane. Quand un patient compare la physio et l'ostéo en cliquant de l'une à l'autre, la barre latérale ne clignote pas, sa position de défilement est intacte, et le filtre de recherche qu'il avait tapé dedans est toujours là. Rien de tout ça n'a demandé de code : c'est gratuit, parce que c'est un layout. Si un jour on veut une transition animée entre deux services, on ajoutera un template.tsx à côté — et seulement à ce moment-là.

🧠 Quiz éclair

Un patient replie la liste des services, puis consulte la physio, l'ostéo et le massage. La liste est-elle encore repliée à la troisième page ? Et que se passerait-il si le développeur renommait ce fichier template.tsx, sans toucher à une seule ligne ?

'use client';
// app/rendez-vous/layout.tsx
import { useState } from 'react';

export default function LayoutRendezVous({
  children,
}: { children: React.ReactNode }) {
  const [ouverte, setOuverte] = useState(true);

  return (
    <div className="grille">
      <button onClick={() => setOuverte((o) => !o)}>
        {ouverte ? 'Masquer' : 'Afficher'} les services
      </button>
      {ouverte && <BarreServices />}
      <main>{children}</main>
    </div>
  );
}

Oui, toujours repliée. Les trois pages sont dans le sous-arbre de ce layout : en naviguant de l'une à l'autre, il n'est pas démonté — seul children est échangé. useState(true) n'est donc jamais rejoué, et ouverte garde la valeur que le patient lui a donnée (sa position de défilement aussi).

Renommé template.tsx : comportement inverse, avec exactement le même code. Le fichier est remonté à chaque navigation : nouvelle instance, useState(true) rejoué, la liste se rouvre à chaque service et le patient la replie trois fois. C'est ce qui rend ce couple particulier — ici, le nom du fichier est l'API : c'est lui qui décide de la durée de vie, pas le contenu.

Les fichiers d'état : chargement, erreur, introuvable

Une page web réelle n'a pas deux états mais quatre : elle charge, elle réussit, elle échoue, ou elle n'existe pas. L'App Router offre un fichier dédié pour chacun, et — c'est l'idée élégante — les poser dans un dossier suffit : aucun branchement à écrire, le framework les câble pour toi.

loading.tsx — une frontière de Suspense automatique

Pose un loading.tsx dans un segment, et Next.js enveloppe automatiquement la page dans un <Suspense> — la balise React qui veut dire « tant que le contenu n'est pas prêt, montre ceci à la place » — dont ton fichier est le repli. Tant que la page (async) n'a pas fini d'attendre ses données, l'utilisateur voit ton squelette ; dès que c'est prêt, le vrai contenu prend sa place. Ce que ce mécanisme fait au flux de la réponse, c'est le module « Rendu & data fetching » qui l'explique ; ici, on retient la convention de fichier.

app/rendez-vous/[service]/loading.tsx
// Pas de props, pas de logique : juste ce qu'on montre pendant l'attente.
// Un squelette de la forme finale est bien meilleur qu'un « Chargement… » :
// la page ne saute pas quand le vrai contenu arrive.
export default function Loading() {
  return (
    <div aria-busy="true" aria-live="polite">
      <div className="squelette-titre" />
      <div className="squelette-ligne" />
      <div className="squelette-ligne" />
    </div>
  );
}

Les attributs aria-busy et aria-live annoncent le chargement aux lecteurs d'écran — même réflexe que dans le module « HTML sémantique & accessibilité ».

Ce qui rend ce fichier précieux, c'est qu'il est local à son segment : un loading.tsx dans [service]/ ne met un squelette que sur la zone de la page de service, tandis que l'en-tête, la barre latérale et le fil d'Ariane restent visibles et interactifs — ils appartiennent à des layouts au-dessus. L'utilisateur n'a jamais l'impression que « tout le site charge » ; il voit un morceau de page se remplir. Énorme différence de perception, obtenue en créant un fichier.

error.tsx — le filet de sécurité, et pourquoi il doit être client

error.tsx devient automatiquement une frontière d'erreur (error boundary) autour du segment. Si un composant du sous-arbre lève une exception non rattrapée, React l'attrape et affiche ton composant à la place — seulement à la place du segment fautif, le reste de la page continue de vivre.

app/rendez-vous/[service]/error.tsx
'use client'; // ← OBLIGATOIRE. Explication juste en dessous.

export default function ErreurService({
  error,  // l'erreur attrapée (avec un `digest` côté serveur pour la retrouver dans les logs)
  reset,  // une fonction qui retente le rendu du segment
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Impossible d'afficher les disponibilités.</h2>
      <p>Le service a peut-être été retiré, ou notre agenda est momentanément injoignable.</p>
      {/* reset() re-tente le rendu SANS recharger la page entière */}
      <button onClick={reset}>Réessayer</button>
    </div>
  );
}

Deux props, toujours les mêmes : error et reset. Le bouton « Réessayer » est presque toujours pertinent, beaucoup d'erreurs étant passagères.

Pourquoi 'use client' est-il obligatoire ici ? Deux raisons qui se renforcent, et c'est un excellent test de compréhension du module « Server et Client Components ».

  1. Le mécanisme lui-même est côté client. Une frontière d'erreur React repose sur des méthodes de cycle de vie (historiquement componentDidCatch) qui n'existent que dans le React du navigateur. Un Server Component ne « vit » pas : il s'exécute une fois, produit une description, et disparaît — personne n'est là pour attraper quoi que ce soit après coup.
  2. La prop reset s'attache à un onClick. Un gestionnaire d'événement suppose de l'interactivité, donc du JavaScript envoyé au navigateur, donc un Client Component. C'est la ligne de partage fondamentale entre les deux mondes.

Si tu oublies la directive, le message de Next.js est explicite. Mais l'intérêt est de comprendre pourquoi : ce n'est pas une règle arbitraire, c'est une conséquence directe de ce qu'est une frontière d'erreur.

not-found.tsx et notFound()

Deux choses portant presque le même nom, et qui travaillent en binôme :

  • notFound() est une fonction de next/navigation, appelée côté serveur quand la ressource demandée n'existe pas : elle interrompt le rendu — comme un throw — et déclenche la 404.
  • not-found.tsx est le fichier qui décrit cette 404. Le plus proche au-dessus du segment gagne.
import { notFound } from 'next/navigation';

export default async function PageService({ params }: { params: Promise<{ service: string }> }) {
  const { service } = await params;
  const fiche = await chargerService(service);

  // Le patient a tapé /rendez-vous/astrologie à la main, ou suit un vieux lien :
  // on renvoie une VRAIE 404 (bon pour l'utilisateur ET pour Google),
  // pas une page vide avec un titre creux.
  if (!fiche) notFound();

  return <FicheService fiche={fiche} />;
}

Après notFound(), rien ne s'exécute — pas besoin de return. TypeScript le sait : la fonction est typée comme ne retournant jamais.

L'enjeu derrière ce petit appel est réel. Une page qui affiche « aucun résultat » mais renvoie un statut HTTP 200 dit au monde entier « tout va bien, cette page existe » : les moteurs l'indexent, les outils de surveillance ne signalent rien, les liens morts restent invisibles. C'est une soft 404, un défaut classique. notFound() renvoie un vrai 404, et le web entier comprend le message — les codes de statut du module « HTTP & fetch » deviennent ici une décision de ta part.

Reste global-error.tsx, le dernier rempart : il n'attrape que ce que personne d'autre ne peut attraper, les erreurs du layout racine lui-même. Comme il remplace ce layout, il doit fournir ses propres <html> et <body> et être lui aussi un Client Component. En pratique tu en écris un une fois, très sobre.

Comment tout ça s'emboîte

Lis-le de l'extérieur vers l'intérieur : chaque couche enveloppe la suivante.

Ce que le framework empile autour de ta page
Layoutlayout.tsx — persiste entre les navigations
Templatetemplate.tsx — remonté à chaque navigation
ErrorBoundaryerror.tsx
Suspenseloading.tsx
ErrorBoundarynot-found.tsx
Pageou un layout imbriqué, et l'histoire recommence
Tu ne rencontreras jamais cet emboîtement écrit quelque part : c'est ce que le framework construit à partir des noms de fichiers présents. Mais il répond d'un coup à trois questions. Pourquoi le layout reste-t-il visible quand la page plante ? Il est au-dessus des trois frontières — les traits violets —, et une frontière n'attrape que ce qui est en dessous d'elle. Pourquoi un error.tsx n'attrape-t-il pas les erreurs de son propre layout ? Même raison — il faut celui du segment parent. Pourquoi le squelette de chargement s'affiche-t-il à l'intérieur de l'en-tête ? Parce que le Suspense est imbriqué sous les layouts.
✍️ Exercice de lecture

Un collègue soumet ces deux fichiers en revue de code. Qu'est-ce qui ne va pas dans chacun ?

app/rendez-vous/[service]/page.tsx
export default async function Page({ params }: { params: Promise<{ service: string }> }) {
  const fiche = await chargerService(params.service);
  return <h1>{fiche.nom}</h1>;
}
app/rendez-vous/[service]/error.tsx
export default function Erreur({ error, reset }: { error: Error; reset: () => void }) {
  return <button onClick={reset}>Réessayer</button>;
}
Voir le corrigé

Fichier 1 — il manque le await sur params. Le type dit bien Promise<{ service: string }>, donc params.service lit une propriété qui n'existe pas sur une promesse. TypeScript refusera de compiler, et en JavaScript pur tu obtiendrais silencieusement undefined, passé tel quel à chargerService() — qui ne trouverait rien, et tu chercherais le bug dans la couche données alors qu'il est ici. Correction en une ligne : const { service } = await params;.

Fichier 2 — il manque 'use client' en première ligne. Sans la directive, ce fichier est un Server Component (le défaut dans l'App Router), et il casse pour les deux raisons cumulées vues plus haut : le mécanisme de frontière d'erreur n'existe que côté navigateur, et onClick={reset} suppose de l'interactivité. Bonus de revue : le message ne dit rien à l'utilisateur, il n'y a qu'un bouton nu — un error.tsx devrait expliquer ce qui a échoué, dans un role="alert".

Groupes (nom) et dossiers privés _nom

On a dit qu'un dossier = un segment d'URL. Deux conventions font exception, et tu en connais déjà une par cœur. Un dossier entre parenthèses — (public), (admin) — est un groupe : il organise les fichiers et leur donne un layout commun, sans apparaître dans l'URL. C'est mot pour mot ton (auth) et ton (app) d'Expo Router, avec la même intention.

RendezVous — deux univers, aucune trace dans l'URL
app/
├── (public)/                     ← patients — layout léger, en-tête public
│   ├── layout.tsx
│   ├── page.tsx                  → /                 (et NON /(public))
│   ├── a-propos/page.tsx         → /a-propos
│   └── rendez-vous/
│       ├── page.tsx              → /rendez-vous
│       └── [service]/page.tsx    → /rendez-vous/physiotherapie
├── (admin)/                      ← personnel de la clinique — layout à barre latérale
│   ├── layout.tsx                   (c'est ici qu'on vérifie les droits)
│   ├── agenda/page.tsx           → /agenda
│   └── patients/page.tsx         → /patients
└── _lib/                         ← dossier PRIVÉ : jamais une route, quoi qu'il contienne
    ├── agenda.ts
    └── slots.ts

(public) et (admin) disparaissent des adresses. /agenda, et non /(admin)/agenda.

Le gain est le même que sur mobile : les pages patients et celles du personnel n'ont rien en commun visuellement — l'une veut un en-tête sobre, l'autre une barre latérale dense d'outils. Sans groupes, il faudrait un if dans un layout unique, ou accepter un segment /admin/ dans toutes les URL du personnel. Avec eux, chaque univers a son propre layout.tsx et les adresses restent courtes.

Le web pousse l'idée un cran plus loin que le mobile : deux groupes peuvent avoir des layouts racines distincts. Si tu ne mets pas de layout.tsx à la racine de app/ mais un dans (public)/ et un dans (admin)/, chacun devient un layout racine à part entière — et doit donc porter ses propres <html> et <body>. Tu obtiens deux documents indépendants : polices, feuilles de style, langue différentes. Le prix à connaître : naviguer d'un layout racine à l'autre provoque un rechargement complet de la page, pas une navigation client — souvent très acceptable pour passer du site patient à l'outil interne.

L'autre convention, le dossier privé préfixé d'un tiret bas (_lib, _components), est plus simple encore : il est retiré du routage, lui et tout son contenu. Tu connais ce signal — c'est le tiret bas de _layout.tsx, réemployé pour dire « ceci n'est pas une route ». Il sert dès qu'un dossier utilitaire risquerait de ressembler à un segment, ou quand tu veux marquer visuellement la frontière entre code de routage et code de support.

🧠 Quiz éclair

Le build échoue avant même d'avoir compilé quoi que ce soit. Rien n'est pourtant en double : deux groupes distincts, deux layouts distincts, deux fichiers à des chemins différents. Où est la collision ?

app/
├── (public)/
│   ├── layout.tsx          ← en-tête patient
│   └── page.tsx            → ?
└── (admin)/
    ├── layout.tsx          ← barre latérale du personnel
    └── page.tsx            → ?

Les parenthèses n'ajoutent aucun segment. Remets donc les deux flèches : (public)/page.tsx répond à /… et (admin)/page.tsx aussi. Deux pages revendiquent la même adresse, et Next.js n'a aucun moyen de trancher : il refuse net, en signalant deux pages qui résolvent vers le même chemin. C'est la conséquence directe de la règle du groupe — il organise les fichiers, il ne crée pas d'espace d'adressage. Deux groupes peuvent donc porter deux arborescences différentes, jamais deux fois la même adresse. Correctif : un seul des deux garde la page racine, l'autre démarre sur un vrai segment ((admin)/agenda/page.tsx/agenda).

Trois façons de changer de page, et choisir la bonne est un réflexe qu'on attend d'un développeur frontend. Elles se distinguent par qui déclenche la navigation : l'utilisateur en cliquant, ton code après une action, ou le serveur avant même d'avoir rendu quoi que ce soit.

1. <Link> — quand l'utilisateur clique

Le cas par défaut, et de très loin le plus fréquent. Le composant Link de next/link produit un vrai <a href> dans le HTML, mais intercepte le clic pour faire une navigation côté client. Pourquoi ne pas écrire un simple <a> ? Parce qu'un <a> ordinaire vers une page interne déclenche une navigation complète du navigateur : la page actuelle est jetée, le document entier redemandé, tout le JavaScript réévalué, l'état repart de zéro, l'écran blanchit. Link apporte trois choses :

  • Le préchargement. Dès qu'un Link entre dans le champ de vision (et au survol, avec plus d'empressement), Next.js va chercher en arrière-plan ce qu'il faudra pour afficher la destination — d'où l'impression de vitesse quasi instantanée des bons sites Next.js.
  • La navigation client. Seule la portion de l'arbre qui change est remplacée. Les layouts persistent, l'état vit, il n'y a pas d'écran blanc.
  • L'accessibilité et les usages du web, intacts. Comme c'est un vrai <a href>, le clic-milieu ouvre dans un nouvel onglet, le clic droit propose « copier l'adresse », le lecteur d'écran l'annonce comme un lien, le clavier l'atteint avec Tab, et les moteurs le suivent. Un <div onClick> n'offre rien de tout cela — un des anti-patrons les plus courants, vu au module « HTML sémantique & accessibilité ».

La règle : Link pour l'interne, <a> pour l'externe. Un lien vers un autre site n'a rien à précharger — un <a href> nu est parfait, avec rel="noopener noreferrer" s'il ouvre un nouvel onglet.

⚠️ Piège fréquent — le lien interne qui marche quand même

Le symptôme est qu'il n'y en a pas : rien ne plante, rien ne proteste, et c'est exactement pour ça qu'un lien interne écrit en <a> survit à une revue de code. Ce qu'il coûte est écrit juste au-dessus, et ne se voit qu'à l'usage.

Le réflexe : ce que l'utilisateur clique pour aller ailleurs dans ton site est un Link. Et méfie-toi de la faute symétrique, qui coûte encore plus cher : un <div onClick> ou un <button> avec router.push() là où un lien suffisait — là, tu perds le clic-milieu, le clavier, l'annonce du lecteur d'écran et l'indexation d'un coup.

2. useRouter() — quand c'est ton code qui décide

Parfois la navigation suit non pas un clic, mais la fin d'une action : formulaire validé, paiement accepté, filtre modifié. C'est le rôle du hook useRouter() de next/navigation, qui expose trois méthodes utiles :

  • push(url) — empile une entrée dans l'historique. Le retour arrière ramène ici.
  • replace(url) — échange l'entrée courante. Pas de retour vers la page qu'on quitte.
  • refresh() — redemande au serveur les données de la route courante, sans perdre l'état client ni la position de défilement. Très utile après une modification de données.

Le choix entre push et replace obéit à la logique que tu appliques sur mobile : après une inscription ou une réservation confirmée, on remplace, pour que le bouton retour ne ramène pas sur un formulaire déjà soumis ; après une navigation ordinaire, on empile. Attention : ces hooks — useRouter, usePathname, useSearchParams, useParams — sont tous réservés aux Client Components et exigent donc 'use client'. Et useRouter se lit depuis next/navigation, jamais depuis next/router, l'ancienne API du Pages Router qui ne fonctionne pas ici.

3. redirect() — quand le serveur tranche avant de rendre

Troisième cas : la décision se prend avant même que quoi que ce soit s'affiche. Un visiteur non authentifié atteint /agenda ; inutile de lui envoyer une page pour la lui reprendre ensuite en JavaScript. On appelle redirect() pendant le rendu serveur : la fonction interrompt tout et renvoie une redirection HTTP, le navigateur n'a jamais vu la page protégée. C'est le pendant web de ton <Redirect href="/sign-in" /> dans le _layout.tsx de (app) — même garde placée dans un layout pour protéger tout un sous-arbre, mais exprimée comme un appel de fonction et exécutée sur le serveur.

import { redirect } from 'next/navigation';

// Layout du groupe (admin) : il protège /agenda, /patients, et tout le reste.
export default async function LayoutAdmin({ children }: { children: React.ReactNode }) {
  const session = await lireSession();

  // Pas connecté → redirection AVANT tout rendu. Rien de protégé ne part sur le réseau.
  if (!session) redirect('/connexion');

  return <div className="grille-admin"><BarreLaterale />{children}</div>;
}

Comme notFound(), redirect() ne rend jamais la main : le code d'après ne s'exécute pas, et TypeScript le sait.

📖 La formule

Le lien qui reste un lien

import Link from 'next/link';

// L'utilisateur clique → toujours un Link (préchargé, accessible, vrai <a>)
<Link href={`/rendez-vous/${service.slug}`}>{service.nom}</Link>

// Et côté client, quand la navigation suit une action :
'use client';
import { useRouter } from 'next/navigation';

const router = useRouter();
router.push('/confirmation');   // push = on empile ; replace = pas de retour

Link pour tout ce que l'utilisateur clique : il précharge la destination, garde le comportement natif d'un lien (nouvel onglet, clavier, lecteur d'écran) et évite le rechargement complet. useRouter() pour les navigations déclenchées par du code — après un formulaire, un paiement, un changement de filtre — et jamais l'inverse : un lien déguisé en bouton, c'est un lien qu'on ne peut ni ouvrir dans un onglet, ni atteindre au clavier.

🧭 Une mention rapide, pour reconnaître le mot

Instant Navigations. Next.js regroupe sous ce nom un ensemble d'optimisations qui rendent les navigations client nettement plus réactives — retenir l'expression suffit largement à ce stade. (Pour proxy.ts, le fichier qui s'exécute avant qu'une requête n'atteigne une route, voir le module « La carte de Next.js ».)

🧠 Quiz éclair

La réservation est bel et bien enregistrée en base — et pourtant l'utilisateur reste sur le formulaire, avec le message « Réservation impossible ». Où est passée la redirection ?

import { redirect } from 'next/navigation';

async function reserver(donnees: Reservation) {
  try {
    await saveBooking(donnees);
    redirect('/confirmation');          // ← jamais suivie
  } catch (err) {
    console.error(err);
    return { erreur: 'Réservation impossible' };
  }
}

Dans le catch. redirect() ne retourne pas une redirection : elle interrompt l'exécution en levant une erreur spéciale, que Next.js intercepte plus haut pour la transformer en vraie redirection HTTP. C'est d'ailleurs pour ça qu'elle « ne rend jamais la main ». Placée dans un try, ce signal est attrapé par ton catch, qui le journalise comme un échec ordinaire et renvoie un message d'erreur — alors que l'écriture, elle, a bien eu lieu : d'où la contradiction à l'écran. Correctif : sortir redirect() du try/catch et l'appeler après, sur le chemin de succès. Même mécanisme et même précaution pour notFound(). Le réflexe général : un catch qui avale tout avale aussi les signaux du framework.

Routes parallèles et interception, en survol honnête

Il reste deux conventions dans l'App Router : réellement puissantes, réellement rares. Je ne vais pas te les enseigner en profondeur — à ton stade, savoir les reconnaître suffit, pour te dire « ah, c'est une route parallèle » au lieu de perdre vingt minutes à croire à un bug.

Les routes parallèles s'écrivent avec un dossier préfixé d'une arobase : @agenda, @statistiques. On les appelle des slots. Le layout parent les reçoit comme des props nommées, en plus de children, et peut donc afficher plusieurs pages en même temps, côte à côte, dans une seule URL — typiquement un tableau de bord dont les panneaux se chargent, échouent et se rechargent indépendamment.

app/(admin)/
├── layout.tsx        ← reçoit { children, agenda, statistiques }
├── page.tsx          → le contenu principal
├── @agenda/
│   ├── page.tsx      ← panneau 1
│   └── default.tsx   ← REPLI : ce qu'on affiche si le slot n'a rien à montrer
└── @statistiques/
    ├── page.tsx      ← panneau 2
    └── default.tsx

Le default.tsx n'est pas décoratif : sans lui, après un rechargement complet, Next.js ne sait pas quoi mettre dans le slot et renvoie une 404. C'est l'erreur classique des routes parallèles.

L'interception de routes, elle, s'écrit avec des points entre parenthèses : (.) pour le même niveau, (..) pour le niveau au-dessus, (..)(..) pour deux niveaux, (...) depuis la racine. L'idée : quand l'utilisateur arrive sur une route par une navigation interne, on lui montre autre chose que s'il y arrive par une URL directe.

Le cas d'école est la modale, et il résout élégamment un vrai problème. Sur RendezVous, un clic sur un créneau ouvre une fenêtre de confirmation par-dessus la liste, sans quitter la page ; mais si le patient partage ce lien ou le recharge, il doit obtenir une vraie page de confirmation en pleine largeur, pas une modale flottant sur du vide. L'interception permet exactement ça : une seule URL, deux rendus différents selon le chemin d'arrivée. Combinée à un slot parallèle, c'est la recette standard des modales partageables — les photos d'Instagram ouvertes en superposition dans un fil, mais en pleine page si on colle l'adresse.

C'est un problème propre au web : sur mobile, une modale n'a pas d'adresse partageable, la question ne se pose jamais. Même leçon qu'en ouverture — dès qu'une URL est publique, chaque état d'interface doit répondre à « et si quelqu'un arrive ici directement ? ».

✍️ Exercice de lecture

Voici deux liens écrits dans une page de RendezVous. Ils fonctionnent tous les deux — on peut cliquer, on arrive à la bonne page. Pourtant l'un des deux est un défaut qu'un relecteur signalerait immédiatement. Lequel, et qu'est-ce qui est perdu concrètement ?

// A
<a href="/rendez-vous/physiotherapie">Physiothérapie</a>

// B
<a href="https://www.inspq.qc.ca/">Institut national de santé publique</a>
Voir le corrigé

C'est A le défaut. Un lien interne écrit avec un <a> nu : au clic, le navigateur fait une navigation complète, et on perd deux choses concrètes. Le préchargement — Next.js ne prépare rien à l'avance, l'attente commence au clic. La navigation client — le document entier est redemandé et réévalué, les layouts sont remontés, la barre latérale se réinitialise, le défilement repart de zéro, l'écran blanchit. Sur une connexion moyenne, la différence est visible à l'œil nu. Correction mécanique : <Link href="/rendez-vous/physiotherapie">, avec l'import de next/link.

B est correct : un lien externe n'a rien à précharger et aucune navigation client n'est possible vers un autre domaine. Un <a href> nu est le bon outil, avec éventuellement target="_blank" rel="noopener noreferrer". Note que le problème n'est pas « <a> c'est mal » — Link produit lui-même un <a> — mais d'utiliser la version nue là où la version enrichie existe.

🎤 En entrevue

« Comment fonctionne le routage de Next.js ? »

C'est un routage basé sur les fichiers : l'arborescence du dossier app/ décrit directement les URL, il n'y a pas de fichier de configuration des routes. Chaque dossier ajoute un segment, et un segment devient public seulement s'il contient un page.tsx — ou un route.ts s'il s'agit d'un endpoint. Tout le reste peut être colocalisé sans créer de route.

Par-dessus ça, une poignée de noms réservés structurent chaque segment : layout pour l'emballage partagé et persistant, loading pour un Suspense automatique, error pour une frontière d'erreur locale, not-found pour la 404. Les crochets créent des segments dynamiques — [slug], [...slug], [[...slug]] — et les parenthèses créent des groupes qui organisent sans apparaître dans l'URL.

Ce que j'aime dans ce modèle, c'est que la carte de l'application se lit dans l'explorateur de fichiers. C'est d'ailleurs le même principe qu'Expo Router, que j'utilise au quotidien sur mobile — Expo Router a repris la convention du web.

« Quelle est la différence entre layout.tsx et template.tsx ? »

Les deux enveloppent le contenu d'un segment, mais leur durée de vie diffère. Un layout persiste entre les navigations à l'intérieur de son sous-arbre : il n'est pas démonté, donc son état local est conservé, sa position de défilement aussi, et ses effets ne sont pas rejoués. Un template est remonté à chaque navigation : nouvelle instance, état réinitialisé, effets relancés.

Le layout est le choix par défaut, parce que la persistance donne gratuitement une navigation fluide, sans clignotement de l'en-tête. On passe au template dans les cas précis où on veut justement la remise à zéro : une animation d'entrée qui doit se rejouer à chaque page, un journal de consultation par page vue, ou un formulaire qui doit se vider entre deux passages.

« Pourquoi utiliser <Link> plutôt qu'une balise <a> ? »

D'abord une précision : Link rend un vrai <a href>. On ne renonce donc à rien du comportement natif — clic-milieu pour ouvrir dans un onglet, clavier, lecteurs d'écran, indexation par les moteurs. Le débat n'est pas « lien ou pas lien ».

Ce que Link ajoute, c'est le préchargement de la destination quand le lien devient visible ou au survol, et la navigation côté client : seule la portion d'arbre qui change est remplacée, les layouts persistent, il n'y a pas de rechargement complet du document ni d'écran blanc. Avec un <a> nu vers une page interne, on perd les deux.

La règle que j'applique : Link pour tout ce qui est interne, <a> pour les liens externes — où il n'y a de toute façon rien à précharger.

À retenir

Sur le web, l'URL est un état partageable : ce qui identifie la page va dans le chemin, ce qui la filtre va en query string, et le reste demeure en état local. Les dossiers de app/ forment les segments, mais seul page.tsx (ou route.ts) rend un segment public — d'où la colocalisation libre. Les segments dynamiques s'écrivent [slug], [...slug], [[...slug]], et params comme searchParams sont des promesses : on écrit await params côté serveur, use() ou useParams() côté client.

Les layouts persistent entre les navigations (état, défilement, lecture en cours conservés) ; template.tsx fait l'inverse. Les fichiers d'état se câblent seuls : loading.tsx devient un Suspense, error.tsx une frontière d'erreur (obligatoirement 'use client', props error et reset), not-found.tsx l'écran de la 404 que déclenche notFound(), global-error.tsx le dernier rempart — pile layout → template → error → loading → not-found → page. Les groupes (nom) organisent sans toucher aux URL et permettent des layouts distincts ; les dossiers _nom sortent du routage. Enfin Link pour les clics, useRouter() pour les navigations déclenchées par du code, redirect() pour les décisions prises côté serveur avant tout rendu.

Et ailleurs : presque tout ce module se transpose. Le routage par fichiers est la convention partagée de Next.js, Remix, SvelteKit, Nuxt et Expo Router : savoir lire une arborescence app/ te rend opérationnel dans n'importe lequel, au vocabulaire près. Les layouts imbriqués et persistants et les fichiers d'état conventionnels (chargement, erreur, introuvable) se retrouvent sous d'autres noms dans tous les frameworks modernes.

Plus profondément, la leçon d'ouverture dépasse Next.js : décider ce qui mérite d'être dans l'URL est une compétence de conception, pas de framework — elle détermine ce qui est partageable, indexable, ajoutable aux favoris, et ce qui survit à un rechargement. Et l'idée d'une garde placée à un point d'entrée unique — ton _layout.tsx de (app) sur mobile, un layout.tsx de groupe ici, un proxy de requête ailleurs — se revoit dans toute l'architecture logicielle, des intercepteurs HTTP aux passerelles d'API.

🗂️ L'aide-mémoire
[x] / [...x] / [[...x]]
exactement un segment / un ou plus / zéro ou plus. Seul le double crochet sert aussi la page d'index
Une valeur de query string
string | string[] | undefined — elle peut manquer ou apparaître plusieurs fois. À valider, toujours
useSearchParams()
rend un URLSearchParams en lecture seule : pour changer un paramètre, on navigue vers la nouvelle URL
D'où vient useRouter
next/navigation, jamais next/router — celui-là est l'API du Pages Router et ne marche pas ici
Les quatre hooks de navigation
useRouter, usePathname, useSearchParams, useParams exigent tous 'use client'
notFound() et redirect()
ne rendent jamais la main : rien ne s'exécute après, et le return est inutile
redirect() dans un try
elle lève une erreur spéciale, donc un catch l'avale — la redirection n'a jamais lieu
global-error.tsx
remplace le layout racine, donc il fournit ses propres <html> et <body>, et il est client