next/dynamic вас не врятує: чому CMS-керовані сторінки Next.js відвантажують кожен чанк
Наші CMS-керовані сторінки Next.js відвантажували всі ~100 компонентів секцій на кожному роуті — попри хрестоматійний next/dynamic. Ми вичерпали всі фікси рівня бандлера (прямий import(), розбиття файлів, конфіг Turbopack, webpack splitChunks), перш ніж знайти справжню причину: досяжність, а не чанкування. Білд-тайм кодген + rewrites зрізали first-load JS на 53%.
У нас був продакшн-сайт на Next.js 16 з приблизно 700 CMS-керованими лендінгами. Кожна сторінка збирається з «секцій» — hero, FAQ, відгуки, ціни тощо — близько 100 React-компонентів у 38 типах секцій. Типова сторінка рендерить 10–15 із них. Головна відвантажувала 5.3 МБ first-load JavaScript. Коли ми розкопали, куди це йде, знайшли один чанк на 1.2 МБ із кодом 108 секцій — на сторінці, що рендерить 15.
Кожна секція вже була обгорнута в next/dynamic. Код-спліт виглядав хрестоматійно правильним. І все одно бандлер відвантажував усе й усюди. Ми витратили дні, перепробувавши кожен задокументований важіль — динамічні імпорти, прямий import(), реорганізацію файлів, конфігурацію бандлера, навіть заміну бандлера — і кожен провалився з однієї неочевидної причини. Ця стаття проходить кожен тупик із реальними цифрами, пояснює справжній корінь проблеми й показує рішення, яке зрізало 53% first-load JS, не торкнувшись жодного компонента секції.
TL;DR: на CMS-керованих динамічних роутах код-спліт — це не проблема чанкування, а проблема досяжності. Жоден прапорець бандлера її не лікує. Лікує перенесення знання «які компоненти використовує ця сторінка» з рантайму в білд-тайм.
Архітектура (яка, ймовірно, є й у вас)
Сетап стандартний для будь-якого page-builder: CMS зберігає сторінку як упорядкований список посилань на секції, а в застосунку є центральний реєстр, що мапить імена секцій на компоненти. Кожен запис обгорнутий у next/dynamic, рівно як радить документація:
// 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
}Серверний компонент резолвить кожну секцію за іменем у момент рендеру. Сторінки — SSG з ISR, тож усе це відбувається на сервері, а клієнт отримує HTML плюс чанки для гідрації:
// 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} />
}Цей дизайн направду хороший: маркетологи збирають сторінки в CMS без деплоїв, один реєстр обслуговує 700 сторінок, кожен варіант секції незалежно лінивий. На папері. Бандл розповів іншу історію.
Чесні виміри (DevTools вам брехатиме)
Перед будь-яким фіксом потрібен вимір, якому можна вірити. Наївні заміри в Network-панелі отруюють три речі:
- Числа в нижньому рядку — кумулятивні за весь час запису. Через кілька секунд після load фреймворковий prefetch посилань починає фоново тягнути бандли ІНШИХ роутів — на бездіяльній вкладці суми сходяться до «зрештою все», ховаючи будь-який виграш first-load.
- Розширення браузера підмішують мегабайти власних скриптів у ваш замір — навіть в інкогніто, якщо їм там дозволено. Ми спостерігали, як одне розширення додало 2 МБ «JS сторінки».
- Рядки з кеша ((disk cache) / (memory cache)) передають нуль байтів мережею — теплий перезавантажений замір не міряє нічого.
Число, яке справді важить — і на яке реагують Core Web Vitals — це initial script set: теги <script> у серверно відрендереному HTML. Саме вони блокують гідрацію. І це тривіально скриптується:
// 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')Для помодульної атрибуції ми тимчасово вмикали productionBrowserSourceMaps і атрибутували кожен згенерований байт до вихідного модуля маленьким VLQ-парсером. Одна грабля, яку варто знати: Turbopack називає .map-файл чанка ІНШИМ хешем, ніж .js — читайте коментар sourceMappingURL із хвоста чанка замість вгадувати js + '.map'.
П'ять тупиків (щоб ви їх не повторювали)
Кожен із наступних кроків перевірений повним продакшн-білдом і виміром, описаним вище. Жоден не зрушив число. Ця повторюваність — і є суть: проблема переживає будь-який інструмент, бо всі ці інструменти розв'язують іншу задачу.
Тупик №1: «просто використай next/dynamic»
Він уже там був. Кожен зі ~100 варіантів був обгорнутий у dynamic(). First-load JS усе одно 5.3 МБ. Що б dynamic() не обіцяв, тут він цього не давав — запам'ятайте цю думку, пояснення прийде за мить.
Тупик №2: прямий await import() у серверному компоненті
Може, проблема в обгортці next/dynamic? В App Router серверний компонент може await-нути динамічний імпорт напряму — офіційний патерн лінивого завантаження бібліотек. Ми замінили реєстр dynamic()-обгорток на мапу сирих import()-thunk-ів:
// 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.Тупик №3: розбити реєстр на 38 файлів
Наступна гіпотеза: бандлер зливає чанки, бо всі ~100 викликів import() живуть в одному модулі. Тож ми згенерували по файлу-лоадеру на тип секції — 38 файлів, кожен тримає лише import()-thunk-и своїх варіантів, плюс тонкий барель для пошуку за іменем.
Результат: байт-у-байт ідентичний вихід. Той самий мегачанк, той самий розмір. Це був перший по-справжньому корисний негативний результат: бандлери групують чанки за графом модулів, а не за файловою розкладкою ваших import(). Перекладання виразів між файлами невидиме для графа — той самий набір модулів лишається досяжним із того самого резолвера.
Тупик №4: конфігурація бандлера
Чанкування Turbopack свідомо неконфігуроване: немає еквівалента splitChunks, а webpack-івські magic comments (webpackChunkName і компанія) ігноруються. Єдиний релевантний експериментальний прапорець вкладеного async-чанкування вже увімкнений за замовчуванням у продакшн-білдах. Крутити було буквально нічого.
Тупик №5: перехід на webpack (і експеримент, що все пояснив)
Webpack конфігурується, тож ми прогнали сайт через next build --webpack. Дефолтний конфіг: той самий мегачанк, ~1 МБ, усі секції. Тоді ми дотиснули питання per-section cacheGroup — один чанк на теку секції, 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.Чанки розбилися красиво — 34 акуратні per-section файли. І сторінка завантажила всі 34. Ті самі сумарні байти, та сама заблокована гідрація, тепер із більшою кількістю HTTP-запитів. У цей момент справжня проблема стала незаперечною: ми оптимізували те, як код запакований, тоді як проблема була в тому, на який код посилається роут.
Справжній корінь: досяжність, а не чанкування
Ось механізм. Компоненти секцій — клієнтські ('use client'): у них хендлери, слайдери, аналітика. Коли серверне дерево посилається на клієнтський компонент, бандлер мусить включити його чанк у клієнтський бандл роуту — інакше не буде гідрації. На які клієнтські компоненти посилається CMS-керований роут? Рендерер резолвить секції за рантайм-рядком із CMS — отже статично КОЖНА секція реєстру досяжна з кожної сторінки, що використовує рендерер. Бандлер не може знати, що /pricing рендерить лише 12 із них. Він мусить підготувати всі ~100.
next/dynamic не допомагає, бо його лінивість — клієнтська: він відкладає момент завантаження чанка відносно рендеру, але сервер уже вирішив, ЩО рендериться, до того, як клієнт виконав хоч байт. Що сервер відрендерив — мусить гідруватись; що може бути відрендерене — мусить бути відвантажене або досяжне. З рантайм-резолвером «може бути відрендерене» дорівнює «все».
Бандлер бандлить досяжне. Якщо ваш резолвер може дістати 100 компонентів — ваш роут відвантажує 100 компонентів: одним чанком чи тридцятьма чотирма, але відвантажує. Єдиний справжній важіль — зменшити саму досяжність.
Рішення: перенести знання в білд-тайм (кодген + rewrites)
Рятівна властивість CMS-керованих SSG-сторінок: у момент білду сервер уже точно знає, які секції використовує кожна сторінка — він фетчить їх із CMS для пререндеру HTML. Знання існує; воно просто замкнене в рантаймі, де бандлер його не бачить. Тож ми матеріалізуємо його в код до запуску бандлера.
Кодген-скрипт виконується першим кроком білду (ланцюжком у build-скрипті пакета — тому кожен пайплайн отримує його безкоштовно: локальний, CI, обидва шляхи деплою):
// 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.На кожну сторінку у скоупі він штампує фізичний файл роуту, чия мапа імпортів містить лише секції цієї сторінки — об'єднання по всіх локалях (різні локалі можуть мати різні набори секцій) і по A/B-варіантах. Для бандлера цей згенерований файл — звичайний вихідний код із вузькою статичною досяжністю, тож він видає маленький per-route бандл без жодної конфігурації:
// 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' }} />
}Згенеровані роути живуть під негарним внутрішнім шляхом — користувачі його ніколи не бачать. Блок rewrites() у next.config читає створений маніфест і мапить реальні URL на згенеровані роути в beforeFiles. Критична властивість: якщо маніфест відсутній чи зламаний — rewrites просто немає, і кожну сторінку віддає незайманий універсальний роут:
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' }
],
}
}Рендер-ланцюг — дзеркало оригінального: той самий фетчинг даних, той самий SEO, та сама аналітика — з однією відмінністю, яка і є всією суттю: мапа компонентів приходить пропом замість імпорту з глобального реєстру. Один інваріант мусить триматися, інакше весь виграш випарується: ніщо в графі модулів згенерованого роуту не сміє імпортувати глобальний реєстр. Включно з непрямими шляхами — наш хелпер фетчингу даних імпортував реєстр лише заради шаблонів запитів, що знову зробило б усі секції досяжними; метадані довелося винести в окремий модуль без реєстру.
// 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} />
}Виживання під A/B-тестами (частина, про яку всі питають)
CMS-сторінки ганяють A/B-експерименти: middleware оцінює feature-флаг на кожен запит і рерайтить користувачів варіантної групи на іншу сторінку під тим самим URL. Саме заради цього рантайм-резолюшн і існував — то як білд-тайм система з цим уживається? Поважаючи порядок обробки. У Next.js middleware завжди виконується до beforeFiles rewrites, тож два шари складаються, а не б'ються:
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)Кодген читає конфіги експериментів із того самого джерела, що й middleware (свідомий інваріант — дрейф неможливий), і штампує згенеровану сторінку на кожен варіант плюс точний rewrite для внутрішнього URL варіанта. Контроль отримує швидку згенеровану сторінку; варіант — свою власну швидку згенеровану сторінку; видимий URL не змінюється ніколи. Експеримент, створений після останнього білду, просто провалюється на універсальний роут до наступного деплою — деградація до вчорашньої швидкості, ніколи не поломка.
Контракт fail-open
Уся система спроєктована деградувати до статус-кво і ніколи нижче. Нам випало побачити це в проді випадково: на першому деплої CI-середовище віддавало URL CMS під іншою назвою змінної, ніж локальні білди. Кодген не знайшов CMS, нічого не створив — і сайт віддав усі сторінки через універсальний роут: білд зелений, нуль впливу на користувачів. Один фікс на два рядки — і згенеровані роути з'явились. Контракт:
- CMS недоступна або сторінка непарсабельна → ця сторінка (або все) пропускається; білд ніколи не падає через кодген.
- Немає маніфеста → немає rewrites → усе обслуговує універсальний роут, точно як до проєкту.
- Структура секцій змінилась у CMS після білду → сторінка рендерить старий набір до наступного деплою (publish-webhook, що тригерить редеплой, скорочує вікно до хвилин). Контент-правки не зачеплені — дані й далі фетчаться наживо через ISR.
- Згенеровані файли в gitignore і перегенеровуються щобілду; рев'ювери дивляться генератор, а не 700 штампованих файлів.
Результати
| Метрика (головна) | До | Після |
|---|---|---|
| First-load JS (raw, initial script set) | 5,127 KB (57 chunks) | 2,411 KB (39 chunks) |
| Компонентів секцій у бандлі | 137 | 16 |
| Мегачанк усіх секцій | ~1,200 KB | 0 KB |
−53% first-load JavaScript, підтверджено незалежно трьома способами: виміром initial script set, помодульною source-map атрибуцією (рівно власні 16 компонентів сторінки — 13 замаплених секцій плюс їхні дочірні) та grep-ом маркерів по чанках живого деплою:
// 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 ✅Ті самі числа відтворилися на продакшн-прев'ю з точністю до кілобайта. Одне очікування варто відкалібрувати: СУМАРНЕ число у вашому DevTools виглядатиме майже незмінним, бо після load роутер фоново префетчить бандли інших (досі універсальних) роутів. Це нормально — фоновий prefetch нічого не блокує. Швидкість — це те, що вантажиться до інтерактивності, і саме це число впало вдвічі. Lighthouse із його treemap показує це однозначно, якщо потрібен доказ для скріншота.
Компроміси, чесно
- Свіжість структури прив'язана до деплою: додавання/видалення секції в CMS потребує ребілду, щоб дійти до оптимізованого роуту (webhook-деплої скорочують вікно до хвилин; fallback покриває розрив).
- Кодген парсить вашу CMS і ваші конвенції роутів — це ~400 рядків коду, якими володієте й підтримуєте ви, включно з парсингом реєстру та пропсів роутів.
- Масштаб: сотні CMS-сторінок → сотні унікальних комбінацій секцій (наші ~700 сторінок дали ~380 унікальних наборів, довгий хвіст). Починайте з топ-трафікових сторінок за явною scope-константою; хвіст обслуговує fallback.
- Це не зменшує сумарний JS сайту — важкі бібліотеки й насичені секції нікуди не діваються. Це зменшує те, що відвантажує кожен роут. Наступний важіль (конвертація презентаційних секцій у серверні компоненти) — окремий, більший проєкт.
Висновки
- next/dynamic створює точки розділення, а не гарантії. На сервер-керованих сторінках лінивість визначається тим, що роут може досягти, а не тим, як написані імпорти.
- Конфігурація чанкування не лікує досяжність. Ми довели це вичерпно: розбиття мегачанку на 34 per-section чанки не змінило нічого — всі 34 завантажились.
- Якщо ваші сторінки — SSG/ISR із CMS, потрібна інформація вже існує в білд-тайм. Кодген — це міст: перетворіть рантайм-пошуки на статичні імпорти per route.
- Складайтеся з middleware, а не воюйте: middleware вирішує, ЯКА сторінка (A/B), rewrites — ЯКА РЕАЛІЗАЦІЯ цієї сторінки. Порядок гарантує, що вони стакаються.
- Будуйте fail-open системи: нема маніфеста = вчорашній сайт. Наш перший продакшн-деплой випадково провів навчання fallback-у — користувачі не помітили нічого.
Патерн узагальнюється на будь-який «реєстр компонентів, резолвлений CMS-даними в рантаймі» — page-builder-и, віджет-системи, темізовані вітрини. Якщо ваш роут може відрендерити будь-що — він відвантажить усе. Дайте білду знати, чим кожна сторінка є насправді, і бандлер нарешті зробить те, що ви завжди від нього очікували.