next/dynamic Won't Save You: Why CMS-Driven Next.js Pages Ship Every Chunk
Our CMS-driven Next.js pages shipped all ~100 section components on every route — despite textbook next/dynamic usage. We exhausted every bundler-level fix (direct import(), file splitting, Turbopack config, webpack splitChunks) before finding the real cause: reachability, not chunking. Build-time codegen + rewrites cut first-load JS by 53%.
We had a production Next.js 16 site with roughly 700 CMS-driven landing pages. Each page is assembled from "sections" — hero, FAQ, reviews, pricing, and so on — around 100 React components across 38 section types. A typical page renders 10–15 of them. The homepage shipped 5.3 MB of first-load JavaScript. When we dug into where it went, we found a single 1.2 MB chunk containing the code of 108 sections — on a page that renders 15.
Every section was already wrapped in next/dynamic. The code-splitting looked textbook-correct. And yet the bundler shipped everything, everywhere. We spent days trying every documented lever — dynamic imports, direct import(), file reorganization, bundler configuration, even switching bundlers — and every single one failed for the same non-obvious reason. This article walks through each dead end with real numbers, explains the actual root cause, and shows the fix that cut 53% of first-load JS without touching a single section component.
TL;DR: on CMS-driven dynamic routes, code splitting is not a chunking problem — it is a reachability problem. No bundler flag fixes it. Moving the "which components does this page use" knowledge from runtime to build time does.
The architecture (that you probably also have)
The setup is the standard one for any page-builder: the CMS stores a page as an ordered list of section references, and the app has a central registry mapping section names to components. Every entry is wrapped in next/dynamic, exactly like the docs recommend:
// 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
}A server component resolves each section by name at render time and renders it. Pages are SSG with ISR, so all of this happens on the server — the client only receives HTML plus the chunks needed for hydration:
// 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} />
}This design is genuinely good: marketers compose pages in the CMS without deploys, one registry serves 700 pages, every section variant is independently lazy. On paper. The bundle told a different story.
Measuring honestly (DevTools will lie to you)
Before any fix, we needed a measurement we could trust. Three things poison naive DevTools Network measurements:
- The bottom-bar totals are cumulative for as long as the panel records. A few seconds after load, the framework's link prefetching starts pulling bundles of OTHER routes in the background — on an idle tab the totals converge to "everything eventually", hiding any first-load win.
- Browser extensions inject megabytes of their own scripts into your measurement — even in incognito if they're allowed there. We watched a single extension add 2 MB of "page JS".
- Cache-served rows ((disk cache) / (memory cache)) transfer zero bytes over the network, so a warm reload measures nothing at all.
The number that actually matters — and the one Core Web Vitals responds to — is the initial script set: the <script> tags in the server-rendered HTML. That's what blocks hydration. It's also trivially 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')For per-module attribution we temporarily enabled productionBrowserSourceMaps and attributed every generated byte to its source module with a small VLQ parser. One gotcha worth knowing: Turbopack names each chunk's .map file with a different hash than the .js — read the sourceMappingURL comment from the chunk tail instead of guessing js + '.map'.
Five dead ends (so you don't repeat them)
Each of the following was verified with a full production build and the measurement above. None of them moved the number. That repetition is the point — the failure mode survives every tool you throw at it, because all these tools solve a different problem.
Dead end #1: "just use next/dynamic"
It was already there. Every one of the ~100 variants was wrapped in dynamic(). The first-load JS was 5.3 MB anyway. Whatever dynamic() promises, it wasn't delivering here — hold that thought, the reason lands in a moment.
Dead end #2: direct await import() in the server component
Maybe next/dynamic's wrapper is the problem? In the App Router, a server component can await a dynamic import directly — the official lazy-loading pattern for libraries. We replaced the registry of dynamic() wrappers with a map of raw import() thunks:
// 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.Dead end #3: splitting the registry across 38 files
Next hypothesis: the bundler merges chunks because all ~100 import() calls live in one module. So we generated one loader file per section type — 38 files, each holding only its own variants' import() thunks, plus a thin barrel to look them up by name.
Result: byte-for-byte identical output. Same megachunk, same hash-adjacent size. This was the first genuinely useful negative result: bundlers group chunks by the module graph, not by the file layout of your import() calls. Moving statements between files is invisible to the graph — the same set of modules stays reachable from the same resolver.
Dead end #4: bundler configuration
Turbopack's chunking is deliberately not configurable: there is no splitChunks equivalent, and webpack magic comments (webpackChunkName and friends) are ignored. The one relevant experimental flag for nested async chunking is already enabled by default in production builds. There was literally no knob left to turn.
Dead end #5: switching to webpack (and the experiment that explained everything)
Webpack IS configurable, so we ran the site through next build --webpack. Default config: same megachunk, ~1 MB, all sections. Then we forced the issue with a per-section cacheGroup — one chunk per section directory, 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.The chunks split beautifully — 34 tidy per-section files. And the page loaded all 34 of them. Same total bytes, same blocked hydration, now with more HTTP requests. This is the moment the real problem became undeniable: we had been optimizing how the code is packaged, when the problem was which code the route references.
The real root cause: reachability, not chunking
Here is the mechanism. The section components are client components ('use client') — they have handlers, sliders, analytics. When a server component tree references a client component, the bundler must include that component's chunk in the route's client bundle so it can hydrate. Which client components does a CMS-driven route reference? The renderer resolves sections by a runtime string from the CMS — so statically, every section in the registry is reachable from every page that uses the renderer. The bundler cannot know that /pricing only ever renders 12 of them. It must prepare all ~100.
next/dynamic does not help, because the laziness it provides is client-side: it defers when a chunk downloads relative to rendering, but the server has already decided what gets rendered before the client runs a single byte. Whatever the server rendered must hydrate; whatever might be rendered must be shipped or reachable. With a runtime-resolved registry, "might be rendered" equals "everything".
A bundler bundles what is reachable. If your resolver can reach 100 components, your route ships 100 components — split into one chunk or thirty-four, but shipped either way. The only real lever is shrinking reachability itself.
The fix: move the knowledge to build time (codegen + rewrites)
The saving grace of CMS-driven SSG pages: at build time, the server already knows exactly which sections every page uses — it fetches them from the CMS to prerender the HTML. The knowledge exists; it's just trapped at runtime where the bundler can't see it. So we materialize it into code before the bundler runs.
A codegen script runs as the first step of the build (chained in the package's build script — so every pipeline gets it for free: local, CI, both deploy paths):
// 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.For each in-scope page it stamps a physical route file whose import map contains only that page's sections — the union across all locales (different locales can have different section sets) and across A/B variants. To the bundler, this generated file is ordinary source code with narrow static reachability, so it produces a small per-route bundle without any 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' }} />
}The generated routes live under an ugly internal path — users never see it. A rewrites() block in next.config reads the emitted manifest and maps the real URLs to the generated routes in beforeFiles. The crucial property: if the manifest is missing or broken, there are simply no rewrites and every page is served by the untouched universal route:
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' }
],
}
}The rendering chain is a mirror of the original — same data fetching, same SEO, same analytics — with one difference that is the entire point: the component map arrives as a prop instead of being imported from the global registry. One invariant must hold or the whole win evaporates: nothing in the generated route's module graph may import the global registry. That includes indirect paths — our data-fetching helper imported the registry just to read query templates, which would have made every section reachable again; the metadata had to be split into its own registry-free module.
// 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} />
}Surviving A/B testing (the part everyone asks about)
CMS pages run A/B experiments: middleware evaluates a feature flag per request and rewrites users in a variant bucket to a different page under the same URL. This is exactly why runtime resolution existed in the first place — so how does a build-time system cope? By respecting the processing order. In Next.js, middleware always runs before beforeFiles rewrites, so the two layers compose instead of fighting:
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)The codegen reads experiment configs from the same source the middleware uses (a deliberate invariant — zero drift possible), and stamps a generated page per variant, plus an exact rewrite for the variant's internal URL. Control users get the fast generated page; variant users get their own fast generated page; the user-visible URL never changes. An experiment created after the last build simply falls through to the universal route until the next deploy — degraded to yesterday's performance, never broken.
The fail-open contract
The entire system is designed to degrade to the status quo, never below it. We got to watch this work in production by accident: on the first deploy, the CI environment exposed the CMS URL under a different variable name than local builds. The codegen found no CMS, emitted nothing — and the site served every page through the universal route, build green, zero user impact. One two-line fix later the generated routes appeared. The contract:
- CMS unreachable or a page unparsable → that page (or everything) is skipped; the build never fails because of the codegen.
- No manifest → no rewrites → the universal route serves everything, exactly as before the project.
- Section structure changed in the CMS after the build → the page renders the old set until the next deploy (a publish-webhook that triggers redeploys makes this window minutes long). Content edits are unaffected — data is still fetched live via ISR.
- Generated files are gitignored and regenerated on every build; reviewers review the generator, not 700 stamped files.
Results
| Metric (homepage) | Before | After |
|---|---|---|
| First-load JS (raw, initial script set) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Section components in the bundle | 137 | 16 |
| The all-sections megachunk | ~1,200 KB | 0 KB |
−53% first-load JavaScript, confirmed independently three ways: the initial-script-set measurement, per-module source-map attribution (exactly the page's own 16 components — 13 mapped sections plus their child components), and a marker grep over the live deployment's chunks:
// 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 ✅The same numbers reproduced on the production preview to the kilobyte. One expectation to manage: your DevTools network TOTAL will look almost unchanged, because after load the router prefetches other routes' (still-universal) bundles in the background. That's fine — background prefetch doesn't block anything. Performance is about what loads before interactivity, and that's the number that halved. Lighthouse and its treemap show it unambiguously if you want a screenshot-friendly proof.
Trade-offs, honestly
- Structure freshness is deploy-bound: adding/removing a section in the CMS needs a rebuild to reach the optimized route (webhook-triggered deploys reduce the window to minutes; the fallback covers the gap).
- The codegen parses your CMS and your route conventions — it's ~400 lines of code you own and must maintain, including its parsing of the registry and route props.
- Scale: hundreds of CMS pages → hundreds of unique section combinations (our ~700 pages had ~380 unique sets, a long tail). Start with the top-traffic pages behind an explicit scope constant; the fallback serves the tail.
- This does not shrink total site JS — variant-heavy libraries and rich sections still exist. It shrinks what each route ships. The next lever (converting presentational sections to server components) is a separate, bigger project.
Takeaways
- next/dynamic creates split points, not guarantees. On server-driven pages, laziness is decided by what the route can reach, not by how imports are written.
- Chunking configuration cannot fix reachability. We proved it exhaustively: splitting the megachunk into 34 per-section chunks changed nothing — all 34 loaded.
- If your pages are SSG/ISR from a CMS, the information you need already exists at build time. Codegen is the bridge: turn runtime lookups into static imports per route.
- Compose with middleware, don't fight it: middleware decides WHICH page (A/B), rewrites decide WHICH IMPLEMENTATION of that page. Order guarantees they stack.
- Build fail-open systems: missing manifest = yesterday's site. Our first production deploy exercised the fallback by accident and users never noticed.
The pattern generalizes to any "registry of components resolved by CMS data at runtime" — page builders, widget systems, themeable storefronts. If your route can render anything, it will ship everything. Make the build know what each page actually is, and the bundler will finally do what you always assumed it did.