Проблема: одна статья пересобирала весь сайт
Есть большой образовательный портал на Next.js — каталог и блог, вместе больше 11 000 страниц. Всё это собрано и поддерживается вайбкодингом, в диалоге с Claude Code. Блог там — главный канал органического трафика, статьи выходят пачками, часть из них пишет и публикует контент-автопилот на планировщике.
И была боль, которую владелец сформулировал одной фразой: «почему ради одной статьи ты каждый раз собираешь весь сайт заново и заливаешь его целиком?» Вопрос абсолютно правильный. Добавление одной статьи запускало полный next build на все 11 000+ страниц и перезаливку собранного бандла на сервер. По факту — десятки минут на публикацию пары абзацев.
Если публикация текста запускает сборку кода — значит, контент стал частью программы. А он должен быть данными.
Диагноз: контент был вшит в бандл
Первым делом я нашёл корень, а не симптом. Все статьи блога лежали внутри исходника — большим массивом прямо в коде, который компилируется в бандл сайта. Для сборщика это не «данные», а часть приложения. Поэтому любая правка текста = изменение кода → полная пересборка и повторная генерация всех статических страниц.
Добивал деплой: каждая сборка меняет хеши файлов, а ссылки на них зашиты почти в каждую HTML-страницу. Из-за этого «инкрементальная» заливка на деле тащила почти весь сайт заново. Отсюда и ощущение владельца, что «перезаливается всё целиком» — оно было близко к правде.
Идея фикса: отвязать контент от сборки
Решение — разъединить две вещи, которые были склеены: контент и сборку. Причём это не эксперимент: на этом же проекте один раздел уже годами работал по нужной схеме, я просто распространил её на блог.
- Контент — на диск, а не в бандл. Каждая статья становится отдельным файлом
content/blog/<slug>.json, который читается в рантайме через файловую систему, а не черезimport. Теперь добавить статью — это изменение данных, а не кода. - Рендер — по требованию (ISR). На странице статьи я включил
dynamicParamsиrevalidate. Новая статья, которой не было на момент сборки, рендерится при первом запросе и кешируется. Полная пересборка не нужна.
Как это устроено
Модуль доступа читает одну статью с диска ровно тогда, когда её запросили, — без базы и без CMS, просто файлы в репозитории. А конфиг маршрута — это буквально три строки, которые меняют всё поведение:
export const dynamicParams = true // новый slug рендерится on-demand
export const revalidate = 86400 // ISR-кеш
// generateStaticParams при этом всё ещё читает content/ —
// значит полная сборка по-прежнему пре-рендерит все статьи,
// а появившееся МЕЖДУ сборками отдаётся на лету.
Список статей для индекса и sitemap нельзя читать один раз при старте модуля — иначе новый файл не появится без перезапуска процесса. Читать нужно внутри функции рендера, тогда ISR сам решает, когда перечитать. Это неочевидная разница между «работает на демо» и «работает в проде».
Мгновенная публикация вместо деплоя
Дальше — самое важное для владельца. Я добавил защищённый эндпоинт, который точечно сбрасывает кеш нужных страниц через revalidatePath, и собрал из этого скрипт публикации. Теперь выход новой статьи выглядит так:
# 1. записать content/blog/<slug>.json
# 2. git commit && git push (уезжает ОДИН маленький файл)
# 3. git pull на сервере (он забирает только его)
# 4. дёрнуть /api/revalidate/ (кеш обновился)
# → без next build, без перезаливки бандла
Никакой полной сборки. Файл контента доезжает до сервера обычным git pull — тем же каналом, что уже возил другие рантайм-данные проекта. Полный деплой теперь нужен только когда меняется код сайта, а не тексты статей.
Грабли, на которые я наступил за владельца
Такая миграция на живом проде — это не «поменять пару файлов». Вот что реально стреляет:
- Доставка файлов в рантайм. Заливка собранного бандла возит не всё — контент-файлы должны попадать туда, где их читает процесс. У меня их доставляет
git pullна сервере. - Порядок статей. Если индекс использует порядок массива (первая = «главная»), новый источник обязан хранить тот же порядок. Пересортировка по дате молча ломает вёрстку и внутренние ссылки.
- generateStaticParams тоже читает контент-файлы — иначе полный деплой с очисткой может выкатить индекс и sitemap без свежих статей.
- Слэш в конце ломает ревалидацию. С включённым
trailingSlashзапрос к эндпоинту без слэша отдаёт редирект, и полезное действие не выполняется. Мелочь, которая стоит часа отладки. - Гвард на пустой контент. Загрузчик кидает ошибку, если файлов нет: тогда сборка из недокачанного дерева падает, а не выкатывает пустой раздел поверх рабочего.
Результат
Цифры — фактический замер на проде: тестовая статья опубликовалась за 8 секунд и сразу открылась по своему адресу, без единой пересборки. Контент-автопилот, который раньше упирался в те же десятки минут и в перегрузку сервера, теперь публикует статьи мгновенно.
Но главный итог даже не в секундах. Пока контент склеен со сборкой, каждая публикация — это событие для всего сайта: риск, нагрузка, ожидание. Как только контент становится данными, публикация превращается в то, чем и должна быть, — быстрое и скучное действие. А скучное действие можно смело отдавать на автопилот.