Статика, динамика и всё между ними

кеш и прод≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Одна и та же страница может выполниться один раз за выкат — или заново на каждый заход. Next.js не спрашивает, чего вам хочется: он пытается собрать всё заранее и отступает только там, где код попросил что-то из запроса. Понять этот выбор — значит понять, почему год в подвале застрял на дне сборки, а список товаров не обновляется до следующего деплоя.

Разделы темы:·

В части II перерисовка запускалась от действия: клик — новое состояние — новая разметка, и вопрос был один — когда перерисовать. У серверного компонента этого вопроса нет: он выполняется один раз за свою жизнь. Весь вопрос в том, когда эта жизнь наступает. Кандидатов ровно два: минута, когда идёт next build, и момент, когда в сервер стучится настоящий HTTP-запрос. Выбирает Next сам, и по умолчанию берёт первый вариант.

Выбрав сборку, он вызывает ваш компонент прямо там, получает разметку и кладёт готовый файл на диск. Дальше на запросы отвечает файл — в ваш код сервер уже не заходит. Разницу удобно разглядеть на модели, где clock() играет роль всего, что может измениться между выкатом и заходом читателя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первые две строки совпали не потому, что часы встали, а потому что Page во второй раз просто не звали. Статическая страница — это не «быстрый рендер», это отсутствие рендера.

Убедиться можно и без модели. Соберите приложение и загляните в .next/server/app/: у статической страницы там лежит настоящий .html со всем текстом внутри, у динамической — только код, который этот текст когда-нибудь построит.

next dev собирает страницу заново на каждый запрос — даже ту, что в проде застынет навсегда. Отсюда классическое «на локалке обновлялось»: заморозку видно только после next build.

Разница заметна и снаружи, в заголовках ответа. Статическая страница приезжает с Cache-Control: s-maxage=31536000 и пометкой x-nextjs-prerender: 1; динамическая — с private, no-cache, no-store, max-age=0, must-revalidate. Это тот же самый выбор, только сформулированный для прокси и браузера.

Гадать о режиме не нужно: в конце сборки Next печатает таблицу маршрутов, а под ней — легенду значков. Для маленького приложения она выглядит так.

МаршрутRevalidateExpire
○ /
○ /about
○ /blog5m1y
● /products/1
● /products/2
ƒ /profile
ЗначокПодписьЧто за ней стоит
○(Static)«prerendered as static content» — компонент выполнился на сборке, дальше отвечает файл
●(SSG)«prerendered as static HTML (uses generateStaticParams)» — то же самое, но для динамического сегмента: адреса перечислены заранее
ƒ(Dynamic)«server-rendered on demand» — компонент выполняется на каждый запрос
◐(Partial Prerender)«prerendered as static HTML with dynamic server-streamed content» — оболочка статикой, дырки потоком; появляется с Cache Components из следующей статьи

Колонки Revalidate и Expire заполняются только у страниц со сроком годности — про них раздел ниже. Пустая колонка у ○ означает буквально «собрано один раз и до следующего выката не изменится».

Читать этот список стоит на каждом выкате. ƒ там, где вы ждали ○, — уже найденная ошибка: кто-то в общем коде дотянулся до данных запроса, и страницы поехали считаться заново на каждый заход.

Одна невнимательная строка в app/layout.tsx делает динамическим весь сайт: корневая раскладка входит в каждый маршрут.

Список того, что превращает ○ в ƒ, невелик, и его стоит держать в голове целиком. Объединяет пункты одно: все они просят что-то, чего на сборке физически нет.

Что в кодеОткудаПочему это динамика
cookies()next/headersкуки приходят с запросом, на сборке их неоткуда взять
headers()next/headersто же самое: заголовки есть только у настоящего запроса
draftMode()next/headersрежим черновика хранится в куке
connection()next/serverпрямая просьба «дождись запроса», когда других причин нет
проп searchParamspage.tsxстроку запроса нельзя перечислить заранее
fetch(url, { cache: "no-store" })ваш кодявный отказ от кеша читается как «данные обязаны быть свежими»
export const dynamic = "force-dynamic"настройка сегментарешение принято за Next
export const revalidate = 0настройка сегментасрок годности ноль — кешировать нечего

Строка про fetch ловит чаще остальных. Обычный fetch(url) статику не ломает: на сборке он один раз сходит за данными, и ответ застынет в разметке вместе со всем прочим. А дописанный cache: "no-store" — это уже заявление о свежести, и весь маршрут уезжает в ƒ.

Полезно помнить и зеркальный список — то, что динамику не включает, хотя многие ждут обратного: Math.random(), new Date(), чтение файла, запрос в базу. Всё это спокойно выполнится на сборке, а результат застынет в разметке до следующего выката.

Отдельная ловушка — похожие названия. У fetch есть значение force-cache, у сегмента — force-static, и перепутать их легко. Компилятор в этом месте помогает: допустимых значений у настройки ровно четыре.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика короткая и точная: Тип "force-cache" не может быть назначен для типа "SegmentDynamic". Выполнение при этом прошло и напечатало строку — типы живут только до запуска, и это ровно тот урок из части III.

