← к блогу

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

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

Обложка статьи: «1 блог, 2 сайта, 1 сервис»

У меня два сайта. ishabanov.ru — под наём, hire.ishabanov.ru — фриланс. И у каждого свой блог.

Контент я держал статикой прямо в коде — массив POSTS в posts.ts. Написал пост — скопировал руками в оба репозитория. Звучит безобидно, пока это не превращается в боль.

Через пару недель сайты разъехались. На одном у поста появилось поле featured, на другом — нет. Классика: нет единого источника правды, и данные начинают дрейфовать сами по себе.

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

Чего я хотел

  • Один пул постов, из которого наполняются оба блога.
  • Курацию: пост можно показать на обоих сайтах или только на одном.
  • Не сломать SEO — у сайтов уже был build-time SSG с OG-карточками и sitemap.
  • Минимум возни с БД: я разработчик, и SQL стараюсь не видеть.

Стек намеренно скучный — и это плюс

Датасет крошечный и read-heavy. Десятки постов, читаются редко. Для такого не нужен ни тяжёлый Postgres, ни тем более ClickHouse — OLAP тут вообще мимо. База должна быть скучной. Поэтому взял Turso — managed SQLite на libSQL. Ноль ops, а Prisma поверх прячет тот самый SQL. Сервер — Hono: лёгкий, TS-first, без церемоний Express. Кэш — Redis, но опционально: нет REDIS_URL — сервис просто работает без него.

Правильный выбор для read-heavy блога — не самая мощная база, а самая скучная.

Главная ловушка: контент приезжает строго на билде

Сайты статические. У них есть build-time слой, который генерит sitemap, rss и впекает в каждую страницу правильные OG-теги. А превью-боты — Телеграм, соцсети — JS не исполняют. Начни я фетчить посты на клиенте в рантайме — боты увидели бы пустую оболочку, и весь SEO с OG обнулился бы. Поэтому правило: каждый сайт забирает контент из сервиса на этапе сборки. Скрипт prebuild делает один запрос, пишет локальный снапшот posts.data.json, и дальше билд читает статику как раньше. На клиенте сервис не дёргается никогда.

// prebuild: один запрос на билде → снапшот, который коммитим как фоллбэк
const res = await fetch(`${API}/v1/posts?site=main`);
if (ok) writeFile('posts.data.json', await res.json());
else warn('сервис недоступен — собираю на закоммиченном снапшоте'); // билд не падает

Курация — одно поле audiences

У каждого поста есть audiences: ('main' | 'hire')[]. Сервис на /v1/posts?site=main отдаёт только нужный срез. Один пост, одна правка — и оба сайта снова в согласии. Этот пост, например, я вижу полезным обоим, поэтому он на обоих.

Публикую пост — сайт пересобирается сам

Сайты деплоятся через docker compose на VPS, контент впекается в образ на билде. Значит обновить пост — это пересобрать сайт. Я повесил это на GitHub: на запись сервис шлёт repository_dispatch в репозитории сайтов, их workflow пересобирает SPA принудительно без кэша (иначе Docker не тронул бы билд) и забирает свежий контент. Никаких ручных заходов на сервер.

Что бы сделал иначе

  • Сразу заложил бы хранилище под обложки вместо путей в public/.
  • Контент-схему вынес бы в общий npm-пакет с первого дня — пока сайты валидируют ответ облегчённо, а строгая Zod-схема живёт в сервисе.
  • Миграции Prisma + Turso (driver adapters, migrate diff) — отдельная история; знал бы заранее, заложил бы на них времени.

Но это всё шлифовка. Главное работает: один источник правды, курация per-site, SEO цел, а публикация пересобирает сайты сама. Стек намеренно скучный — зато я понимаю в нём каждую строчку, и через год за него не будет стыдно.

P.S. Дальше разберу всё это по частям — и начну с базы: почему для прода я взял именно SQLite на Turso вместо Postgres, и где у read-heavy блога проходит граница, за которой скучной базы перестаёт хватать.

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

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

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

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

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

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

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

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

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

Обложка статьи: «Одна схема», z.infer
// архитектура7 мин

Одна схема на всех: контент-контракт на Zod как источник истины

Три копии типа Post в трёх репозиториях тихо разъезжались, пока я не сделал Zod-схему единственным источником и не начал выводить типы из неё. Рассказываю, как один parse на границе закрывает рантайм и компайл-тайм разом — и где это честный оверкилл.