To use a headless CMS with Nuxt, connect to the CMS inside server/api and call your own endpoint from a page with useFetch(). This keeps the API key on the server while rendering article content with SSR; public pages can also be prerendered when a rebuild-based publishing workflow fits your site.
This article is written by the genko.me team. It covers a direct SDK integration for Nuxt 4, checked on October 20, 2026. If you prefer prebuilt display components, the Nuxt integration guide documents the separate Vue-component approach.
Should a Nuxt CMS blog use SSR or prerendering?
Delivery approach | A practical fit | Operational requirement |
|---|---|---|
SSR | Retrieve content when a page is requested | A server and a CMS failure strategy |
Prerendering | Public articles shared by all readers | Rebuild after content changes |
Static public pages with SSR previews | Separate public delivery from editorial review | Hosting that supports a request-time preview route |
Begin with the publishing requirement. If an editor can wait for a build, prerendering may be sufficient. If the site must retrieve a newly published article at request time, SSR provides that opportunity, subject to any cache you add.
The examples start with SSR. When moving to static delivery, the central questions are which URLs get generated and what triggers their regeneration.
Prepare Nuxt 4 and the CMS
Use an existing Nuxt 4 project with pages in
app/pagesand server handlers in the rootserverdirectory.Create a workspace at genko.me.
Add a list API named
blogwith a text fieldtitleand rich-text fieldbody.Save and publish a test article, then issue an API key permitted to read that API.
Set
NUXT_GENKO_API_KEYin your server environment.
npm install @genko-me/sdkThe quickstart covers the CMS setup. This tutorial uses the SDK alone, so it does not use the pagination-signing helpers from the Vue integration. If you adopt those helpers later, follow their documented GENKO_API_KEY and runtime requirements as well.
Keep the CMS key in private runtimeConfig
// nuxt.config.tsexport default defineNuxtConfig({ runtimeConfig: { genkoApiKey: "" },});// server/utils/genko.tsimport { createGenko, listApi } from "@genko-me/sdk";
type Blog = { title: string; body: string };const apis = { blog: listApi<Blog>({ fields: [ { id: "title", type: "text" }, { id: "body", type: "richtext" }, ], }),};
export function createBlogClient(apiKey: string) { if (!apiKey) throw new Error("NUXT_GENKO_API_KEY is required"); return createGenko({ workspace: "your-workspace", apiKey, apis }).apis.blog;}Do not place genkoApiKey inside runtimeConfig.public. Nuxt can override this private setting from NUXT_GENKO_API_KEY at runtime. Configure the variable on the production server as well as in your development environment, and replace your-workspace with your workspace identifier.
The schema here is handwritten and must match the editor's fields. If you generate a client with the CLI instead, check its destination: Nuxt's ~ alias normally points at app/, so a generated root-level lib/ directory will not match ~/lib/client. The integration guide explains that path adjustment.
Fetch the post index with useFetch
Create the server handler below and put the Vue component in app/pages/blog/index.vue.
// server/api/blog/index.get.tsimport { createBlogClient } from "../../utils/genko";
export default defineEventHandler(async event => { const config = useRuntimeConfig(event); return createBlogClient(config.genkoApiKey).getAll({ orders: "-publishedAt", fields: ["id", "title"], });});<script setup lang="ts">const { data: posts, error } = await useFetch("/api/blog");if (error.value) { throw createError({ statusCode: 502, statusMessage: "Unable to load posts" });}useHead({ title: "Blog" });</script>
<template> <main> <h1>Blog</h1> <ul> <li v-for="post in posts" :key="post.id"> <NuxtLink :to="`/blog/${post.id}`">{{ post.title }}</NuxtLink> </li> </ul> <p v-if="posts?.length === 0">No published posts.</p> </main></template>useFetch() transfers server-fetched data to the client during hydration. Return the data needed to display the page, without returning the API credential or client configuration.
The browser-accessible /api/blog endpoint is intended to expose published article data. Keep it narrowly scoped rather than turning it into a general proxy for CMS administration operations.
getAll() handles pagination while collecting the complete published archive. This makes a small example easy to follow, but a larger blog should use list() with explicit limits and offsets. Continue selecting only the fields needed by the index. The sample treats retrieval failure as an error instead of making a broken CMS connection look like an empty blog.
Add article details and draft previews
Create the server handler and put the Vue component in app/pages/blog/[id].vue.
// server/api/blog/[id].get.tsimport { createBlogClient } from "../../utils/genko";
export default defineEventHandler(async event => { const id = getRouterParam(event, "id"); if (!id) throw createError({ statusCode: 400, statusMessage: "Missing id" }); const query = getQuery(event); const draftKey = typeof query.draftKey === "string" ? query.draftKey : undefined; const config = useRuntimeConfig(event); const post = await createBlogClient(config.genkoApiKey).getOrNull(id, { draftKey }); if (!post) throw createError({ statusCode: 404, statusMessage: "Post not found" }); if (draftKey) setResponseHeader(event, "Cache-Control", "private, no-store"); return { post, preview: Boolean(draftKey) };});<script setup lang="ts">const route = useRoute();const { data, error } = await useFetch( () => `/api/blog/${encodeURIComponent(String(route.params.id))}`, { query: computed(() => ({ draftKey: route.query.draftKey })) },);if (error.value || !data.value) { throw createError({ statusCode: error.value?.statusCode === 404 ? 404 : 502, statusMessage: "Unable to load post", });}if (import.meta.server && data.value.preview) { useResponseHeader("Cache-Control").value = "private, no-store";}useHead(() => ({ title: data.value?.preview ? "Draft preview" : data.value?.post.title, meta: data.value?.preview ? [ { name: "robots", content: "noindex, nofollow" }, { name: "referrer", content: "no-referrer" }, ] : [],}));</script>
<template> <main v-if="data"> <p v-if="data.preview">Draft preview</p> <h1>{{ data.post.title }}</h1> <div v-html="data.post.body" /> </main></template>Set the CMS preview URL to https://your-site.example/blog/{id}?draftKey={key}. The CMS replaces the {id} and {key} placeholders.
The page's query string does not automatically travel to a different /api/blog/... URL. The query option explicitly forwards draftKey, and the server passes it to the SDK's detail request. A preview reads saved content, so save the draft before opening its preview link.
The reactive URL getter also follows changes to the route identifier. Verify navigation between two article detail pages rather than only testing a full browser refresh on the first one.
This custom SDK implementation does not automatically add the Vue integration's preview toolbar. The component displays a simple notice, and removing draftKey returns to the published request. Treat the preview URL as a secret because it carries the key used to read that draft through your server.
The v-html directive renders genko's saved, sanitized rich-text HTML. It does not provide sanitization for arbitrary HTML from other sources.
Prevent draft content from entering shared caches
The example sets private, no-store on both the preview API response and the server-rendered preview page. It also supplies indexing and referrer metadata. Check that your hosting configuration does not force a shared HTML cache over those responses.
Do not use preview URLs as canonical URLs or share-button targets. Review query-string logging so that a draft key is not unnecessarily retained in access logs. If you add social metadata, avoid embedding draft titles or bodies in public preview cards.
For the component-based workflow, see draft previews and the Nuxt tutorial. Their automatic preview behavior belongs to those components, rather than to every use of the low-level SDK.
Prerender a Nuxt blog from CMS content
For a public static archive, one possible configuration starts crawling from the article index:
// nuxt.config.tsexport default defineNuxtConfig({ runtimeConfig: { genkoApiKey: "" }, nitro: { prerender: { routes: ["/blog"], crawlLinks: true }, },});This assumes the index links to every article you intend to generate. Explicitly enumerate additional routes if content is not reachable from that page. When you paginate the archive, include links or route entries that let generation reach every relevant detail page.
A static-only deployment has no runtime server/api process. Generate the public pages and their required data during the build, and check that the deployed website does not retain interactions requiring a live internal API.
Draft previews need a separate server environment or a deployment that retains server-rendered routes. A static artifact cannot start processing an arbitrary draftKey merely because the development server could do so.
To refresh public output, receive a genko webhook and trigger your build workflow. Account for manual publication through creation or update events, and for article removal. A scheduled-publication event alone does not cover every change to the public archive.
Troubleshoot a Nuxt headless CMS integration
Symptom | What to inspect |
|---|---|
Empty archive | The article is saved, published, and still within its publication window |
Authentication failure | Production |
Old content after moving between posts | The fetch URL follows the route parameter |
Published content appears in preview |
|
Works locally but fails on static hosting | A remaining interaction requires a runtime server |
Run npm run build and test production-like delivery. Check direct requests and navigation through NuxtLink, a published article, a saved draft, a missing identifier, and the result after an edit. Decide how API failures will be surfaced to whoever maintains the site.
For a build-focused alternative, see Astro with a headless CMS. The SvelteKit guide and Next.js CMS guide show other framework integrations.
FAQ
Can useFetch call the CMS directly from a Nuxt page?
If the CMS requires a private key, call a server route and keep the key there. Do not put it in browser code or public runtime configuration.
Why does my Nuxt draft preview show the published article?
Check both forwarding steps: page to internal API, then internal API to the SDK. Also save the draft before testing its preview URL.
Do Nuxt server routes run on a static-only host?
No runtime server is included in a static-only deployment. Generate the required public output at build time or choose hosting with a server runtime.
Should I use the SDK or genko's Vue components?
Use the SDK when you want to own retrieval, rendering, and pagination. Choose the Vue integration when you want its display components, and configure the corresponding server helpers as documented.
Sources
Official sources checked on October 20, 2026.
