Я попытался мигрировать продакшен‑приложение NextJS с тяжёлого рантайма Node.js standalone на облегчённую архитектуру. Цель — использовать NextJS output: 'export' для генерации статических ассетов, которые затем обслуживались бы кастомным, лёгким сервером на Go, упакованным в тот же Docker‑образ.
Приложение представляло собой сложный, интернационализированный сайт с поддержкой восьми различных локалей. Ключевое требование — сохранить универсальную сборку. Это означало, что Docker‑образ должен быть независим от переменных окружения, чтобы его можно было разворачивать на разных стадиях без пересборки. Главная цель — избавиться от потребления RAM тяжёлым рантаймом NextJS. Рантайм NodeJS/Bun потреблял ~ 200-450MB RAM в режиме standalone и ~140MB при статическом экспорте.
Чтобы добиться универсальной сборки, переменные окружения были исключены из процесса сборки. Вместо того чтобы вшивать конечные точки API в статический фронтенд, использовались статические пути вроде /api/graphql. Обработку этих запросов переложили на встроенный сервер на Go, выступавший прокси и перенаправлявший трафик к соответствующим бэкенд‑сервисам, задаваемым переменными окружения на рантайме.
Оптимизацию изображений аналогично отвязали от рантайма NextJS. Реализовали кастомный загрузчик изображений, который переписывал запросы к изображениям на выделенный путь CDN, чтобы статический экспорт оставался независимым от логики обработки изображений.
Существенным препятствием стала обработка динамических маршрутов, например постов блога, доступных по слагу. Статический экспорт NextJS требует generateStaticParams для определения всех путей на этапе сборки. Чтобы избежать неэффективного предварительного рендеринга каждого возможного поста, архитектуру изменили на использование query‑параметров вместо динамических сегментов маршрута. Это позволило обслуживать один общий статический HTML‑файл для всех запросов контента, который затем клиентское приложение наполняло конкретными данными.
В итоге проект пришлось свернуть из‑за неразрешимого конфликта, связанного с SEO, а именно с требованиями к метаданным канонического URL.
Большинство метаданных может оставаться статичными без заметных последствий, но тег canonical требует абсолютный URL с полным доменом, чтобы работать корректно. В app‑директории NextJS метаданные должны задаваться через generateMetadata или статический экспорт metadata, и оба варианта в случае статического экспорта выполняются исключительно на этапе сборки. Встроенного механизма отложить это на рантайм без сервера Node.js нет. Следовательно, при универсальном статическом экспорте имя домена на этапе сборки неизвестно.
Чтобы исправить это в рамках предложенной архитектуры, сервер на Go должен был бы парсить статический HTML и динамически подменять домен или доменоподобный плейсхолдер внутри строки тега canonical на этапе рантайма. Этот подход был отвергнут, поскольку по сути требует реализовать шаблонизацию на Go поверх статического экспорта. Такая манипуляция не выглядит изящно и вносит серьёзный риск проблем с гидратацией, когда клиентское приложение React расходится с модифицированным HTML, отправленным сервером.
Если вам не нужен SEO‑рейтинг, облегчённый рантайм с multi‑env Docker‑образом достижим. Иначе это возможно лишь через очень костыльные решения, вроде замены плейсхолдеров переменных ENV внутри статических .html и .js с помощью кастомного движка шаблонов.
Для приложений NextJS, где нужны динамические переменные окружения вроде BACKEND_URL, API_ENDPOINT или кастомный rewrite, можно использовать лёгкий кастомный сервер на Golang. Это просто и работает (~13 MB потребления RAM). Есть множество обходных путей, которые можно реализовать под разные фичи.
Для сайтов, где SEO не важно, есть множество серверов, способных хостить ваш статический экспорт. Например, в одном из моих проектов я использую Rust-based static-web-server (~8 MB потребления RAM).
Вот как эта попытка выглядела в цифрах:
Если бы я добивался желаемого результата любой ценой, обновление потребовало бы примерно в 4× больше добавленных строк и изменений примерно в ~100 файлах.
