Module 4 · Construire

Route Handlers & Server Actions

Jusqu'ici, dans toute ta vie de développeur, tu as consommé des API : tu envoyais des requêtes à Supabase et tu affichais ce qui revenait. Ce module est celui où tu passes de l'autre côté du comptoir. Next.js te donne deux façons d'écrire du code qui s'exécute sur le serveur, dans le même projet que tes composants : les Route Handlers, qui exposent une vraie URL d'API, et les Server Actions, qui branchent une fonction serveur directement sur un formulaire. Deux outils, deux usages, et une question d'entrevue quasi garantie en 2026 : « comment choisis-tu entre les deux ? »

Ton frontend a un serveur

Mesure la bascule mentale, elle est plus grande qu'elle n'en a l'air. Dans ton app mobile Halterofit, l'architecture est nette : d'un côté ton code, sur le téléphone de l'utilisateur ; de l'autre Supabase, un serveur que tu n'as pas écrit et à qui tu parles en HTTP. La frontière est physique et organisationnelle — le « backend » est le produit de quelqu'un d'autre, tu es un client au sens strict.

Une application Next.js casse cette séparation : elle est fullstack par construction. Une partie de ton code s'exécute dans le navigateur, une autre sur un serveur Node, et les deux vivent dans le même dossier, le même dépôt Git, le même package.json. Tu n'as pas deux projets qui se parlent par-dessus un réseau : tu as un projet qui a deux moitiés. Le réseau n'a pas disparu, mais il ne sépare plus deux équipes, il sépare deux parties de ton fichier.

Ce module ajoute la dernière pièce. Le module sur le rendu et le data fetching t'a appris à lire depuis le serveur ; mais une application réelle doit aussi écrire : créer un rendez-vous, annuler une réservation, mettre à jour un profil. L'écriture — la mutation, dans le vocabulaire du métier — pose des questions que la lecture ne pose pas. Qui a le droit ? Les données envoyées sont-elles valides ? Que faire si l'opération échoue à mi-chemin ? Comment rafraîchir l'écran après coup ? Next.js répond avec deux mécanismes.

💡 Le concept

Un Route Handler est une URL que tu exposes : n'importe quel client — ton propre navigateur, une app mobile, un service tiers — peut l'appeler en HTTP. Une Server Action est une fonction serveur que tu appelles depuis ton interface : c'est Next.js qui fabrique la requête HTTP pour toi, en coulisses, et tu n'écris jamais l'URL. Le premier expose un contrat public ; la seconde raccourcit un aller-retour interne. Garde cette phrase, c'est déjà la moitié de la réponse d'entrevue de la section « Route Handler ou Server Action ? Le tableau de décision ».

Pourquoi ça compte pour un stagiaire frontend ? Parce que la frontière du métier a bougé : une offre « développeur frontend Next.js » suppose presque toujours que tu saches écrire une route d'API simple, valider ce qui arrive et répondre avec le bon statut. Il ne s'agit pas de devenir architecte de bases de données, mais d'écrire les trente lignes de serveur dont ta page a besoin sans mendier un endpoint à l'équipe backend. En entrevue, c'est exactement la différence entre « je fais des composants » et « je livre des fonctionnalités ».

Le Route Handler : une URL, un fichier, des fonctions nommées

Commençons par le mécanisme le plus proche de ce que tu connais déjà, parce qu'il ne fait que te placer de l'autre côté de la conversation du module HTTP & fetch. Un Route Handler, c'est un fichier nommé route.ts posé dans un segment du dossier app/. Le chemin du dossier devient l'URL ; le fichier contient les fonctions qui répondent.

app/
├─ page.tsx                          → la page   /
├─ reserver/
│  └─ page.tsx                       → la page   /reserver
└─ api/
   ├─ services/
   │  └─ route.ts                    → l'API     /api/services
   └─ appointments/
      ├─ route.ts                    → l'API     /api/appointments
      └─ [id]/
         └─ route.ts                 → l'API     /api/appointments/42

Même convention de routage par dossiers que dans le module sur le routing — un page.tsx produit une page HTML, un route.ts produit une réponse HTTP brute. Le dossier api/ n'a rien de magique, c'est une simple convention de lisibilité.

Le cœur de la convention vaut d'être savouré : le nom de la fonction exportée EST la méthode HTTP. Tu exportes GET, elle répond aux requêtes GET ; tu exportes POST, elle répond aux POST. Les noms reconnus sont les verbes du module HTTP & fetch : GET, POST, PUT, PATCH, DELETE, plus HEAD et OPTIONS. Aucun routeur à configurer, aucun switch (method) : le tableau de routes devient littéralement une liste d'export.

app/api/services/route.ts
import { listerServices } from '@/lib/services';

// Le NOM de la fonction exportée est le verbe HTTP auquel elle répond.
// Ici : GET /api/services
export async function GET() {
  const services = await listerServices();   // requête base de données, côté serveur

  // Response.json() fabrique une réponse : il sérialise l'objet en JSON
  // ET pose l'en-tête Content-Type: application/json pour toi.
  // Sans second argument, le statut est 200.
  return Response.json(services);
}

Six lignes utiles, et l'URL /api/services existe. C'est exactement la réponse 200 OK que tu décodais dans le module HTTP & fetch — sauf que cette fois, c'est toi qui l'écris.

Regarde les deux types en jeu, familiers et c'est volontaire : un Route Handler reçoit une Request et renvoie une Response — les objets standard de la plateforme web, les mêmes que ceux que fetch manipule côté navigateur. Quand tu écrivais const res = await fetch(...) puis res.status, tu tenais un objet Response ; ici tu en fabriques un. Next.js n'a pas inventé un format maison, ce que tu apprends ici te resservira dans un Cloudflare Worker, une Edge Function Supabase ou un serveur Deno.

Le statut se choisit avec le second argument, et c'est ta responsabilité éditoriale : tu décides désormais du verdict en trois chiffres. Rappelle-toi le panthéon du module HTTP & fetch — 200 pour une lecture réussie, 201 pour une création, 400 pour une requête mal formée, 404 pour un introuvable, 409 pour un conflit — car ce tableau n'est plus une grille de lecture, c'est une grille d'écriture.

// Le second argument porte le statut et, si besoin, des en-têtes.
return Response.json({ error: 'not_found' }, { status: 404 });

// Response.json est un raccourci. L'équivalent long, utile quand
// tu renvoies autre chose que du JSON (du texte, un fichier, rien) :
return new Response('Créneau indisponible', { status: 409 });

// Une réponse sans corps du tout (ex. après un DELETE réussi) : 204 No Content.
return new Response(null, { status: 204 });

Response.json(data, { status }) couvre 90 % des cas. Next.js fournit aussi NextResponse, une sous-classe qui ajoute quelques commodités (cookies, redirections) ; commence par la version standard, tu ajouteras NextResponse quand un besoin précis l'exigera.

🧭 Une règle à connaître : jamais les deux

