next/dynamic nu te va salva: de ce paginile Next.js bazate pe CMS livrează fiecare chunk
Paginile noastre Next.js bazate pe CMS livrau toate cele ~100 de componente de secțiune pe fiecare rută — în ciuda unei utilizări exemplare a next/dynamic. Am epuizat orice remediu la nivel de bundler (import() direct, împărțirea fișierelor, configurarea Turbopack, splitChunks în webpack) înainte de a găsi cauza reală: accesibilitatea, nu chunking-ul. Codegen la build time plus rewrites au redus JavaScript-ul de la prima încărcare cu 53%.
Aveam un site Next.js 16 în producție cu aproximativ 700 de pagini de destinație gestionate din CMS. Fiecare pagină este asamblată din „secțiuni” — hero, FAQ, recenzii, prețuri și așa mai departe — în jur de 100 de componente React distribuite pe 38 de tipuri de secțiune. O pagină tipică randează 10–15 dintre ele. Pagina principală livra 5.3 MB de JavaScript la prima încărcare. Când am săpat să vedem unde se duce, am găsit un singur chunk de 1.2 MB care conținea codul a 108 secțiuni — pe o pagină care randează 15.
Fiecare secțiune era deja împachetată în next/dynamic. Împărțirea codului părea corectă ca la carte. Și totuși bundler-ul livra totul, peste tot. Am petrecut zile întregi încercând fiecare pârghie documentată — importuri dinamice, import() direct, reorganizarea fișierelor, configurarea bundler-ului, chiar și schimbarea bundler-ului — și fiecare a eșuat din același motiv neevident. Acest articol parcurge fiecare fundătură cu cifre reale, explică adevărata cauză de fond și arată soluția care a tăiat 53% din JS-ul de la prima încărcare fără a atinge o singură componentă de secțiune.
TL;DR: pe rutele dinamice gestionate din CMS, împărțirea codului nu este o problemă de chunking — este o problemă de accesibilitate. Niciun flag de bundler nu o rezolvă. Mutarea cunoașterii „ce componente folosește această pagină” din runtime în build time o rezolvă.
Arhitectura (pe care probabil o ai și tu)
Configurația este cea standard pentru orice page-builder: CMS-ul stochează o pagină ca o listă ordonată de referințe către secțiuni, iar aplicația are un registru central care mapează numele secțiunilor la componente. Fiecare intrare este împachetată în next/dynamic, exact așa cum recomandă documentația:
// 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
}O componentă server rezolvă fiecare secțiune după nume la momentul randării și o randează. Paginile sunt SSG cu ISR, deci toate acestea se întâmplă pe server — clientul primește doar HTML plus chunk-urile necesare pentru hidratare:
// 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} />
}Acest design este cu adevărat bun: marketerii compun pagini în CMS fără deploy-uri, un singur registru servește 700 de pagini, fiecare variantă de secțiune este independent lazy. Pe hârtie. Bundle-ul spunea o altă poveste.
Măsurarea onestă (DevTools te va minți)
Înainte de orice remediu, aveam nevoie de o măsurătoare în care să putem avea încredere. Trei lucruri otrăvesc măsurătorile naive din panoul Network al DevTools:
- Totalurile din bara de jos sunt cumulative atâta timp cât panoul înregistrează. La câteva secunde după încărcare, prefetch-ul de linkuri al framework-ului începe să tragă în fundal bundle-uri ale ALTOR rute — pe un tab inactiv totalurile converg spre „în cele din urmă totul”, ascunzând orice câștig la prima încărcare.
- Extensiile de browser injectează megabytes din propriile scripturi în măsurătoarea ta — chiar și în incognito dacă le este permis acolo. Am văzut o singură extensie adăugând 2 MB de „JS al paginii”.
- Rândurile servite din cache ((disk cache) / (memory cache)) transferă zero bytes prin rețea, deci o reîncărcare caldă nu măsoară absolut nimic.
Numărul care contează cu adevărat — și cel la care răspund Core Web Vitals — este setul inițial de scripturi: tag-urile <script> din HTML-ul randat pe server. Acesta este ceea ce blochează hidratarea. Și este trivial de obținut prin 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')Pentru atribuirea per-modul am activat temporar productionBrowserSourceMaps și am atribuit fiecare byte generat modulului sursă cu un mic parser VLQ. O capcană de care merită să știi: Turbopack denumește fișierul .map al fiecărui chunk cu un hash diferit de cel al fișierului .js — citește comentariul sourceMappingURL de la coada chunk-ului în loc să ghicești js + '.map'.
Cinci fundături (ca să nu le repeți)
Fiecare dintre următoarele a fost verificat cu un build de producție complet și cu măsurătoarea de mai sus. Niciuna nu a mișcat numărul. Această repetiție este chiar ideea — modul de eșec supraviețuiește oricărui instrument pe care îl arunci asupra lui, pentru că toate aceste instrumente rezolvă o problemă diferită.
Fundătura #1: „folosește pur și simplu next/dynamic”
Era deja acolo. Fiecare dintre cele ~100 de variante era împachetată în dynamic(). JS-ul de la prima încărcare era oricum 5.3 MB. Orice ar promite dynamic(), aici nu livra — reține gândul ăsta, motivul apare într-o clipă.
Fundătura #2: await import() direct în componenta server
Poate wrapper-ul lui next/dynamic este problema? În App Router, o componentă server poate face await pe un import dinamic direct — pattern-ul oficial de lazy-loading pentru biblioteci. Am înlocuit registrul de wrappere dynamic() cu o hartă de thunk-uri import() brute:
// 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.Fundătura #3: împărțirea registrului în 38 de fișiere
Următoarea ipoteză: bundler-ul îmbină chunk-urile pentru că toate cele ~100 de apeluri import() trăiesc într-un singur modul. Așa că am generat câte un fișier loader per tip de secțiune — 38 de fișiere, fiecare conținând doar thunk-urile import() ale propriilor variante, plus un barrel subțire pentru a le căuta după nume.
Rezultat: output identic byte cu byte. Același megachunk, aceeași dimensiune adiacentă la hash. Acesta a fost primul rezultat negativ cu adevărat util: bundler-ele grupează chunk-urile după graful de module, nu după dispunerea în fișiere a apelurilor tale import(). Mutarea instrucțiunilor între fișiere este invizibilă pentru graf — același set de module rămâne accesibil din același resolver.
Fundătura #4: configurarea bundler-ului
Chunking-ul din Turbopack este deliberat neconfigurabil: nu există un echivalent al splitChunks, iar comentariile magice din webpack (webpackChunkName și celelalte) sunt ignorate. Singurul flag experimental relevant pentru chunking-ul async imbricat este deja activat implicit în build-urile de producție. Nu mai rămăsese absolut nicio manetă de răsucit.
Fundătura #5: trecerea la webpack (și experimentul care a explicat totul)
Webpack ESTE configurabil, așa că am rulat site-ul prin next build --webpack. Config implicit: același megachunk, ~1 MB, toate secțiunile. Apoi am forțat lucrurile cu un cacheGroup per-secțiune — un chunk per director de secțiune, 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.Chunk-urile s-au împărțit frumos — 34 de fișiere ordonate per-secțiune. Și pagina le-a încărcat pe toate 34. Aceiași bytes în total, aceeași hidratare blocată, acum cu mai multe cereri HTTP. Acesta este momentul în care problema reală a devenit de netăgăduit: optimizaserăm cum este împachetat codul, când problema era ce cod referențiază ruta.
Adevărata cauză de fond: accesibilitatea, nu chunking-ul
Iată mecanismul. Componentele de secțiune sunt componente client ('use client') — au handlere, slidere, analytics. Când un arbore de componente server referențiază o componentă client, bundler-ul trebuie să includă chunk-ul acelei componente în bundle-ul client al rutei ca să o poată hidrata. Ce componente client referențiază o rută gestionată din CMS? Renderer-ul rezolvă secțiunile după un string de runtime din CMS — deci, static, fiecare secțiune din registru este accesibilă din fiecare pagină care folosește renderer-ul. Bundler-ul nu poate ști că /pricing randează vreodată doar 12 dintre ele. Trebuie să le pregătească pe toate cele ~100.
next/dynamic nu ajută, pentru că lazitatea pe care o oferă este pe partea de client: amână momentul în care un chunk se descarcă în raport cu randarea, dar serverul a decis deja ce se randează înainte ca clientul să ruleze un singur byte. Ce a randat serverul trebuie să se hidrateze; ce ar putea fi randat trebuie livrat sau accesibil. Cu un registru rezolvat la runtime, „ar putea fi randat” înseamnă „totul”.
Un bundler împachetează ce este accesibil. Dacă resolver-ul tău poate ajunge la 100 de componente, ruta ta livrează 100 de componente — împărțite într-un singur chunk sau în treizeci și patru, dar livrate oricum. Singura pârghie reală este micșorarea accesibilității în sine.
Soluția: mută cunoașterea în build time (codegen + rewrites)
Salvarea paginilor SSG gestionate din CMS: la momentul build-ului, serverul știe deja exact ce secțiuni folosește fiecare pagină — le aduce din CMS pentru a prerandera HTML-ul. Cunoașterea există; este doar prinsă la runtime, unde bundler-ul nu o poate vedea. Așa că o materializăm în cod înainte ca bundler-ul să ruleze.
Un script de codegen rulează ca primul pas al build-ului (înlănțuit în scriptul build al pachetului — astfel fiecare pipeline îl primește gratuit: local, CI, ambele căi 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.Pentru fiecare pagină din domeniul de aplicare, ștanțează un fișier de rută fizic a cărui hartă de importuri conține doar secțiunile acelei pagini — reuniunea peste toate localele (locale diferite pot avea seturi de secțiuni diferite) și peste variantele A/B. Pentru bundler, acest fișier generat este cod sursă obișnuit cu o accesibilitate statică îngustă, deci produce un bundle mic per-rută fără nicio configurare:
// 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' }} />
}Rutele generate trăiesc sub o cale internă urâtă — utilizatorii nu o văd niciodată. Un bloc rewrites() în next.config citește manifestul emis și mapează URL-urile reale la rutele generate în beforeFiles. Proprietatea crucială: dacă manifestul lipsește sau este corupt, pur și simplu nu există niciun rewrite și fiecare pagină este servită de ruta universală neatinsă:
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' }
],
}
}Lanțul de randare este o oglindă a celui original — aceeași aducere de date, același SEO, același analytics — cu o singură diferență care este întregul scop: harta de componente sosește ca prop în loc să fie importată din registrul global. Un invariant trebuie să se mențină, altfel tot câștigul se evaporă: nimic din graful de module al rutei generate nu are voie să importe registrul global. Asta include căile indirecte — helper-ul nostru de aducere a datelor importa registrul doar ca să citească template-urile de query, ceea ce ar fi făcut din nou fiecare secțiune accesibilă; metadatele au trebuit împărțite într-un modul propriu, fără registru.
// 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} />
}Supraviețuirea testării A/B (partea despre care întreabă toată lumea)
Paginile CMS rulează experimente A/B: middleware-ul evaluează un feature flag per cerere și rescrie utilizatorii dintr-un bucket de variantă către o altă pagină sub același URL. Exact de asta a existat rezolvarea la runtime în primul rând — deci cum face față un sistem de build time? Respectând ordinea de procesare. În Next.js, middleware-ul rulează întotdeauna înaintea rewrites-urilor beforeFiles, deci cele două straturi se compun în loc să se lupte:
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-ul citește configurațiile experimentelor din aceeași sursă pe care o folosește middleware-ul (un invariant deliberat — niciun drift posibil) și ștanțează o pagină generată per variantă, plus un rewrite exact pentru URL-ul intern al variantei. Utilizatorii de control primesc pagina generată rapidă; utilizatorii de variantă primesc propria lor pagină generată rapidă; URL-ul vizibil pentru utilizator nu se schimbă niciodată. Un experiment creat după ultimul build pur și simplu cade prin plasă către ruta universală până la următorul deploy — degradat la performanța de ieri, niciodată stricat.
Contractul fail-open
Întregul sistem este proiectat să degradeze la status quo, niciodată sub el. Am ajuns să vedem asta funcționând în producție din întâmplare: la primul deploy, mediul CI expunea URL-ul CMS sub un nume de variabilă diferit față de build-urile locale. Codegen-ul nu a găsit niciun CMS, nu a emis nimic — iar site-ul a servit fiecare pagină prin ruta universală, build verde, zero impact asupra utilizatorilor. Un fix de două linii mai târziu, rutele generate au apărut. Contractul:
- CMS inaccesibil sau o pagină neparsabilă → acea pagină (sau totul) este sărită; build-ul nu eșuează niciodată din cauza codegen-ului.
- Niciun manifest → niciun rewrite → ruta universală servește totul, exact ca înainte de proiect.
- Structura secțiunilor s-a schimbat în CMS după build → pagina randează setul vechi până la următorul deploy (un webhook de publicare care declanșează redeploy-uri face ca această fereastră să dureze minute). Editările de conținut nu sunt afectate — datele sunt în continuare aduse live prin ISR.
- Fișierele generate sunt în gitignore și regenerate la fiecare build; recenzenții revizuiesc generatorul, nu 700 de fișiere ștanțate.
Rezultate
| Metrică (pagina principală) | Înainte | După |
|---|---|---|
| JS la prima încărcare (brut, set inițial de scripturi) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Componente de secțiune în bundle | 137 | 16 |
| Megachunk-ul cu toate secțiunile | ~1,200 KB | 0 KB |
−53% JavaScript la prima încărcare, confirmat independent în trei feluri: măsurătoarea setului inițial de scripturi, atribuirea per-modul prin source-map (exact cele 16 componente proprii ale paginii — 13 secțiuni mapate plus componentele lor copil) și un grep de markere peste chunk-urile deployment-ului live:
// 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 ✅Aceleași cifre s-au reprodus pe preview-ul de producție până la kilobyte. O așteptare de gestionat: TOTALUL din rețeaua DevTools va părea aproape neschimbat, pentru că după încărcare router-ul face prefetch în fundal la bundle-urile altor rute (încă universale). Este în regulă — prefetch-ul în fundal nu blochează nimic. Performanța este despre ce se încarcă înainte de interactivitate, iar acesta este numărul care s-a înjumătățit. Lighthouse și treemap-ul său o arată fără echivoc dacă vrei o dovadă bună pentru un screenshot.
Compromisuri, sincer
- Prospețimea structurii este legată de deploy: adăugarea/eliminarea unei secțiuni în CMS necesită un rebuild pentru a ajunge la ruta optimizată (deploy-urile declanșate de webhook reduc fereastra la minute; fallback-ul acoperă golul).
- Codegen-ul parsează CMS-ul tău și convențiile tale de rute — sunt ~400 de linii de cod pe care le deții și trebuie să le întreții, inclusiv parsarea registrului și a props-urilor de rută.
- Scala: sute de pagini CMS → sute de combinații unice de secțiuni (cele ~700 de pagini ale noastre aveau ~380 de seturi unice, o coadă lungă). Începe cu paginile cu cel mai mare trafic în spatele unei constante de domeniu explicite; fallback-ul servește coada.
- Asta nu micșorează JS-ul total al site-ului — bibliotecile grele de variante și secțiunile bogate există în continuare. Micșorează ce livrează fiecare rută. Următoarea pârghie (convertirea secțiunilor prezentaționale în componente server) este un proiect separat, mai mare.
Concluzii
- next/dynamic creează puncte de împărțire, nu garanții. Pe paginile gestionate de server, lazitatea este decisă de ce poate atinge ruta, nu de cum sunt scrise importurile.
- Configurarea chunking-ului nu poate remedia accesibilitatea. Am dovedit-o exhaustiv: împărțirea megachunk-ului în 34 de chunk-uri per-secțiune nu a schimbat nimic — toate 34 s-au încărcat.
- Dacă paginile tale sunt SSG/ISR dintr-un CMS, informația de care ai nevoie există deja la build time. Codegen-ul este puntea: transformă căutările de la runtime în importuri statice per rută.
- Compune cu middleware-ul, nu te lupta cu el: middleware-ul decide CARE pagină (A/B), rewrites-urile decid CARE IMPLEMENTARE a acelei pagini. Ordinea garantează că se stivuiesc.
- Construiește sisteme fail-open: manifest lipsă = site-ul de ieri. Primul nostru deploy de producție a exersat fallback-ul din întâmplare, iar utilizatorii nu au observat niciodată.
Pattern-ul se generalizează la orice „registru de componente rezolvate de datele CMS la runtime” — page builder-e, sisteme de widget-uri, vitrine tematizabile. Dacă ruta ta poate randa orice, va livra totul. Fă build-ul să știe ce este de fapt fiecare pagină, iar bundler-ul va face în sfârșit ceea ce ai presupus mereu că face.