В настоящем page.tsx перед строкой стоит export. Песочница выполняет один файл без модулей, поэтому в примерах это слово опущено — во всём остальном код тот же.

export const dynamic = "force-static" не уговаривает Next постараться — он выключает сам механизм. cookies() внутри такой страницы возвращает пустую банку, headers() — пустой набор, а searchParams разрешается в {}. Код не падает и ни о чём не предупреждает: он честно работает с пустотой.

Проверяется за минуту. Соберите страницу с force-static, которая печатает число кук и число ключей строки запроса, и зайдите на неё с куками и с ?sort=new. В ответе будут нули.

Поэтому force-static уместен там, где вы точно знаете, что запрос не нужен, и хотите защититься от чужой правки. Если данные из запроса всё-таки нужны — это force-dynamic или ничего.

Откуда фреймворк знает, что маршрут динамический? Он не ищет в вашем коде слово cookies — он пробует отрисовать страницу и смотрит, чем это кончится. Запроса на сборке нет, поэтому cookies() не возвращает значение, а бросает ошибку. Рендер срывается, Next её ловит и делает вывод: заранее эту страницу не собрать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Смотрите на порядок строк. Header успел отрисоваться — и его работа выброшена: сорвался рендер целиком, а не одно поддерево. Именно поэтому динамика заразна вверх: одна cookies() в глубине дерева делает динамическим весь маршрут, вместе с шапкой, подвалом и всем, что прекрасно лежало бы файлом.

В обычной сборке этот срыв молчалив: маршрут просто получает ƒ, и никто ничего не сообщает. Но молчание можно выключить.

export const dynamic = "error" меняет реакцию: вместо тихого перевода в динамику сборка останавливается с внятным текстом.

Error: Route /profile with `dynamic = "error"` couldn't be rendered statically because it used `cookies()`.

Это хорошая страховка для страниц, которые обязаны остаться статикой: лендинг, документация, статьи учебника. Кто-нибудь однажды добавит в общий компонент чтение заголовков — и узнает об этом на сборке, а не через месяц по счёту за сервер.

В настоящем коде всё это выглядит буднично — обычный async-компонент из главы про серверные данные.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Динамическим маршрут делает не сам ThemeBadge, а строчка await cookies() внутри него. Уберите её — и страница вернётся в ○, ничего больше менять не придётся.

Между «раз в выкат» и «на каждый запрос» есть середина, и она закрывает большинство настоящих случаев. Каталог товаров не обязан быть свежим до секунды — достаточно, чтобы он обновлялся раз в минуту. Это и делает revalidate: страница остаётся статической, но у файла появляется срок годности.

export const revalidate = 60 в page.tsx или в layout.tsx — и в таблице сборки у маршрута заполнится колонка Revalidate значением 1m. Значок при этом остаётся ○: страница по-прежнему собрана заранее, просто не навсегда.

Самое важное — что происходит, когда срок вышел. Запрос, заставший страницу протухшей, получает старую версию: немедленно, без ожидания. И только следом Next пересобирает страницу в фоне; свежее увидит уже следующий заход.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверяется это одной командой. Соберите приложение с revalidate = 5, запустите next start и походите к странице curlом: первый запрос после истечения срока приезжает с заголовком x-nextjs-cache: STALE и старым содержимым, следующий — с HIT и новым.

Отсюда вывод, экономящий часы отладки: увидев старые данные ровно один раз, обновите страницу ещё раз, прежде чем идти чинить кеш.

Второй способ занять середину — перечислить адреса заранее. generateStaticParams из статьи «Динамические сегменты и асинхронные params» возвращает список значений сегмента, и Next собирает по странице на каждое; в таблице они получают ●. Адреса, которых в списке не было, отрисуются по требованию при первом заходе — если только вы не запретили это через dynamicParams = false, тогда будет 404.

Тонкость, о которую спотыкаются: список важен не только для тех адресов, что в нём перечислены. Пока generateStaticParams у сегмента есть, адрес вне списка отрисуется при первом заходе и ляжет в кеш — второй запрос получит его уже с x-nextjs-cache: HIT. А если функции нет вовсе, перечислять Next нечего: маршрут помечается ƒ и считается заново на каждый заход.

Одна ошибка тут повторяется у всех, и в статье про сегменты она уже попадалась: id в базе — число, а сегмент адреса всегда строка.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор разворачивает причину до самой сути: Тип "number" не может быть назначен для типа "string". Починка — String(product.id) при сборке списка, а не приведение типа.

РежимКогда выполняется компонентЧем включается
Статикаодин раз, на next buildпо умолчанию — если код ничего не просит у запроса
Статика со срокомна сборке, потом в фоне не чаще раза в N секундexport const revalidate = N
Перечисленные адресана сборке, по разу на каждый адрес из спискаgenerateStaticParams
По требованию с кешемпри первом заходе на адрес, дальше из кешадинамический сегмент вне списка
Динамикана каждый запросcookies(), headers(), searchParams и остальной список выше

