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 MB first-load JavaScript. Когда мы раскопали, куда это уходит, нашли один чанк на 1.2 MB с кодом 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-панели DevTools отравляют три вещи:

  • Итоги в нижней строке кумулятивны за всё время записи панели. Через несколько секунд после load фреймворковый prefetch ссылок начинает фоново подтягивать бандлы ДРУГИХ роутов — на бездействующей вкладке суммы сходятся к «в итоге всё», пряча любой выигрыш first-load.
  • Расширения браузера подмешивают мегабайты собственных скриптов в ваш замер — даже в инкогнито, если им там разрешено. Мы наблюдали, как одно расширение добавило 2 MB «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 MB. Что бы 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 MB, все секции. Тогда мы дожали вопрос через 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-ы, виджет-системы, темизируемые витрины. Если ваш роут может отрендерить что угодно — он отгрузит всё. Дайте билду знать, чем каждая страница является на самом деле, и бандлер наконец сделает то, что вы всегда от него ожидали.