Статический экспорт: сайт без Node на сервере

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

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

Обычная сборка Next.js оставляет после себя работающий процесс: на сервере крутится next start, и каждый запрос проходит через него. Строка output: "export" заканчивает сборку иначе — папкой out/ с готовыми HTML, CSS и JavaScript. Отдать её умеет любой файловый сервер, но вместе с процессом исчезает половина возможностей фреймворка. Этот учебник собран именно так.

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

В .next после обычной сборки лежат не только готовые страницы, но и код, который досчитывает остальные: серверные компоненты, действия, обработчики маршрутов. Поднять всё это может только Node — отсюда и next start на боевой машине, и контейнер с рантаймом, и память, которую он ест круглые сутки.

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

После этой строки next build дописывает к сборке второй шаг — экспорт. Он проходит по всем маршрутам, рисует каждый и складывает результат в out/. Никакого server.js, никакого рантайма: в папке только то, что умеет отдать сервер, ничего не выполняющий.

АдресФайл в out/Кто его создал
/index.htmlapp/page.tsx
/closuresclosures.htmlapp/[slug]/page.tsx, одно имя из списка
/sitemap.xmlsitemap.xmlapp/sitemap.ts
адрес вне списка404.htmlapp/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 exportimages: { unoptimized: true } или свой loader
redirects, rewrites, headers из конфигапредупреждение на сборке, в out/ они не попадаютте же правила в конфиге сервера

Самая обидная потеря — действия. Форма из главы про мутации выглядит безупречно и в разработке работает: next dev поднимает настоящий сервер, там есть кому принять вызов.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · next build
> Server Actions are not supported with static export.
Read more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features

Сборка останавливается со словами 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: возвращает список, а шаг экспорта рисует по файлу на каждый его элемент.

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

В учебнике эта функция выглядит почти так же — только список берётся из оглавления, и файлов выходит по числу статей. Новая статья появляется в chapters.ts, и следующая сборка сама заводит ей адрес: руками ничего перечислять не нужно.

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

Забыть про эту функцию не получится — сборка не даст. Три сообщения, которые стоит узнавать в лицо:

Что сделалиЧто скажет next build
нет generateStaticParamsPage "/[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 списки объявляют оба уровня: верхний перечисляет категории, нижний получает готовую категорию аргументом и возвращает свои идентификаторы. Экспорт перемножает уровни и пишет файл на каждую пару.

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

Если нижний список вернёт объекты без category, сборка скажет returned incomplete params from "generateStaticParams()" и назовёт недостающий ключ: имя файла собрать не из чего.

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

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

Вторая строка вывода — та самая, что увидит читатель три месяца спустя. Файл не пересчитывается от того, что его открыли; «только что» в нём написано навсегда. Это не беда экспорта как такового — так же ведёт себя любая статическая страница, — но при output: "export" статическими становятся все страницы разом, и ловушка перестаёт быть редкой.

Лечится тем же, чем всегда: значение, которого не было на сборке, вычисляется после монтирования. Первый кадр показывает то, что лежит в файле, второй — настоящее.

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

В выводе две строки «рендер», и в первой — null: это и есть содержимое out/, тот кадр, который лежит в файле. Настоящее число приезжает только со второй отрисовкой — третья строка и говорит, что заглушки в разметке уже нет. Само число пример не печатает нарочно: оно зависит от дня, когда вы читаете эту страницу. Отсюда правило вёрстки для статики: заглушка должна быть не пустотой, а осмысленным текстом. Её увидят все — и читатель с отключённым JavaScript, и поисковый робот, и любой, у кого страница ещё не ожила.

Так же переезжают в браузер и данные: список комментариев, курс валют, содержимое корзины. Это старый добрый эффект из главы про синхронизацию или use() с <Suspense> — с одной поправкой. Запрос уходит из браузера читателя, значит, чужой API должен разрешать CORS, а ключ доступа к нему уезжает в бандл и виден в исходниках страницы. Прятать в статике нечего.

Переменные NEXT_PUBLIC_* вшиваются в файлы на сборке. Поменять адрес API после выкладки, не пересобирая, невозможно — в out/ лежит уже подставленное значение.

Ссылки в разметке ведут на /closures, а в папке лежит closures.html. Пока работал next start, это соответствие держал он. Теперь его держите вы — правилами файлового сервера.

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

Три строки конфига — и сайт открывается. Без них он выглядит сломанным ровно наполовину: главная работает, любая внутренняя страница отдаёт 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 dev Node на месте.
  • Каждый динамический сегмент обязан назвать свои значения в 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" останавливается.

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

Список статей уже есть — из него нужно оставить только то, что попадает в адрес.

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

Компилятор пропустил бы и return articles; — структурная типизация не против лишнего поля title в переменной. Next тоже не упадёт, но title ему не нужен: имя файла строится только из тех ключей, которые есть в маршруте. Возвращать ровно нужное честнее — иначе через месяц кто-то решит, что title куда-то попадает.

Дата, которая не врёт

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

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

Значение, которого не существовало на сборке, появляется после монтирования — состояние плюс эффект без зависимостей.

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

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

Адрес без файла

Сервер ищет только точное имя файла — поэтому после экспорта открывается главная и больше ничего. Научите serve тем же трём попыткам, что делает try_files: имя как есть, имя с .html, имя как папка с index.html.

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

Три попытки по порядку; отдельного внимания требует слэш на конце, иначе для корня получится //index.html.

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

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