PROWEB

Headless CMS for a Blog: Cockpit CMS + Nuxt 3

How to connect Cockpit CMS to Nuxt 3: a plugin with $fetch.create and an api-key, field projection and sorting on the CMS side, reactive content refetching on language change, a dynamic sitemap with lastmod, and OG images from the CMS.

Muza Neuronova
9 minutes reading
Headless CMS for a Blog: Cockpit CMS + Nuxt 3

A blog has two clients: the reader and the editor. The reader needs a fast SSR page; the editor needs a panel where texts change without a release. “A blog in code” is strong on the first and weak on the second: editing a text means a commit. A headless CMS flips the scheme: content lives in a separate system and is served over an API, while the site only renders. Here is how the blog you are reading right now works: Nuxt 3 and Cockpit CMS — and, as a bonus, article drafts here are prepared by an AI agent through a custom MCP server.

What a headless CMS is, and when it beats “a blog in code”

A headless CMS is a content admin panel without a “head”: it is not responsible for how the site looks — it only stores content and serves it over REST or GraphQL. The frontend remains a regular Nuxt app with SSR. Headless is the better fit when:

  • Non-developers manage the content. An editor publishes an article in the admin panel — no commits, branches or deploys
  • One piece of content feeds several consumers. The home page list, the article page, the sitemap, OG previews — all come from a single record
  • Multiple languages. Locales are fields of a record, not folders in a repository
  • You need a “draft → review → publish” cycle without a git process around it

When content barely changes for years (a business card site, a landing page), you don't need a CMS — markdown in the repository is simpler and cheaper.

The scheme: Nuxt SSR fetches articles over REST

The scheme is minimal: browser → Nuxt (SSR) → Cockpit. When rendering a page, the Nuxt server requests the article from the CMS over REST with an api-key header and hands the browser ready HTML. The client $fetch instance is created once, in a plugin:

// 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 } }
  },
})

The CMS address and the key live in runtimeConfig: one config for SSR and the client, nothing hardcoded.

Field projection and sorting — on the CMS side

A common mistake is pulling the full article and slicing it on the client. Cockpit supports field projection (fields), sorting (sort) and limits right in the query. Fetching cards for the home page:

// 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, // only the needed fields
  })
  // ...
}
  • fields — only what the card renders: title, description, image, slug, tags. Article bodies never travel over the network for lists
  • sort — newest first, right in the database, no client-side sorting
  • locale — the CMS serves the content in the requested language: locales are stored as suffixed fields of one record (title, title_en…)

The same principle applies to single articles and the sitemap — everywhere, the minimal set is requested.

Locale reactivity: content refetches itself

The site is bilingual, and content must switch together with the interface language. In Nuxt this is solved by pairing useAsyncData with a watcher on the locale:

const { data: contactsData } = await useAsyncData(
  CONTACTS_ASYNC_DATA_KEY,
  () => contactsStore.getContacts({ locale: locale.value }),
  { watch: [locale] },
)

The language changed — useAsyncData re-ran the handler with the new locale, the data arrived from the SSR cache or a fresh request, the page re-rendered. Nothing extra to write.

A nuance worth remembering: useAsyncData keys matter. Without an explicit key, Nuxt generates one by hashing the handler's source — the file and the call site. Two identical requests in different places can get different keys and duplicate requests. Explicit named keys ('contacts', 'company') make the cache predictable and let components share data.

A dynamic sitemap: articles from the CMS with lastmod

So that new articles reach the sitemap without a release, the URL list is assembled on the fly in a server handler, server/api/__sitemap__/urls.get.ts: it requests the articles from the CMS (slug, dates and image only) and turns them into sitemap entries:

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 comes from the system field _modified — search engines see the article was updated without reindexing the whole blog
  • locales — separate URLs /blog/… and /en/blog/… for every article
  • the article image lands in the sitemap too (the images extension)

Publish an article in the CMS — on the next request it is already in the sitemap; nothing to update by hand.

OG images and meta — also from the CMS

Previews in social networks and messengers are part of the article. The CMS record has ogTitle, ogDescription and a separate OG image fields: the editor uploads them together with the text, and the page meta tags are assembled from the record at render time. Updating a preview means clicking “save” in the admin panel — no code, no deploy.

Who writes the articles: an AI agent and a custom MCP server

The least obvious part of this scheme is who writes the texts. Article drafts for this blog are prepared by an AI agent. MCP (Model Context Protocol) is an open protocol for connecting AI agents to external tools: instead of “giving the agent an admin login”, you hand it a set of tools with described parameters. For Cockpit we wrote our own MCP server, and the agent works with articles through it.

Why a wrapper when the CMS has a Content API? The API is low-level and full of its own conventions: saves go wrapped in {data: …}, locales are suffixed fields, drafts are not findable by slug, images upload as a multipart form. The server translates simple intentions into those conventions and hides the pitfalls:

  • Thin and by the book. ~940 lines of TypeScript on the official MCP SDK with zod input schemas: a thin client over the Content API — one request() with the api-key and listItems / getItem / saveItem / uploadAsset methods — and tools on top: model schemas, article/author/tag lists, upsert_article, upload_asset, publish_article
  • A write whitelist. Only the article and skill models are writable — the server physically cannot perform a write to any other collection, no matter what the agent asks for
  • A slug→id cache. The CMS list endpoint returns only published items: a freshly created draft is invisible to slug lookups, so a repeated upsert would create a duplicate. The server remembers slug→_id for every record it creates, so a follow-up upsert by slug edits exactly that draft
  • Locales in a single call. The server knows the list of localizable fields and unfolds {locales: {ru: …, en: …}} into the record's suffixed fields itself — the agent never thinks about suffixes
  • Humane errors. A 403 on reading a collection becomes a hint “pass the _id instead of the slug”; image upload retries files → files[] for a picky form field; reading time is computed from the HTML automatically
  • The right to publish belongs to a human. The server has a publish_article tool, but the API key holds no publish permission: a status change is simply ignored by the CMS. “Publish” is said only by the owner, and the “draft → review → publish” process is fixed in the agent's instructions

This very article went through that path: a draft from the agent, post-review edits — this section, for example, was added after the owner's remark — and a manual publish.

The no-CMS alternative

To be fair: headless is not the only path. If developers manage the content and edits live happily in git, the blog is built on Nuxt Content: markdown files in the repository, typed content, a build on deploy. This very blog has a detailed article, “A Blog on Nuxt 3 + Nuxt Content” — that is exactly that approach, and it explains well when you don't need a CMS. Our choice was about something else: the editor needed a panel, and the content needed two locales, publish states and SEO metadata. For that, the Nuxt + Cockpit pairing turned out to be short: one plugin with $fetch.create, queries with field projection, and a sitemap straight from the CMS.

In short

A headless CMS separates content from presentation: Cockpit stores the articles and serves them over REST with an api-key, Nuxt 3 renders pages with SSR. Worth taking into your own project: field projection and sorting in the query instead of client-side processing; locales as record fields and a watch on locale in useAsyncData — content switches by itself; explicit useAsyncData keys for a predictable cache; a dynamic sitemap from the CMS with lastmod from _modified; OG meta and images as record fields. And the articles are prepared by an AI agent through a custom MCP server with a write whitelist; reviewing and publishing still belongs to a human.

Share

Trust professionals to develop your project.