Un même segment ne peut pas contenir à la fois un page.tsx et un route.ts : les deux prétendraient répondre à la même URL, l'un avec du HTML, l'autre avec du JSON, et Next.js n'a aucun moyen de trancher. L'erreur de build « conflicting route and page », c'est ça — correctif : déplacer l'API dans son propre segment (/reserver pour la page, /api/appointments pour l'API).

🔗 Pont — les Edge Functions de Supabase font exactement ça

Tu as déjà croisé ce modèle sans l'appeler par son nom. Une Edge Function Supabase (revois Supabase : le backend) est un fichier qui reçoit une Request, exécute du code serveur, et renvoie une Response — littéralement la même signature que le Route Handler ci-dessus. Même modèle mental, même API Web standard, deux hébergeurs différents : dans un cas le code vit chez Supabase et s'exécute à côté de ta base ; dans l'autre il vit dans ton projet Next.js et se déploie avec ton frontend. Comprendre l'un t'a donc déjà à moitié appris l'autre — et la question d'architecture « où poser cette logique ? » devient une vraie question de conception, pas un mystère technique.

🧠 Quiz éclair

Le fichier est au bon endroit, la fonction est bien exportée, tout compile. Et pourtant GET /api/services répond 405 Method Not Allowed — pas un 404, un 405. Que manque-t-il ?

// app/api/services/route.ts
import { listerServices } from '@/lib/services';

export async function handler(request: Request) {
  const services = await listerServices();
  return Response.json(services);
}

Le nom. Dans un route.ts, Next.js ne cherche pas « la fonction exportée » : il cherche une fonction dont le nom est le verbe HTTP — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. handler n'en est aucun : c'est un export nommé ordinaire, que personne n'appelle. Et le statut te le dit précisément : le segment existe — il y a bien un route.ts — donc ce n'est pas un 404 ; il ne sert simplement aucune méthode, d'où le 405. Renomme la fonction GET et c'est réglé. Retiens la lecture inverse : un fichier qui exporte GET et DELETE sert exactement ces deux verbes et répond 405 à tous les autres — la table des routes est littéralement la liste des export.

POST, ou les quatre gestes d'une mutation

Une route de lecture est facile. Une route d'écriture, c'est là que le métier commence, parce qu'elle doit se méfier. Le corps qui arrive vient du monde extérieur : il peut être incomplet, mal typé, hostile. Et la réponse doit dire la vérité — pas juste « 200, ça s'est bien passé » par réflexe.

Toute mutation honnête se déroule en quatre gestes, toujours dans le même ordre : lire le corps, valider son contenu, agir (c'est-à-dire faire le vrai travail), répondre avec le bon statut. Cet ordre n'est pas décoratif : valider avant de lire est impossible, agir avant de valider est un bug de sécurité, et répondre sans distinguer les cas est ce qui rend une API inutilisable pour celui qui la consomme.

Premier geste : lire. Le corps s'obtient avec await request.json() — la même méthode que res.json() côté client, et pour la même raison : le corps arrive en flux, donc c'est une Promesse. La symétrie est complète, celui qui envoie fait JSON.stringify, celui qui reçoit fait request.json(). Deuxième geste : valider, et tu as déjà l'outil — le validateur pur du module Formulaires 2, une fonction qui prend les données, ne touche à rien et rend un objet d'erreurs par champ. Une fonction pure ignore si elle tourne dans un navigateur ou sur un serveur ; tu appelles donc la même fonction, importée du même fichier, dans ton handler. Ce n'est pas un hasard heureux, c'est précisément le bénéfice qu'on cherchait en la gardant pure.

Troisième geste : agir, en déléguant à une fonction métier — la section « Où mettre la logique : le handler traduit, la fonction métier travaille » dira pourquoi c'est un principe et pas un caprice. Quatrième geste : répondre, avec le statut qui décrit honnêtement ce qui vient de se passer. Les quatre gestes assemblés, sur la vraie route de réservation de RendezVous :

app/api/appointments/route.ts
import { validerReservation } from '@/lib/validation';
import { creerRendezVous, SlotTakenError } from '@/lib/appointments';

export async function POST(request: Request) {
  // 1. LIRE — request.json() rend une Promesse, comme res.json() côté client.
  const body = await request.json();

  // 2. VALIDER — le validateur PUR de Formulaires 2, réutilisé tel quel ici.
  const errors = validerReservation(body);
  if (Object.keys(errors).length > 0) {
    // 400 : "ta requête est mal formée" — on renvoie le détail par champ,
    // pour que le client puisse afficher l'erreur sous le bon input.
    return Response.json({ errors }, { status: 400 });
  }

  try {
    // 3. AGIR — la fonction métier ne connaît rien à HTTP.
    const rdv = await creerRendezVous(body);
    // 4. RÉPONDRE — 201 Created, avec la ressource créée dans le corps.
    return Response.json(rdv, { status: 201 });
  } catch (err) {
    // Un conflit métier n'est PAS un plantage : c'est un 409 assumé.
    if (err instanceof SlotTakenError) {
      return Response.json(
        { error: 'slot_taken', message: 'Ce créneau vient d\'être réservé.' },
        { status: 409 },
      );
    }
    throw err;   // inconnu : on laisse remonter, Next.js répondra 500 et loguera
  }
}

Relis le corrigé de l'exercice 409 du module HTTP & fetch : tu décodais alors cette réponse depuis le siège du client. Tu viens d'écrire le serveur qui l'émet.

Deux détails méritent qu'on s'y arrête. D'abord, le corps d'erreur du 400 n'est pas une chaîne vague mais un objet structuré par champ : c'est ce qui permet au formulaire d'afficher « la date est obligatoire » sous l'input date plutôt qu'un bandeau rouge générique. Une API qui renvoie { "error": "invalid" } oblige le frontend à deviner ; { "errors": { "date": "..." } } lui donne de quoi travailler.

Ensuite, la distinction entre 409 et 500. Un créneau déjà pris n'est pas un bug : c'est une situation métier normale qui mérite un statut précis et un message lisible. Un plantage inattendu, lui, doit remonter — la maxime « 4xx ta faute, 5xx la mienne » appliquée depuis le siège du conducteur : avaler une exception inconnue pour renvoyer un joli 200 est la façon la plus efficace de rendre un bug indébogable. D'où le throw err final, qui n'est pas de la négligence : Next.js l'attrape, le journalise côté serveur avec sa trace complète (invisible du client) et répond 500. Tu as géré ce que tu comprends, et tu es honnête sur le reste.

📖 La formule

Le handler POST honnête

export async function POST(request: Request) {
  const body = await request.json();
  const errors = validate(body);
  if (Object.keys(errors).length) return Response.json({ errors }, { status: 400 });
  const created = await creerRendezVous(body);
  return Response.json(created, { status: 201 });
}

Lire, valider, agir, répondre avec le bon statut — les quatre gestes, toujours dans cet ordre. Le jour où tu vois un handler qui agit avant de valider, ou qui répond 200 à une création, tu tiens le bug avant même d'avoir lu le reste du fichier.

🔗 Pont — le validateur pur, enfin rentabilisé

Dans le module Formulaires 2, on a insisté pour que validerReservation soit une fonction pure : pas d'accès au DOM, pas de setState, pas de fetch — que des données en entrée et un objet d'erreurs en sortie. On disait alors « c'est plus facile à tester ». Voici la vraie récompense : parce qu'elle ne dépend de rien d'environnemental, cette fonction s'exécute aussi bien dans le navigateur que dans le Route Handler. Une seule définition des règles, deux lieux d'exécution. Côté client, elle sert le confort (feedback immédiat, avant même l'envoi) ; côté serveur, elle sert la sécurité (personne ne contourne la règle en désactivant JavaScript ou en appelant l'URL avec curl). Même code, deux missions — et c'est la raison profonde pour laquelle on n'a jamais accepté d'y glisser un document.querySelector.

✍️ Exercice de lecture

Un collègue soumet ce Route Handler en revue de code. Le formulaire de RendezVous l'appelle, et « ça a l'air de marcher » en développement :

export async function POST(request: Request) {
  const body = await request.json();
  try {
    const rdv = await creerRendezVous(body);
    return Response.json({ ok: true, rdv });
  } catch (err) {
    return Response.json({ ok: false, message: 'Erreur' });
  }
}

Questions : (1) Quel statut renvoie ce handler quand la création réussit, et quel statut aurait-il fallu ? (2) Quel statut renvoie-t-il quand la création échoue, et pourquoi est-ce beaucoup plus grave ? (3) Quel geste de la formule manque complètement ? (4) Que va faire le code client écrit avec le « fetch honnête » du module HTTP & fetch, face à cette réponse ?

Voir le corrigé

(1) Il renvoie 200Response.json sans second argument met 200 par défaut. Pour une création, la convention est 201 Created : ce n'est pas du purisme, 201 dit « une nouvelle ressource existe maintenant », information différente de « ta demande a été traitée », et les outils comme les clients tiers s'appuient dessus.

(2) Il renvoie 200 aussi, puisque le catch répond avec un Response.json sans statut. C'est le vrai défaut du code : une création qui a raté est annoncée comme un succès HTTP, le seul indice étant un ok: false enfoui dans le corps — un protocole maison qui double et contredit le protocole standard. Il fallait 409 pour un créneau déjà pris, ou laisser remonter en 500.

(3) La validation. Le corps part directement dans creerRendezVous sans aucune vérification : date au mauvais format, serviceId inexistant, champ manquant ou charge envoyée par un curl malveillant atterrissent dans la fonction métier. Résultat probable : une exception, donc un « 200 avec ok:false », donc un utilisateur qui ne comprend rien.

(4) Rien du tout — et c'est le pire. res.ok vaut true (200 est dans la plage 2xx), donc le if (!res.ok) throw ne se déclenche jamais. Le client affichera « Rendez-vous confirmé ! » alors que rien n'a été créé. Le fetch honnête ne protège que si le serveur est honnête sur ses statuts : les deux formules sont les deux moitiés d'un même contrat.

Paramètres dynamiques et query string

Une API sérieuse a des routes qui parlent d'une ressource : /api/appointments/42. Rien de neuf ici — les segments dynamiques [id], le second argument de contexte et le await params obligatoire (avec le piège du undefined silencieux) sont traités en détail dans le module Routing : segments, layouts, navigation ; la règle est strictement la même pour un Route Handler que pour une page.

app/api/appointments/[id]/route.ts
import { lireRendezVous, cancelAppointment } from '@/lib/appointments';

// Le contexte : params est une PROMESSE d'objet, pas un objet.
type Contexte = { params: Promise<{ id: string }> };

export async function GET(request: Request, { params }: Contexte) {
  const { id } = await params;              // ⚠️ le await est OBLIGATOIRE

  const rdv = await lireRendezVous(id);
  if (!rdv) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(rdv);
}

// Même fichier, autre verbe : DELETE /api/appointments/42
export async function DELETE(request: Request, { params }: Contexte) {
  const { id } = await params;
  await cancelAppointment(id);
  return new Response(null, { status: 204 });   // 204 : succès, pas de corps
}

Deux verbes, un fichier, une URL. Le nom du dossier [id] devient la clé dans params : renomme le dossier en [appointmentId] et c'est params.appointmentId qu'il faudra lire.

Et la query string — cette partie de l'URL après le ? que tu sais lire depuis le module HTTP & fetch ? Elle n'arrive pas dans params, et c'est normal : elle ne fait pas partie du chemin, elle le précise. Elle vit dans l'URL de la requête, et on la lit avec un outil standard du web, l'objet URL :

export async function GET(request: Request) {
  // request.url est la chaîne complète ; new URL(...) la découpe pour toi.
  const { searchParams } = new URL(request.url);

  // .get() rend une chaîne, ou null si le paramètre est absent.
  const service = searchParams.get('service');        // 'bone-scan' | null
  const jour = searchParams.get('date') ?? aujourdhui();

  // ⚠️ TOUT ce qui vient de l'URL est une CHAÎNE, et vient du client.
  // Un "?limit=abc" ou "?limit=999999" est parfaitement possible :
  // on convertit ET on borne, on ne fait jamais confiance.
  const limite = Math.min(Number(searchParams.get('limit')) || 20, 100);

  const slots = await listerCreneaux({ service, jour, limite });
  return Response.json(slots);
}

searchParams est un URLSearchParams — la même classe que celle disponible dans le navigateur. Encore une fois : API Web standard, pas invention Next.js.

Le commentaire sur limit n'est pas anodin : la query string est du texte fourni par le client, au même titre que le corps d'un POST. Un ?limit=1000000 tapé dans la barre d'adresse est une requête parfaitement valide du point de vue de HTTP — et si ton handler le passe tel quel à la base, tu viens d'offrir un bouton « ralentis mon serveur » à n'importe quel visiteur. Convertir, borner, se méfier : le réflexe de la validation du corps, appliqué à l'URL.

🧠 Quiz éclair

En production, GET /api/appointments/42 renvoie 404. Et /57 aussi. Et tous les autres, y compris les rendez-vous qui existent bel et bien en base. Aucun plantage, aucune trace dans les journaux. Où est le bug, et pourquoi est-il si long à trouver ?

// app/api/appointments/[id]/route.ts
type Contexte = { params: Promise<{ id: string }> };

export async function GET(request: Request, { params }: Contexte) {
  const rdv = await lireRendezVous(params.id);
  if (!rdv) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(rdv);
}

Il manque l'await : const { id } = await params;. Le type le dit pourtant — params est une Promesse, et une promesse n'a pas de propriété id. params.id vaut donc undefined, lireRendezVous(undefined) ne trouve rien… et le handler fait alors honnêtement son travail : 404.

Voilà la vraie leçon, et elle vaut pour tous les undefined silencieux : le bug emprunte le chemin d'erreur légitime. Pas d'exception, pas de trace, un statut parfaitement plausible — rien n'a l'air cassé, et tu passes une heure dans la couche données à te demander pourquoi elle ne retrouve plus rien. Note au passage ce que rapporte le fait d'avoir typé le contexte au lieu de mettre any : c'est exactement ce qui permet à TypeScript de refuser ce code avant qu'il ne parte en production.

Où mettre la logique : le handler traduit, la fonction métier travaille

Tous les handlers de ce module appellent des fonctions (listerServices, creerRendezVous, cancelAppointment) sans jamais montrer leur contenu. Ce n'est pas de l'esquive pédagogique, c'est la règle d'architecture du module : le handler traduit du HTTP, la fonction métier fait le travail. Le handler est un traducteur de frontière dont la seule compétence est HTTP — lire un corps, extraire des paramètres, choisir un statut, poser des en-têtes ; il ne sait rien du métier des rendez-vous. Inversement, creerRendezVous ne sait rien de HTTP : elle reçoit des données propres, vérifie les règles du domaine, écrit en base, lève une exception métier si le créneau est pris, et ignore jusqu'à l'existence des codes de statut.

Pourquoi séparer, alors que tout tiendrait dans un seul fichier ? Trois raisons, par ordre d'importance croissante.

  • La testabilité. Tester une fonction quasi-pure, c'est l'appeler avec des arguments et regarder ce qui sort ; tester un handler, c'est fabriquer une Request, l'invoquer, puis désassembler une Response. Faisable — on le fera dans le module Tester le web — mais chaque règle métier testée à travers HTTP coûte cinq fois plus cher et se casse au moindre changement de format.
  • La réutilisation. La même logique « créer un rendez-vous » sera appelée depuis le Route Handler et depuis la Server Action de la section « Les Server Actions : la deuxième voie » : deux points d'entrée, une seule implémentation des règles. Si « pas plus de deux rendez-vous par jour et par patient » vit dans le handler, la Server Action ne l'applique pas — une faille par duplication oubliée.
  • La lisibilité en revue. Un handler de quinze lignes se relit d'un coup d'œil : on voit la séquence lire/valider/agir/répondre et on peut juger. Un handler de quarante lignes qui ouvre une transaction, calcule des disponibilités, envoie un email et formate une réponse est un signal d'alarme en revue — non parce que quarante serait un chiffre magique, mais parce qu'à cette longueur il mélange forcément deux niveaux de préoccupation.
app/api/appointments/route.ts   ← 15 lignes : HTTP, rien que HTTP
lib/appointments.ts             ← les règles métier, testables seules
lib/validation.ts               ← les validateurs purs, partagés client/serveur

Trois fichiers, trois responsabilités. Le dossier lib/ n'est pas une convention Next.js imposée — c'est juste le nom que la communauté a adopté pour « le code qui n'est ni une page ni une route ».

📅 Dans RendezVous

La règle métier la plus délicate de l'app est « ce créneau est-il encore libre ? » : elle tient compte des rendez-vous existants, de la durée du service, des heures d'ouverture et des jours fériés, avec des cas limites (un examen de 45 minutes ne rentre pas dans le trou de 30 minutes de 16h30). Cette logique vit dans lib/slots.ts, se teste avec une trentaine de cas sans jamais démarrer de serveur, et elle est appelée de trois endroits : le Server Component qui affiche le calendrier, le Route Handler GET /api/slots que consommera la future app mobile, et la Server Action de réservation qui revérifie au dernier moment. Un seul endroit où corriger un bug de calcul.

Les Server Actions : la deuxième voie

Deuxième mécanisme, et changement de philosophie. Reprends le trajet complet d'une réservation via Route Handler : un onSubmit client intercepte l'envoi, appelle preventDefault(), rassemble l'état des champs, construit un objet, le passe à JSON.stringify, appelle fetch avec la bonne URL et le bon en-tête, vérifie res.ok, parse la réponse, met à jour l'état d'erreur. Ça marche, mais c'est beaucoup de plomberie pour dire « enregistre ça » — et les deux moitiés de ce travail, sérialisation et désérialisation du même JSON, sont dans ton projet, écrites par toi.

Les Server Actions suppriment cette plomberie. Une Server Action est une fonction asynchrone marquée par la directive 'use server', placée soit en tête de fichier (tout le fichier devient un module de Server Actions), soit en tête de la fonction elle-même. Cette directive dit à Next.js : « cette fonction s'exécute uniquement sur le serveur, mais elle peut être référencée depuis le client ». Le compilateur remplace alors, côté client, la fonction par une référence, et fabrique automatiquement l'aller-retour HTTP. Tu écris un appel de fonction ; Next.js écrit la requête.

💡 Ce que 'use server' veut dire (et ne veut pas dire)

Le module sur les Server et Client Components t'a présenté 'use client'. Les deux directives ne sont pas symétriques, et c'est une confusion très fréquente. 'use client' marque une frontière de bundle : « à partir d'ici, ce code part aussi dans le navigateur ». 'use server' ne marque pas « ce code est serveur » — dans l'App Router, tout est serveur par défaut, ça n'aurait aucun intérêt. Il marque un point d'entrée exposé : « cette fonction peut être déclenchée depuis le client, donc Next.js lui crée un endpoint ». Retiens ce mot, point d'entrée : toute la section « Sécurité, sobrement » sur la sécurité en découle.

Voici la Server Action de réservation de RendezVous. Compte les fetch : il n'y en a aucun.

app/actions/reserver.ts
'use server';   // en tête de fichier : tout ce module est un point d'entrée serveur

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { validerReservation } from '@/lib/validation';
import { creerRendezVous } from '@/lib/appointments';
import { utilisateurCourant } from '@/lib/auth';

export async function reserver(formData: FormData) {
  // Branchée sur <form action={...}>, l'action reçoit un FormData.
  // Les clés sont les attributs name= des champs : le name redevient VITAL.
  const data = {
    serviceId: String(formData.get('serviceId') ?? ''),
    date: String(formData.get('date') ?? ''),
    heure: String(formData.get('heure') ?? ''),
  };

  const errors = validerReservation(data);
  if (Object.keys(errors).length > 0) return { errors };   // on y revient section « L'état d'une action côté interface »

  // L'authentification se vérifie ICI, dans l'action (voir section « Sécurité, sobrement »).
  const user = await utilisateurCourant();
  if (!user) redirect('/connexion');

  const rdv = await creerRendezVous({ ...data, patientId: user.id });

  revalidatePath('/mes-rendez-vous');   // la liste est périmée : rafraîchis-la
  redirect(`/mes-rendez-vous/${rdv.id}`);
}

Aucune URL, aucun JSON.stringify, aucun res.ok. La requête HTTP existe toujours — ouvre l'onglet Network, tu la verras — mais c'est Next.js qui l'écrit.

Côté page, le branchement tient en un attribut. L'attribut action d'un <form> attend historiquement une URL ; React 19 accepte désormais qu'on lui passe une fonction. Et remarque : cette page est un Server Component — pas de 'use client', pas de useState, pas de gestionnaire d'événement.

app/reserver/page.tsx
import { reserver } from '@/app/actions/reserver';

export default function BookingPage() {
  return (
    <form action={reserver}>
      {/* Chaque name= devient une clé du FormData reçu par l'action. */}
      <input type="hidden" name="serviceId" value="bone-scan" />

      <label htmlFor="date">Date</label>
      <input id="date" name="date" type="date" required />

      <label htmlFor="heure">Heure</label>
      <input id="heure" name="heure" type="time" required />

      <button type="submit">Réserver</button>
    </form>
  );
}

Un formulaire complet et fonctionnel, sans une ligne de JavaScript client. Compare avec la version « contrôlée » du module Formulaires 1 : ici, il n'y a plus d'état React du tout.

Le retour de l'attribut name mérite un paragraphe. Le module Formulaires 1 opposait le champ contrôlé (React détient la valeur dans un useState) au champ non contrôlé (le DOM la détient, on la relit à la soumission) ; avec une Server Action branchée sur action, on est franchement du côté non contrôlé : le navigateur collecte lui-même les champs et les identifie par leur name. Ce vieil attribut HTML, presque oublié à l'ère du tout-contrôlé, redevient le contrat entre ton balisage et ta fonction serveur — un name mal orthographié, et formData.get('date') rend null, sans la moindre erreur, juste un champ vide qui échoue à la validation.

Vient enfin l'argument qui emporte la décision, et que tu dois pouvoir sortir en entrevue : ce formulaire fonctionne sans JavaScript côté client. C'est l'amélioration progressive (progressive enhancement). Le HTML sait déjà soumettre un formulaire — une de ses fonctions les plus anciennes, antérieure à tout framework — et Next.js s'appuie dessus : le formulaire rendu côté serveur est un vrai <form> avec une vraie destination. Si le JavaScript n'a pas fini de charger, si le réseau est mauvais, si le bundle a planté, la soumission part quand même en POST HTML classique ; quand le JavaScript est là, React intercepte et améliore (pas de rechargement, état d'envoi, erreurs en place). La base fonctionne toujours, l'interactivité est un bonus — alors qu'un formulaire écrit avec onSubmit et fetch est un bouton mort tant que le bundle n'est pas chargé.

🧭 Depuis un Client Component aussi

Une Server Action n'est pas réservée à l'attribut action d'un formulaire. Un Client Component peut l'importer et l'appeler comme une fonction asynchrone ordinaire — dans un onClick, par exemple, pour un bouton « Annuler ce rendez-vous ». Dans ce cas elle ne reçoit pas de FormData mais les arguments que tu lui passes, à condition qu'ils soient sérialisables (des données simples : chaînes, nombres, objets, tableaux — pas de fonctions ni de classes, puisque ça doit traverser le réseau). Tu perds l'amélioration progressive, tu gardes la suppression de la plomberie.

🧠 Quiz éclair

Le formulaire s'envoie, l'action s'exécute… et la validation refuse tout : « la date est obligatoire », « l'heure est obligatoire ». Les deux champs étaient pourtant remplis — le navigateur ne s'est même pas plaint du required. Que manque-t-il ?

// app/reserver/page.tsx — Server Component
import { reserver } from '@/app/actions/reserver';

export default function BookingPage() {
  return (
    <form action={reserver}>
      <label htmlFor="date">Date</label>
      <input id="date" type="date" required />

      <label htmlFor="heure">Heure</label>
      <input id="heure" type="time" required />

      <button type="submit">Réserver</button>
    </form>
  );
}

L'attribut name. Les deux champs n'ont qu'un id : parfait pour le <label htmlFor> — cliquer l'étiquette met le champ au focus — mais le navigateur, lui, assemble le FormData à partir des name. Un champ sans name n'est tout simplement pas inclus dans l'envoi. Côté serveur, formData.get('date') rend null, devient '', et le validateur refuse — à juste titre. Et rien ne t'a averti : le required du navigateur était satisfait, les champs étaient remplis.

Correctif : <input id="date" name="date" type="date" required /> — on garde les deux attributs, ils n'ont pas le même métier. C'est là-dessus que repose toute la Server Action : le name du balisage et la clé lue dans formData.get(...) sont la même chaîne de caractères, et aucun outil ne vérifie qu'elles correspondent.

Après la mutation : rafraîchir et rediriger

Une mutation qui réussit laisse un problème derrière elle : l'écran ment. La page « Mes rendez-vous » a été rendue avant la réservation, elle affiche une liste qui n'a plus cours. Comme l'a montré le module sur le rendu et le data fetching, ce qui a été mis en cache côté serveur — ou déjà rendu et gardé par le routeur pour les navigations suivantes — ne se rafraîchit pas tout seul : il faut dire explicitement à Next.js ce qui vient de devenir faux. D'où deux fonctions importées de next/cache, à appeler après l'écriture :

  • revalidatePath('/mes-rendez-vous') — « les données de cette route sont périmées ». C'est l'outil du quotidien : tu sais quelle page affiche ce que tu viens de changer, tu la nommes.
  • revalidateTag('appointments') — « toutes les données étiquetées ainsi sont périmées ». C'est ici que se referme la boucle ouverte dans le module Rendu & data fetching : l'étiquette que tu révoques est exactement celle que cacheTag('appointments') avait posée à la lecture. Même chaîne de caractères des deux côtés, sinon rien ne se passe — et rien ne t'avertira. Plus indirect que revalidatePath, mais plus juste quand la même donnée nourrit cinq pages différentes : tu invalides la donnée, pas la liste des écrans qui l'affichent. Le jour où une sixième page apparaît, elle est couverte sans que personne ait à y penser.

Et redirect('/mes-rendez-vous/57'), importé de next/navigation, envoie l'utilisateur ailleurs une fois le travail fait. Un détail qui surprend tout le monde une fois : redirect() fonctionne en levant une exception interne que Next.js intercepte — ne l'appelle donc jamais dans un try dont le catch avalerait tout, sinon tu attrapes le signal et rien ne se passe. Place-le après ton bloc try/catch.

L'ordre a du sens : on écrit, on invalide, on redirige. Invalider avant d'écrire ne sert à rien (on remettrait en cache l'ancienne valeur), et rediriger avant d'invalider fait arriver l'utilisateur sur une page encore périmée. Une chorégraphie en trois temps, toujours la même.

📅 Dans RendezVous

Une annulation touche trois écrans : la liste « Mes rendez-vous », la fiche du rendez-vous annulé, et le calendrier de disponibilités du service — puisqu'un créneau vient de se libérer, et qu'un autre patient doit pouvoir le voir. Écrire trois revalidatePath marche, mais chaque nouvel écran qui affichera des créneaux devra penser à s'ajouter à la liste : c'est une dette qui se paie en bugs d'affichage. La version qui vieillit bien étiquette les chargements de créneaux avec 'slots' et appelle un seul revalidateTag('slots') après l'annulation. La règle : revalidatePath quand une page précise est concernée, revalidateTag quand c'est une donnée qui circule partout.

🧠 Quiz éclair

L'annulation fonctionne : le rendez-vous disparaît bien de la base. Mais le créneau libéré ne réapparaît sur le calendrier qu'au bout de plusieurs heures, quand la durée de vie du cache expire d'elle-même. Aucune erreur, aucun avertissement. Pourquoi ?

// LECTURE — app/creneaux/creneaux-caches.ts
import { cacheTag } from 'next/cache';

export async function lireCreneaux(serviceId: string) {
  'use cache';
  cacheTag('slots');                 // ← étiquette POSÉE ici
  return chargerCreneauxDepuisAgenda(serviceId);
}
// ÉCRITURE — app/actions/annuler.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function annuler(id: string) {
  await cancelAppointment(id);
  revalidateTag('creneaux');         // ← étiquette RÉVOQUÉE ici
}

Les deux chaînes ne sont pas la même. On étiquette 'slots' à la lecture, on révoque 'creneaux' à l'écriture : la révocation porte donc sur une étiquette que personne n'a jamais posée, et ne fait rien du tout. Le résultat gardé sous 'slots', lui, n'est jamais jeté — il attend sagement l'expiration de sa durée de vie.

Ce qui rend l'erreur coûteuse, c'est le silence : révoquer une étiquette inexistante n'est pas une faute, rien ne s'en plaint. La boucle ne se referme que si c'est exactement la même chaîne des deux côtés. Réflexe pratique : déclare tes étiquettes à un seul endroit — export const TAG_CRENEAUX = 'slots'; — et importe la constante dans les deux fichiers. Le compilateur attrape alors la faute de frappe que personne ne verrait autrement.

L'état d'une action côté interface

Le formulaire de la section « Les Server Actions : la deuxième voie » est fonctionnel, mais muet : si la validation échoue, l'utilisateur ne voit rien ; si l'envoi prend deux secondes, rien ne bouge et il reclique — et POST n'étant pas idempotent (module HTTP & fetch), deux clics peuvent produire deux réservations. Il manque les deux autres moments de toute opération asynchrone, « ça charge » et « ça a raté ». React 19 fournit un hook pour chacun.

useActionState encapsule l'action et te rend son résultat : tu lui donnes ta Server Action et un état initial, il te rend un triplet — l'état courant (ce que l'action a retourné la dernière fois), une action enveloppée à brancher sur le formulaire, et un booléen « en cours ». Contrepartie : ta Server Action reçoit alors un argument de plus, en première position, l'état précédent — sa signature devient (etatPrecedent, formData).

app/reserver/FormulaireReservation.tsx
'use client';

import { useActionState } from 'react';
import { reserver } from '@/app/actions/reserver';
import { SubmitButton } from './SubmitButton';

const ETAT_INITIAL = { errors: {} as Record<string, string> };

export function BookingForm() {
  // etat   : ce que l'action a RETOURNÉ (ici { errors })
  // action : l'action enveloppée, à brancher sur le form
  // enCours: true pendant l'aller-retour serveur
  const [etat, action, enCours] = useActionState(reserver, ETAT_INITIAL);

  return (
    <form action={action}>
      <label htmlFor="date">Date</label>
      <input id="date" name="date" type="date" required />
      {/* L'erreur renvoyée par le SERVEUR, affichée sous le bon champ. */}
      {etat.errors.date && <p role="alert">{etat.errors.date}</p>}

      <SubmitButton />   {/* enfant : il lira l'état d'envoi lui-même */}
    </form>
  );
}

role="alert" : le module HTML sémantique & accessibilité expliquait qu'un lecteur d'écran annonce spontanément le contenu d'une région ainsi marquée. Une erreur qu'on ne peut pas entendre n'est pas affichée pour tout le monde.

useFormStatus (importé de react-dom) donne l'état d'envoi du formulaire parent. Il sert typiquement à désactiver le bouton pendant l'aller-retour — la protection anti-double-envoi dont on parlait à l'instant.

app/reserver/BoutonEnvoyer.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  // pending = "le <form> PARENT est en train d'envoyer"
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Envoi…' : 'Réserver'}
    </button>
  );
}

