Le routage : dossiers, groupes et layouts

Comment une URL devient une page, pourquoi le site, le tableau de bord et la documentation ont chacun leur enveloppe, et comment l'accès au tableau de bord est contrôlé à trois niveaux.

Précédemment. Le projet est installé, vérifié par pnpm check avant chaque commit, et nous savons repérer les erreurs qui ne produisent aucun message. Nous entrons maintenant dans le code, par la question la plus fondamentale : quand une adresse est demandée, quel fichier répond ?

Un dossier, une route#

Dans l'App Router de Next.js, l'arborescence de apps/web/src/app est la carte du site. Il n'existe aucune liste de routes à maintenir : chaque dossier crée un segment d'URL, et quelques fichiers aux noms réservés disent ce que ce segment fait. Ajouter une page revient à créer un dossier et un fichier ; la retrouver, à lire son adresse.

Fichier

Rôle

Dans le projet

page.tsx
Rend une page : sans lui, le segment n’est pas une adresse visitable.
app/(public)/agenda/page.tsx répond à /agenda
layout.tsx
Enveloppe toutes les pages en dessous ; il n’est pas re-rendu quand on navigue entre elles.
app/(public)/layout.tsx : header, ruban des flashs info, footer
route.ts
Un point d’accès HTTP brut (GET, POST…) au lieu d’une page.
app/api/trpc/[trpc]/route.ts
[slug]
Un segment variable, reçu dans params.
/disciplines/tchoy-lee-fut
[...slug]
Un segment qui avale tout le reste du chemin.
/docs/dev/front/routage
sitemap.ts, robots.ts
Du code qui produit des fichiers.
/sitemap.xml, /robots.txt

Le premier niveau du dossier app/ :

  • app/layout.tsx

    le layout RACINE, commun à tout

  • app/(public)

    le site du club

    • layout.tsx

      header, ruban des flashs info, footer

    • page.tsx

      l’accueil, /

    • disciplines/[slug], agenda, gallery, profil, mes-espaces…
  • app/(admin)

    la zone d’administration

    • layout.tsx

      garde SERVEUR : vérifie les rôles

    • dashboard/layout.tsx

      header, barre latérale, zone qui défile

    • dashboard/posts, disciplines, library, users, groups…
  • app/docs

    cette documentation

  • app/api

    les points d’accès HTTP : tRPC, médias, calendrier…

Deux dossiers portent des parenthèses : (public) et (admin).

Les groupes de routes#

Le projet abrite trois mondes aux mises en page opposées.

Le site public est fait de pages longues dont le document entier défile. Un visiteur arrive souvent par un moteur de recherche : la page doit être complète dès le premier octet reçu.

Le tableau de bord est une application : il occupe exactement la hauteur de l'écran, avec un header fixe, une barre latérale, et une zone de contenu qui défile seule.

La documentation a sa propre barre latérale et pas de header du site.

Chacun a donc besoin de son propre layout. Un dossier public/ les séparerait, mais ajouterait /public à chaque adresse. Un groupe de routes, dossier entre parenthèses, range des pages et porte son propre layout sans apparaître dans l'URL :

  • app/(public)/disciplines/[slug]/page.tsx répond à /disciplines/tchoy-lee-fut ;
  • app/(public)/page.tsx répond à / ;
  • app/(admin)/dashboard/posts/page.tsx répond à /dashboard/posts.

Les groupes permettent ainsi d'organiser le code par monde, avec une enveloppe par monde, tout en gardant des adresses courtes et lisibles.

Un nom entre parenthèses n'existe que dans les fichiers. Partout où l'on raisonne en URL (le matcher du proxy, robots.txt, un lien, une redirection), on écrit le chemin sans le groupe : /dashboard, jamais /(admin)/dashboard. Un matcher écrit /(admin)/:path* ne correspondrait à aucune adresse, et le proxy ne s'exécuterait jamais.

Trois niveaux de contrôle#

Le tableau de bord contient des données personnelles. Son accès est contrôlé à trois niveaux, chacun avec un rôle précis.

Le proxy : écarter les visiteurs sans session#

apps/web/src/proxy.ts s'exécute avant les pages. Son matcher déclare les adresses qu'il surveille, écrites en URL réelles :

apps/web/src/proxy.ts · extrait
ts
export const config = {  matcher: [    "/dashboard/:path*",    "/profil/:path*",    "/test-finder/:path*",    "/test-picker/:path*",    "/simpleEditor/:path*",  ],};

