# Nuxt Headless CMS Tutorial: useFetch, SSR, and Draft Previews

Published: 2026-10-20 | Updated: 2026-10-20 | URL: https://blog.genko.me/en/nuxt-headless-cms

> Connect Nuxt 4 to genko with server routes and useFetch. Keep API keys private, render posts with SSR, add draft previews, and plan prerendering.

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](https://docs.genko.me/en/frameworks/nuxt/getting-started) 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

1. Use an existing Nuxt 4 project with pages in `app/pages` and server handlers in the root `server` directory.
2. Create a workspace at [genko.me](https://genko.me/).
3. Add a list API named `blog` with a text field `title` and rich-text field `body`.
4. Save and publish a test article, then issue an API key permitted to read that API.
5. Set `NUXT_GENKO_API_KEY` in your server environment.

```bash
npm install @genko-me/sdk
```

The [quickstart](https://docs.genko.me/en/getting-started) 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

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: { genkoApiKey: "" },
});
```

```ts
// server/utils/genko.ts
import { 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](https://docs.genko.me/en/frameworks/nuxt/getting-started) 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`.

```ts
// server/api/blog/index.get.ts
import { createBlogClient } from "../../utils/genko";

export default defineEventHandler(async event => {
  const config = useRuntimeConfig(event);
  return createBlogClient(config.genkoApiKey).getAll({
    orders: "-publishedAt",
    fields: ["id", "title"],
  });
});
```

```vue
<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`.

```ts
// server/api/blog/[id].get.ts
import { 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) };
});
```

```vue
<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](https://docs.genko.me/en/guides/preview) and the [Nuxt tutorial](https://docs.genko.me/en/frameworks/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:

```ts
// nuxt.config.ts
export 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](https://docs.genko.me/en/guides/webhooks) 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 `NUXT_GENKO_API_KEY` and API access scope |
| Old content after moving between posts | The fetch URL follows the route parameter |
| Published content appears in preview | `draftKey` reaches the internal endpoint and SDK |
| 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](https://blog.genko.me/en/astro-blog-cms). The [SvelteKit guide](https://blog.genko.me/en/sveltekit-headless-cms) and [Next.js CMS guide](https://blog.genko.me/en/headless-cms-nextjs) 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

- [genko Nuxt integration](https://docs.genko.me/en/frameworks/nuxt/getting-started)
- [genko Nuxt tutorial](https://docs.genko.me/en/frameworks/nuxt/tutorial)
- [genko SDK data fetching](https://docs.genko.me/en/sdk/fetching)
- [genko draft previews](https://docs.genko.me/en/guides/preview)
- [genko webhooks](https://docs.genko.me/en/guides/webhooks)
- [Nuxt data fetching](https://nuxt.com/docs/4.x/getting-started/data-fetching)
- [Nuxt runtime configuration](https://nuxt.com/docs/4.x/guide/going-further/runtime-config)
- [Nuxt rendering modes](https://nuxt.com/docs/4.x/guide/concepts/rendering)

Official sources checked on October 20, 2026.