Un composant minuscule, dont l'existence même est la leçon de la section.

Pourquoi un composant séparé, alors qu'un disabled={enCours} dans le formulaire aurait suffi ? Parce que useFormStatus lit le <form> le plus proche au-dessus de lui dans l'arbre. Appelé dans le composant qui rend le formulaire, il ne trouve aucun formulaire parent — le <form> est son enfant, pas son ancêtre — et retourne éternellement pending: false : le bouton ne se désactive jamais, aucune erreur n'apparaît, et tu passes une heure à chercher. La règle est mécanique : le composant qui appelle useFormStatus doit être rendu à l'intérieur du <form>.

Où l'appel est posé dans l'arbre — et c'est tout le bug
l'appel dans le composant qui REND le formulaire
BookingFormuseFormStatus() est appelé ici — hors de la zone
<form action={reserver}>à partir d'ici, et vers le bas seulement
<button>pending vaut false, toujours
l'appel dans un composant rendu DANS le formulaire
BookingForm
<form action={reserver}>à partir d'ici, et vers le bas seulement
SubmitButtonuseFormStatus() est appelé ici — dans la zone
Le trait violet est la zone que le <form> ouvre sous lui : c'est là, et nulle part ailleurs, que useFormStatus trouve quelque chose. La question à te poser devant n'importe quel appel devient donc visuelle — est-il dans le trait, ou au-dessus ? Voilà pourquoi ce composant minuscule existe : il n'est pas là pour factoriser du code, il est là pour descendre d'un cran.