Pour /dashboard et /profil, il lit le cookie de session et vérifie que le jeton est correctement signé :

apps/web/src/proxy.ts · extrait
ts
const token = req.cookies.get(COOKIE_NAME)?.value;if (!token || !JWT_SECRET) {  return NextResponse.redirect(new URL("/", origin));}try {  jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });  return NextResponse.next();} catch {  return NextResponse.redirect(new URL("/", origin));}

Il ne consulte pas la base, volontairement : il s'exécute à chaque requête et doit répondre instantanément. Il renvoie donc à l'accueil tout visiteur sans jeton valide, mais ne connaît ni les rôles ni l'état de la session en base. Ce n'est pas son travail.

Les trois dernières entrées du matcher concernent les bancs d'essai du développement (test-finder, test-picker, simpleEditor), des pages qui montent un composant seul le temps de le mettre au point :

apps/web/src/proxy.ts · extrait
ts
const DEV_ONLY = ["/test-finder", "/test-picker", "/simpleEditor"];export async function proxy(req: NextRequest) {  const { origin, pathname } = req.nextUrl;  if (DEV_ONLY.some((p) => pathname === p || pathname.startsWith(`${p}/`))) {    return process.env.NODE_ENV === "production"      ? new NextResponse("Not Found", { status: 404 })      : NextResponse.next();  }  // … vérification du jeton pour /dashboard et /profil}

Ils restent disponibles en développement et répondent 404 en production, comme une adresse inexistante. Ajouter un banc d'essai demande deux lignes : une entrée dans DEV_ONLY, une dans le matcher.

Le layout serveur : vérifier les rôles#

Le dossier (admin) porte un layout serveur, app/(admin)/layout.tsx. Il s'exécute avant tout rendu du tableau de bord et a le droit de lire la base :

apps/web/src/app/(admin)/layout.tsx · extrait
tsx
export default async function AdminZoneLayout({ children }: { children: ReactNode }) {  const session = await getSessionFromRequest().catch(() => null);  const user = session?.user;  if (!user) redirect("/");  if (!user.isAdmin) {    const categories = await handledCategoryIds(prisma, user.id).catch(() => []);    const handlesTickets = categories === null || categories.length > 0;    if (!handlesTickets) redirect("/");  }  return children;}

Il laisse passer deux profils : les administrateurs, et les membres qui traitent les demandes d'au moins une catégorie de tickets (leur boîte de réception est dans le tableau de bord). handledCategoryIds est la fonction du module des tickets : la règle « qui traite quoi » n'est écrite qu'une fois dans le projet. Tout autre membre est renvoyé à l'accueil avant qu'une seule ligne du tableau de bord ne soit rendue.

tRPC : protéger les données#

Chaque procédure d'administration passe par le middleware isAdmin, qui relit la session et refuse toute requête non autorisée :

packages/backend/src/trpc/middleware.ts
export const isAdmin = t.middleware(({ ctx, next }) => {  const user = ctx.sessionClient?.user;  if (!user) {    throw new TRPCError({ code: "UNAUTHORIZED", message: "Connexion requise." });  }  if (!user.isAdmin) {    throw new TRPCError({ code: "FORBIDDEN", message: "Action réservée aux administrateurs." });  }  return next();});

C'est lui qui a le dernier mot : même une requête qui contournerait l'interface n'obtiendrait aucune donnée.

Défense en profondeur : le proxy écarte les visiteurs sans session, le layout serveur vérifie les rôles avant d'afficher l'interface, tRPC protège les données. Chaque niveau reste correct même si le précédent cédait.

Pourquoi la vérification des rôles vit-elle dans un layout à part, au-dessus de celui du tableau de bord ? La réponse tient à la nature de chaque layout.

Le layout racine#

app/layout.tsx enveloppe tout : le site, le tableau de bord, la documentation. Chaque ligne qu'on y ajoute s'exécute pour chaque page. Il ne contient donc que l'universel, et il s'exécute côté serveur.

Il lit dans la base le titre et la description du site, réglés par les administrateurs :

apps/web/src/app/layout.tsx · extrait
tsx
export async function generateMetadata(): Promise<Metadata> {  const settings = await prisma.siteSettings    .findUnique({ where: { id: "site" }, select: { shortTitle: true, longTitle: true, tagline: true } })    .catch(() => null);  const shortTitle = settings?.shortTitle?.trim() || "AKFC";  // …}

Le .catch(() => null) permet au site de rester affichable avec un titre par défaut si la base est indisponible : une balise <title> ne doit jamais provoquer une page d'erreur.

Il injecte aussi le style réglable du site, des variables CSS lues en base et sérialisées en html:root{…}. Ce sélecteur est plus spécifique que :root : la surcharge l'emporte quel que soit l'ordre dans lequel Next assemble les feuilles. Il pose la classe dark si le cookie du visiteur le demande, ce qui permet d'envoyer directement le bon thème sans changement de couleur au chargement. Il monte les fournisseurs communs : tRPC, React Query, le moteur des tutoriels. Et il lit la session du visiteur, pour une raison détaillée plus bas.

Comme il dépend de la base et des cookies à chaque requête, il déclare export const dynamic = 'force-dynamic' : son rendu est calculé à chaque visite.

Le layout du site public#

app/(public)/layout.tsx est un composant serveur. Il lit la base avant d'envoyer la page :

apps/web/src/app/(public)/layout.tsx · extrait
tsx
export default async function PublicLayout({ children }: { children: React.ReactNode }) {  const now = new Date();  const activeNews = await prisma.breakingNews.findMany({    where: {      publicationDate: { not: null, lte: now },      OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],    },    orderBy: { publicationDate: "desc" },    take: 20,  });  // … logo et nom du site, lus de la même façon  return (    <div className="flex min-h-dvh flex-col bg-background">      <div className="sticky top-0 z-50 bg-background">        <Header initialLogoUrl={initialLogoUrl} initialBrand={initialBrand} />        <FirstLoginRedirect />      </div>      <BreakingNewsShell news={activeNews} />      <main className="flex-1">{children}</main>      <Footer />      <DraftPageBadge />    </div>  );}

Le ruban des flashs info arrive ainsi dans le HTML initial : la page s'affiche complète, sans bandeau qui apparaîtrait ensuite en poussant le contenu vers le bas. Le logo suit la même règle : lu côté serveur, passé au Header comme valeur initiale, présent dès la première image. Le header le relit ensuite dans le navigateur, si bien qu'un nouveau logo déposé dans les Réglages apparaît sans recharger.

La mise en page laisse défiler le document. min-h-dvh garde le footer en bas d'une page courte. Le header est collant ; le ruban, placé sous le bloc collant, défile avec la page au lieu d'occuper de la hauteur en permanence.

Le principe : ce que le serveur peut savoir avant d'envoyer la page, il le met dans la page. Le navigateur n'a pas à redemander ce que le serveur connaissait déjà.

Le layout du tableau de bord#

app/(admin)/dashboard/layout.tsx commence par 'use client'. Il gère un état (l'ouverture du tiroir de navigation sur téléphone) et réagit au clavier, ce qu'un composant serveur ne sait pas faire. Un composant client, en revanche, ne lit pas la base : c'est pourquoi la vérification des rôles se fait un niveau au-dessus, dans le layout serveur de (admin). Le serveur décide, le client affiche.

La mise en page est celle d'une application : hauteur fixe, h-dvh, et c'est la zone de contenu qui défile :

apps/web/src/app/(admin)/dashboard/layout.tsx · extrait
tsx
<main className="relative min-h-0 flex-1 overflow-y-auto p-4 lg:p-10">  {children}</main>

min-h-0 autorise l'élément flex à être plus petit que son contenu : une liste de 300 membres défile dans le <main>, et le header comme la barre latérale restent en place. La bibliothèque de fichiers, qui gère son propre défilement comme un explorateur de fichiers, se borne elle-même (h-full overflow-hidden). La règle : le cas général défile, le cas spécial se borne.

Fermer le tiroir à la navigation#

Sur téléphone, quand l'administrateur choisit une page, le tiroir doit se fermer. Le layout ajuste l'état pendant le rendu :

apps/web/src/app/(admin)/dashboard/layout.tsx · extrait
tsx
const [lastPath, setLastPath] = useState(pathname);if (pathname !== lastPath) {  setLastPath(pathname);  setSidebarOpen(false);}

Quand React rencontre un setState en plein rendu, il recommence aussitôt le rendu avec le nouvel état, avant d'afficher quoi que ce soit. La nouvelle page apparaît directement tiroir fermé. Un useEffect, qui s'exécute après l'affichage, montrerait d'abord la page tiroir ouvert, puis la redessinerait.

Les pages et l'API#

Les deux groupes ne sont pas faits de la même matière :

Groupe

Pages

Dont « use client »

Pourquoi

(public)
19
5
Lecture seule, référencement, premier affichage complet. Les 5 pages client sont réservées aux membres connectés.
(admin)/dashboard
58
28
Formulaires, mutations, listes à rafraîchir. Les autres sont de minces pages serveur qui montent un composant client.

Une page publique lit la base elle-même. Celle d'une discipline :

apps/web/src/app/(public)/disciplines/[slug]/page.tsx · extrait
tsx
export default async function PublicDisciplinePage({  params,}: {  params: Promise<{ slug: string }>;}) {  const { slug } = await params;  const discipline = await prisma.discipline.findUnique({ where: { slug }, /* … */ });  if (!discipline) notFound();  // Brouillon (ou publication programmée) : invisible du public.  if (!discipline.publicationDate || discipline.publicationDate > new Date()) {    notFound();  }  // …}

params est une promesse depuis Next 15, ce qui permet à Next de commencer le rendu avant d'avoir résolu les paramètres ; il faut donc l'attendre avec await. Un brouillon répond 404, exactement comme une page inexistante : rien ne révèle qu'une discipline est en préparation.

Toutes les procédures tRPC passent par un unique fichier de route :

apps/web/src/app/api/trpc/[trpc]/route.ts
ts
const handler = async (req: NextRequest): Promise<Response> => {  return fetchRequestHandler({    endpoint: "/api/trpc",    req,    router: appRouter,    createContext: async () => createTRPCContext({ req }),  })}export { handler as GET, handler as POST }

Le segment [trpc] reçoit le nom de la procédure (post.create, discipline.getAll…) et l'aiguille vers le bon routeur de packages/backend. Le front n'écrit jamais d'URL : il appelle des fonctions typées. La partie consacrée à l'API détaillera ce mécanisme.

La session dès le premier affichage#

Revenons à la session lue par le layout racine.

Côté navigateur, la session vit dans un store Zustand. Ce store est un module : sur le serveur, il serait partagé par toutes les requêtes de tous les visiteurs. Il reste donc vide pendant le rendu serveur, et ne peut pas servir à produire le HTML.

Le layout racine lit la session lui-même et la transmet par un contexte React, propre à chaque requête :

apps/web/src/app/layout.tsx · extrait
tsx
const initialSession = await getSessionFromRequest().catch(() => null);// …<SessionLoader initialSession={initialSession}>{children}</SessionLoader>

Un hook choisit la bonne source selon le moment :

apps/web/src/features/auth/InitialSession.tsx · extrait
tsx
export function useCurrentUser() {  const initial = useContext(InitialSessionContext);  const status = useSessionStore((s) => s.status);  const user = useSessionStore((s) => s.session?.user ?? null);  return status === "idle" ? (initial?.user ?? null) : user;}

Tant que le store n'est pas prêt (sur le serveur et au premier rendu dans le navigateur), la valeur du serveur fait foi : le HTML et le premier rendu client sont identiques, et le header affiche d'emblée le bon état, connecté ou non. SessionLoader remplit ensuite le store avec cette même session, sans nouvelle requête, et le store prend le relais pour suivre les connexions et déconnexions.

Pour savoir qui est connecté dans un composant, on appelle useCurrentUser(), pas le store directement : c'est lui qui garantit un premier affichage juste.

À suivre#

Trois décisions de ce chapitre reposent sur la même distinction. Le proxy ne vérifie pas les rôles parce qu'il n'a pas accès à la base. La vérification des rôles vit au-dessus du tableau de bord parce que celui-ci est un composant client. Le header lit une session fournie par le serveur parce que le store client y est vide.

D'un côté, du code qui s'exécute sur le serveur, lit la base et connaît le visiteur. De l'autre, du code qui s'exécute dans le navigateur, réagit aux clics, et ne sait que ce qu'on lui transmet. Cette frontière traverse chaque fichier du projet, et elle se déclare d'un seul mot en première ligne : 'use client'.

Ce mot a des conséquences que l'on ne soupçonne pas en le tapant. Tout ce qu'un composant serveur transmet à un composant client est sérialisé dans la page : un objet complet passé en propriété, avec un champ de trop, se retrouve lisible par quiconque affiche la source.

Chapitre suivant : la frontière serveur / client. Ce que 'use client' change réellement, ce qui peut franchir la frontière et sous quelle forme, comment un composant serveur peut contenir un composant client, et comment le projet s'assure qu'aucune donnée sensible ne la traverse.

Sur cette page