Frontières serveur/client

Suffixes de runtime, barrels mixtes, imports lazy de next/headers et interdiction du self-fetch HTTP.

Next.js sépare trois mondes : le code serveur strict, le code client, et le code neutre utilisable des deux côtés. Une erreur de frontière ne se manifeste presque jamais à l'endroit où elle a été commise, d'où les règles qui suivent.

Suffixes de runtime

Tout fichier épinglé à une frontière porte le suffixe dans son nom.

SuffixeSignification
.server.jsCode serveur strict. Peut importer pg, fs, node:*.
.client.jsDébute par 'use client'.
actions.jsDébute par 'use server' : server actions Next.js.
Aucun suffixeModule neutre, utilisable des deux côtés.

Trois fichiers d'un module échappent à la convention parce qu'ils nomment un point d'entrée du contrat plutôt qu'un runtime : index.js, client.js et public-page.js. Le premier et le dernier sont du code serveur, le second débute par 'use client'.

Ces suffixes ne sont pas cosmétiques : le build les utilise comme source de vérité. Il compile l'intégralité de src/ avec bundle: false, donc chaque fichier reste un module séparé, ce qui permet à Next.js de respecter les frontières RSC et 'use client' sans que le bundler ne fusionne les modules.

La règle qui produit le plus d'erreurs

Tout fichier qui importe pg, fs, net, node:* doit porter .server.js.

Sans ce suffixe, le fichier est réputé neutre. Un barrel client peut alors le ré-exporter par mégarde, et Turbopack comme Webpack tracent la chaîne d'imports statiques jusqu'à pg dans le bundle navigateur.

Le symptôme typique est une erreur qui ne pointe pas vers le fichier fautif :

Module not found: Can't resolve 'dns'

Elle vient d'un fichier serveur sans suffixe atteint par un barrel mixte. Le correctif n'est pas d'ajouter dns en external, c'est de rendre la frontière explicite.

Barrels mixtes

Un barrel mixte ré-exporte du code serveur à côté de valeurs utilisables côté client. Il est toujours un piège : l'appelant importe une constante inoffensive et emporte tout le graphe serveur avec elle.

Deux barrels du core illustrent la sortie de ce piège :

  • @lrvo/core/users reste serveur, et les constantes de permissions vivent dans @lrvo/core/users/constants, qui n'importe rien.
  • @lrvo/core/features/admin reste neutre et ne ré-exporte pas protect.js, qui importe next/navigation au top-level. Les gardes sont exposées à part, sous @lrvo/core/features/admin/protect.

Le catalogue complet des sous-chemins et de leur runtime est dans Points d'entrée.

Pas de next/headers au top-level dans la chaîne register()

Tout fichier atteignable depuis le register() d'un module externe, directement ou transitivement via un barrel @lrvo/core/* qu'il importe, ne doit pas importer next/headers ni next/navigation au top-level. L'import doit être lazy, à l'intérieur du handler.

export async function handleSomething(request) {
  const { cookies } = await import('next/headers');
  // …
}

Pourquoi

discover.server.js charge chaque module via import(/* turbopackIgnore */ name), pour empêcher Turbopack et Webpack de bundler un nom dynamique. Conséquence : Node résout tout l'arbre d'imports transitifs en ESM natif, sans la condition react-server.

Or next/headers n'est exposé qu'à travers cette condition depuis Next.js 15. Un import top-level échoue donc avec :

Cannot find module 'next/headers'

Conséquences pratiques

  • Les fichiers qui ont besoin de Next.js au top-level (features/admin/protect.js, features/auth/actions.js) ne sont jamais ré-exportés par les barrels accessibles aux modules. Ils sont exposés par des sous-chemins dédiés dans package.json#exports.
  • Les handlers qui restent dans un barrel exposé, comme core/api/router.js ou core/storage/api.js, importent cookies en lazy à l'intérieur du handler.

Avant d'ajouter un import { cookies } from 'next/headers' à un fichier core, vérifier qu'aucun barrel exposé aux modules ne le tire transitivement. Dans le doute, garder l'import lazy.

Pas de self-fetch HTTP

Un composant serveur, un template, un bloc ou un sitemap ne doit pas faire de fetch vers les routes /lrvo/api/* du même processus.

Deux raisons.

Latence inutile. Le fetch ré-entre dans Next.js, re-route, ré-interroge la base et sérialise puis désérialise du JSON, alors que le code tourne déjà sur le serveur.

Échec silencieux en production. S'appeler via l'URL publique ressort sur le reverse proxy ou le WAF, qui bloque ou réécrit ces requêtes serveur à serveur. La réponse n'est pas le JSON attendu, le code fait if (!res.ok) return [], et la section disparaît sans erreur, alors qu'elle s'affichait en développement.

À la place, lire la donnée directement côté serveur via les helpers ./public que chaque module de contenu expose : getPublicPosts, getPublicPostBySlug, getPublicTaxonomyTerms pour @lrvo/module-posts, listPublicChildPages, getPublishedPageBySegments pour @lrvo/module-pages.

Les routes HTTP restent réservées aux clients navigateur et à l'admin.

L'ancien helper getInternalBaseUrl(), qui bouclait sur le loopback, a été retiré : il traitait le second symptôme sans régler le premier.

Composants client dans le manifeste de modules

Le manifeste client généré par lrvo-modules sync exporte un composant à rendre, pas seulement un fichier à importer.

Sous Next.js 15 et plus avec Turbopack, un import purement side-effect d'un fichier 'use client' orphelin inclut bien le fichier dans le bundle navigateur, mais n'exécute pas son code top-level. Les registerPage() et registerWidget() ne tourneraient jamais. Rendre le composant force l'exécution.

Voir Cycle de vie d'un module.

Composants client d'une page publique de module

Le cas symétrique, et la confusion est facile : ici le problème n'est pas l'exécution du code top-level, c'est la présence au manifeste.

Une page publique de module est retrouvée au rendu par getPublicModulePage(moduleName), une lecture de registre. Aucun import ne relie la route au composant. Le compilateur RSC de app/lrvo/[...path] ne descend donc jamais jusqu'aux fichiers 'use client' de cette page, et ils n'entrent pas dans le React Client Manifest de la route. Au rendu, React cherche la référence client et lève :

Could not find the module "…/InvoiceView.client.js#default" in the React Client Manifest.

Enregistrer depuis instrumentation.js ne suffit pas : c'est une compilation distincte, avec son propre manifeste. Le remède est une arête d'import statique depuis la route elle-même, via la sous-entrée ./public-page du module et le manifeste app/.lrvo/public-pages.server.js.

Contrairement au manifeste client, rien n'est à rendre : public-pages.server.js est un module serveur ordinaire, évalué normalement dans le rendu RSC. Un import side-effect suffit.

Deux configurations cassent ce mécanisme, et il faut les refuser :

  • un @lrvo/module-* dans serverExternalPackages : le package sort du bundler, ses frontières client disparaissent du manifeste ;
  • un "sideEffects": false dans le package.json d'un module : le bundler devient libre d'élaguer l'arête d'import.

Voir Pages publiques.

Lecture de process.env côté client

Une variable sans préfixe NEXT_PUBLIC_ n'existe pas dans le bundle navigateur. La lire retourne undefined sans erreur, et un flag booléen se comporte alors comme désactivé en silence.

Les composants client de l'admin ne lisent donc jamais process.env. AdminLayout lit les flags côté serveur et les fait descendre par le contexte de config d'AdminShell, lu avec useLrvoConfig().

Sur cette page