Статика, динамика и всё между ними
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Одна и та же страница может выполниться один раз за выкат — или заново на каждый заход. Next.js не спрашивает, чего вам хочется: он пытается собрать всё заранее и отступает только там, где код попросил что-то из запроса. Понять этот выбор — значит понять, почему год в подвале застрял на дне сборки, а список товаров не обновляется до следующего деплоя.
В части II перерисовка запускалась от действия: клик — новое состояние — новая разметка, и вопрос был один — когда перерисовать. У серверного компонента этого вопроса нет: он выполняется один раз за свою жизнь. Весь вопрос в том, когда эта жизнь наступает. Кандидатов ровно два: минута, когда идёт next build, и момент, когда в сервер стучится настоящий HTTP-запрос. Выбирает Next сам, и по умолчанию берёт первый вариант.
Выбрав сборку, он вызывает ваш компонент прямо там, получает разметку и кладёт готовый файл на диск. Дальше на запросы отвечает файл — в ваш код сервер уже не заходит. Разницу удобно разглядеть на модели, где clock() играет роль всего, что может измениться между выкатом и заходом читателя.
Первые две строки совпали не потому, что часы встали, а потому что 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 печатает таблицу маршрутов, а под ней — легенду значков. Для маленького приложения она выглядит так.
| Маршрут | Revalidate | Expire |
|---|---|---|
○ / | ||
○ /about | ||
○ /blog | 5m | 1y |
● /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 | прямая просьба «дождись запроса», когда других причин нет |
проп searchParams | page.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, и перепутать их легко. Компилятор в этом месте помогает: допустимых значений у настройки ровно четыре.
Диагностика короткая и точная: Тип "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 её ловит и делает вывод: заранее эту страницу не собрать.
Смотрите на порядок строк. Header успел отрисоваться — и его работа выброшена: сорвался рендер целиком, а не одно поддерево. Именно поэтому динамика заразна вверх: одна cookies() в глубине дерева делает динамическим весь маршрут, вместе с шапкой, подвалом и всем, что прекрасно лежало бы файлом.
В обычной сборке этот срыв молчалив: маршрут просто получает ƒ, и никто ничего не сообщает. Но молчание можно выключить.
export const dynamic = "error" меняет реакцию: вместо тихого перевода в динамику сборка останавливается с внятным текстом.
Error: Route /profile with `dynamic = "error"` couldn't be rendered statically because it used `cookies()`.
Это хорошая страховка для страниц, которые обязаны остаться статикой: лендинг, документация, статьи учебника. Кто-нибудь однажды добавит в общий компонент чтение заголовков — и узнает об этом на сборке, а не через месяц по счёту за сервер.
В настоящем коде всё это выглядит буднично — обычный async-компонент из главы про серверные данные.
Динамическим маршрут делает не сам ThemeBadge, а строчка await cookies() внутри него. Уберите её — и страница вернётся в ○, ничего больше менять не придётся.
Между «раз в выкат» и «на каждый запрос» есть середина, и она закрывает большинство настоящих случаев. Каталог товаров не обязан быть свежим до секунды — достаточно, чтобы он обновлялся раз в минуту. Это и делает revalidate: страница остаётся статической, но у файла появляется срок годности.
export const revalidate = 60 в page.tsx или в layout.tsx — и в таблице сборки у маршрута заполнится колонка Revalidate значением 1m. Значок при этом остаётся ○: страница по-прежнему собрана заранее, просто не навсегда.
Самое важное — что происходит, когда срок вышел. Запрос, заставший страницу протухшей, получает старую версию: немедленно, без ожидания. И только следом Next пересобирает страницу в фоне; свежее увидит уже следующий заход.
Проверяется это одной командой. Соберите приложение с revalidate = 5, запустите next start и походите к странице curlом: первый запрос после истечения срока приезжает с заголовком x-nextjs-cache: STALE и старым содержимым, следующий — с HIT и новым.
Отсюда вывод, экономящий часы отладки: увидев старые данные ровно один раз, обновите страницу ещё раз, прежде чем идти чинить кеш.
Второй способ занять середину — перечислить адреса заранее. generateStaticParams из статьи «Динамические сегменты и асинхронные params» возвращает список значений сегмента, и Next собирает по странице на каждое; в таблице они получают ●. Адреса, которых в списке не было, отрисуются по требованию при первом заходе — если только вы не запретили это через dynamicParams = false, тогда будет 404.
Тонкость, о которую спотыкаются: список важен не только для тех адресов, что в нём перечислены. Пока generateStaticParams у сегмента есть, адрес вне списка отрисуется при первом заходе и ляжет в кеш — второй запрос получит его уже с x-nextjs-cache: HIT. А если функции нет вовсе, перечислять Next нечего: маршрут помечается ƒ и считается заново на каждый заход.
Одна ошибка тут повторяется у всех, и в статье про сегменты она уже попадалась: id в базе — число, а сегмент адреса всегда строка.
Компилятор разворачивает причину до самой сути: Тип "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 прошла без срыва и напечатала готовую ссылку.
Заголовок здесь нужен ради значения, которое никогда не меняется. Значит, спрашивать его у запроса не за чем — достаточно константы.
В настоящем проекте на месте константы стоит переменная окружения — что-нибудь вроде process.env.NEXT_PUBLIC_SITE_URL. Она известна на сборке, поэтому маршрут остаётся ○.
Чтение headers() ради неизменного значения — самая частая случайная причина динамики. Проверить себя просто: если ответ одинаков для всех посетителей, данные запроса не нужны.
Модель протухания
Допишите handle, чтобы страница с revalidate = 10 вела себя как настоящая: ответ отдаётся немедленно из того, что уже лежит в cached, а пересборка происходит после выбора ответа, если с прошлой сборки прошло десять секунд или больше.
Весь фокус в порядке двух действий: сначала запомнить ответ, потом пересобирать.
Если поменять строки местами и вернуть cached.html в конце, запрос на пятнадцатой секунде получит «версия 2» — и модель начнёт врать. Настоящий Next никогда не заставляет посетителя ждать пересборку: он отвечает тем, что есть, и обновляет файл следом.
Прочитать таблицу сборки
Вот что напечатал next build. Скажите про каждый маршрут: покажет ли он новые данные без нового выката — и какой из них ответит медленнее остальных.
| Маршрут | Значок | Revalidate |
|---|---|---|
/ | ○ | |
/blog | ○ | 5m |
/blog/react | ● | |
/profile | ƒ |
/ и /blog/react застыли до следующего выката. Значок ● не означает «умнее, чем ○»: это та же статика, просто для адреса, который перечислили через generateStaticParams.
/blog обновляется сам, но не чаще раза в пять минут — и запрос, заставший страницу протухшей, всё равно получит старую версию.
/profile собирается заново на каждый заход: только он всегда свежий, и только он ответит медленнее остальных — вместо готового файла посетитель ждёт выполнение компонента и всё, что тот успеет запросить. Он же единственный, который нельзя раздать с ближайшего к читателю сервера.