Статический экспорт: сайт без Node на сервере
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Обычная сборка Next.js оставляет после себя работающий процесс: на сервере крутится next start, и каждый запрос проходит через него. Строка output: "export" заканчивает сборку иначе — папкой out/ с готовыми HTML, CSS и JavaScript. Отдать её умеет любой файловый сервер, но вместе с процессом исчезает половина возможностей фреймворка. Этот учебник собран именно так.
В .next после обычной сборки лежат не только готовые страницы, но и код, который досчитывает остальные: серверные компоненты, действия, обработчики маршрутов. Поднять всё это может только Node — отсюда и next start на боевой машине, и контейнер с рантаймом, и память, которую он ест круглые сутки.
После этой строки next build дописывает к сборке второй шаг — экспорт. Он проходит по всем маршрутам, рисует каждый и складывает результат в out/. Никакого server.js, никакого рантайма: в папке только то, что умеет отдать сервер, ничего не выполняющий.
| Адрес | Файл в out/ | Кто его создал |
|---|---|---|
/ | index.html | app/page.tsx |
/closures | closures.html | app/[slug]/page.tsx, одно имя из списка |
/sitemap.xml | sitemap.xml | app/sitemap.ts |
| адрес вне списка | 404.html | app/not-found.tsx |
/_next/static/… | чанки с хешем в имени | сборщик |
Дальше папку можно положить куда угодно: на бесплатный статический хостинг, в бакет за CDN, в контейнер с Caddy или nginx рядом с другими сайтами. Учебник, который вы читаете, — больше четырёхсот статей, столько же HTML-файлов — отдаётся именно так: образ собирается из caddy:2-alpine, внутрь копируется out/, и всё.
next start в этой схеме не участвует и прямо об этом скажет: "next start" does not work with "output: export" configuration. Use "npx serve@latest out" instead.
Правило одно, и оно короткое: всё, что должно выполниться в момент запроса, не переживает экспорт. Файл уже написан — переписывать его в ответ на визит некому. Отсюда весь список потерь, и он выводится, а не заучивается.
| Что не работает | Что происходит | Чем заменить |
|---|---|---|
| Server Actions | сборка падает: Server Actions are not supported with static export | форма отправляется из браузера на чужой эндпоинт |
route.ts с POST | принимать запрос некому | отдельный сервис на своём адресе |
cookies(), headers() | запроса на сборке ещё не было | document.cookie и прочее чтение после монтирования |
revalidatePath, revalidateTag, ISR | обновлять нечего: сервера нет | пересборка и перевыкладка |
proxy.ts | перед файлами никто не стоит | правила самого файлового сервера |
dynamic = "force-dynamic" | сборка падает на этом маршруте | убрать: страница обязана быть статической |
next/image с оптимизацией | сборка падает: Image Optimization using the default loader is not compatible with export | images: { unoptimized: true } или свой loader |
redirects, rewrites, headers из конфига | предупреждение на сборке, в out/ они не попадают | те же правила в конфиге сервера |
Самая обидная потеря — действия. Форма из главы про мутации выглядит безупречно и в разработке работает: next dev поднимает настоящий сервер, там есть кому принять вызов.
Сборка останавливается со словами Server Actions are not supported with static export — и это лучший из возможных исходов. Хуже был бы молчаливо собранный сайт, где кнопка «Отправить» не делает ничего.
Проверять экспорт надо сборкой, а не разработкой: под next dev рядом стоит Node, и всё перечисленное в таблице отлично работает. Ошибка приходит только на next build.
Отдельная ловушка — файлы-конвенции. sitemap.ts, robots.ts и любой route.ts с обработчиком GET при экспорте обязаны объявить себя статическими явно, иначе Next считает их динамическими и выгружать отказывается: export const dynamic = "force-static"/export const revalidate not configured on route "/sitemap.xml" with "output: export". В этом учебнике строка export const dynamic = "force-static" стоит в обоих файлах ровно по этой причине.
Проверки разбросаны по этапам, и первая же остановит сборку — остальных вы не увидите. Конфиг ругается на i18n ещё до компиляции, обработчики маршрутов — при разборе своего файла, действия и перехватывающие маршруты — на шаге экспорта, картинки — в самом конце, когда выяснилось, что next/image где-то импортирован. Так что чинить придётся по одной ошибке за прогон.
Динамический сегмент [slug] — это переменная. Пока есть сервер, подставить в неё значение можно в момент запроса; без сервера подставлять некому, а значит, все значения нужно перечислить заранее. Этим и занимается generateStaticParams: возвращает список, а шаг экспорта рисует по файлу на каждый его элемент.
В учебнике эта функция выглядит почти так же — только список берётся из оглавления, и файлов выходит по числу статей. Новая статья появляется в chapters.ts, и следующая сборка сама заводит ей адрес: руками ничего перечислять не нужно.
Забыть про эту функцию не получится — сборка не даст. Три сообщения, которые стоит узнавать в лицо:
| Что сделали | Что скажет next build |
|---|---|
нет generateStaticParams | Page "/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config |
| вернули пустой массив | returned an empty array from "generateStaticParams()". With "output: export", at least one route must be generated |
написали dynamicParams = true | "dynamicParams: true" cannot be used with "output: export" |
Последняя строка важнее, чем кажется. При экспорте dynamicParams всегда считается ложным: адрес, которого нет в списке, не существует вовсе — файловый сервер отдаст 404.html. Для учебника это ровно то, что нужно. Для доски объявлений, где карточки заводят пользователи, — приговор: страница появится только после следующей сборки.
У app/[category]/[id]/page.tsx списки объявляют оба уровня: верхний перечисляет категории, нижний получает готовую категорию аргументом и возвращает свои идентификаторы. Экспорт перемножает уровни и пишет файл на каждую пару.
Если нижний список вернёт объекты без category, сборка скажет returned incomplete params from "generateStaticParams()" и назовёт недостающий ключ: имя файла собрать не из чего.
Экспорт делает время сборки единственным моментом, когда что-то считается. Всё, что вы напишете в теле серверного компонента, выполнится один раз — и результат застынет в файле. С датами это выходит боком.
Вторая строка вывода — та самая, что увидит читатель три месяца спустя. Файл не пересчитывается от того, что его открыли; «только что» в нём написано навсегда. Это не беда экспорта как такового — так же ведёт себя любая статическая страница, — но при output: "export" статическими становятся все страницы разом, и ловушка перестаёт быть редкой.
Лечится тем же, чем всегда: значение, которого не было на сборке, вычисляется после монтирования. Первый кадр показывает то, что лежит в файле, второй — настоящее.
В выводе две строки «рендер», и в первой — null: это и есть содержимое out/, тот кадр, который лежит в файле. Настоящее число приезжает только со второй отрисовкой — третья строка и говорит, что заглушки в разметке уже нет. Само число пример не печатает нарочно: оно зависит от дня, когда вы читаете эту страницу. Отсюда правило вёрстки для статики: заглушка должна быть не пустотой, а осмысленным текстом. Её увидят все — и читатель с отключённым JavaScript, и поисковый робот, и любой, у кого страница ещё не ожила.
Так же переезжают в браузер и данные: список комментариев, курс валют, содержимое корзины. Это старый добрый эффект из главы про синхронизацию или use() с <Suspense> — с одной поправкой. Запрос уходит из браузера читателя, значит, чужой API должен разрешать CORS, а ключ доступа к нему уезжает в бандл и виден в исходниках страницы. Прятать в статике нечего.
Переменные NEXT_PUBLIC_* вшиваются в файлы на сборке. Поменять адрес API после выкладки, не пересобирая, невозможно — в out/ лежит уже подставленное значение.
Ссылки в разметке ведут на /closures, а в папке лежит closures.html. Пока работал next start, это соответствие держал он. Теперь его держите вы — правилами файлового сервера.
Три строки конфига — и сайт открывается. Без них он выглядит сломанным ровно наполовину: главная работает, любая внутренняя страница отдаёт 404. Это самая частая причина растерянности после первого экспорта, и Next тут ни при чём — просто никто не сказал серверу про .html.
От правила про .html можно избавиться вовсе — поменяв раскладку папки. Настройка trailingSlash: true заставляет экспорт писать closures/index.html вместо closures.html: такой сайт открывается почти на любом сервере без настройки, потому что index.html в папке находят все. Платой будут адреса со слэшем на конце и лишний уровень вложенности. Выбирать между двумя раскладками стоит один раз — до того, как ссылки на статьи разойдутся по сети.
Второе, что настраивается руками, — HTTP-кеш браузера, тот, что слушается заголовков (кеш service worker из части II — отдельный склад, заголовков он не читает). Раздавать всё одинаково нельзя: имена чанков содержат хеш содержимого и меняются при каждой правке, а имена страниц постоянны. У учебника это выглядит так:
| Что | Заголовок | Почему |
|---|---|---|
/_next/static/* | public, max-age=31536000, immutable | в имени файла хеш: изменилось содержимое — изменилось имя |
| HTML-страницы | no-cache | выкат должен быть виден сразу, а не когда истечёт кеш у читателя |
| картинка для соцсетей | Content-Type: image/png | экспортируется файлом без расширения, тип сам не определится |
| адрес со слэшем на конце | редирект на адрес без слэша | иначе один документ живёт по двум адресам и делит сигналы поиска |
Теперь честно про выбор. Экспорт — не режим «для маленьких сайтов», а размен: вы отдаёте динамику и получаете взамен три вещи, которые дорого стоят. Нечему падать в три часа ночи — файлы не умеют. Нагрузка не упирается в приложение — хоть сто тысяч читателей разом. Сервер стоит копейки, а часто и вовсе ничего.
| Экспорт подходит | Экспорт не подходит |
|---|---|
| документация, учебники, блог | личный кабинет, корзина, что угодно с cookies |
| лендинги и сайты-визитки | поиск по базе на стороне сервера |
| контент меняется реже, чем идёт выкат | пользователь заводит страницы сам |
| всем читателям нужен один и тот же HTML | нужны ISR и перевалидация по тегу |
Цена у этого размена одна, и она известна заранее: любое изменение контента — это пересборка и перевыкладка. Для учебника, где статьи пишутся и правятся пачками, а не по одной в минуту, это не цена вовсе. Для новостной ленты — уже нет.
Тогда экспорт не отменяется — динамический кусок просто переезжает за пределы сайта. Статика остаётся статикой, а формы, лайки и комментарии ходят из браузера в отдельный сервис по своему адресу. Дальше в учебнике этот сервис пишется руками; чужой готовый (форма обратной связи, комментарии, поиск как услуга) обычно решает вопрос за вечер.
Границу проводят или по домену — java-script.pro файлы, api.java-script.pro приложение, — или по путям на одном домене. У самого учебника она по путям: Caddy отдаёт страницы с диска, а /api и /trpc проксирует в контейнер с сервисом.
output: "export"дописывает к сборке шаг экспорта: вместо сервера получается папкаout/с готовыми файлами, которую отдаст любой файловый сервер.- Всё, чему нужен код в момент запроса, экспорт не переживает: действия,
cookies(), перевалидация,proxy.ts, оптимизация картинок. Проверяется это сборкой, а не разработкой — подnext devNode на месте. - Каждый динамический сегмент обязан назвать свои значения в
generateStaticParams;dynamicParamsпри экспорте всегда ложный, и адрес вне списка получает404.html. - Серверный код выполняется один раз, на сборке: даты, курсы и всё личное считаются уже в браузере — после монтирования, поверх осмысленной заглушки.
- Соответствие адреса файлу и заголовки кеша настраиваются в сервере: без правила
try_files … {path}.htmlвнутренние страницы отдают 404. - Размен честный: нет процесса — нечему падать и не во что упираться под нагрузкой, но любое изменение контента стоит пересборки и выкладки.
1/4В проекте с output: "export" есть app/posts/[id]/page.tsx, но generateStaticParams в нём не написан. Что произойдёт?
2/4Форма с Server Action прекрасно работает под next dev. Что будет, когда тот же проект соберут с output: "export"?
3/4Серверный компонент печатает new Date().getFullYear() в подвале. Сайт экспортирован в декабре 2026-го и с тех пор не пересобирался. Что увидит читатель в феврале 2027-го?
4/4В out/ лежит closures.html, а ссылки в разметке ведут на /closures. Кто делает так, чтобы адрес открывался?
Назвать все адреса
Функция должна вернуть список параметров для app/[slug]/page.tsx — по одному объекту на статью. Сейчас она возвращает пустой массив, а это ровно тот случай, на котором сборка с output: "export" останавливается.
Список статей уже есть — из него нужно оставить только то, что попадает в адрес.
Компилятор пропустил бы и return articles; — структурная типизация не против лишнего поля title в переменной. Next тоже не упадёт, но title ему не нужен: имя файла строится только из тех ключей, которые есть в маршруте. Возвращать ровно нужное честнее — иначе через месяц кто-то решит, что title куда-то попадает.
Дата, которая не врёт
Строка «обновлено» посчиталась на сборке и застыла в файле: читатель через полгода видит то же «только что». Перепишите компонент так, чтобы число дней считалось в браузере, а в файл попадала честная заглушка.
Значение, которого не существовало на сборке, появляется после монтирования — состояние плюс эффект без зависимостей.
Отрисовок будет две, и первая — это то, что лежит в out/. Поэтому заглушка «недавно» лучше пустой строки: её увидят и поисковый робот, и читатель, у которого страница ещё не ожила.
Адрес без файла
Сервер ищет только точное имя файла — поэтому после экспорта открывается главная и больше ничего. Научите serve тем же трём попыткам, что делает try_files: имя как есть, имя с .html, имя как папка с index.html.
Три попытки по порядку; отдельного внимания требует слэш на конце, иначе для корня получится //index.html.
На настоящем сервере адрес со слэшем обычно не просто отдают, а редиректят на вариант без слэша: один документ должен жить по одному адресу.