Le mécanisme repose sur une idée que tu connais depuis le guide Halterofit : un contexte React. Le <form> publie son état d'envoi à ses descendants et le hook s'y abonne ; or un contexte descend, il ne remonte jamais — d'où la contrainte. Bénéfice pratique : ton SubmitButton devient réutilisable dans n'importe quel formulaire de l'app, sans prop à lui passer.

⚠️ Piège fréquent

Croire qu'une Server Action est privée parce qu'elle est écrite « côté serveur ». C'est le contresens majeur de ce module, et il est très naturel : le fichier ne part pas dans le bundle du navigateur, on ne voit son code nulle part dans les DevTools, donc on la croit inatteignable. Faux. La directive 'use server' ne cache pas la fonction — elle lui fabrique un point d'entrée HTTP public. C'est tout son travail. Une fois déployée, n'importe qui peut envoyer une requête à cet endpoint, avec les arguments de son choix, sans jamais avoir vu ta page. Cacher le bouton, désactiver le champ, afficher le formulaire seulement aux connectés : tout ça se passe dans une interface que l'attaquant n'utilise pas. La seule protection qui existe est celle que tu écris à l'intérieur du corps de l'action.

🧠 Quiz éclair

Le bouton ne se désactive jamais pendant l'envoi : un patient pressé clique deux fois et repart avec deux réservations. Aucune erreur en console ; pending vaut simplement false pour l'éternité. Pourquoi ?

