next/dynamic redder dig ikke: Hvorfor CMS-drevne Next.js-sider sender hver eneste chunk
Vores CMS-drevne Next.js-sider sendte alle ~100 sektionskomponenter på hver rute — på trods af lærebogsagtig brug af next/dynamic. Vi udtømte alle bundler-niveau-fixes (direkte import(), fildeling, Turbopack-konfiguration, webpack splitChunks), før vi fandt den egentlige årsag: nåbarhed, ikke chunking. Codegen på build-tidspunkt + rewrites skar first-load JS med 53%.
Vi havde et produktionssite på Next.js 16 med omkring 700 CMS-drevne landingssider. Hver side samles af „sektioner" — hero, FAQ, anmeldelser, priser og så videre — cirka 100 React-komponenter fordelt på 38 sektionstyper. En typisk side renderer 10–15 af dem. Forsiden sendte 5.3 MB first-load JavaScript. Da vi gravede i, hvor det gik hen, fandt vi en enkelt chunk på 1.2 MB med koden fra 108 sektioner — på en side, der renderer 15.
Hver sektion var allerede pakket ind i next/dynamic. Code-splittingen så lærebogsagtigt korrekt ud. Og alligevel sendte bundleren alt, alle steder. Vi brugte dage på at prøve hver eneste dokumenterede løftestang — dynamiske imports, direkte import(), omorganisering af filer, bundler-konfiguration, endda skift af bundler — og hver eneste fejlede af den samme ikke-oplagte grund. Denne artikel gennemgår hver blindgyde med rigtige tal, forklarer den egentlige rodårsag og viser det fix, der skar 53% af first-load JS uden at røre en eneste sektionskomponent.
TL;DR: på CMS-drevne dynamiske ruter er code splitting ikke et chunking-problem — det er et nåbarhedsproblem. Ingen bundler-flag løser det. Det gør derimod at flytte viden om „hvilke komponenter bruger denne side" fra runtime til build-tidspunkt.
Arkitekturen (som du sandsynligvis også har)
Opsætningen er den standard, enhver page-builder har: CMS'et gemmer en side som en ordnet liste af sektionsreferencer, og appen har et centralt register, der mapper sektionsnavne til komponenter. Hvert element er pakket ind i next/dynamic, præcis som dokumentationen anbefaler:
// 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
}En server-komponent resolver hver sektion ud fra navn på render-tidspunktet og renderer den. Siderne er SSG med ISR, så alt dette sker på serveren — klienten modtager kun HTML plus de chunks, der skal bruges til hydrering:
// 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} />
}Dette design er faktisk godt: marketingfolk sammensætter sider i CMS'et uden deploys, ét register betjener 700 sider, hver sektionsvariant er uafhængigt lazy. På papiret. Bundlen fortalte en anden historie.
Ærlig måling (DevTools vil lyve for dig)
Før noget fix havde vi brug for en måling, vi kunne stole på. Tre ting forgifter naive DevTools Network-målinger:
- Totalerne i bundlinjen er kumulative, så længe panelet optager. Nogle få sekunder efter load begynder frameworkets link-prefetching at hente bundles fra ANDRE ruter i baggrunden — på en inaktiv fane konvergerer totalerne mod „alting til sidst", hvilket skjuler enhver first-load-gevinst.
- Browserudvidelser injicerer megabytes af deres egne scripts i din måling — selv i inkognito, hvis de er tilladt der. Vi så en enkelt udvidelse tilføje 2 MB „side-JS".
- Rækker serveret fra cache ((disk cache) / (memory cache)) overfører nul bytes over netværket, så en varm genindlæsning måler slet ingenting.
Det tal, der faktisk betyder noget — og det, Core Web Vitals reagerer på — er det initiale script-sæt: de <script>-tags, der ligger i den server-renderede HTML. Det er dem, der blokerer hydreringen. Det er også trivielt at scripte:
// 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')Til attribution pr. modul aktiverede vi midlertidigt productionBrowserSourceMaps og henførte hver genereret byte til sit kildemodul med en lille VLQ-parser. Én faldgrube værd at kende: Turbopack navngiver hver chunks .map-fil med en anden hash end .js'en — læs sourceMappingURL-kommentaren fra chunkens hale i stedet for at gætte på js + '.map'.
Fem blindgyder (så du ikke gentager dem)
Hver af de følgende blev verificeret med et fuldt produktionsbuild og målingen ovenfor. Ingen af dem rykkede tallet. Den gentagelse er selve pointen — fejltilstanden overlever hvert værktøj, du kaster efter den, fordi alle disse værktøjer løser et andet problem.
Blindgyde #1: „brug bare next/dynamic"
Den var der allerede. Hver eneste af de ~100 varianter var pakket ind i dynamic(). First-load JS var 5.3 MB alligevel. Uanset hvad dynamic() lover, leverede den det ikke her — hold fast i den tanke, grunden lander om et øjeblik.
Blindgyde #2: direkte await import() i server-komponenten
Måske er next/dynamics wrapper problemet? I App Router kan en server-komponent await'e et dynamisk import direkte — det officielle lazy-loading-mønster for biblioteker. Vi erstattede registret af dynamic()-wrappers med en map af rå 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.Blindgyde #3: at dele registret op i 38 filer
Næste hypotese: bundleren lægger chunks sammen, fordi alle ~100 import()-kald bor i ét modul. Så vi genererede én loader-fil pr. sektionstype — 38 filer, hver med kun sine egne varianters import()-thunks, plus en tynd barrel til at slå dem op efter navn.
Resultat: byte-for-byte identisk output. Samme megachunk, samme hash-nære størrelse. Dette var det første virkelig nyttige negative resultat: bundlere grupperer chunks efter modulgrafen, ikke efter fillayoutet af dine import()-kald. At flytte statements mellem filer er usynligt for grafen — det samme sæt moduler forbliver nåbart fra den samme resolver.
Blindgyde #4: bundler-konfiguration
Turbopacks chunking er bevidst ikke konfigurerbar: der er ingen splitChunks-ækvivalent, og webpack magic comments (webpackChunkName og selskab) ignoreres. Det ene relevante eksperimentelle flag for nested async chunking er allerede aktiveret som standard i produktionsbuilds. Der var bogstaveligt talt ingen knap tilbage at dreje på.
Blindgyde #5: skift til webpack (og eksperimentet, der forklarede alt)
Webpack ER konfigurerbar, så vi kørte sitet gennem next build --webpack. Standardkonfiguration: samme megachunk, ~1 MB, alle sektioner. Så tvang vi sagen igennem med en cacheGroup pr. sektion — én chunk pr. sektionsmappe, 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.Chunksene blev delt smukt op — 34 pæne filer pr. sektion. Og siden indlæste alle 34 af dem. Samme totale bytes, samme blokerede hydrering, nu med flere HTTP-requests. Det var i det øjeblik, det egentlige problem blev umuligt at benægte: vi havde optimeret på, hvordan koden pakkes, mens problemet var, hvilken kode ruten refererer til.
Den egentlige rodårsag: nåbarhed, ikke chunking
Her er mekanismen. Sektionskomponenterne er client-komponenter ('use client') — de har handlers, sliders, analytics. Når et server-komponenttræ refererer til en client-komponent, skal bundleren inkludere den komponents chunk i rutens client-bundle, så den kan hydrere. Hvilke client-komponenter refererer en CMS-drevet rute til? Rendereren resolver sektioner ud fra en runtime-streng fra CMS'et — så statisk set er hver eneste sektion i registret nåbar fra hver side, der bruger rendereren. Bundleren kan ikke vide, at /pricing kun nogensinde renderer 12 af dem. Den er nødt til at forberede alle ~100.
next/dynamic hjælper ikke, fordi den lazy-hed, den giver, er client-side: den udskyder, hvornår en chunk downloades i forhold til rendering, men serveren har allerede besluttet, hvad der bliver renderet, før klienten kører en eneste byte. Hvad end serveren renderede, skal hydreres; hvad end der måtte blive renderet, skal sendes eller være nåbart. Med et runtime-resolvet register er „måtte blive renderet" lig med „alting".
En bundler bundler det, der er nåbart. Hvis din resolver kan nå 100 komponenter, sender din rute 100 komponenter — delt i én chunk eller fireogtredive, men sendt uanset hvad. Den eneste egentlige løftestang er at skrumpe selve nåbarheden.
Fixet: flyt viden til build-tidspunkt (codegen + rewrites)
Det frelsende ved CMS-drevne SSG-sider: på build-tidspunktet ved serveren allerede præcis, hvilke sektioner hver side bruger — den henter dem fra CMS'et for at prærendere HTML'en. Viden findes; den er bare fanget i runtime, hvor bundleren ikke kan se den. Så vi materialiserer den til kode, før bundleren kører.
Et codegen-script kører som første trin i buildet (kædet ind i pakkens build-script — så hver pipeline får det gratis: lokalt, CI, begge deploy-veje):
// 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 hver side i scope stempler den en fysisk rute-fil, hvis import-map kun indeholder den sides sektioner — foreningsmængden på tværs af alle locales (forskellige locales kan have forskellige sektionssæt) og på tværs af A/B-varianter. For bundleren er denne genererede fil ganske almindelig kildekode med snæver statisk nåbarhed, så den producerer en lille bundle pr. rute uden nogen konfiguration:
// 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' }} />
}De genererede ruter bor under en grim intern sti — brugerne ser den aldrig. Et rewrites()-blok i next.config læser det udsendte manifest og mapper de rigtige URL'er til de genererede ruter i beforeFiles. Den afgørende egenskab: hvis manifestet mangler eller er defekt, er der simpelthen ingen rewrites, og hver side serveres af den urørte universelle rute:
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' }
],
}
}Render-kæden er et spejlbillede af den oprindelige — samme datahentning, samme SEO, samme analytics — med én forskel, der er hele pointen: komponent-mappet ankommer som en prop i stedet for at blive importeret fra det globale register. Ét invariant skal holde, ellers fordamper hele gevinsten: intet i den genererede rutes modulgraf må importere det globale register. Det inkluderer indirekte veje — vores datahentnings-helper importerede registret bare for at læse query-templates, hvilket ville have gjort hver sektion nåbar igen; metadataen måtte splittes ud i sit eget register-fri modul.
// 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} />
}At overleve A/B-testing (den del alle spørger om)
CMS-sider kører A/B-eksperimenter: middleware evaluerer et feature flag pr. request og rewriter brugere i en variant-bucket til en anden side under samme URL. Det er præcis derfor, runtime-resolution eksisterede i første omgang — så hvordan klarer et build-tidspunkt-system det? Ved at respektere behandlingsrækkefølgen. I Next.js kører middleware altid før beforeFiles-rewrites, så de to lag komponerer i stedet for at slås:
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)Codegen læser eksperiment-konfigurationer fra den samme kilde, som middlewaren bruger (et bevidst invariant — nul drift muligt), og stempler en genereret side pr. variant, plus en præcis rewrite for variantens interne URL. Control-brugere får den hurtige genererede side; variant-brugere får deres egen hurtige genererede side; den brugersynlige URL ændrer sig aldrig. Et eksperiment oprettet efter det seneste build falder simpelthen igennem til den universelle rute indtil næste deploy — degraderet til gårsdagens performance, aldrig i stykker.
Fail-open-kontrakten
Hele systemet er designet til at degradere til status quo, aldrig under det. Vi fik lov til at se dette virke i produktion ved et uheld: ved det første deploy eksponerede CI-miljøet CMS-URL'en under et andet variabelnavn end lokale builds. Codegen fandt intet CMS, udsendte intet — og sitet serverede hver side gennem den universelle rute, build grønt, nul brugerpåvirkning. Ét fix på to linjer senere dukkede de genererede ruter op. Kontrakten:
- CMS utilgængeligt eller en side uparsbar → den side (eller alting) springes over; buildet fejler aldrig på grund af codegen.
- Intet manifest → ingen rewrites → den universelle rute serverer alting, præcis som før projektet.
- Sektionsstruktur ændret i CMS'et efter buildet → siden renderer det gamle sæt indtil næste deploy (et publish-webhook, der udløser redeploys, gør dette vindue få minutter langt). Indholdsredigeringer er upåvirkede — data hentes stadig live via ISR.
- Genererede filer er gitignored og regenereres ved hvert build; reviewere gennemgår generatoren, ikke 700 stemplede filer.
Resultater
| Metrik (forside) | Før | Efter |
|---|---|---|
| First-load JS (rå, initialt script-sæt) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Sektionskomponenter i bundlen | 137 | 16 |
| Megachunken med alle sektioner | ~1,200 KB | 0 KB |
−53% first-load JavaScript, bekræftet uafhængigt på tre måder: målingen af det initiale script-sæt, source-map-attribution pr. modul (præcis sidens egne 16 komponenter — 13 mappede sektioner plus deres børnekomponenter) og et marker-grep over den live deployerede versions 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 ✅De samme tal reproduceredes på produktions-previewet ned til kilobyten. Én forventning at styre: din DevTools-netværks-TOTAL vil se næsten uændret ud, fordi routeren efter load prefetcher andre ruters (stadig universelle) bundles i baggrunden. Det er fint — baggrunds-prefetch blokerer ingenting. Performance handler om, hvad der indlæses før interaktivitet, og det er det tal, der blev halveret. Lighthouse og dets treemap viser det utvetydigt, hvis du vil have et screenshot-venligt bevis.
Afvejninger, ærligt
- Strukturens friskhed er bundet til deploy: at tilføje/fjerne en sektion i CMS'et kræver et rebuild for at nå den optimerede rute (webhook-udløste deploys reducerer vinduet til minutter; fallbacken dækker hullet).
- Codegen parser dit CMS og dine rute-konventioner — det er ~400 linjers kode, du ejer og skal vedligeholde, inklusive dets parsing af registret og rute-props.
- Skala: hundredvis af CMS-sider → hundredvis af unikke sektionskombinationer (vores ~700 sider havde ~380 unikke sæt, en lang hale). Start med de mest trafikerede sider bag en eksplicit scope-konstant; fallbacken serverer halen.
- Dette skrumper ikke sitets samlede JS — variant-tunge biblioteker og rige sektioner findes stadig. Det skrumper, hvad hver rute sender. Den næste løftestang (at konvertere præsentationssektioner til server-komponenter) er et separat, større projekt.
Konklusioner
- next/dynamic skaber split-punkter, ikke garantier. På server-drevne sider afgøres lazy-hed af, hvad ruten kan nå, ikke af hvordan imports er skrevet.
- Chunking-konfiguration kan ikke fikse nåbarhed. Vi beviste det udtømmende: at dele megachunken op i 34 chunks pr. sektion ændrede ingenting — alle 34 blev indlæst.
- Hvis dine sider er SSG/ISR fra et CMS, findes den information, du har brug for, allerede på build-tidspunktet. Codegen er broen: forvandl runtime-opslag til statiske imports pr. rute.
- Komponér med middleware, bekæmp den ikke: middleware bestemmer HVILKEN side (A/B), rewrites bestemmer HVILKEN IMPLEMENTERING af den side. Rækkefølgen garanterer, at de stakker.
- Byg fail-open-systemer: manglende manifest = gårsdagens site. Vores første produktions-deploy afprøvede fallbacken ved et uheld, og brugerne mærkede aldrig noget.
Mønstret generaliserer til ethvert „register af komponenter resolvet af CMS-data i runtime" — page-buildere, widget-systemer, temabare storefronts. Hvis din rute kan rendere hvad som helst, vil den sende alting. Få buildet til at vide, hvad hver side faktisk er, og bundleren vil endelig gøre det, du altid antog, den gjorde.