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

У меня два сайта. 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 блога проходит граница, за которой скучной базы перестаёт хватать.
Есть похожая задача?
Расскажите — вернусь с оценкой в тот же день.