'use client';

import { useFormStatus } from 'react-dom';
import { reserver } from '@/app/actions/reserver';

export function BookingForm() {
  const { pending } = useFormStatus();

  return (
    <form action={reserver}>
      <input id="date" name="date" type="date" required />

      <button type="submit" disabled={pending}>
        {pending ? 'Envoi…' : 'Réserver'}
      </button>
    </form>
  );
}

Parce que useFormStatus lit le <form> le plus proche au-dessus de lui dans l'arbre. Ici, le <form> est l'enfant de ce composant, pas son ancêtre : le hook ne trouve aucun formulaire auquel s'abonner et retourne pending: false à vie — silencieusement, puisque ne pas trouver de formulaire n'est pas une erreur. Le mécanisme est un contexte React : le <form> publie son état d'envoi à ses descendants, et un contexte descend, il ne remonte jamais.

Correctif : sortir le bouton dans son propre petit composant client, rendu à l'intérieur du <form>. Bénéfice au passage — ce bouton devient réutilisable dans n'importe quel formulaire de l'app, sans une seule prop à lui passer. Et la double réservation n'est pas un détail cosmétique : POST n'est pas idempotent, deux clics font vraiment deux rendez-vous.

Route Handler ou Server Action ? Le tableau de décision

Voilà la question d'entrevue, et elle est parfaite pour évaluer un candidat : pas de réponse unique, il faut comprendre les deux mécanismes, et elle révèle si tu as réfléchi ou récité. Le critère de tri tient en une question : qui appelle ce code ? Si c'est « ma propre page, en réaction à un formulaire ou à un bouton », c'est une Server Action — on ne définit pas un contrat public pour un usage interne. Si c'est « quelqu'un d'autre » — une app mobile, un service tiers qui te POSTe un webhook quand un événement survient chez lui, un script, une autre équipe — c'est un Route Handler. Un contrat public a besoin d'une URL stable, d'une méthode, de statuts documentés : ce qu'une Server Action, dont l'endpoint est un identifiant généré par le compilateur, ne peut pas offrir.

