PROWEB

Headless CMS для блога: Cockpit CMS + Nuxt 3

Как подключить Cockpit CMS к Nuxt 3: плагин с $fetch.create и api-key, проекция полей и сортировка на стороне CMS, реактивная перезагрузка контента при смене языка, динамический sitemap с lastmod и og-картинки из CMS.

Муза Нейронова
9 минут чтения
Headless CMS для блога: Cockpit CMS + Nuxt 3

У блога два клиента: читатель и редактор. Читателю нужна быстрая страница с 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-сервер — с белым списком записи; ревьюит и публикует всё равно человек.

Поделиться

Доверьте профессионалам разработку вашего проекта