next/dynamic ne vous sauvera pas : pourquoi les pages Next.js pilotées par un CMS embarquent chaque chunk
Nos pages Next.js pilotées par un CMS embarquaient les ~100 composants de section sur chaque route — malgré un usage exemplaire de next/dynamic. Nous avons épuisé tous les correctifs au niveau du bundler (import() direct, découpage des fichiers, config Turbopack, splitChunks de webpack) avant de trouver la vraie cause : la atteignabilité, pas le chunking. Un codegen au build + des rewrites ont réduit le first-load JS de 53%.
Nous avions un site Next.js 16 en production avec environ 700 landing pages pilotées par un CMS. Chaque page est assemblée à partir de « sections » — hero, FAQ, avis, tarifs, etc. — soit une centaine de composants React répartis sur 38 types de sections. Une page typique en rend 10 à 15. La page d'accueil embarquait 5.3 MB de JavaScript au premier chargement. Quand nous avons creusé pour savoir où tout cela partait, nous avons trouvé un unique chunk de 1.2 MB contenant le code de 108 sections — sur une page qui en rend 15.
Chaque section était déjà enveloppée dans next/dynamic. Le code splitting semblait parfaitement conforme aux manuels. Et pourtant le bundler embarquait tout, partout. Nous avons passé des jours à essayer chaque levier documenté — imports dynamiques, import() direct, réorganisation des fichiers, configuration du bundler, et même changement de bundler — et chacun d'eux a échoué pour la même raison, peu évidente. Cet article parcourt chaque impasse avec des chiffres réels, explique la véritable cause racine et montre le correctif qui a supprimé 53% du first-load JS sans toucher à un seul composant de section.
TL;DR : sur des routes dynamiques pilotées par un CMS, le code splitting n'est pas un problème de chunking — c'est un problème de atteignabilité. Aucun flag de bundler ne le corrige. Ce qui le corrige, c'est de déplacer la connaissance « quels composants cette page utilise » du runtime vers le build.
L'architecture (que vous avez probablement aussi)
Le montage est le standard de tout page-builder : le CMS stocke une page sous la forme d'une liste ordonnée de références de sections, et l'application dispose d'un registre central qui associe les noms de sections à des composants. Chaque entrée est enveloppée dans next/dynamic, exactement comme la documentation le recommande :
// One central registry: every section variant wrapped in next/dynamic
export const sectionRegistry = {
'sections.hero': {
concept_1: dynamic(() => import('./sections/Hero/HeroV1')),
concept_2: dynamic(() => import('./sections/Hero/HeroV2')),
// ...10 more hero variants
},
'sections.faq': {
concept_1: dynamic(() => import('./sections/Faq/FaqV1')),
},
// ...38 section types, ~100 variants total
}Un composant serveur résout chaque section par son nom au moment du rendu et la rend. Les pages sont en SSG avec ISR, si bien que tout cela se passe sur le serveur — le client ne reçoit que du HTML plus les chunks nécessaires à l'hydratation :
// Server component: picks ONE section by name from CMS data
export async function SectionRenderer({ name, concept, id }) {
const data = await fetchSectionData(name, id)
const Component = sectionRegistry[name].concepts[concept]
return <Component {...data} />
}Ce design est réellement bon : les marketeurs composent les pages dans le CMS sans déploiement, un seul registre sert 700 pages, chaque variante de section est indépendamment paresseuse. Sur le papier. Le bundle racontait une autre histoire.
Mesurer honnêtement (les DevTools vous mentiront)
Avant tout correctif, il nous fallait une mesure digne de confiance. Trois choses faussent les mesures naïves de l'onglet Network des DevTools :
- Les totaux de la barre du bas sont cumulatifs tant que le panneau enregistre. Quelques secondes après le chargement, le prefetching des liens du framework se met à tirer en arrière-plan les bundles d'AUTRES routes — sur un onglet inactif, les totaux convergent vers « tout, au bout du compte », masquant tout gain sur le premier chargement.
- Les extensions de navigateur injectent des mégaoctets de leurs propres scripts dans votre mesure — même en navigation privée si on les y autorise. Nous avons vu une seule extension ajouter 2 MB de « JS de page ».
- Les lignes servies depuis le cache ((disk cache) / (memory cache)) transfèrent zéro octet sur le réseau, si bien qu'un rechargement à chaud ne mesure absolument rien.
Le nombre qui compte vraiment — et celui auquel réagissent les Core Web Vitals — c'est l'initial script set : les balises <script> présentes dans le HTML rendu par le serveur. C'est ce qui bloque l'hydratation. Et c'est trivialement scriptable :
// Count the REAL first-load JS: the <script> set of the prerendered HTML.
// (DevTools totals lie — more on that below.)
const html = fs.readFileSync('.next/server/app/en.html', 'utf8')
const scripts = [...new Set(
[...html.matchAll(/<script src="([^"?]+\.js)[^"]*"/g)].map(m => m[1])
)]
let bytes = 0
for (const src of scripts) bytes += fs.statSync('.next' + src).size
console.log(scripts.length, 'chunks,', (bytes / 1024).toFixed(0), 'KB')Pour l'attribution par module, nous avons temporairement activé productionBrowserSourceMaps et attribué chaque octet généré à son module source à l'aide d'un petit parseur de VLQ. Un piège à connaître : Turbopack nomme le fichier .map de chaque chunk avec un hash différent de celui du .js — lisez le commentaire sourceMappingURL en fin de chunk au lieu de deviner js + '.map'.
Cinq impasses (pour que vous ne les répétiez pas)
Chacune des pistes suivantes a été vérifiée par un build de production complet et la mesure décrite ci-dessus. Aucune n'a bougé le chiffre. Cette répétition est précisément le propos : le mode de défaillance survit à chaque outil qu'on lui oppose, parce que tous ces outils résolvent un problème différent.
Impasse n°1 : « il suffit d'utiliser next/dynamic »
Il était déjà là. Chacune des ~100 variantes était enveloppée dans dynamic(). Le first-load JS faisait 5.3 MB malgré tout. Quoi que dynamic() promette, il ne le tenait pas ici — gardez cette idée en tête, la raison arrive dans un instant.
Impasse n°2 : await import() direct dans le composant serveur
Et si le wrapper de next/dynamic était le problème ? Dans l'App Router, un composant serveur peut await directement un import dynamique — le pattern officiel de lazy-loading pour les bibliothèques. Nous avons remplacé le registre de wrappers dynamic() par une map de thunks import() bruts :
// Dead end #2: replace next/dynamic with a direct server-side import()
const loaders = {
'sections.hero': { concept_1: () => import('./sections/Hero/HeroV1') },
// ...
}
export async function SectionRenderer({ name, concept, id }) {
const data = await fetchSectionData(name, id)
const { default: Component } = await loaders[name][concept]()
return <Component {...data} />
}
// Result: byte-for-byte the same megachunk. Nothing changed.Impasse n°3 : éclater le registre sur 38 fichiers
Hypothèse suivante : le bundler fusionne les chunks parce que les ~100 appels import() vivent dans un seul module. Nous avons donc généré un fichier loader par type de section — 38 fichiers, chacun ne contenant que les thunks import() de ses propres variantes, plus un mince barrel pour les retrouver par nom.
Résultat : une sortie identique octet pour octet. Le même mégachunk, la même taille au hash près. Ce fut le premier résultat négatif réellement utile : les bundlers regroupent les chunks selon le graphe de modules, pas selon la disposition des fichiers de vos import(). Déplacer des instructions entre fichiers est invisible pour le graphe — le même ensemble de modules reste atteignable depuis le même resolver.
Impasse n°4 : la configuration du bundler
Le chunking de Turbopack est délibérément non configurable : il n'existe aucun équivalent de splitChunks, et les magic comments de webpack (webpackChunkName et consorts) sont ignorés. Le seul flag expérimental pertinent pour le chunking async imbriqué est déjà activé par défaut dans les builds de production. Il ne restait littéralement plus aucun bouton à tourner.
Impasse n°5 : passer à webpack (et l'expérience qui a tout expliqué)
webpack, LUI, est configurable, nous avons donc fait passer le site par next build --webpack. Config par défaut : le même mégachunk, ~1 MB, toutes les sections. Ensuite nous avons forcé la question avec un cacheGroup par section — un chunk par répertoire de section, enforce: true :
config.optimization.splitChunks.cacheGroups.sections = {
test: /[\\/]components[\\/]sections[\\/]/,
chunks: 'all',
minSize: 0,
enforce: true,
priority: 50,
name: (module) => 'section-' + dirNameOf(module), // one chunk per section
}
// Result: the 1 MB megachunk split into 34 neat per-section files...
// ...and the page loaded ALL 34 of them. Same total bytes. Zero win.Les chunks se sont découpés magnifiquement — 34 fichiers par section bien rangés. Et la page a chargé les 34. Le même total d'octets, la même hydratation bloquée, désormais avec plus de requêtes HTTP. C'est le moment où le vrai problème est devenu indéniable : nous avions optimisé la manière dont le code est empaqueté, alors que le problème était de savoir quel code la route référence.
La véritable cause racine : la atteignabilité, pas le chunking
Voici le mécanisme. Les composants de section sont des composants client ('use client') — ils ont des handlers, des sliders, de l'analytics. Quand un arbre de composants serveur référence un composant client, le bundler doit inclure le chunk de ce composant dans le bundle client de la route pour qu'il puisse s'hydrater. Quels composants client une route pilotée par un CMS référence-t-elle ? Le renderer résout les sections à partir d'une chaîne de caractères issue du CMS au runtime — donc, statiquement, chaque section du registre est atteignable depuis chaque page qui utilise le renderer. Le bundler ne peut pas savoir que /pricing n'en rend jamais que 12. Il doit toutes les préparer, les ~100.
next/dynamic n'aide en rien, car la paresse qu'il apporte est côté client : il diffère le moment où un chunk se télécharge par rapport au rendu, mais le serveur a déjà décidé ce qui est rendu avant que le client n'exécute le moindre octet. Ce que le serveur a rendu doit s'hydrater ; ce qui pourrait être rendu doit être embarqué ou atteignable. Avec un registre résolu au runtime, « pourrait être rendu » équivaut à « tout ».
Un bundler embarque ce qui est atteignable. Si votre resolver peut atteindre 100 composants, votre route embarque 100 composants — répartis en un chunk ou en trente-quatre, mais embarqués dans les deux cas. Le seul vrai levier consiste à réduire la atteignabilité elle-même.
Le correctif : déplacer la connaissance vers le build (codegen + rewrites)
La grâce salvatrice des pages SSG pilotées par un CMS : au moment du build, le serveur sait déjà exactement quelles sections chaque page utilise — il les récupère depuis le CMS pour prérendre le HTML. La connaissance existe ; elle est simplement piégée au runtime, là où le bundler ne peut pas la voir. Nous la matérialisons donc en code avant que le bundler ne s'exécute.
Un script de codegen s'exécute comme première étape du build (enchaîné dans le script build du package — ainsi chaque pipeline en bénéficie gratuitement : local, CI, les deux chemins de déploiement) :
// Runs BEFORE next build (chained in the "build" script).
// 1. Find the routes that render CMS pages (scan for the renderer import).
// 2. Ask the CMS which sections each page actually uses —
// union across every locale, and across A/B variants.
// 3. Stamp a physical page file per route whose import map contains
// ONLY those sections. The bundler does the rest.
const pages = await fetchAllCmsPages() // slug -> sections[]
for (const page of inScope(pages)) {
const concepts = unionAcrossLocalesAndVariants(page)
writeFileSync(
`app/[lang]/(generated)/g/${page.key}/page.tsx`,
stampTemplate({ page, concepts }) // inline import() per concept
)
}
writeFileSync('generated-routes.manifest.json', { rewrites })
// Any failure => write nothing => the site behaves exactly as before.Pour chaque page dans le périmètre, il génère un fichier de route physique dont la map d'imports ne contient que les sections de cette page — l'union à travers toutes les locales (différentes locales peuvent avoir différents jeux de sections) et à travers les variantes A/B. Pour le bundler, ce fichier généré est du code source ordinaire à atteignabilité statique étroite, si bien qu'il produit un petit bundle par route sans la moindre configuration :
// AUTO-GENERATED on every build — the "manifest" is literal code.
const LOADERS: SectionLoaders = {
'sections.hero': {
concept_5: () => import('@/components/sections/Hero/HeroV5'),
},
'sections.faq': {
concept_1: () => import('@/components/sections/Faq/FaqV1'),
},
// ...exactly the 13 section types this page renders. Nothing else.
}
export default async function GeneratedHome({ params }) {
const { lang } = await params
return <CmsPage loaders={LOADERS} params={{ lang, slug: 'home' }} />
}Les routes générées vivent sous un chemin interne peu ragoûtant — les utilisateurs ne le voient jamais. Un bloc rewrites() dans next.config lit le manifeste émis et associe les vraies URL aux routes générées dans beforeFiles. La propriété cruciale : si le manifeste est absent ou cassé, il n'y a tout simplement aucun rewrite et chaque page est servie par la route universelle intacte :
async rewrites() {
// Missing/broken manifest => zero rewrites => yesterday's behavior.
// The codegen can never take the site down.
let generated: Array<{ source: string; destination: string }> = []
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, 'generated-routes.manifest.json'), 'utf8')
)
if (Array.isArray(manifest?.rewrites)) generated = manifest.rewrites
} catch { /* fall back to universal routes */ }
return {
beforeFiles: [
...generated,
// e.g. { source: '/:lang(en|de|fr|...)', destination: '/:lang/g/home' }
],
}
}La chaîne de rendu est un miroir de l'originale — même récupération de données, même SEO, même analytics — avec une seule différence qui est tout l'intérêt : la map de composants arrive en tant que prop au lieu d'être importée depuis le registre global. Un invariant doit tenir, sinon tout le gain s'évapore : rien dans le graphe de modules de la route générée ne doit importer le registre global. Cela inclut les chemins indirects — notre helper de récupération de données importait le registre juste pour lire des templates de requêtes, ce qui aurait rendu toutes les sections de nouveau atteignables ; il a fallu isoler les métadonnées dans leur propre module dépourvu de registre.
// Same rendering logic as before — but the component map arrives
// as a PROP from the generated page. Nothing in this chain may import
// the global registry, or every section becomes reachable again.
export async function SectionRendererGen({ loaders, name, concept, id }) {
const data = await fetchSectionData(name, id)
const loader = loaders[name]?.[concept] ?? loaders[name]?.['concept_1']
if (!data || !loader) return null
const { default: Component } = await loader()
return <Component {...data} />
}Survivre aux tests A/B (la partie que tout le monde questionne)
Les pages CMS font tourner des expériences A/B : un middleware évalue un feature flag à chaque requête et réécrit les utilisateurs d'un bucket de variante vers une page différente sous la même URL. C'est exactement pour cela que la résolution au runtime existait au départ — alors comment un système au build s'en accommode-t-il ? En respectant l'ordre de traitement. Dans Next.js, le middleware s'exécute toujours avant les rewrites beforeFiles, si bien que les deux couches se composent au lieu de se combattre :
request /
│
▼
proxy (middleware) — A/B decision, ALWAYS runs first
├─ no experiment → pass through as /en
├─ user in CONTROL → set cookie, pass through as /en
└─ user in VARIANT → rewrite to /en/ab/<variant-slug>
│
▼
beforeFiles rewrites — OUR manifest, runs on whatever path proxy chose
├─ /en → /en/g/home (generated, small)
├─ /en/ab/<variant> → /en/g/ab-<variant> (generated, small)
└─ anything unmatched → untouched → universal route (fallback)Le codegen lit les configs d'expériences depuis la même source que le middleware (un invariant délibéré — aucune dérive possible), et génère une page par variante, plus un rewrite exact pour l'URL interne de la variante. Les utilisateurs du groupe de contrôle obtiennent la page générée rapide ; les utilisateurs de la variante obtiennent leur propre page générée rapide ; l'URL visible par l'utilisateur ne change jamais. Une expérience créée après le dernier build retombe simplement sur la route universelle jusqu'au prochain déploiement — dégradée à la performance d'hier, jamais cassée.
Le contrat fail-open
L'ensemble du système est conçu pour se dégrader vers le statu quo, jamais en dessous. Nous avons pu voir cela fonctionner en production par accident : au premier déploiement, l'environnement de CI exposait l'URL du CMS sous un nom de variable différent de celui des builds locaux. Le codegen n'a trouvé aucun CMS, n'a rien émis — et le site a servi chaque page via la route universelle : build au vert, zéro impact utilisateur. Un correctif de deux lignes plus tard, les routes générées sont apparues. Le contrat :
- CMS injoignable ou page non parsable → cette page (ou tout) est ignorée ; le build n'échoue jamais à cause du codegen.
- Pas de manifeste → pas de rewrites → la route universelle sert tout, exactement comme avant le projet.
- Structure des sections modifiée dans le CMS après le build → la page rend l'ancien jeu jusqu'au prochain déploiement (un webhook de publication qui déclenche des redéploiements réduit cette fenêtre à quelques minutes). Les modifications de contenu ne sont pas affectées — les données sont toujours récupérées en direct via l'ISR.
- Les fichiers générés sont gitignorés et régénérés à chaque build ; les relecteurs relisent le générateur, pas 700 fichiers estampillés.
Résultats
| Métrique (page d'accueil) | Avant | Après |
|---|---|---|
| First-load JS (brut, initial script set) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Composants de section dans le bundle | 137 | 16 |
| Le mégachunk de toutes les sections | ~1,200 KB | 0 KB |
−53% de JavaScript au premier chargement, confirmé indépendamment de trois manières : la mesure de l'initial script set, l'attribution par module via source-maps (exactement les 16 composants propres de la page — 13 sections mappées plus leurs composants enfants), et un grep de marqueurs sur les chunks du déploiement en direct :
// Verify on the LIVE deployment without source maps: CSS class names
// are embedded in the JS chunks, so grep for section markers.
let all = ''
for (const src of initialScriptsOf('/en')) all += await fetchText(src)
// must be there: the sections the page renders
console.log(all.includes('HeroV5')) // true ✅
// must NOT be there: everything else
console.log(all.includes('fortune_wheel')) // false ✅
console.log(all.includes('holiday_offer')) // false ✅Les mêmes chiffres se sont reproduits sur la preview de production au kilo-octet près. Une attente à gérer : le TOTAL réseau de vos DevTools paraîtra quasiment inchangé, car après le chargement le routeur préfétche en arrière-plan les bundles d'autres routes (encore universelles). C'est normal — le prefetch en arrière-plan ne bloque rien. La performance, c'est ce qui charge avant l'interactivité, et c'est ce chiffre qui a été divisé par deux. Lighthouse et sa treemap le montrent sans ambiguïté si vous voulez une preuve prête à capturer en screenshot.
Compromis, en toute honnêteté
- La fraîcheur de la structure est liée au déploiement : ajouter/supprimer une section dans le CMS nécessite un rebuild pour atteindre la route optimisée (les déploiements déclenchés par webhook réduisent la fenêtre à quelques minutes ; le fallback couvre l'écart).
- Le codegen parse votre CMS et vos conventions de routes — ce sont ~400 lignes de code qui vous appartiennent et que vous devez maintenir, y compris son parsing du registre et des props de route.
- Échelle : des centaines de pages CMS → des centaines de combinaisons de sections uniques (nos ~700 pages donnaient ~380 jeux uniques, une longue traîne). Commencez par les pages à plus fort trafic derrière une constante de périmètre explicite ; le fallback sert la traîne.
- Cela ne réduit pas le JS total du site — les bibliothèques riches en variantes et les sections chargées existent toujours. Cela réduit ce que chaque route embarque. Le levier suivant (convertir les sections présentationnelles en composants serveur) est un projet distinct et plus ambitieux.
À retenir
- next/dynamic crée des points de découpage, pas des garanties. Sur les pages pilotées par le serveur, la paresse est décidée par ce que la route peut atteindre, pas par la façon dont les imports sont écrits.
- La configuration du chunking ne peut pas corriger la atteignabilité. Nous l'avons prouvé de manière exhaustive : découper le mégachunk en 34 chunks par section n'a rien changé — les 34 ont été chargés.
- Si vos pages sont en SSG/ISR depuis un CMS, l'information dont vous avez besoin existe déjà au moment du build. Le codegen est le pont : transformez les lookups au runtime en imports statiques par route.
- Composez avec le middleware, ne le combattez pas : le middleware décide QUELLE page (A/B), les rewrites décident QUELLE IMPLÉMENTATION de cette page. L'ordre garantit qu'ils s'empilent.
- Construisez des systèmes fail-open : manifeste manquant = le site d'hier. Notre premier déploiement en production a exercé le fallback par accident et les utilisateurs n'ont jamais rien remarqué.
Le pattern se généralise à tout « registre de composants résolus par des données de CMS au runtime » — page-builders, systèmes de widgets, vitrines thématisables. Si votre route peut rendre n'importe quoi, elle embarquera tout. Faites en sorte que le build sache ce que chaque page est réellement, et le bundler fera enfin ce que vous avez toujours supposé qu'il faisait.