SituationChoixPourquoi
Le formulaire de réservation de ta propre page Server Action Zéro plomberie, et ça marche sans JavaScript.
Un bouton « Annuler » dans ta liste Server Action Mutation interne ; revalidatePath rafraîchit la vue.
Une future app mobile qui lit les créneaux Route Handler Client externe : il lui faut une URL et un contrat stables.
Un webhook (Stripe, Twilio…) qui te notifie Route Handler Le service tiers POSTe sur une URL que tu lui as donnée.
Un flux public : sitemap.xml, un export CSV, une image générée Route Handler Il faut contrôler le Content-Type et le corps brut.
Charger des données pour afficher une page Ni l'un ni l'autre Un Server Component lit directement — pas d'aller-retour HTTP à s'infliger.

La dernière ligne est la plus importante, et c'est le piège tendu aux candidats venus du monde purement client, dont le réflexe est « pour afficher des données, j'écris une route d'API et je la fetch depuis mon composant ». Dans l'App Router, c'est un aller-retour gratuit et nuisible : ton Server Component tourne déjà sur le serveur et peut interroger la base directement. Écrire GET /api/services pour que ta propre page se l'auto-appelle, c'est faire voyager la donnée du serveur au serveur en passant par HTTP. La route d'API se justifie quand un autre client en a besoin, pas par habitude.

