Le contexte : douze mois plus tard, le bilan réel
En janvier 2025, nous publiions un guide de migration Next.js 14 vers 15. Douze mois plus tard, trois applications tournent en RSC par défaut en production chez VALRY LABS : le site institutionnel, un back-office client SaaS, et une plateforme interne de gestion des leads. Le terrain a parlé.
Le constat général tient en une ligne : la promesse de performance et de simplicité architecturale s'est confirmée à 70 %. Les 30 % restants, c'est l'impôt sur la frontière client/serveur, l'écosystème de librairies encore en cours d'alignement, et quelques angles morts que la documentation officielle n'éclaire qu'à moitié.
Cet article n'est pas un tutoriel. C'est un retour de chantier : les bugs qui ont mangé nos sprints, les chiffres que nous avons mesurés en vraie condition, et les règles internes que nous avons figées pour ne plus répéter les mêmes erreurs.
Ce qui a cassé en pratique : sérialisation, cycles, `use cache`
Le piège le plus fréquent reste la frontière de sérialisation. Passer un objet `Date` ou une `Map` d'un Server Component à un Client Component déclenche l'erreur `only plain objects, and a few built-ins, can be passed to Client Components`. Nous l'avons vue 23 fois en douze mois, dont deux remontées en production parce que le type parent n'exposait pas explicitement un champ `Date` enfoui.
Le deuxième incident récurrent : les cycles d'imports accidentels client/serveur. Un composant client qui importe un module serveur (typiquement un helper qui appelle Prisma) passe en build, puis explose au runtime avec `Cannot read property of undefined`. La règle interne est devenue stricte : tout fichier qui touche `lib/infra/` ou `lib/application/` ne peut jamais figurer dans le graphe d'import d'un composant marqué `"use client"`. Un ESLint custom vérifie ça en CI.
Enfin, `use cache` (stable depuis React 19.1) a ses propres footguns. Nous avons vu une route marketing servie stale pendant 4 heures parce qu'un développeur avait posé la directive sur une fonction qui consommait `headers()` dynamiquement. La règle : `use cache` uniquement sur des fonctions pures sans dépendance à la requête, jamais sur une logique métier qui lit `cookies()` ou `headers()`.
Sur Next 15, l'autre collision subtile concerne les APIs dynamiques (`cookies()`, `headers()`, `params` devenus async) combinées au PPR. Une page marquée `experimental_ppr = true` qui appelle `await cookies()` dans un segment enfant casse le prerender statique et mute silencieusement la route en dynamique. À surveiller via la sortie `next build` qui liste les raisons d'opt-out.
Ce qui a surpris positivement : bundle, INP, auth
Le bundle client du site institutionnel est passé de 187 kB à 119 kB gzippé (-36 %) entre la version Next 14 et la version actuelle full-RSC. Le gain vient moins de RSC lui-même que de la disparition des librairies de fetching (`swr`, `react-query`) sur les surfaces marketing, désormais lues côté serveur. Le back-office client, plus riche, descend de 312 kB à 241 kB (-23 %).
Sur l'INP, la médiane mobile sur 28 jours est passée de 184 ms à 96 ms sur la page d'accueil, et de 230 ms à 128 ms sur la liste de blog (75e centile). La raison principale est la réduction massive du nombre de composants hydratés. Moins d'arbres React à hydrater, moins d'event listeners attachés au `TBT`, et un INP mécaniquement meilleur. Sur mobiles milieu de gamme (Pixel 4a), le gain est encore plus visible.
Le troisième gain inattendu concerne l'authentification. Sur Next 14, nous cascadions `getServerSession` via un Provider Client. En RSC, on lit directement `cookies()` dans le Server Component racine, on valide le JWT Supabase côté serveur via `lib/infra/auth`, et on passe un objet `session` typé en prop aux îlots clients qui en ont besoin. Plus de Provider, plus de flash de contenu non authentifié, et un code auth divisé par deux.
La discipline du `"use client"` : où placer la frontière
La règle que nous appliquons désormais : un composant devient client si et seulement si au moins un de ces quatre critères est rempli. État local (`useState`, `useReducer`), effet (`useEffect`, `useLayoutEffect`), gestionnaire d'événement (`onClick`, `onSubmit`), ou API navigateur (`window`, `matchMedia`, `IntersectionObserver`). Framer Motion impose aussi le marqueur, ce qui pousse à isoler les animations dans des wrappers dédiés plutôt que de marquer des sections entières.
L'erreur la plus coûteuse observée : placer `"use client"` en haut d'un fichier parent qui importe toute une feature. En une ligne, on bascule des dizaines de composants en bundle client. Notre convention est de descendre le marqueur au plus près de la feuille : un `Button` animé reste client, mais son parent `Card` reste serveur et inclut simplement le `Button`. Le cost-of-misplacement se mesure au First Load JS — un marqueur mal placé peut ajouter 30 à 80 kB gzippé.
Le cas pathologique est la prop non sérialisable remontée en production. Un développeur passe une `Date` à un composant client en pensant que c'est permis ; le build passe en dev, et crash en production avec un message cryptique. Nous avons ajouté un test de type qui vérifie en CI que tout composant `"use client"` n'accepte que des props JSON-sérialisables (via une utility `Serializable<T>` appliquée aux `interface` de props).
Enfin, méfiance sur les librairies tierces qui posent `"use client"` à la racine. Plusieurs éditeurs de UI kits popularisent ce pattern, ce qui annule le bénéfice RSC. Sur VALRY LABS, nous imposons un audit des dépendances UI : si la librairie ne propose pas de mode RSC-friendly (Server Components comme défaut), on cherche une alternative ou on isole dans un wrapper client minimal.
Anti-patterns observés en production
Le premier anti-pattern est le prop-drilling serveur vers îlots clients. Un Server Component fetch 40 Ko de données, les passe à travers quatre niveaux de composants pour nourrir un graphique client. Résultat : payload HTML gonflé, sérialisation coûteuse, et une arborescence difficile à refactorer. La règle : si une donnée n'est consommée que par un îlot client, on la fetch dans l'îlot via une Server Action dédiée, ou mieux via `fetch` côté serveur avec `cache: 'force-cache'`.
Le deuxième est le ping-pong serveur/client/serveur via Server Actions. Un formulaire appelle une Server Action qui revalidate une page, qui déclenche un refetch serveur, qui notifie un client via Realtime. Sur un back-office, ce pattern peut générer jusqu'à 6 allers-retours par interaction. Nous limitons à 2 (action + revalidation ciblée), et bannissons `revalidatePath('/')` générique au profit de `revalidateTag('resource')` précis.
Le troisième anti-pattern, plus subtil : faire du fetching dans un Client Component (`useEffect` + `fetch`) quand un Server Component ferait l'affaire. Cela arrive quand un développeur convertit une page Next 14 sans repenser l'architecture. La trace typique est un Client Component qui fetch des données statiques au montage, avec un loading spinner qui clignote. Refactorer en RSC supprime le spinner, le fetch côté client, et ajoute directement les données au HTML servi.
Server Actions en production : idempotence, UX, erreurs
Les Server Actions ont remplacé 90 % de nos Route Handlers POST côté back-office. Le gain en DX est réel : moins de boilerplate, validation Zod centralisée, retour typé via `ActionState`. Mais en production, trois problèmes reviennent : l'idempotence, la résilience réseau, et la gestion d'erreur UX.
Sur l'idempotence : un utilisateur peut cliquer deux fois avant que `useFormStatus` ne désactive le bouton. Sur une action non idempotente (création de lead, envoi d'email), cela génère des doublons. Notre convention est d'injecter un `idempotencyKey` (UUID v4 généré côté client) dans le payload, stocké en base avec un unique constraint. La deuxième soumission avec la même clé renvoie le résultat de la première. Coût : une colonne, un index, zéro doublon.
Sur la résilience réseau : que se passe-t-il quand le réseau coupe en milieu d'action ? Sans garde-fou, l'utilisateur a un état `pending` infini. Nous posons un `AbortController` avec un timeout de 12 secondes côté client, et un `error.tsx` à l'échelle du segment qui capture les échecs. Côté serveur, l'action doit être conçue comme transactionnelle : soit elle réussit, soit elle rollback via `$transaction` Prisma. Jamais d'écriture partielle.
Sur l'UX d'erreur, `useActionState` sépare proprement succès/erreur du résultat. Pour l'optimistic UI, nous combinons `useOptimistic` avec une stratégie de rollback explicite : on applique la mutation localement, et en cas d'échec serveur on restaure l'état précédent via un snapshot. Pour les mutations sensibles (paiement, suppression définitive), on désactive l'optimistic et on attend la confirmation serveur avant de mettre à jour l'UI.
Conseils de migration pour les équipes encore sur Next 14
Le chemin de migration commence par le codemod officiel : `npx @next/codemod@latest upgrade`. Il gère correctement les changements de signature (`params` qui devient `Promise<{...}>`, `cookies()` et `headers()` async), les imports `next/headers`, et quelques renommages. Sur nos trois applications, le codemod a traité 70 à 80 % du travail mécanique. Le reste est à la main.
Ce que le codemod ne gère pas : les Server Components implicites qui cassent parce qu'ils utilisent des hooks, les composants qui passent des fonctions en props (désormais interdit à la frontière client/serveur), et les librairies tierces non compatibles React 19. Comptez 2 à 5 jours de dev par application pour traiter ces cas à la main, plus 1 jour de revue de dépendances (vérifier les versions minimales de `swr`, `react-query`, Radix, etc.).
Vient ensuite la fenêtre de monitoring post-migration. Nous imposons 7 jours calendaires en production avec surveillance renforcée avant de déclarer la migration close. Les indicateurs surveillés : Sentry (any new error spike > 2x baseline), CrUX (LCP, INP, CLS par device), Better Stack (logs serveur, latence des Server Actions), Plausible (taux de conversion sur les formulaires). Un incident majeur dans cette fenêtre déclenche un rollback immédiat, pas un hotfix.
Enfin, le piège humain à anticiper : la courbe d'apprentissage de la frontière `"use client"`. Vos développeurs habitués au tout-client de Next 14 vont sur-place des marqueurs par réflexe. Prévoyez une session de formation de 2 heures, un cheat sheet à côté du repo, et une revue de code attentive pendant les 30 premiers jours. Une fois la discipline intégrée, la productivité revient à son niveau antérieur, voire au-dessus grâce à la simplification de l'auth et du fetching.