← к блогу

// архитектура

Для бота вся твоя страница — превью-карточка. Почему я впекаю контент в билд

Обложка статьи: «Контент на билде», боты не исполняют JS

Кидаешь ссылку на свою свежую статью в Телеграм — и вместо аккуратной карточки с заголовком и картинкой вылезает голый URL. Ни title, ни описания, ни превью.

Открываешь ту же ссылку в браузере — всё на месте. Разница в одном: браузер исполнил твой JavaScript, а бот Телеграма — нет. Я уткнулся в это, когда собирал единый блог-сервис на два сайта — ishabanov.ru и hire.ishabanov.ru.

Тезис простой: баг рендера и кривой Open Graph тут ни при чём. Это фундаментальное свойство того, как ты доставляешь контент. От ответа на вопрос, на каком этапе контент попадает в HTML, зависит, увидит ли статью поисковик и нарисуется ли карточка в мессенджере.

Боты не исполняют JS — и точка

Краулер или превью-бот заходит на страницу, качает HTML и читает его как текст. Он не поднимает движок, не ждёт гидрации, не дёргает твой fetch к API. Берёт ровно те байты, что сервер отдал на первый GET. А классический CSR на любом маршруте отдаёт одно и то же: пустой div и подключённый бандл. Пользователь разницы не заметит — у него всё дорисуется. Для бота страница так и остаётся пустым div'ом.

Превью-карточка для бота — это и есть весь твой сайт. Если в первом HTML её нет, для него её нет нигде.

Три способа положить контент в первый HTML

  • CSR — сервер отдаёт пустую оболочку, контент дорисовывает клиент. Дёшево, но боты и краулеры видят пустоту.
  • SSR — на каждый запрос сервер рендерит готовый HTML. Боты счастливы, но появляется живой Node-рантайм, который надо держать, греть и чинить под нагрузкой.
  • SSG + head-stamping — контент впекается в статический HTML на этапе сборки. Боты видят всё, а в рантайме крутится только nginx с готовыми файлами. Платишь только временем билда — прод остаётся простым.

Для блога, который обновляется только по факту публикации, третий вариант почти всегда выигрывает. Контент известен заранее — значит, его можно зафиксировать заранее. Рантайм не должен делать работу, которую можно сделать один раз на сборке.

Почему я не пошёл в полный SSR

У меня была конкретная причина. Главный экран сайта — это ASCII-арт, который растеризуется на canvas прямо в браузере: я рисую глиф на canvas, считываю пиксели и собираю из них строку для тега pre. Это принципиально клиентская операция — на сервере нет ни canvas, ни той же шрифтовой растеризации. Полный SSR этот экран просто сломал бы — либо пустота, либо рассинхрон между серверной и клиентской вёрсткой.

А ботам и не нужно серверно рендерить всё тело. Им нужна голова документа — правильные title, description, og:image, canonical. Это и есть head-stamping: на сборке я генерирую per-route index.html с проштампованной головой под конкретный маршрут, а тело остаётся SPA-оболочкой с живым ASCII. SEO-скрипт на билде собирает robots.txt, sitemap.xml, rss.xml и штампует head, а nginx отдаёт нужный per-route файл.

# nginx: сначала конкретный per-route файл, потом SPA-фоллбэк
location / {
  try_files $uri $uri/ /index.html;
}

Снапшот, закоммиченный в репозиторий

Сайты не ходят в блог-сервис на клиенте. Они забирают контент на этапе сборки: prebuild-скрипт делает один fetch к GET /v1/posts?site=main, получает срез ровно для этого сайта (курация — через поле audiences) и пишет снапшот в posts.data.json. Этот снапшот закоммичен в репозиторий как фоллбэк. Лежит блог-сервис в момент сборки — билд спокойно берёт последний закоммиченный срез. В худшем случае выкатится чуть устаревший контент, но выкатится.

И тут тонкость с Docker. Контент впекается в образ на билде, а Docker агрессивно кэширует слои. Просто пнёшь пересборку — слой с SPA не пересоберётся, и контент не обновится. Поэтому на публикацию сервис шлёт repository_dispatch в репы сайтов, а их workflow форсит build --no-cache для web — иначе свежий prebuild-фетч не отработает поверх старого кэша.

Что это даёт на проде

  • Боты и краулеры получают наполненный head с первого запроса — карточки рисуются, страницы индексируются.
  • В проде нет рендер-рантайма под фронт: только nginx и статика, нечему падать под нагрузкой.
  • Билд отказоустойчив — закоммиченный снапшот гарантирует сборку даже при лежащем контент-сервисе.
  • Уникальный ASCII-экран жив, потому что я не потащил тело в SSR ради задачи, которую решает голова.

Правильная архитектура доставки контента — это не выбор фреймворка, а выбор момента. Ответь на один вопрос — когда контент попадает в HTML, на сборке или в рантайме — и от этого зависит, существует ли страница для всех, кто не исполняет твой JavaScript. А таких — половина важных гостей.

P.S. Скоро покажу, как публикация поста сама пересобирает сайт — почему repository_dispatch вместо ручного деплоя, и как один вебхук доводит свежую статью до прода без меня.

Есть похожая задача?

Расскажите — вернусь с оценкой в тот же день.

Обсудить проект

// читать дальше

Кадр из клипа про то, почему ИИ-агенты рисуют серые интерфейсы
0:36
// видео

Почему ИИ-агенты рисуют одинаково серые интерфейсы

Обложка статьи: «Claude Design в проде»
// дизайн6 мин

Как использовать Claude Design в проде

Я разработчик, дизайнер из меня никакой. Но сайт, который ты читаешь, выглядит как надо — дизайн я собрал с Claude, руками не рисовал. Показываю весь путь и артефакты, которые реально пошли в работу.

Обложка статьи: «1 блог, 2 сайта, 1 сервис»
// архитектура8 мин

Два сайта — один блог: как я не уронил SEO, сливая контент в один сервис

Слил блоги двух сайтов в один сервис на Hono и Turso. База оказалась простой частью, вся коварность — в доставке: контент должен приезжать на билде, потому что при фетче на клиенте превью-боты ловят пустую страницу.