Et rien n'oblige à choisir globalement : dans RendezVous, les formulaires internes passent par des Server Actions et /api/appointments existe en parallèle pour la future app mobile. Les deux appellent creerRendezVous — la fonction métier de la section « Où mettre la logique : le handler traduit, la fonction métier travaille ». Deux portes, un seul couloir.

Sécurité, sobrement

Pas de chapitre paniqué sur la cybersécurité — juste trois règles qu'un stagiaire frontend doit connaître, parce que leur absence se voit immédiatement en revue de code.

Un point d'entrée est public, point. Route Handler comme Server Action : dès que c'est déployé, c'est joignable, et toute vérification faite dans l'interface — bouton caché, route protégée, condition {user && ...} — est un confort d'utilisation, jamais une sécurité, puisque l'attaquant n'ouvre pas ta page mais envoie une requête. L'authentification (« qui es-tu ? ») et l'autorisation (« as-tu le droit ? ») se vérifient donc dans le corps de la fonction serveur, à chaque appel. Et souviens-toi du couple 401/403 du module HTTP & fetch : deux questions distinctes, et l'autorisation est celle qu'on oublie.

Deux chemins, une seule porte
le chemin que tu as prévu
  1. navigateurLa page s'affichele bouton « Annuler » n'est rendu que si le patient est connecté
  2. navigateurIl cliqueet seulement sur ses rendez-vous à lui : les autres ne sont pas affichés
  3. réseauPOST vers le point d'entréecelui que le compilateur a fabriqué, avec l'id en argument
  4. serveurcancelAppointment(id) s'exécute
le chemin de qui n'ouvre pas ta page
  1. réseauPOST vers le point d'entréerelevé une fois dans l'onglet Réseau, rejoué avec l'id qu'on veut
  2. serveurcancelAppointment(id) s'exécute les deux étapes du haut n'ont jamais eu lieu
La dernière étape est écrite deux fois avec exactement les mêmes mots, et c'est le propos : ta fonction ne sait pas par où l'on est arrivé. Le second visiteur n'a pas franchi tes gardes — il a pris un chemin où elles n'existent pas.
'use server';

export async function cancelAppointment(id: string) {
  // 1. AUTHENTIFICATION — qui es-tu ? (sinon : 401 / redirection connexion)
  const user = await utilisateurCourant();
  if (!user) redirect('/connexion');

  // 2. AUTORISATION — ce rendez-vous est-il bien LE TIEN ? (sinon : 403)
  //    C'est l'étape qu'on oublie : sans elle, changer l'id dans la requête
  //    suffit à annuler le rendez-vous de n'importe quel autre patient.
  const rdv = await lireRendezVous(id);
  if (!rdv || rdv.patientId !== user.id) {
    throw new Error('Accès refusé');
  }

  await annuler(id);
  revalidatePath('/mes-rendez-vous');
}

Cinq lignes de garde avant une ligne de travail. C'est le ratio normal d'une mutation, et personne ne trouvera ça excessif en revue.

Ne jamais faire confiance à ce qui arrive. C'est la leçon du module Formulaires 2, transposée : la validation côté client est une courtoisie (dire à l'utilisateur qu'il s'est trompé avant de le faire attendre), la validation côté serveur est la seule vraie. Corps de POST, FormData, query string, paramètre d'URL, identifiant passé à une action : tout vient de l'extérieur, tout se vérifie. Et vérifier ne veut pas seulement dire « le format est bon » — ça veut aussi dire « cet identifiant appartient à cet utilisateur », comme ci-dessus.

Les secrets restent côté serveur. Le module sur la carte de Next.js a posé la règle : sans le préfixe NEXT_PUBLIC_, une variable d'environnement n'est lisible que sur le serveur. Les Route Handlers et les Server Actions étant du code serveur, c'est là que doivent vivre les clés d'API, les jetons de service et les identifiants de base — un des grands bénéfices du modèle : ta clé secrète Stripe s'utilise sans jamais quitter le serveur.

Et le garde global ? Le fichier proxy.ts, présenté dans le module La carte de Next.js, intercepte bien les requêtes en amont — mais c'est l'endroit d'une redirection globale, pas d'un contrôle de droits fin : un garde de couloir ne dispense jamais de verrouiller les portes. Tes vérifications restent dans l'action.

🔗 Pont — les policies RLS de Supabase disent la même chose

Tu as déjà écrit ce contrôle d'autorisation, mais en SQL. Une policy RLS (Row Level Security) dans Supabase — using (auth.uid() = user_id) — dit exactement ce que dit la ligne rdv.patientId !== user.id ci-dessus : « tu ne touches que tes propres lignes ». Et la motivation est identique : dans Halterofit, ton app mobile parle directement à la base, donc filtrer côté app ne protégerait rien du tout (n'importe qui peut appeler l'API avec la clé publique). Le contrôle devait vivre à l'endroit que le client ne contrôle pas — la base. Ici, cet endroit est le corps de ta Server Action. Même principe, deux emplacements : l'autorisation se vérifie du côté que l'utilisateur ne peut pas modifier. Si tu as compris RLS, tu as déjà compris cette section — tu ne l'avais juste pas encore appliquée à du TypeScript.

✍️ Exercice de lecture

Dans RendezVous, le bouton « Voir toutes les réservations de la clinique » n'est affiché qu'aux comptes administrateurs, grâce à cette condition dans la page :

{user.role === 'admin' && (
  <form action={exporterToutesLesReservations}>
    <button type="submit">Exporter (CSV)</button>
  </form>
)}

Et voici la Server Action correspondante :

'use server';

export async function exporterToutesLesReservations() {
  // Le bouton n'est visible que pour les admins, donc on est tranquille.
  const toutes = await lireToutesLesReservations();
  return { csv: versCSV(toutes) };
}

Questions : (1) Quelle est la faille ? (2) Concrètement, comment un utilisateur non-admin l'exploiterait-il, sans aucun outil sophistiqué ? (3) Quelles lignes ajouterais-tu, et à quel endroit précis ? (4) Le commentaire du développeur décrit-il un contrôle d'authentification ou d'autorisation ?

