next/dynamic räddar dig inte: därför levererar CMS-drivna Next.js-sidor varje chunk
Våra CMS-drivna Next.js-sidor levererade alla ~100 sektionskomponenter på varje route — trots läroboksmässig användning av next/dynamic. Vi uttömde varenda fix på bundler-nivå (direkt import(), filuppdelning, Turbopack-konfiguration, webpack splitChunks) innan vi hittade den verkliga orsaken: nåbarhet, inte chunkning. Kodgenerering vid byggtid + rewrites skar bort 53 % av first-load JS.
Vi hade en produktionssajt på Next.js 16 med ungefär 700 CMS-drivna landningssidor. Varje sida sätts ihop av "sektioner" — hero, FAQ, recensioner, priser och så vidare — runt 100 React-komponenter fördelade på 38 sektionstyper. En typisk sida renderar 10–15 av dem. Startsidan levererade 5.3 MB first-load JavaScript. När vi grävde i vart det tog vägen hittade vi en enda chunk på 1.2 MB som innehöll koden för 108 sektioner — på en sida som renderar 15.
Varje sektion var redan inkapslad i next/dynamic. Kodsplittringen såg läroboksrätt ut. Och ändå levererade bundlern allt, överallt. Vi lade dagar på att prova varenda dokumenterad spak — dynamiska importer, direkt import(), omorganisering av filer, bundler-konfiguration, till och med byte av bundler — och varenda en misslyckades av samma icke-uppenbara skäl. Den här artikeln går igenom varje återvändsgränd med riktiga siffror, förklarar den faktiska grundorsaken och visar fixen som skar bort 53 % av first-load JS utan att röra en enda sektionskomponent.
TL;DR: på CMS-drivna dynamiska routes är kodsplittring inte ett chunkningsproblem — det är ett nåbarhetsproblem. Ingen bundler-flagga löser det. Det gör däremot att flytta kunskapen om "vilka komponenter använder den här sidan" från runtime till byggtid.
Arkitekturen (som du förmodligen också har)
Uppsättningen är standard för vilken page-builder som helst: CMS:et lagrar en sida som en ordnad lista av sektionsreferenser, och appen har ett centralt register som mappar sektionsnamn till komponenter. Varje post är inkapslad i next/dynamic, precis som dokumentationen rekommenderar:
// 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 serverkomponent resolvar varje sektion via namn vid renderingstillfället och renderar den. Sidorna är SSG med ISR, så allt detta sker på servern — klienten får bara HTML plus de chunks som behövs för 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} />
}Den här designen är genuint bra: marknadsförare komponerar sidor i CMS:et utan deployer, ett register betjänar 700 sidor, varje sektionsvariant är oberoende lat. På pappret. Bundlen berättade en annan historia.
Mät ärligt (DevTools kommer att ljuga för dig)
Innan någon fix behövde vi en mätning vi kunde lita på. Tre saker förgiftar naiva mätningar i DevTools Network-panel:
- Totalsummorna i nedre listen är kumulativa så länge panelen spelar in. Några sekunder efter load börjar ramverkets länk-prefetch dra in bundles för ANDRA routes i bakgrunden — på en overksam flik konvergerar summorna mot "allt till slut", vilket döljer varje first-load-vinst.
- Webbläsartillägg injicerar megabyte av sina egna skript i din mätning — även i inkognito om de tillåts där. Vi såg ett enda tillägg lägga till 2 MB "sid-JS".
- Rader som serveras från cachen ((disk cache) / (memory cache)) överför noll byte över nätet, så en varm omladdning mäter ingenting alls.
Talet som faktiskt spelar roll — och som Core Web Vitals reagerar på — är den initiala skriptuppsättningen: <script>-taggarna i den serverrenderade HTML:en. Det är de som blockerar hydreringen. Och det är trivialt att skripta:
// 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')För attribuering per modul aktiverade vi tillfälligt productionBrowserSourceMaps och attribuerade varje genererad byte till sin källmodul med en liten VLQ-parser. En fälla värd att känna till: Turbopack namnger varje chunks .map-fil med en annan hash än .js:en — läs sourceMappingURL-kommentaren från chunkens svans istället för att gissa js + '.map'.
Fem återvändsgränder (så att du slipper upprepa dem)
Var och en av följande verifierades med ett fullständigt produktionsbygge och mätningen ovan. Ingen av dem rörde talet. Den upprepningen är själva poängen — felläget överlever varje verktyg du kastar på det, eftersom alla dessa verktyg löser ett annat problem.
Återvändsgränd #1: "använd bara next/dynamic"
Den fanns redan där. Var och en av de ~100 varianterna var inkapslad i dynamic(). First-load JS var 5.3 MB ändå. Vad dynamic() än lovar, så levererade det inte det här — håll den tanken, skälet landar strax.
Återvändsgränd #2: direkt await import() i serverkomponenten
Kanske är next/dynamics wrapper problemet? I App Router kan en serverkomponent await:a en dynamisk import direkt — det officiella mönstret för lat inläsning av bibliotek. Vi ersatte registret av dynamic()-wrappers med en karta av råa 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.Återvändsgränd #3: dela upp registret över 38 filer
Nästa hypotes: bundlern slår ihop chunks eftersom alla ~100 import()-anrop bor i en enda modul. Så vi genererade en loader-fil per sektionstyp — 38 filer, var och en som bara håller sina egna varianters import()-thunks, plus en tunn barrel för att slå upp dem via namn.
Resultat: byte-för-byte identisk utdata. Samma megachunk, samma hash-närliggande storlek. Det här var det första genuint användbara negativa resultatet: bundlers grupperar chunks efter modulgrafen, inte efter fillayouten för dina import()-anrop. Att flytta satser mellan filer är osynligt för grafen — samma uppsättning moduler förblir nåbar från samma resolver.
Återvändsgränd #4: bundler-konfiguration
Turbopacks chunkning är medvetet inte konfigurerbar: det finns ingen motsvarighet till splitChunks, och webpacks magiska kommentarer (webpackChunkName med släktingar) ignoreras. Den enda relevanta experimentella flaggan för nästlad async-chunkning är redan aktiverad som standard i produktionsbyggen. Det fanns bokstavligen inget vred kvar att vrida på.
Återvändsgränd #5: byta till webpack (och experimentet som förklarade allt)
Webpack ÄR konfigurerbart, så vi körde sajten genom next build --webpack. Standardkonfiguration: samma megachunk, ~1 MB, alla sektioner. Sedan tvingade vi fram frågan med en cacheGroup per sektion — en chunk per sektionskatalog, 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.Chunkarna delades upp vackert — 34 prydliga filer, en per sektion. Och sidan laddade in alla 34. Samma totala byte, samma blockerade hydrering, nu med fler HTTP-anrop. Det var i det ögonblicket det verkliga problemet blev obestridligt: vi hade optimerat hur koden paketeras, när problemet var vilken kod routen refererar till.
Den verkliga grundorsaken: nåbarhet, inte chunkning
Här är mekanismen. Sektionskomponenterna är klientkomponenter ('use client') — de har handlers, sliders, analys. När ett serverkomponentträd refererar till en klientkomponent måste bundlern inkludera den komponentens chunk i routens klientbundle så att den kan hydreras. Vilka klientkomponenter refererar en CMS-driven route till? Renderaren resolvar sektioner via en runtime-sträng från CMS:et — så statiskt sett är varje sektion i registret nåbar från varje sida som använder renderaren. Bundlern kan inte veta att /pricing någonsin bara renderar 12 av dem. Den måste förbereda alla ~100.
next/dynamic hjälper inte, eftersom den lathet den erbjuder är klientsidig: den skjuter upp när en chunk laddas ned relativt renderingen, men servern har redan avgjort vad som renderas innan klienten kört en enda byte. Vad servern renderade måste hydreras; vad som skulle kunna renderas måste levereras eller vara nåbart. Med ett runtime-resolvat register är "skulle kunna renderas" lika med "allt".
En bundler buntar det som är nåbart. Om din resolver kan nå 100 komponenter levererar din route 100 komponenter — uppdelade i en chunk eller trettiofyra, men levererade oavsett. Den enda verkliga spaken är att krympa själva nåbarheten.
Fixen: flytta kunskapen till byggtid (kodgenerering + rewrites)
Räddningen med CMS-drivna SSG-sidor: vid byggtid vet servern redan exakt vilka sektioner varje sida använder — den hämtar dem från CMS:et för att prerendera HTML:en. Kunskapen finns; den är bara instängd i runtime där bundlern inte kan se den. Så vi materialiserar den till kod innan bundlern körs.
Ett kodgenereringsskript körs som första steget i bygget (kedjat i paketets build-skript — så varje pipeline får det gratis: lokalt, CI, båda deploy-vägarna):
// 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.För varje sida inom scope stämplar det en fysisk route-fil vars importkarta bara innehåller den sidans sektioner — unionen över alla locales (olika locales kan ha olika sektionsuppsättningar) och över A/B-varianter. För bundlern är den här genererade filen vanlig källkod med smal statisk nåbarhet, så den producerar en liten bundle per route utan någon 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 genererade routes bor under en ful intern sökväg — användarna ser den aldrig. Ett rewrites()-block i next.config läser det emitterade manifestet och mappar de riktiga URL:erna till de genererade routes i beforeFiles. Den avgörande egenskapen: om manifestet saknas eller är trasigt finns det helt enkelt inga rewrites och varje sida serveras av den orörda universella 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' }
],
}
}Renderingskedjan är en spegling av originalet — samma datahämtning, samma SEO, samma analys — med en skillnad som är hela poängen: komponentkartan anländer som en prop istället för att importeras från det globala registret. En invariant måste hålla annars avdunstar hela vinsten: ingenting i den genererade routens modulgraf får importera det globala registret. Det inkluderar indirekta vägar — vår datahämtningshjälpare importerade registret bara för att läsa frågemallar, vilket skulle ha gjort varje sektion nåbar igen; metadatan fick brytas ut i sin egen registerfria 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} />
}Att överleva A/B-testning (delen alla frågar om)
CMS-sidor kör A/B-experiment: middleware utvärderar en feature-flagga per request och skriver om användare i en variantgrupp till en annan sida under samma URL. Det är precis därför runtime-resolvingen fanns till att börja med — så hur klarar ett byggtidssystem detta? Genom att respektera bearbetningsordningen. I Next.js körs middleware alltid före beforeFiles-rewrites, så de två lagren komponeras istället för att 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)Kodgenereringen läser experimentkonfigurationer från samma källa som middleware använder (en medveten invariant — noll drift möjlig), och stämplar en genererad sida per variant, plus en exakt rewrite för variantens interna URL. Kontrollanvändare får den snabba genererade sidan; variantanvändare får sin egen snabba genererade sida; den användarsynliga URL:en ändras aldrig. Ett experiment som skapas efter det senaste bygget faller helt enkelt igenom till den universella routen fram till nästa deploy — nedgraderat till gårdagens prestanda, aldrig trasigt.
Fail-open-kontraktet
Hela systemet är designat för att degradera till status quo, aldrig under det. Vi fick se detta fungera i produktion av en olyckshändelse: vid första deployen exponerade CI-miljön CMS-URL:en under ett annat variabelnamn än lokala byggen. Kodgenereringen hittade ingen CMS, emitterade ingenting — och sajten serverade varje sida via den universella routen, bygget grönt, noll användarpåverkan. En tvåradsfix senare dök de genererade routes upp. Kontraktet:
- CMS onåbar eller en sida oparsbar → den sidan (eller allt) hoppas över; bygget misslyckas aldrig på grund av kodgenereringen.
- Inget manifest → inga rewrites → den universella routen serverar allt, precis som före projektet.
- Sektionsstrukturen ändrades i CMS:et efter bygget → sidan renderar den gamla uppsättningen fram till nästa deploy (en publish-webhook som utlöser omdeployer gör det fönstret minutlångt). Innehållsredigeringar påverkas inte — data hämtas fortfarande live via ISR.
- Genererade filer är gitignore:ade och regenereras vid varje bygge; granskare granskar generatorn, inte 700 stämplade filer.
Resultat
| Mätvärde (startsida) | Före | Efter |
|---|---|---|
| First-load JS (raw, initial skriptuppsättning) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Sektionskomponenter i bundlen | 137 | 16 |
| Megachunken med alla sektioner | ~1,200 KB | 0 KB |
−53 % first-load JavaScript, bekräftat oberoende på tre sätt: mätningen av den initiala skriptuppsättningen, source-map-attribuering per modul (exakt sidans egna 16 komponenter — 13 mappade sektioner plus deras barnkomponenter) och en grep efter markörer över den live-deployade sajtens 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 ✅Samma siffror reproducerades på produktionsförhandsvisningen ned till kilobyten. En förväntning att hantera: din DevTools nätverks-TOTAL kommer att se nästan oförändrad ut, eftersom routern efter load prefetchar andra routes (fortfarande universella) bundles i bakgrunden. Det är helt okej — bakgrunds-prefetch blockerar ingenting. Prestanda handlar om vad som laddas före interaktivitet, och det är det talet som halverades. Lighthouse och dess treemap visar det otvetydigt om du vill ha ett skärmdumpsvänligt bevis.
Avvägningar, ärligt
- Strukturfärskheten är bunden till deploy: att lägga till/ta bort en sektion i CMS:et kräver ett ombygge för att nå den optimerade routen (webhook-utlösta deployer minskar fönstret till minuter; fallbacken täcker glappet).
- Kodgenereringen parsar din CMS och dina route-konventioner — det är ~400 rader kod som du äger och måste underhålla, inklusive dess parsning av registret och route-props.
- Skala: hundratals CMS-sidor → hundratals unika sektionskombinationer (våra ~700 sidor hade ~380 unika uppsättningar, en lång svans). Börja med sidorna med mest trafik bakom en explicit scope-konstant; fallbacken serverar svansen.
- Detta krymper inte sajtens totala JS — variantstinna bibliotek och rika sektioner finns fortfarande kvar. Det krymper vad varje route levererar. Nästa spak (att konvertera presentationssektioner till serverkomponenter) är ett separat, större projekt.
Slutsatser
- next/dynamic skapar splittringspunkter, inte garantier. På serverdrivna sidor avgörs latheten av vad routen kan nå, inte av hur importerna är skrivna.
- Chunkningskonfiguration kan inte lösa nåbarhet. Vi bevisade det uttömmande: att dela megachunken i 34 chunks per sektion ändrade ingenting — alla 34 laddades.
- Om dina sidor är SSG/ISR från en CMS finns informationen du behöver redan vid byggtid. Kodgenerering är bryggan: förvandla runtime-uppslag till statiska importer per route.
- Komponera med middleware, slåss inte mot det: middleware avgör VILKEN sida (A/B), rewrites avgör VILKEN IMPLEMENTATION av den sidan. Ordningen garanterar att de staplas.
- Bygg fail-open-system: saknat manifest = gårdagens sajt. Vår första produktionsdeploy tränade fallbacken av en olyckshändelse och användarna märkte aldrig något.
Mönstret generaliserar till varje "register av komponenter som resolvas av CMS-data vid runtime" — page-builders, widget-system, temabara butiker. Om din route kan rendera vad som helst kommer den att leverera allt. Låt bygget veta vad varje sida faktiskt är, så kommer bundlern äntligen att göra det du alltid antog att den gjorde.