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.
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, 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.
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.tsxrend le segment public en tant que page (du HTML pour un humain) ;route.tsle 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.
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
là 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 :
// 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.
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 :
| Écriture | Nom | Ce 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 :
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 ».
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.
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.
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 :
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.
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.tsx | template.tsx | |
|---|---|---|
| À la navigation interne | Reste monté | Démonté puis remonté |
État local (useState) | Conservé | Remis à zéro |
useEffect de montage | Joué une seule fois | Rejoué à chaque route |
| Défilement, focus | Conservés | Réinitialisés |
| Quand l'utiliser | Par défaut, dans 95 % des cas | Animation 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.
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à.
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.
// 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.
'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 ».
-
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. -
La prop
resets'attache à unonClick. 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 denext/navigation, appelée côté serveur quand la ressource demandée n'existe pas : elle interrompt le rendu — comme unthrow— et déclenche la 404. -
not-found.tsxest 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.
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.
Un collègue soumet ces deux fichiers en revue de code. Qu'est-ce qui ne va pas dans chacun ?
export default async function Page({ params }: { params: Promise<{ service: string }> }) {
const fiche = await chargerService(params.service);
return <h1>{fiche.nom}</h1>;
}
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.
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.
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).
Naviguer : Link, useRouter, redirect
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
Linkentre 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.
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.
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.
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 ».)
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 ? ».
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.
« 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.
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.
[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, toujoursuseSearchParams()- rend un
URLSearchParamsen lecture seule : pour changer un paramètre, on navigue vers la nouvelle URL - D'où vient
useRouter next/navigation, jamaisnext/router— celui-là est l'API du Pages Router et ne marche pas ici- Les quatre hooks de navigation
useRouter,usePathname,useSearchParams,useParamsexigent tous'use client'notFound()etredirect()- ne rendent jamais la main : rien ne s'exécute après, et le
returnest inutile redirect()dans untry- elle lève une erreur spéciale, donc un
catchl'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