Voir le corrigé

(1) L'action ne vérifie rien. Elle s'appuie entièrement sur le fait que le bouton n'est pas affiché — c'est-à-dire sur une décision prise dans l'interface, que l'appelant n'est pas obligé d'utiliser. C'est le piège de la section « L'état d'une action côté interface » dans sa forme la plus pure : « écrit côté serveur » a été confondu avec « inaccessible ».

(2) Pas besoin de contourner React : une Server Action est un endpoint HTTP. Il suffit d'observer l'identifiant de l'action (onglet Network d'un compte admin, ou le HTML rendu) puis d'envoyer soi-même la requête avec curl ou la console du navigateur. Aucun code client n'entre en jeu — ni le &&, ni le rendu conditionnel, ni la page. Résultat : toutes les réservations de la clinique, des données médicales, exportées par un patient ordinaire.

(3) Les deux gardes, en tête du corps de l'action, avant toute lecture : récupérer l'utilisateur côté serveur (const user = await utilisateurCourant();), refuser s'il est absent, puis refuser si user.role !== 'admin'. La condition d'affichage peut rester — elle évite de montrer un bouton inutile — mais elle devient un confort d'interface, plus une protection.

(4) Ni l'un ni l'autre : il ne décrit aucun contrôle, mais une hypothèse sur le chemin emprunté par l'appelant — ce qui le rend dangereux, il a l'air rassurant. En revue, un commentaire qui justifie l'absence de vérification par le comportement de l'interface est un signal d'alarme à part entière.

🎤 En entrevue

« Route Handler ou Server Action : comment choisis-tu ? »

« Je me demande qui appelle. Si c'est un client externe — une app mobile, un webhook d'un service tiers, un script, une autre équipe — il faut un Route Handler : ça expose une URL stable, une méthode HTTP et des statuts documentés, c'est un contrat public. Si c'est ma propre interface qui déclenche une mutation — un formulaire, un bouton « annuler » — je prends une Server Action : je n'écris ni URL ni fetch, et surtout le formulaire fonctionne sans JavaScript, donc j'ai l'amélioration progressive gratuitement. Troisième cas qu'on oublie souvent : si je veux juste afficher des données, je n'écris ni l'un ni l'autre — mon Server Component lit directement la source, s'auto-appeler en HTTP serait un aller-retour inutile. Et rien n'empêche d'avoir les deux dans un projet, tant qu'ils partagent la même fonction métier. »

« Comment sécurises-tu une Server Action ? »

« Je pars du principe qu'elle est publique : 'use server' ne cache pas la fonction, il lui crée un point d'entrée HTTP. Donc tout ce qui a été décidé dans l'interface — bouton caché, page protégée, champ désactivé — ne compte pas, parce que l'appelant peut ignorer l'interface. Concrètement, dans le corps de l'action je fais trois choses, dans cet ordre : je récupère l'utilisateur côté serveur et je refuse s'il n'y en a pas (authentification) ; je vérifie qu'il a le droit sur cette ressource précise, pas juste qu'il est connecté (autorisation — c'est l'étape qu'on saute le plus souvent) ; et je valide les données reçues avec la même fonction pure que côté client, parce que le FormData vient de l'extérieur. C'est exactement la logique des policies RLS en base : le contrôle vit du côté que l'utilisateur ne contrôle pas. »

« Quel statut renvoies-tu après une création ? »

« 201 Created, avec la ressource créée dans le corps — pratique pour que le client récupère l'identifiant généré sans refaire une requête. 200 n'est pas faux au sens strict, mais 201 porte une information de plus : une nouvelle ressource existe maintenant. Et si la création échoue, je change vraiment de statut : 400 si les données sont invalides, avec le détail par champ pour que le formulaire affiche l'erreur au bon endroit ; 409 si c'est un conflit métier, typiquement un créneau déjà réservé ; 401 ou 403 si c'est un problème d'identité ou de droits ; et je laisse remonter en 500 ce que je ne sais pas qualifier. Ce que je ne fais jamais, c'est répondre 200 avec un { ok: false } dans le corps — le client qui vérifie res.ok croirait à un succès. »

À retenir

Deux façons d'écrire du serveur dans ton projet frontend. Le Route Handler : un route.ts dans un segment, des fonctions exportées dont le nom est le verbe HTTP, qui reçoivent une Request standard et renvoient une Response — souvent Response.json(data, { status }). Jamais de page.tsx et de route.ts dans le même segment ; les params dynamiques arrivent en Promesse (await params), la query string se lit par new URL(request.url).searchParams. La formule du handler POST honnête : lire, valider, agir, répondre — 201 pour une création, 400 avec le détail par champ, 409 pour un conflit, et on laisse remonter le reste en 500. Le handler traduit du HTTP, la fonction métier dans lib/ fait le travail. La Server Action : 'use server', une fonction async branchée sur <form action={...}>, qui reçoit un FormData — donc l'attribut name redevient vital — et qui fonctionne sans JavaScript. Après la mutation : revalidatePath / revalidateTag, puis redirect. Côté interface, useActionState pour le résultat, useFormStatus dans un composant enfant pour l'état d'envoi. Le tri : client externe → handler, formulaire de ta page → action, simple affichage → ni l'un ni l'autre. Et la règle qui prime sur tout : les deux sont des points d'entrée publics, donc authentification, autorisation et validation se vérifient dedans.

Et ailleurs : ce que tu viens d'apprendre dépasse largement Next.js. La séquence lire / valider / agir / répondre est la structure de toute route d'écriture, dans n'importe quel framework et n'importe quel langage — Express, FastAPI, Rails, une Edge Function Supabase, un Cloudflare Worker : les noms changent, les quatre gestes non. La séparation « la frontière traduit, le métier travaille » porte des noms savants dans les gros systèmes (architecture hexagonale, ports et adaptateurs), mais c'est le réflexe que tu viens d'acquérir en quinze lignes. « Ne jamais faire confiance au client » est la première ligne de tout manuel de sécurité applicative, et tu le connaissais déjà sous sa forme SQL avec les policies RLS. Quant aux objets Request et Response, ce sont les standards de la plateforme web : les mêmes te serviront partout où du JavaScript tourne côté serveur. Tu n'as pas appris une fonctionnalité de framework, tu as appris le métier de la frontière — et les frameworks passent.

🗂️ L'aide-mémoire
request.json()
une promesse — le corps arrive en flux, donc await, comme res.json() côté client
Response.json(data) sans statut
répond 200, y compris quand tu viens d'échouer. Le statut s'écrit : { status: 201 }
NextResponse
une sous-classe de Response (cookies, redirections). La standard couvre 90 % des cas — commence par elle
'use server'
n'est pas le miroir de 'use client' : celle-ci marque une frontière de bundle, celle-là expose une fonction
L'adresse d'une Server Action
un identifiant généré par le compilateur, pas une URL que tu écris — il n'y a donc rien de stable à publier
cacheTagrevalidateTag
la même chaîne des deux côtés, à la lettre près. Sinon rien ne se rafraîchit, et rien ne t'avertit
La signature d'une action passée à useActionState
(etatPrecedent, formData) — l'état arrive en première position. Le hook ajoute cet argument, il ne remplace pas le FormData