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.
| Suffixe | Signification |
|---|---|
.server.js | Code serveur strict. Peut importer pg, fs, node:*. |
.client.js | Débute par 'use client'. |
actions.js | Débute par 'use server' : server actions Next.js. |
| Aucun suffixe | Module 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/usersreste serveur, et les constantes de permissions vivent dans@lrvo/core/users/constants, qui n'importe rien.@lrvo/core/features/adminreste neutre et ne ré-exporte pasprotect.js, qui importenext/navigationau 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 danspackage.json#exports. - Les handlers qui restent dans un barrel exposé, comme
core/api/router.jsoucore/storage/api.js, importentcookiesen 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-*dansserverExternalPackages: le package sort du bundler, ses frontières client disparaissent du manifeste ; - un
"sideEffects": falsedans lepackage.jsond'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().