Долгое время этот блог обслуживал бэкенд, который мы написали сами: Laraue.CmsBackend — библиотека на .NET, превращающая markdown-файлы в API с запросами, и небольшой .NET-хост вокруг неё. Фронтенд на Nuxt обращался к этому API за каждым списком, каждой статьёй и каждым тегом. Это работало — и это была плохая идея. Мы только что удалили хост и перенесли markdown-файлы в приложение на Nuxt: теперь оно читает их само и рендерит всё через SSR. Эта статья о том, почему.
Если вы собираетесь спрятать markdown за отдельным API, прочитайте её сначала.
Что мы построили и зачем
Цель была разумной. Хранить контент в Git в виде markdown-файлов отдельно от фронтенда и запрашивать его по API: фильтрация по тегам, сортировка по дате, пагинация, без базы данных. Полноценные CMS казались тяжёлыми, а куча markdown внутри фронтенда — смешением ответственностей. Так появился третий путь: типизированная библиотека на .NET, читающая файлы с frontmatter, и хост с несколькими эндпоинтами — лента, категории, теги, статьи, проекты, RSS, карта сайта и генератор превью-картинок.
Первая версия блога была без SSR, и Google индексировал новые посты неделями. Мы включили SSR, и с тех пор каждый запрос страницы заставлял сервер Nuxt обращаться к API, который возвращал готовый HTML. Поисковики остались довольны. Мы — нет, по другой причине.
Во что это обошлось: фронтенд и бэкенд никогда не были синхронизированы
Блог — это в основном фронтенд. Почти каждая маленькая правка, которую мы хотели сделать, затрагивала обе стороны, потому что API решал, что странице вообще известно:
- Даты не на том языке. Даты форматировал сам бэкенд — выражением прямо в запросе:
format(createdAt, "dd MMM yyyy"). На русских страницах было написано «26 Jun 2026». Чтобы это исправить, пришлось менять контракт бэкенда, а не страницу. - Проект в виде слага. Статья знала связанные проекты только по имени файла, поэтому в боковой панели стояло «boards» с ракетой рядом. Читаемое название означало новое поле в бэкенде, новый запрос в хосте и согласованную правку фронтенда.
- Чего не умел markdown-рендерер. У нашего рендерера были свои представления о прекрасном: он не экранировал амперсанд, терял обратные слеши в блоках кода и начинал нумерованный список заново после блока кода. Каждая из этих проблем была багом в отдельном проекте, который нужно было починить, выпустить и задеплоить.
- DTO на всё. Каждому экрану требовались DTO для списка и для детальной страницы с явным списком свойств в виде строк. Добавление «предыдущей и следующей статьи» или счётчика тегов означало правку C#-классов, строковых выражений и TypeScript-интерфейсов, которые приходилось согласовывать вручную.
- Два деплоя ради одной правки. Изменение в библиотеке — это выпуск NuGet-пакета, затем деплой хоста, затем деплой фронтенда, именно в таком порядке. Ошибка в порядке означала сломанную страницу до следующего деплоя.
- Перезапуск, чтобы увидеть правку. Хост читал markdown-файлы один раз при старте, поэтому любая правка — даже опечатка — появлялась только после перезапуска контейнера бэкенда. Всё на стороне фронтенда применяется в dev-сервере сразу, а контент был единственным, что требовало ручного перезапуска.
- Функции не на своих местах. Время чтения считал бэкенд по длине отрендеренного HTML, хлебные крошки фронтенд угадывал по сегментам маршрута, карта сайта жила в третьем месте, а генератор превью — это отдельный C#-код.
Ничто из этого не сложно. Но всё вместе приводило к тому, что дело, которое хотелось сделать вечером — сделать блог чуть лучше — никогда не было одной правкой. Это был небольшой согласованный релиз двух репозиториев, а это время мы предпочли бы потратить на статьи. Когда правка стоит так дорого, их делают меньше.
Поворот с SSR
SSR только усугубил разделение. Серверный рендеринг — как раз тот случай, когда страница и её данные хотят жить в одном процессе: сервер рендерит страницу, и контент нужен ему прямо сейчас. С отдельным API сервер Nuxt при каждом запросе ходил по HTTP в другой сервис за контентом, который мог бы быть файлом на его собственном диске. Мы заплатили за архитектуру, которую SSR затем сделал бессмысленной.
Что мы сделали вместо этого
Мы перенесли markdown-файлы в репозиторий Nuxt — в content/blog/en/… и content/blog/ru/… — и написали небольшой каталог на TypeScript: он разбирает frontmatter, рендерит markdown и отвечает на вопросы, на которые отвечал старый API — списки, теги, соседние страницы, категории. Сервер читает файлы один раз при старте. Страницы получают данные через тот же сервер Nuxt, без сетевого вызова во время рендеринга. RSS, карта сайта и превью-картинки — маршруты того же приложения.
Каталог и его маршруты — около 700 строк на TypeScript. Хост, который они заменили, — около 870 строк на C#, не считая библиотеки за ним. Мы сохранили все публичные URL, сравнили обход старого и нового сайта (статус, canonical, hreflang, og:image) и не потеряли ни одной страницы.
Результаты
- Один деплой. Один репозиторий, одна сборка, один контейнер вместо двух сервисов и библиотеки.
- Правки применяются сразу. В разработке изменённый markdown-файл появляется после обновления страницы, как любое другое изменение, без контейнера, который нужно перезапускать.
- Изменения локальны. Переведённые даты, подписи тегов со счётчиками, кнопки «предыдущая» и «следующая» на каждой странице, короткие названия связанных проектов, новые заголовки для поисковой выдачи: каждая правка делалась в одном месте, а некоторые заняли минуты.
- Контент покрыт тестами. Тест читает настоящие файлы и роняет сборку при отсутствующем переводе, битой ссылке или неверном frontmatter. При первом же запуске он нашёл в русской статье ссылку, которая вела на английскую страницу.
- Читатели ничего не заметили. Страницы статей и проектов, RSS, карта сайта и адреса превью-картинок остались прежними.
- Меньше всего запускать. Нет отдельного хоста, health-check'а, метрик для него и маршрутов в nginx.
От чего мы отказались
Это компромисс, и у него есть цена. Контент теперь часть фронтенда, поэтому исправление опечатки в статье означает деплой всего сайта: сборку, тесты и новый контейнер для каждой страницы, а не просто перезапуск небольшого сервиса, который отдаёт текст. Старая схема позволяла менять контент, вообще не трогая фронтенд.
Для нас это приемлемо: деплой автоматический, занимает минуты, а статей мы публикуем несколько в месяц. Но это именно то, что было бы больно на сайте с частыми правками контента или с редакторами, которые не разработчики.
Когда отдельный бэкенд для контента всё же оправдан
Мы не считаем, что headless API для контента — всегда ошибка. Он подходит, когда:
- Контент редактируют не разработчики, которым нужна админка, а не работа через Git.
- Один контент использует несколько клиентов — сайт, мобильное приложение, рассылка — и API служит контрактом между ними.
- У контента своя жизнь: публикация, черновики и отложенный выпуск, не связанные с деплоем фронтенда.
К нам ничего из этого не относилось: небольшая команда, один сайт, контент в Git. Если это похоже на вас и вы используете Nuxt, сначала посмотрите на Nuxt Content — та же идея, но её поддерживают другие люди. Свой каталог мы написали, потому что наши потребности были небольшими, и хотели, чтобы так и осталось.
Выводы
- Отдельный API — это цена, которую платишь за каждое изменение, а не один раз. Считайте её по тому, сколько правок затрагивают обе стороны.
- Если страница и её данные — один продукт, держите их в одной единице деплоя.
- С SSR аргументы за отдельный сервис контента слабеют: сервер, который рендерит страницу, может сам прочитать файлы.
- Если честно, библиотека нам больше не нравится. Мы её заархивировали, но не удалили: она остаётся на GitHub как часть истории и на случай, если появятся новые идеи.
Если хотите прочитать предысторию — почему мы вообще выбрали такой стек — см. Выбор стека пет-проекта для соло-разработки.