Next.js App Router en production : ce qui change vraiment

Next.js React Production Guide
Publié le

Les tutoriels sur l’App Router s’arrêtent tous au même endroit : la page s’affiche en développement. Voici ce qui se passe après, sur une application Next 15 qui tourne pour de vrai, avec une base, des sessions et des données qui changent.

params est asynchrone, et ça se propage

Depuis Next 15, params et searchParams sont des promesses.

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = await props.params
}

La conséquence n’est pas l’await, elle est que le composant doit être async. Et un composant asynchrone ne peut pas être appelé par un composant client. Sur une application existante, la migration remonte donc l’arbre jusqu’à trouver une frontière, et cette remontée est le vrai coût de la mise à jour.

Deux issues quand la remontée devient trop longue : déplacer le sous-arbre côté client entièrement, ou passer la donnée en propriété depuis le parent déjà asynchrone. La deuxième est presque toujours la bonne, et elle rend le composant plus simple au passage.

La mise en cache par défaut

Next met en cache agressivement, et il le fait bien. Le problème est qu’une page d’administration qui affiche l’état d’aujourd’hui n’a aucune raison d’être mise en cache, et pourtant elle l’est.

Sur une application dont presque toutes les pages lisent une base, la ligne qui règle le sujet est :

export const dynamic = 'force-dynamic'

Posée page par page, pas globalement, sinon on perd le bénéfice sur les pages qui peuvent vraiment être mises en cache. Le symptôme qui doit vous alerter : une donnée modifiée dans l’interface qui réapparaît à l’ancienne valeur après un rechargement, et qui se corrige toute seule au bout de quelques minutes.

output: 'standalone' n’embarque pas tout

Pour un déploiement en conteneur, standalone produit un dossier autonome avec un serveur minimal. Il ne contient que ce que le graphe de modules atteint.

Ce qui s’en trouve exclu, et qu’il faut recopier à la main dans l’image :

  • les binaires que vous appelez en sous-processus,
  • le client de base de données généré, si le générateur tourne après la construction,
  • tout fichier lu par chemin plutôt qu’importé.

Le symptôme est toujours le même : ça marche en local, ça échoue au démarrage du conteneur avec un module introuvable. Le remède est de lire ce que contient réellement .next/standalone avant de construire l’image, pas après.

Un cas qui m’est arrivé et qui m’a coûté une heure : un script d’administration importait une bibliothèque absorbée dans le paquet de l’application. Elle n’était donc plus dans node_modules, et le script échouait alors que l’application tournait.

Les variables d’environnement, figées à la construction

NEXT_PUBLIC_* est inséré dans le paquet au moment de la construction. Une image construite avec l’adresse d’une instance ne peut pas servir une autre instance.

Si vous livrez la même image à plusieurs clients, tout ce qui varie doit être lu côté serveur à l’exécution, et descendu au client par propriété. Ça fait plus de code. Sans ça, une image ne sert qu’un client.

Les composants serveur et la session

Un composant serveur lit les cookies et la session sans passer par une requête réseau, ce qui est confortable. Mais il s’exécute avant toute interaction, donc il ne peut pas réagir à un changement de droit survenu entre-temps.

La règle que j’applique : la vérification de droit qui compte est celle de la route d’API, pas celle du composant. Le composant cache un bouton, la route refuse l’action. Cacher sans refuser donne une interface propre et une application ouverte.

L’hydratation échoue en silence

Un écart entre le rendu serveur et le rendu client produit une erreur numérotée dans la console, souvent la 418 ou la 423, et rien d’autre. La page s’affiche, puis les interactions ne répondent pas.

Les trois causes que je rencontre, dans l’ordre :

  • une date formatée sans fuseau fixe, qui diffère entre le serveur et le navigateur,
  • Math.random ou Date.now dans un rendu,
  • une lecture de localStorage faite pendant le rendu plutôt que dans un effet.

La première est de loin la plus fréquente, et la plus sournoise parce qu’elle ne se manifeste qu’à certaines heures.

Le temps de construction, toujours pas réglé

Le temps de construction. Sur l’application citée, une construction complète prend quatre à six minutes, et le cache d’une construction à l’autre reste capricieuse en conteneur. Je n’ai pas trouvé de réglage satisfaisant, et j’ai arrêté d’essayer. Les contournements que j’avais empilés coûtaient plus d’entretien que les minutes qu’ils faisaient gagner sur la durée.