У блога два клиента: читатель и редактор. Читателю нужна быстрая страница с SSR, редактору — панель, где тексты правятся без релиза. «Блог в коде» силён первым и слаб вторым: правка текста — это коммит. Headless CMS переворачивает схему: контент живёт в отдельной системе и отдаётся по API, а сайт только рисует. Расскажем, как устроен блог, который вы сейчас читаете: Nuxt 3 и Cockpit CMS, — и бонусом: черновики статей здесь готовит ИИ-агент через самописный MCP-сервер.
Что такое headless CMS и когда она лучше «блога в коде»
Headless CMS — это панель управления контентом без «головы»: она не отвечает за то, как сайт выглядит, а только хранит контент и отдаёт его по REST или GraphQL. Фронтенд остаётся обычным Nuxt-приложением с SSR. Headless удобнее, когда:
- Контент ведут не-программисты. Редактор публикует статью в админке — без коммитов, веток и деплоев
- Один контент питает несколько потребителей. Список на главной, страница статьи, sitemap, og-превью — всё берётся из одной записи
- Несколько языков. Локали — это поля записи, а не папки в репозитории
- Нужен цикл «черновик → ревью → публикация» без git-процесса вокруг
Когда контент не меняется годами (визитка, лендинг), CMS не нужна — markdown в репозитории проще и дешевле.
Схема: Nuxt SSR забирает статьи по REST
Схема минимальная: браузер → Nuxt (SSR) → Cockpit. При рендере страницы сервер Nuxt запрашивает статью у CMS по REST с api-key в заголовке и отдаёт браузеру готовый HTML. Клиентский instance $fetch создаётся один раз в плагине:
// plugins/api.ts
export default defineNuxtPlugin({
name: 'api',
setup(nuxtApp) {
const cmsApiKey = nuxtApp.$config.public.CMS_API_KEY
const api = $fetch.create({
baseURL: `${nuxtApp.$config.public.CMS_URL}/api`,
headers: cmsApiKey ? { 'api-key': cmsApiKey } : undefined,
})
return { provide: { api } }
},
})
Адрес CMS и ключ живут в runtimeConfig: один конфиг для SSR и клиента, без хардкода в коде.
Проекция полей и сортировка — на стороне CMS
Частая ошибка — тянуть статью целиком и резать на клиенте. Cockpit умеет проекцию полей (fields), сортировку (sort) и лимит прямо в запросе. Запрос карточек для главной страницы:
// stores/article.ts (Pinia)
const getHomeArticles = async () => {
const response = await getArticles({
locale: locale.value,
sort: { _created: -1 },
limit: HOME_ARTICLES_LIMIT,
fields: ARTICLE_LIST_FIELDS, // только нужные поля
})
// ...
}
- fields — только то, что рисуется в карточке: заголовок, описание, картинка, slug, теги. Тела статей по сети не гоняются
- sort — свежие первыми, прямо в базе, без сортировки на клиенте
- locale — CMS отдаёт контент нужного языка: локали хранятся как суффиксные поля одной записи (
title,title_en…)
Тот же принцип работает для одиночных статей и для sitemap — везде запрашивается минимальный набор.
Реактивность по локали: контент перезапрашивается сам
Сайт двуязычный, и контент должен переключаться вместе с языком интерфейса. В Nuxt это решается связкой useAsyncData и watch на локаль:
const { data: contactsData } = await useAsyncData(
CONTACTS_ASYNC_DATA_KEY,
() => contactsStore.getContacts({ locale: locale.value }),
{ watch: [locale] },
)
Сменили язык — useAsyncData перезапустил обработчик с новой локалью, данные приехали из кэша SSR или свежим запросом, страница перерисовалась. Дописывать ничего не нужно.
Нюанс, о котором стоит помнить: ключи useAsyncData важны. Без явного ключа Nuxt генерирует его сам, хешируя исходник обработчика — файл и место вызова. Два одинаковых запроса в разных местах кода могут получить разные ключи и продублировать запросы. Явный именованный ключ ('contacts', 'company') делает кэш предсказуемым и позволяет переиспользовать данные между компонентами.
Динамический sitemap: статьи из CMS с lastmod
Чтобы новые статьи попадали в sitemap без релиза, список URL собирается на лету в серверном обработчике server/api/__sitemap__/urls.get.ts: он запрашивает у CMS статьи (только slug, даты и картинку) и превращает их в элементы sitemap:
const getSitemapUrl = (article, locale) => {
const lastmod = new Date(
(article._modified || article._created) * 1000,
).toISOString()
return {
loc: `/${locale === 'ru' ? '' : locale + '/'}blog/${article.slug}`,
lastmod,
changefreq: 'weekly',
priority: 0.8,
images: [{ loc: article.image, title: article.imageAlt }],
}
}
- lastmod берётся из системного поля
_modified— поисковик видит, что статья обновилась, без переиндексации всего блога - локали — отдельные URL
/blog/…и/en/blog/…для каждой статьи - картинка статьи тоже попадает в sitemap (расширение
images)
Опубликовали статью в CMS — при следующем запросе она уже в sitemap, руками ничего обновлять не нужно.
og-картинки и мета — тоже из CMS
Превью в соцсетях и мессенджерах — часть статьи. У записи в CMS есть поля ogTitle, ogDescription и отдельная og-картинка: редактор загружает их вместе с текстом, а мета-теги страницы собираются из записи при рендере. Обновить превью — значит нажать «сохранить» в админке, без кода и деплоя.
Кто готовит статьи: ИИ-агент и самописный MCP-сервер
Самое неочевидное в этой схеме — кто пишет тексты. Черновики статей этого блога готовит ИИ-агент. MCP (Model Context Protocol) — открытый протокол, которым ИИ-агенты подключаются к внешним инструментам: вместо «дайте агенту логин от админки» ему выдают набор тулз с описанными параметрами. Для Cockpit мы написали свой MCP-сервер, и со статьями агент работает через него.
Зачем обёртка, если у CMS есть Content API? Он низкоуровневый и со своими соглашениями: сохранение идёт в обёртке {data: …}, локали — суффиксные поля, черновики не ищутся по слагу, картинки грузятся multipart-формой. Сервер переводит простые намерения в эти соглашения и прячет грабли:
- Тонко и по-белому. ~940 строк TypeScript на официальном MCP SDK с zod-схемами входных параметров: тонкий клиент над Content API — один
request()с api-key и методы listItems / getItem / saveItem / uploadAsset — и тулзы поверх: схемы моделей, списки статей, авторов и тегов,upsert_article,upload_asset,publish_article - Белый список записи. Писать разрешено только в модели
articleиskill— сервер физически не выполнит запись в другие коллекции, что бы агент ни запросил - Кэш slug→id. Списочный эндпоинт CMS отдаёт только опубликованное: свежесозданный черновик по слагу невидим, и повторный upsert создал бы дубликат. Сервер помнит slug→_id созданных им записей, поэтому апдейт по слагу правит именно черновик
- Локали одним вызовом. Сервер знает список локализуемых полей и сам раскладывает
{locales: {ru: …, en: …}}по суффиксным полям записи — агент про суффиксы не думает - Гуманные ошибки. 403 на чтение коллекции превращается в подсказку «передайте _id вместо слага»; загрузка картинки при спорном поле формы ретраит
files→files[]; время чтения считается из HTML автоматически - Право публикации — у человека. Сервер умеет
publish_article, но у API-ключа нет права publish: попытка сменить статус просто игнорируется CMS. «Публикуй» звучит только от владельца, а процесс «черновик → ревью → публикация» зафиксирован в инструкции агента
Эта статья прошла через тот же путь: черновик от агента, правки после ревью — этот раздел, например, добавлен по замечанию владельца, — и публикация вручную.
Альтернатива без CMS
Справедливости ради: headless — не единственный путь. Если контент ведут разработчики и правки спокойно живут в git, блог собирается на Nuxt Content: markdown-файлы в репозитории, контент типизирован, сборка на деплое. На этом блоге есть отдельная подробная статья «Блог на Nuxt 3 + Nuxt Content» — это как раз такой подход, и он хорошо объясняет, когда CMS не нужна. Наш выбор был про другое: редактору нужна панель, а контенту — две локали, статусы публикации и метаданные для SEO. Для этого связка Nuxt + Cockpit оказалась короткой: один плагин с $fetch.create, запросы с проекцией полей и sitemap прямо из CMS.
Коротко
Headless CMS отделяет контент от отображения: Cockpit хранит статьи и отдаёт их по REST с api-key, Nuxt 3 рендерит страницы с SSR. Что стоит забрать в свой проект: проекция полей и сортировка в запросе вместо обработки на клиенте; локали как поля записи и watch на locale в useAsyncData — контент переключается сам; явные ключи useAsyncData для предсказуемого кэша; динамический sitemap из CMS с lastmod из _modified; og-мета и картинки — полями записи. А готовят статьи ИИ-агентом через самописный MCP-сервер — с белым списком записи; ревьюит и публикует всё равно человек.
