Skip to main content
Назад до блогу
Next.jsPerformanceCode SplittingTurbopackReact Server Components

next/dynamic вас не врятує: чому CMS-керовані сторінки Next.js відвантажують кожен чанк

Наші CMS-керовані сторінки Next.js відвантажували всі ~100 компонентів секцій на кожному роуті — попри хрестоматійний next/dynamic. Ми вичерпали всі фікси рівня бандлера (прямий import(), розбиття файлів, конфіг Turbopack, webpack splitChunks), перш ніж знайти справжню причину: досяжність, а не чанкування. Білд-тайм кодген + rewrites зрізали first-load JS на 53%.

Опубліковано 18 липня 2026 р.14 хв читання

У нас був продакшн-сайт на 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, рівно як радить документація:

sectionRegistry.tsx
// 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 плюс чанки для гідрації:

SectionRenderer.tsx
// 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:

next.config.js (webpack experiment)
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, обидва шляхи деплою):

scripts/generate-landing-routes.mjs
// 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 бандл без жодної конфігурації:

app/[lang]/(generated)/g/home/page.tsx (auto-generated)
// 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 просто немає, і кожну сторінку віддає незайманий універсальний роут:

next.config.ts
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, та сама аналітика — з однією відмінністю, яка і є всією суттю: мапа компонентів приходить пропом замість імпорту з глобального реєстру. Один інваріант мусить триматися, інакше весь виграш випарується: ніщо в графі модулів згенерованого роуту не сміє імпортувати глобальний реєстр. Включно з непрямими шляхами — наш хелпер фетчингу даних імпортував реєстр лише заради шаблонів запитів, що знову зробило б усі секції досяжними; метадані довелося винести в окремий модуль без реєстру.

SectionRendererGen.tsx
// 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)
Компонентів секцій у бандлі13716
Мегачанк усіх секцій~1,200 KB0 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 сайту — важкі бібліотеки й насичені секції нікуди не діваються. Це зменшує те, що відвантажує кожен роут. Наступний важіль (конвертація презентаційних секцій у серверні компоненти) — окремий, більший проєкт.

Висновки

  1. next/dynamic створює точки розділення, а не гарантії. На сервер-керованих сторінках лінивість визначається тим, що роут може досягти, а не тим, як написані імпорти.
  2. Конфігурація чанкування не лікує досяжність. Ми довели це вичерпно: розбиття мегачанку на 34 per-section чанки не змінило нічого — всі 34 завантажились.
  3. Якщо ваші сторінки — SSG/ISR із CMS, потрібна інформація вже існує в білд-тайм. Кодген — це міст: перетворіть рантайм-пошуки на статичні імпорти per route.
  4. Складайтеся з middleware, а не воюйте: middleware вирішує, ЯКА сторінка (A/B), rewrites — ЯКА РЕАЛІЗАЦІЯ цієї сторінки. Порядок гарантує, що вони стакаються.
  5. Будуйте fail-open системи: нема маніфеста = вчорашній сайт. Наш перший продакшн-деплой випадково провів навчання fallback-у — користувачі не помітили нічого.

Патерн узагальнюється на будь-який «реєстр компонентів, резолвлений CMS-даними в рантаймі» — page-builder-и, віджет-системи, темізовані вітрини. Якщо ваш роут може відрендерити будь-що — він відвантажить усе. Дайте білду знати, чим кожна сторінка є насправді, і бандлер нарешті зробить те, що ви завжди від нього очікували.