next/dynamic redder deg ikke: Hvorfor CMS-drevne Next.js-sider sender hver eneste chunk
Våre CMS-drevne Next.js-sider sendte alle ~100 seksjonskomponentene på hver eneste route — til tross for lærebokriktig bruk av next/dynamic. Vi uttømte alle fikser på bundler-nivå (direkte import(), oppdeling i filer, Turbopack-config, webpack splitChunks) før vi fant den egentlige årsaken: nåbarhet, ikke chunking. Kodegenerering ved byggetid + rewrites kuttet first-load JS med 53%.
Vi hadde et produksjonssite på Next.js 16 med omtrent 700 CMS-drevne landingssider. Hver side settes sammen av «seksjoner» — hero, FAQ, anmeldelser, priser og så videre — rundt 100 React-komponenter fordelt på 38 seksjonstyper. En typisk side rendrer 10–15 av dem. Forsiden sendte 5.3 MB first-load JavaScript. Da vi gravde i hvor det gikk, fant vi én enkelt chunk på 1.2 MB som inneholdt koden til 108 seksjoner — på en side som rendrer 15.
Hver seksjon var allerede pakket inn i next/dynamic. Code splitting-oppsettet så lærebokriktig ut. Og likevel sendte bundleren alt, overalt. Vi brukte dager på å prøve hver eneste dokumenterte spak — dynamiske imports, direkte import(), omorganisering av filer, bundler-konfigurasjon, til og med bytte av bundler — og hver eneste feilet av samme ikke-åpenbare grunn. Denne artikkelen går gjennom hver blindvei med reelle tall, forklarer den faktiske rotårsaken og viser fiksen som kuttet 53% av first-load JS uten å røre en eneste seksjonskomponent.
TL;DR: på CMS-drevne dynamiske routes er code splitting ikke et chunking-problem — det er et nåbarhetsproblem. Ingen bundler-flagg fikser det. Det gjør derimot å flytte kunnskapen om «hvilke komponenter bruker denne siden» fra runtime til byggetid.
Arkitekturen (som du sannsynligvis også har)
Oppsettet er standarden for enhver page-builder: CMS-en lagrer en side som en ordnet liste av seksjonsreferanser, og appen har et sentralt register som mapper seksjonsnavn til komponenter. Hver oppføring er pakket inn i next/dynamic, akkurat slik dokumentasjonen 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 seksjon ved navn på rendertidspunktet og rendrer den. Sidene er SSG med ISR, så alt dette skjer på serveren — klienten mottar kun HTML pluss chunkene som trengs for 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} />
}Denne designen er genuint god: markedsførere komponerer sider i CMS-en uten deploy, ett register betjener 700 sider, hver seksjonsvariant er uavhengig lazy. På papiret. Bundelen fortalte en annen historie.
Å måle ærlig (DevTools kommer til å lyve for deg)
Før noen fiks trengte vi en måling vi kunne stole på. Tre ting forgifter naive DevTools Network-målinger:
- Totalene i bunnlinjen er kumulative så lenge panelet spiller inn. Noen sekunder etter load begynner rammeverkets link-prefetching å hente inn bundler for ANDRE routes i bakgrunnen — på en inaktiv fane konvergerer totalene mot «alt til slutt», og skjuler enhver first-load-gevinst.
- Nettleserutvidelser injiserer megabyte med sine egne skript inn i målingen din — selv i inkognito hvis de er tillatt der. Vi så én enkelt utvidelse legge til 2 MB «side-JS».
- Rader som serveres fra cache ((disk cache) / (memory cache)) overfører null byte over nettverket, så en varm reload måler ingenting i det hele tatt.
Tallet som faktisk betyr noe — og det Core Web Vitals reagerer på — er det initielle skriptsettet: <script>-taggene i den server-rendrede HTML-en. Det er det som blokkerer hydrering. Det er dessuten trivielt å skripte:
// 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 attribusjon per modul aktiverte vi midlertidig productionBrowserSourceMaps og attribuerte hver genererte byte til sin kildemodul med en liten VLQ-parser. Én felle verdt å kjenne til: Turbopack navngir hver chunks .map-fil med en annen hash enn .js-en — les sourceMappingURL-kommentaren fra halen av chunken i stedet for å gjette js + '.map'.
Fem blindveier (så du slipper å gjenta dem)
Hver av de følgende ble verifisert med et fullstendig produksjonsbygg og målingen over. Ingen av dem rørte tallet. Den gjentakelsen er hele poenget — feilmodusen overlever hvert verktøy du kaster på den, fordi alle disse verktøyene løser et annet problem.
Blindvei #1: «bare bruk next/dynamic»
Den var allerede der. Hver eneste av de ~100 variantene var pakket inn i dynamic(). First-load JS var 5.3 MB uansett. Hva enn dynamic() lover, leverte den ikke det her — hold fast ved den tanken, grunnen kommer om et øyeblikk.
Blindvei #2: direkte await import() i server-komponenten
Kanskje det er next/dynamics wrapper som er problemet? I App Router kan en server-komponent await-e en dynamisk import direkte — det offisielle lazy-loading-mønsteret for biblioteker. Vi erstattet registeret av dynamic()-wrappere med et map av rene 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.Blindvei #3: å splitte registeret over 38 filer
Neste hypotese: bundleren slår sammen chunks fordi alle ~100 import()-kallene bor i én modul. Så vi genererte én loader-fil per seksjonstype — 38 filer, hver med kun sine egne varianters import()-thunks, pluss en tynn barrel for å slå dem opp ved navn.
Resultat: byte-for-byte identisk output. Samme megachunk, samme hash-nære størrelse. Dette var det første genuint nyttige negative resultatet: bundlere grupperer chunks etter modulgrafen, ikke etter filoppsettet til import()-kallene dine. Å flytte utsagn mellom filer er usynlig for grafen — samme sett med moduler forblir nåbart fra samme resolver.
Blindvei #4: bundler-konfigurasjon
Turbopacks chunking er bevisst ikke konfigurerbar: det finnes ingen splitChunks-ekvivalent, og webpack magic comments (webpackChunkName og venner) ignoreres. Det ene relevante eksperimentelle flagget for nøstet async-chunking er allerede aktivert som standard i produksjonsbygg. Det var bokstavelig talt ingen knapp igjen å skru på.
Blindvei #5: å bytte til webpack (og eksperimentet som forklarte alt)
Webpack ER konfigurerbart, så vi kjørte siten gjennom next build --webpack. Standardconfig: samme megachunk, ~1 MB, alle seksjoner. Så tvang vi frem saken med en cacheGroup per seksjon — én chunk per seksjonsmappe, 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.Chunkene splittet seg vakkert — 34 ryddige filer per seksjon. Og siden lastet alle 34 av dem. Samme totale byte, samme blokkerte hydrering, nå med flere HTTP-forespørsler. Dette var øyeblikket det egentlige problemet ble ubestridelig: vi hadde optimalisert hvordan koden pakkes, når problemet var hvilken kode routen refererer til.
Den egentlige rotårsaken: nåbarhet, ikke chunking
Her er mekanismen. Seksjonskomponentene er client-komponenter ('use client') — de har handlers, slidere, analytics. Når et server-komponenttre refererer til en client-komponent, må bundleren inkludere den komponentens chunk i routens client-bundle for at den skal kunne hydrere. Hvilke client-komponenter refererer en CMS-drevet route til? Rendereren resolver seksjoner ut fra en runtime-streng fra CMS-en — så statisk sett er hver seksjon i registeret nåbar fra hver side som bruker rendereren. Bundleren kan umulig vite at /pricing kun noensinne rendrer 12 av dem. Den må forberede alle ~100.
next/dynamic hjelper ikke, fordi latheten den gir er client-side: den utsetter når en chunk lastes ned relativt til rendring, men serveren har allerede bestemt hva som blir rendret før klienten kjører en eneste byte. Det serveren rendret må hydrere; det som kanskje blir rendret må sendes eller være nåbart. Med et runtime-resolvet register er «kanskje rendret» lik «alt».
En bundler bundler det som er nåbart. Hvis resolveren din kan nå 100 komponenter, sender routen din 100 komponenter — splittet i én chunk eller trettifire, men sendt uansett. Den eneste reelle spaken er å krympe selve nåbarheten.
Fiksen: flytt kunnskapen til byggetid (kodegenerering + rewrites)
Den forløsende egenskapen ved CMS-drevne SSG-sider: ved byggetid vet serveren allerede nøyaktig hvilke seksjoner hver side bruker — den henter dem fra CMS-en for å prerendre HTML-en. Kunnskapen finnes; den er bare fanget i runtime der bundleren ikke kan se den. Så vi materialiserer den til kode før bundleren kjører.
Et kodegenereringsskript kjører som første steg i byggingen (kjedet inn i pakkens build-skript — så hver pipeline får det gratis: lokalt, CI, begge deploy-veiene):
// 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 innenfor omfanget stempler det en fysisk route-fil hvis import-map kun inneholder den sidens seksjoner — unionen på tvers av alle locales (ulike locales kan ha ulike seksjonssett) og på tvers av A/B-varianter. For bundleren er denne genererte filen helt vanlig kildekode med smal statisk nåbarhet, så den produserer en liten bundle per route uten noen konfigurasjon:
// 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 genererte routene bor under en stygg intern sti — brukere ser dem aldri. En rewrites()-blokk i next.config leser det emitterte manifestet og mapper de reelle URL-ene til de genererte routene i beforeFiles. Den avgjørende egenskapen: hvis manifestet mangler eller er ødelagt, finnes det rett og slett ingen rewrites, og hver side betjenes av den urørte universelle routen:
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' }
],
}
}Rendringskjeden er et speilbilde av den opprinnelige — samme datahenting, samme SEO, samme analytics — med én forskjell som er hele poenget: komponent-mapet ankommer som en prop i stedet for å importeres fra det globale registeret. Én invariant må holde, ellers fordamper hele gevinsten: ingenting i den genererte routens modulgraf får importere det globale registeret. Det inkluderer indirekte stier — vår datahentingshjelper importerte registeret bare for å lese query-maler, noe som ville gjort hver seksjon nåbar igjen; metadataen måtte splittes ut i sin egen registerfrie 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} />
}Å overleve A/B-testing (delen alle spør om)
CMS-sidene kjører A/B-eksperimenter: middleware evaluerer et feature-flagg per forespørsel og rewriter brukere i en variantbøtte til en annen side under samme URL. Dette er nettopp derfor runtime-resolusjon eksisterte i utgangspunktet — så hvordan takler et byggetidssystem det? Ved å respektere prosesseringsrekkefølgen. I Next.js kjører middleware alltid før beforeFiles-rewrites, så de to lagene komponerer i stedet for å slåss:
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)Kodegenereringen leser eksperimentkonfigurasjoner fra samme kilde som middlewaren bruker (en bevisst invariant — null drift mulig), og stempler en generert side per variant, pluss en eksakt rewrite for variantens interne URL. Kontrollbrukere får den raske genererte siden; variantbrukere får sin egen raske genererte side; den brukersynlige URL-en endres aldri. Et eksperiment opprettet etter siste bygg faller rett og slett gjennom til den universelle routen frem til neste deploy — degradert til gårsdagens ytelse, aldri ødelagt.
Fail-open-kontrakten
Hele systemet er designet for å degradere til status quo, aldri under det. Vi fikk se dette virke i produksjon ved en tilfeldighet: ved første deploy eksponerte CI-miljøet CMS-URL-en under et annet variabelnavn enn lokale bygg. Kodegenereringen fant ingen CMS, emitterte ingenting — og siten betjente hver side gjennom den universelle routen: bygg grønt, null brukerpåvirkning. Én tolinjers fiks senere dukket de genererte routene opp. Kontrakten:
- CMS unåbar eller en side uparsbar → den siden (eller alt) hoppes over; byggingen feiler aldri på grunn av kodegenereringen.
- Ingen manifest → ingen rewrites → den universelle routen betjener alt, nøyaktig som før prosjektet.
- Seksjonsstruktur endret i CMS-en etter byggingen → siden rendrer det gamle settet frem til neste deploy (en publish-webhook som trigger redeploy gjør dette vinduet minutter langt). Innholdsredigeringer påvirkes ikke — data hentes fortsatt live via ISR.
- Genererte filer er gitignorert og regenereres ved hvert bygg; anmeldere gjennomgår generatoren, ikke 700 stemplede filer.
Resultater
| Metrikk (forside) | Før | Etter |
|---|---|---|
| First-load JS (rå, initielt skriptsett) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Seksjonskomponenter i bundelen | 137 | 16 |
| Megachunken med alle seksjoner | ~1,200 KB | 0 KB |
−53% first-load JavaScript, bekreftet uavhengig på tre måter: målingen av det initielle skriptsettet, source-map-attribusjon per modul (nøyaktig sidens egne 16 komponenter — 13 mappede seksjoner pluss deres barnekomponenter), og en marker-grep over chunkene til det live deployet:
// 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 tallene reproduserte seg på produksjons-previewen ned til kilobyten. Én forventning å håndtere: DevTools-nettverkets TOTAL vil se nesten uendret ut, fordi routeren etter load prefetcher andre routers (fortsatt universelle) bundler i bakgrunnen. Det er greit — bakgrunns-prefetch blokkerer ingenting. Ytelse handler om hva som lastes før interaktivitet, og det er tallet som ble halvert. Lighthouse og treemapen dens viser det utvetydig hvis du vil ha et skjermbildevennlig bevis.
Avveininger, ærlig talt
- Strukturfriskhet er bundet til deploy: å legge til / fjerne en seksjon i CMS-en krever et gjenoppbygg for å nå den optimaliserte routen (webhook-utløste deploys reduserer vinduet til minutter; fallbacken dekker gapet).
- Kodegenereringen parser CMS-en din og route-konvensjonene dine — det er ~400 linjer kode du eier og må vedlikeholde, inkludert dens parsing av registeret og route-props.
- Skala: hundrevis av CMS-sider → hundrevis av unike seksjonskombinasjoner (våre ~700 sider hadde ~380 unike sett, en lang hale). Start med sidene med mest trafikk bak en eksplisitt scope-konstant; fallbacken betjener halen.
- Dette krymper ikke sitens totale JS — variant-tunge biblioteker og rike seksjoner finnes fortsatt. Det krymper det hver route sender. Neste spak (å konvertere presentasjonelle seksjoner til server-komponenter) er et separat, større prosjekt.
Læringspunkter
- next/dynamic skaper delepunkter, ikke garantier. På server-drevne sider avgjøres latheten av hva routen kan nå, ikke av hvordan importene er skrevet.
- Chunking-konfigurasjon kan ikke fikse nåbarhet. Vi beviste det uttømmende: å splitte megachunken i 34 chunks per seksjon endret ingenting — alle 34 lastet.
- Hvis sidene dine er SSG/ISR fra en CMS, finnes informasjonen du trenger allerede ved byggetid. Kodegenerering er broen: gjør runtime-oppslag om til statiske imports per route.
- Komponer med middleware, ikke kjemp mot det: middleware avgjør HVILKEN side (A/B), rewrites avgjør HVILKEN IMPLEMENTASJON av den siden. Rekkefølgen garanterer at de stables.
- Bygg fail-open-systemer: manglende manifest = gårsdagens site. Vårt første produksjonsdeploy trente opp fallbacken ved en tilfeldighet, og brukerne merket aldri noe.
Mønsteret generaliserer til ethvert «register av komponenter resolvet av CMS-data ved runtime» — page-buildere, widget-systemer, temabare butikkfronter. Hvis routen din kan rendre hva som helst, kommer den til å sende alt. La byggingen vite hva hver side faktisk er, og bundleren vil endelig gjøre det du alltid antok at den gjorde.