next/dynamic no te salvará: por qué las páginas de Next.js gestionadas por CMS envían todos los chunks
Nuestras páginas de Next.js gestionadas por CMS enviaban los ~100 componentes de sección en cada ruta, pese a un uso de next/dynamic de manual. Agotamos todos los arreglos a nivel de bundler (import() directo, división de archivos, configuración de Turbopack, splitChunks de webpack) antes de encontrar la causa real: la alcanzabilidad, no el chunking. La generación de código en tiempo de build más rewrites recortó el JS de primera carga en un 53%.
Teníamos un sitio en producción con Next.js 16 y unas 700 landing pages gestionadas por CMS. Cada página se ensambla a partir de «secciones» —hero, FAQ, reseñas, precios, etc.—, alrededor de 100 componentes de React repartidos en 38 tipos de sección. Una página típica renderiza entre 10 y 15 de ellos. La página de inicio enviaba 5.3 MB de JavaScript de primera carga. Cuando investigamos a dónde iba todo eso, encontramos un único chunk de 1.2 MB que contenía el código de 108 secciones, en una página que renderiza 15.
Cada sección ya estaba envuelta en next/dynamic. La división de código parecía correcta de manual. Y aun así el bundler enviaba todo, en todas partes. Pasamos días probando cada palanca documentada —imports dinámicos, import() directo, reorganización de archivos, configuración del bundler, incluso cambiar de bundler— y cada una de ellas falló por la misma razón nada obvia. Este artículo recorre cada callejón sin salida con números reales, explica la causa raíz de verdad y muestra el arreglo que recortó el 53% del JS de primera carga sin tocar un solo componente de sección.
TL;DR: en rutas dinámicas gestionadas por CMS, la división de código no es un problema de chunking, es un problema de alcanzabilidad. Ningún flag del bundler lo arregla. Lo arregla mover el conocimiento de «qué componentes usa esta página» del runtime al tiempo de build.
La arquitectura (que probablemente tú también tengas)
El montaje es el estándar de cualquier page-builder: el CMS almacena una página como una lista ordenada de referencias a secciones, y la aplicación tiene un registro central que mapea nombres de sección a componentes. Cada entrada está envuelta en next/dynamic, exactamente como recomienda la documentación:
// 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 componente de servidor resuelve cada sección por su nombre en el momento del render y la renderiza. Las páginas son SSG con ISR, así que todo esto ocurre en el servidor: el cliente solo recibe HTML más los chunks necesarios para la hidratación:
// 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} />
}Este diseño es genuinamente bueno: los marketers componen páginas en el CMS sin deploys, un único registro sirve 700 páginas, cada variante de sección es lazy de forma independiente. Sobre el papel. El bundle contaba otra historia.
Medir con honestidad (las DevTools te mentirán)
Antes de cualquier arreglo necesitábamos una medición en la que pudiéramos confiar. Tres cosas envenenan las mediciones ingenuas del panel Network de las DevTools:
- Los totales de la barra inferior son acumulativos durante todo el tiempo que el panel esté grabando. Unos segundos después de la carga, el prefetch de enlaces del framework empieza a traer en segundo plano bundles de OTRAS rutas; en una pestaña inactiva los totales convergen hacia «al final, todo», ocultando cualquier mejora de primera carga.
- Las extensiones del navegador inyectan megabytes de sus propios scripts en tu medición, incluso en incógnito si tienen permiso allí. Vimos cómo una sola extensión añadía 2 MB de «JS de la página».
- Las filas servidas desde caché ((disk cache) / (memory cache)) transfieren cero bytes por la red, así que una recarga en caliente no mide absolutamente nada.
El número que de verdad importa —y al que responde Core Web Vitals— es el conjunto inicial de scripts: las etiquetas <script> del HTML renderizado en el servidor. Eso es lo que bloquea la hidratación. Y además es trivial de obtener por script:
// 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')Para la atribución por módulo activamos temporalmente productionBrowserSourceMaps y atribuimos cada byte generado a su módulo de origen con un pequeño parser de VLQ. Un detalle que conviene conocer: Turbopack nombra el archivo .map de cada chunk con un hash distinto al del .js; lee el comentario sourceMappingURL de la cola del chunk en lugar de adivinar js + '.map'.
Cinco callejones sin salida (para que no los repitas)
Cada uno de los siguientes se verificó con un build de producción completo y con la medición anterior. Ninguno movió el número. Esa repetición es justo el punto: el modo de fallo sobrevive a cualquier herramienta que le eches encima, porque todas esas herramientas resuelven un problema distinto.
Callejón sin salida n.º 1: «solo usa next/dynamic»
Ya estaba ahí. Cada una de las ~100 variantes estaba envuelta en dynamic(). El JS de primera carga era de 5.3 MB igualmente. Fuera lo que fuera lo que dynamic() promete, aquí no lo cumplía; guarda esa idea, la razón aparece en un momento.
Callejón sin salida n.º 2: await import() directo en el componente de servidor
¿Quizá el problema sea el wrapper de next/dynamic? En el App Router, un componente de servidor puede hacer await de un import dinámico directamente: el patrón oficial de carga diferida para bibliotecas. Reemplazamos el registro de wrappers dynamic() por un mapa de thunks de import() en crudo:
// 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.Callejón sin salida n.º 3: dividir el registro en 38 archivos
Siguiente hipótesis: el bundler fusiona los chunks porque las ~100 llamadas a import() viven en un solo módulo. Así que generamos un archivo loader por tipo de sección: 38 archivos, cada uno con solo los thunks de import() de sus propias variantes, más un barrel delgado para buscarlos por nombre.
Resultado: salida idéntica byte a byte. El mismo megachunk, el mismo tamaño hasta el hash. Este fue el primer resultado negativo genuinamente útil: los bundlers agrupan los chunks por el grafo de módulos, no por la distribución en archivos de tus llamadas a import(). Mover sentencias entre archivos es invisible para el grafo: el mismo conjunto de módulos sigue siendo alcanzable desde el mismo resolvedor.
Callejón sin salida n.º 4: configuración del bundler
El chunking de Turbopack es deliberadamente no configurable: no hay equivalente a splitChunks, y los magic comments de webpack (webpackChunkName y compañía) se ignoran. El único flag experimental relevante para el chunking asíncrono anidado ya está activado por defecto en los builds de producción. Literalmente no quedaba ninguna perilla que girar.
Callejón sin salida n.º 5: cambiar a webpack (y el experimento que lo explicó todo)
Webpack SÍ es configurable, así que pasamos el sitio por next build --webpack. Config por defecto: el mismo megachunk, ~1 MB, todas las secciones. Entonces forzamos el asunto con un cacheGroup por sección: un chunk por directorio de sección, 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.Los chunks se dividieron de maravilla: 34 archivos pulcros, uno por sección. Y la página cargó los 34. Los mismos bytes totales, la misma hidratación bloqueada, ahora con más peticiones HTTP. Ese fue el momento en que el problema real se volvió innegable: llevábamos optimizando cómo se empaqueta el código, cuando el problema era a qué código hace referencia la ruta.
La verdadera causa raíz: alcanzabilidad, no chunking
Este es el mecanismo. Los componentes de sección son componentes de cliente ('use client'): tienen manejadores, sliders, analítica. Cuando un árbol de componentes de servidor hace referencia a un componente de cliente, el bundler debe incluir el chunk de ese componente en el bundle de cliente de la ruta para que pueda hidratar. ¿A qué componentes de cliente hace referencia una ruta gestionada por CMS? El renderizador resuelve las secciones a partir de un string obtenido en runtime desde el CMS, así que, estáticamente, cada sección del registro es alcanzable desde cada página que use el renderizador. El bundler no puede saber que /pricing solo renderiza 12 de ellas. Debe preparar las ~100.
next/dynamic no ayuda, porque la pereza que proporciona es del lado del cliente: difiere cuándo se descarga un chunk respecto al render, pero el servidor ya ha decidido qué se renderiza antes de que el cliente ejecute un solo byte. Lo que el servidor renderizó debe hidratar; lo que podría renderizarse debe enviarse o ser alcanzable. Con un registro resuelto en runtime, «podría renderizarse» equivale a «todo».
Un bundler empaqueta lo que es alcanzable. Si tu resolvedor puede alcanzar 100 componentes, tu ruta envía 100 componentes: divididos en un chunk o en treinta y cuatro, pero enviados de todas formas. La única palanca real es reducir la alcanzabilidad en sí misma.
El arreglo: mover el conocimiento al tiempo de build (codegen + rewrites)
La gracia salvadora de las páginas SSG gestionadas por CMS: en tiempo de build, el servidor ya sabe exactamente qué secciones usa cada página, porque las obtiene del CMS para prerrenderizar el HTML. El conocimiento existe; solo está atrapado en el runtime, donde el bundler no puede verlo. Así que lo materializamos en código antes de que el bundler se ejecute.
Un script de codegen se ejecuta como primer paso del build (encadenado en el script build del paquete, para que cada pipeline lo obtenga gratis: local, CI, ambas vías de deploy):
// 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.Para cada página dentro del alcance, estampa un archivo de ruta físico cuyo mapa de imports contiene solo las secciones de esa página: la unión a través de todos los locales (distintos locales pueden tener conjuntos de secciones diferentes) y a través de las variantes A/B. Para el bundler, este archivo generado es código fuente ordinario con una alcanzabilidad estática estrecha, así que produce un bundle pequeño por ruta sin ninguna configuración:
// 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' }} />
}Las rutas generadas viven bajo una ruta interna fea que los usuarios nunca ven. Un bloque rewrites() en next.config lee el manifiesto emitido y mapea las URL reales a las rutas generadas en beforeFiles. La propiedad crucial: si el manifiesto falta o está roto, simplemente no hay rewrites y cada página la sirve la ruta universal intacta:
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 cadena de renderizado es un espejo de la original —el mismo fetching de datos, el mismo SEO, la misma analítica— con una diferencia que es todo el sentido del asunto: el mapa de componentes llega como una prop en lugar de importarse del registro global. Un invariante debe cumplirse o toda la ganancia se evapora: nada en el grafo de módulos de la ruta generada puede importar el registro global. Eso incluye las rutas indirectas: nuestro helper de fetching de datos importaba el registro solo para leer las plantillas de consulta, lo que habría vuelto a hacer alcanzable cada sección; los metadatos tuvieron que separarse en su propio módulo libre de registro.
// 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} />
}Sobrevivir a los tests A/B (la parte que todos preguntan)
Las páginas del CMS ejecutan experimentos A/B: el middleware evalúa un feature flag por petición y reescribe a los usuarios de un grupo de variante hacia una página distinta bajo la misma URL. Esto es exactamente el motivo por el que existía la resolución en runtime en primer lugar, así que ¿cómo se las arregla un sistema en tiempo de build? Respetando el orden de procesamiento. En Next.js, el middleware siempre se ejecuta antes que los rewrites de beforeFiles, así que las dos capas se componen en lugar de pelearse:
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)El codegen lee las configuraciones de experimentos de la misma fuente que usa el middleware (un invariante deliberado: cero deriva posible), y estampa una página generada por variante, más un rewrite exacto para la URL interna de la variante. Los usuarios de control obtienen la página generada rápida; los usuarios de variante obtienen su propia página generada rápida; la URL visible para el usuario nunca cambia. Un experimento creado después del último build simplemente cae a la ruta universal hasta el siguiente deploy: degradado al rendimiento de ayer, nunca roto.
El contrato fail-open
Todo el sistema está diseñado para degradar hasta el statu quo, nunca por debajo. Nos tocó verlo funcionar en producción por accidente: en el primer deploy, el entorno de CI exponía la URL del CMS bajo un nombre de variable distinto al de los builds locales. El codegen no encontró CMS, no emitió nada, y el sitio sirvió cada página a través de la ruta universal: build verde, cero impacto para los usuarios. Un arreglo de dos líneas después, las rutas generadas aparecieron. El contrato:
- CMS inalcanzable o una página imparseable → esa página (o todo) se omite; el build nunca falla por culpa del codegen.
- Sin manifiesto → sin rewrites → la ruta universal lo sirve todo, exactamente como antes del proyecto.
- La estructura de secciones cambió en el CMS después del build → la página renderiza el conjunto antiguo hasta el siguiente deploy (un webhook de publicación que dispara redeploys reduce esa ventana a minutos). Las ediciones de contenido no se ven afectadas: los datos se siguen obteniendo en vivo vía ISR.
- Los archivos generados están en gitignore y se regeneran en cada build; los revisores revisan el generador, no 700 archivos estampados.
Resultados
| Métrica (página de inicio) | Antes | Después |
|---|---|---|
| JS de primera carga (raw, conjunto inicial de scripts) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Componentes de sección en el bundle | 137 | 16 |
| El megachunk con todas las secciones | ~1,200 KB | 0 KB |
−53% de JavaScript de primera carga, confirmado de forma independiente por tres vías: la medición del conjunto inicial de scripts, la atribución por módulo mediante source maps (exactamente los 16 componentes propios de la página: 13 secciones mapeadas más sus componentes hijos) y un grep de marcadores sobre los chunks del despliegue en vivo:
// 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 ✅Los mismos números se reprodujeron al kilobyte en la preview de producción. Una expectativa que conviene gestionar: el TOTAL de tu panel Network en las DevTools se verá casi sin cambios, porque tras la carga el router hace prefetch en segundo plano de los bundles (todavía universales) de otras rutas. No pasa nada: el prefetch en segundo plano no bloquea nada. El rendimiento tiene que ver con lo que se carga antes de la interactividad, y ese es el número que se redujo a la mitad. Lighthouse y su treemap lo muestran sin ambigüedad si quieres una prueba apta para captura de pantalla.
Compromisos, con honestidad
- La frescura de la estructura está atada al deploy: añadir o quitar una sección en el CMS requiere un rebuild para llegar a la ruta optimizada (los deploys disparados por webhook reducen la ventana a minutos; el fallback cubre el hueco).
- El codegen parsea tu CMS y tus convenciones de rutas: son ~400 líneas de código que tú posees y debes mantener, incluido su parseo del registro y de las props de ruta.
- Escala: cientos de páginas del CMS → cientos de combinaciones únicas de secciones (nuestras ~700 páginas tenían ~380 conjuntos únicos, una larga cola). Empieza por las páginas con más tráfico tras una constante de alcance explícita; el fallback sirve la cola.
- Esto no reduce el JS total del sitio: las bibliotecas pesadas por variantes y las secciones ricas siguen existiendo. Reduce lo que envía cada ruta. La siguiente palanca (convertir las secciones presentacionales en componentes de servidor) es un proyecto aparte y más grande.
Conclusiones
- next/dynamic crea puntos de división, no garantías. En páginas gestionadas por el servidor, la pereza la decide lo que la ruta puede alcanzar, no cómo están escritos los imports.
- La configuración del chunking no puede arreglar la alcanzabilidad. Lo demostramos de forma exhaustiva: dividir el megachunk en 34 chunks por sección no cambió nada; los 34 se cargaron.
- Si tus páginas son SSG/ISR desde un CMS, la información que necesitas ya existe en tiempo de build. El codegen es el puente: convierte las búsquedas en runtime en imports estáticos por ruta.
- Compón con el middleware, no pelees contra él: el middleware decide QUÉ página (A/B), los rewrites deciden QUÉ IMPLEMENTACIÓN de esa página. El orden garantiza que se apilen.
- Construye sistemas fail-open: manifiesto ausente = el sitio de ayer. Nuestro primer deploy de producción ejercitó el fallback por accidente y los usuarios nunca lo notaron.
El patrón se generaliza a cualquier «registro de componentes resueltos por datos del CMS en runtime»: page-builders, sistemas de widgets, escaparates tematizables. Si tu ruta puede renderizar cualquier cosa, enviará todo. Haz que el build sepa qué es realmente cada página, y el bundler por fin hará lo que siempre supusiste que hacía.