Правило простое: не мешайте Next собирать страницу заранее. Динамика — не «более честный» режим, а счёт, который выставляется на каждом заходе: время ответа, нагрузка на базу, невозможность отдать готовый файл с ближайшего к читателю сервера.

Что видноЧто произошлоКуда смотреть
Год в подвале застрял на прошломnew Date() посчитался на сборкепоказывать год клиентским компонентом или дать сегменту revalidate
На локалке данные свежие, на проде — нетnext dev рендерит каждый запрос, прод отдаёт файлпроверять после next build, а не в разработке
После мелкой правки половина маршрутов стала ƒв общую раскладку или общий компонент попал cookies() или headers()таблица маршрутов в конце сборки
Страница ждёт медленный запрос на каждом заходединамика без всякого кешаrevalidate, а дальше — Cache Components

Когда динамика действительно нужна, её стараются загнать в маленький кусок страницы, а не в маршрут целиком. В классическом режиме такой возможности нет: как вы видели, сорванный рендер обрывает весь маршрут. Именно эту дыру закрывают Cache Components и директива 'use cache' — оболочка остаётся статикой, а динамические куски приезжают потоком, и в таблице сборки появляется тот самый ◐. Об этом следующая статья.

Крайний случай другого конца шкалы — сайт, у которого динамики нет вовсе. Тогда сервер Node не нужен совсем, и приложение выкладывается набором файлов; про это статья «Статический экспорт: сайт без Node на сервере» в конце главы.

  • По умолчанию Next выполняет компонент один раз, на сборке, и кладёт готовую разметку на диск; дальше на запросы отвечает файл, а не ваш код.
  • В динамику страницу переводит обращение к данным запроса: cookies(), headers(), draftMode(), connection(), проп searchParams, fetch с cache: "no-store" и настройки dynamic / revalidate.
  • Math.random(), new Date() и запрос в базу динамику не включают — они выполняются на сборке, и результат застывает в разметке до выката.
  • Механизм — сорванный рендер: на сборке cookies() бросает ошибку, и она обрывает весь маршрут, а не одно поддерево.
  • revalidate = N даёт середину: статика со сроком годности, где запрос, заставший страницу протухшей, получает старую версию, а свежую видит следующий.
  • Режим каждого маршрута печатается в конце next build значками ○, ●, ƒ, ◐; ƒ там, где вы ждали ○, — уже найденная ошибка.

1/4В page.tsx появился проп searchParams: страница читает ?sort=new. Что напечатает next build рядом с этим маршрутом?

2/4Сайт собран в сентябре 2026 года. В подвале статической страницы стоит new Date().getFullYear(). Какой год увидит посетитель в феврале 2027-го, если выката с сентября не было?

3/4У страницы export const revalidate = 60. С последней сборки прошло сто секунд, и приходит запрос. Что получит этот посетитель?

4/4В глубине дерева, внутри маленького компонента-бейджа, вызвали cookies(). Что станет динамическим?

Вернуть страницу в статику

Главная читает заголовок host, чтобы собрать абсолютный адрес для кнопки «поделиться». Из-за этого весь маршрут динамический, хотя домен у сайта один и известен задолго до сборки. Почините модель так, чтобы фаза prerender прошла без срыва и напечатала готовую ссылку.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Заголовок здесь нужен ради значения, которое никогда не меняется. Значит, спрашивать его у запроса не за чем — достаточно константы.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В настоящем проекте на месте константы стоит переменная окружения — что-нибудь вроде process.env.NEXT_PUBLIC_SITE_URL. Она известна на сборке, поэтому маршрут остаётся ○.

Чтение headers() ради неизменного значения — самая частая случайная причина динамики. Проверить себя просто: если ответ одинаков для всех посетителей, данные запроса не нужны.

Модель протухания

Допишите handle, чтобы страница с revalidate = 10 вела себя как настоящая: ответ отдаётся немедленно из того, что уже лежит в cached, а пересборка происходит после выбора ответа, если с прошлой сборки прошло десять секунд или больше.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Весь фокус в порядке двух действий: сначала запомнить ответ, потом пересобирать.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если поменять строки местами и вернуть cached.html в конце, запрос на пятнадцатой секунде получит «версия 2» — и модель начнёт врать. Настоящий Next никогда не заставляет посетителя ждать пересборку: он отвечает тем, что есть, и обновляет файл следом.

Прочитать таблицу сборки

Вот что напечатал next build. Скажите про каждый маршрут: покажет ли он новые данные без нового выката — и какой из них ответит медленнее остальных.

МаршрутЗначокRevalidate
/○
/blog○5m
/blog/react●
/profileƒ

/ и /blog/react застыли до следующего выката. Значок ● не означает «умнее, чем ○»: это та же статика, просто для адреса, который перечислили через generateStaticParams.

/blog обновляется сам, но не чаще раза в пять минут — и запрос, заставший страницу протухшей, всё равно получит старую версию.

/profile собирается заново на каждый заход: только он всегда свежий, и только он ответит медленнее остальных — вместо готового файла посетитель ждёт выполнение компонента и всё, что тот успеет запросить. Он же единственный, который нельзя раздать с ближайшего к читателю сервера.