UnfoldCMS + Nuxt: Headless Integration Guide
Use UnfoldCMS as a headless backend for Nuxt 3/4 with plain $fetch — no SDK needed
Nuxt can render the same page four different ways — server, static, ISR-style, or client — and a headless CMS has to play nice with all of them. This guide wires UnfoldCMS to Nuxt 3/4 end to end: fetching published posts over the REST API with useFetch and $fetch, building the [slug].vue post page, picking a rendering mode with routeRules, and updating content on publish with an HMAC-signed webhook. Real code, about 30 minutes, honest limits. See also: the best CMS for Nuxt and the Next.js integration guide.
TL;DR: UnfoldCMS runs on your own PHP host. Your Nuxt app fetches published content from /api/v1/posts (public, no auth, 60 req/min) using plain $fetch — no SDK exists and none is needed. Put the CMS base URL in runtimeConfig, render posts in pages/blog/[slug].vue, and choose freshness per route with routeRules. When an editor publishes, UnfoldCMS fires an HMAC-SHA256-signed webhook at a Nitro server route that triggers your deploy hook. No GraphQL, just JSON.
How Do UnfoldCMS and Nuxt Fit Together?
UnfoldCMS is the content backend; Nuxt is the frontend. They talk over a versioned REST API at /api/v1/*. The CMS stays on your PHP server (it's Laravel — PHP and MySQL); Nuxt renders wherever you want — a Node server, the edge, or static files. No client library, just $fetch.
The flow has three moving parts:
- Read — Nuxt calls
GET /api/v1/postsandGET /api/v1/posts/{slug}to pull published content. - Render —
useFetch/useAsyncDataruns the call during SSR or prerender, so content ships as HTML with zero client-side waterfalls. - Refresh — On publish, UnfoldCMS POSTs a signed webhook to a Nitro server route, which kicks off a rebuild (for prerendered routes) — SSR and
swrroutes refresh on their own.
That third part is what most "nuxt cms integration" tutorials skip, and it's the difference between a demo and something you'd run in production. The full endpoint surface shipped with the public API v1 launch.
What You Need Before Starting
- A running UnfoldCMS install with at least one published post — the install docs cover setup, and the public read API ships in every tier including Core.
- A Nuxt 3 or Nuxt 4 project (
npx nuxi init my-site). - Your CMS base URL, e.g.
https://cms.yoursite.com. - About 30 minutes. The fetch layer takes 10; the webhook takes 20 the first time.
You do not need an API key to read published content. The public endpoints are open, rate-limited to 60 requests per minute. Tokens only exist for writes, which a frontend never does.
Step 1: Point Nuxt at the CMS With runtimeConfig
Put the CMS base URL in runtimeConfig so it's swappable per environment via NUXT_PUBLIC_CMS_URL, then wrap the { success, message, data } response envelope once in a tiny helper. That helper is the entire "SDK" — about ten lines.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
webhookSecret: '', // server-only — set via NUXT_WEBHOOK_SECRET (Step 5)
public: {
cmsUrl: 'https://cms.yoursite.com', // override via NUXT_PUBLIC_CMS_URL
},
},
})
// composables/useCms.ts
interface CmsEnvelope<T> {
success: boolean
message: string
data: T
}
export function cmsFetch<T>(path: string, query: Record<string, any> = {}) {
const { public: { cmsUrl } } = useRuntimeConfig()
return $fetch<CmsEnvelope<T>>(`/api/v1${path}`, { baseURL: cmsUrl, query })
.then((json) => json.data)
}
Every response from UnfoldCMS is wrapped in { success, message, data }, so unwrapping data here means the rest of the app never thinks about the envelope again.
Step 2: List Posts on the Blog Index
GET /api/v1/posts returns a paginated list of published posts. Call it inside useAsyncData so it runs on the server during SSR (or at build time when prerendering) and hydrates without a second request.
<script setup lang="ts">
// pages/blog/index.vue
const page = ref(1)
const { data: posts } = await useAsyncData(
() => `posts-page-${page.value}`,
() => cmsFetch<{ data: any[] }>('/posts', { page: page.value }),
{ watch: [page] },
)
</script>
<template>
<section>
<article v-for="post in posts?.data" :key="post.slug">
<NuxtLink :to="`/blog/${post.slug}`">
<h2>{{ post.title }}</h2>
</NuxtLink>
</article>
<button @click="page++">Next page</button>
</section>
</template>
The watch: [page] option re-runs the fetch when the page ref changes — pagination with no extra plumbing.
Step 3: The Single Post Page — [slug].vue
Create pages/blog/[slug].vue. Fetch the post by slug, throw a 404 when the API does (drafts and future-scheduled posts return 404 from the public API), and render the body with v-html — UnfoldCMS runs content through an HTML purifier before serving it, so this is the intended path.
<script setup lang="ts">
// pages/blog/[slug].vue
const route = useRoute()
const slug = route.params.slug as string
const { data, error } = await useAsyncData(`post-${slug}`, () =>
cmsFetch<{ post: any }>(`/posts/${slug}`),
)
if (error.value || !data.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}
const post = computed(() => data.value!.post)
useSeoMeta({
title: () => post.value.title,
description: () => post.value.excerpt,
})
// Related posts — non-blocking, fine to resolve after first paint
const { data: related } = useAsyncData(`related-${slug}`, () =>
cmsFetch<{ data: any[] }>(`/posts/${slug}/related`),
)
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<div v-html="post.body" />
</article>
<aside v-if="related?.data?.length">
<h2>Related posts</h2>
<NuxtLink v-for="r in related.data" :key="r.slug" :to="`/blog/${r.slug}`">
{{ r.title }}
</NuxtLink>
</aside>
</template>
Two details worth keeping: createError with statusCode: 404 gives you a real 404 status during SSR (good for SEO, not a soft client-side error), and useSeoMeta maps CMS fields straight into meta tags.
Want to poke at the API before writing more code? Open the live demo and hit https://demo.unfoldcms.com/api/v1/posts in your browser — the JSON you see is exactly what your Nuxt app gets.
Step 4: Categories, Search, and Menus
Categories, search, and menus follow the same pattern as posts — public GET endpoints, same envelope, same helper. A category archive and a search page each take a few lines.
<script setup lang="ts">
// pages/search.vue
const q = ref('')
const { data: results, status } = await useFetch('/api/v1/search', {
baseURL: useRuntimeConfig().public.cmsUrl,
query: { q },
transform: (json: any) => json.data,
immediate: false,
watch: [q],
})
</script>
<template>
<input v-model="q" type="search" placeholder="Search posts…" />
<p v-if="status === 'pending'">Searching…</p>
<ul v-else>
<li v-for="hit in results?.data" :key="hit.slug">
<NuxtLink :to="`/blog/${hit.slug}`">{{ hit.title }}</NuxtLink>
</li>
</ul>
</template>
Here useFetch with a reactive query ref refires automatically as the user types — this is where useFetch beats the raw helper. Pull your nav from the CMS too, so editors control it: cmsFetch('/menus/header') returns the menu tree for a location.
Here's the full public read surface:
| Endpoint | Returns |
|---|---|
GET /api/v1/posts |
Paginated published posts |
GET /api/v1/posts/{slug} |
Single post (404 for drafts) |
GET /api/v1/posts/{slug}/related |
Related posts |
GET /api/v1/pages/{slug} |
Single published page |
GET /api/v1/categories |
All categories |
GET /api/v1/search?q= |
Site search results |
GET /api/v1/menus/{location} |
Menu tree for a location |
GET /api/v1/settings/public |
Public-allowlisted settings |
All public, no auth, throttled at 60 requests/minute. Full details in the API docs.
Step 5: Pick a Rendering Mode With routeRules
Nuxt's routeRules let you mix rendering modes per route — prerender the blog, SSR the search page, cache with swr in between. Against UnfoldCMS, the rule of thumb: swr for most content sites, prerender plus a rebuild webhook when you want pure static output.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // static at build time
'/blog': { swr: 600 }, // cached, re-rendered every 10 min
'/blog/**': { swr: 3600 }, // post pages: cached 1 hour
'/search': { ssr: true }, // always fresh, per request
},
})
How each mode behaves with CMS content:
| Mode | Freshness | Needs a server? | Best for |
|---|---|---|---|
swr (stale-while-revalidate) |
Up to N seconds stale | Yes (Nitro) | Most content sites — recommended |
prerender + rebuild webhook |
Minutes after publish | No | Pure static hosting |
ssr: true |
Always fresh | Yes | Search, high-churn pages |
Full static (nuxi generate) |
Stale until next deploy | No | Docs, rarely-edited sites |
With swr, stale pages serve instantly while Nitro re-renders in the background — publishes show up within your cache window with no webhook at all. Prerendered routes are the ones that need a push, which is Step 6.
Step 6: Update on Publish With a Signed Webhook
UnfoldCMS ships outgoing HMAC-SHA256-signed webhooks. On the post.published event it POSTs a signed payload to a URL you register — point that at a Nitro server route that verifies the signature, then triggers your host's deploy hook (Vercel, Netlify, and Cloudflare Pages all expose one as a plain URL).
// server/api/cms-webhook.post.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
export default defineEventHandler(async (event) => {
const raw = (await readRawBody(event)) ?? ''
// UnfoldCMS signs with spatie/laravel-webhook-server — header "Signature",
// value hash_hmac('sha256', rawBody, secret).
const signature = getHeader(event, 'signature') ?? ''
const expected = createHmac('sha256', useRuntimeConfig().webhookSecret)
.update(raw)
.digest('hex')
const ok =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!ok) {
throw createError({ statusCode: 401, statusMessage: 'Bad signature' })
}
// Payload: { event, occurred_at, data: { id, title, slug, url, posted_at } }
const payload = JSON.parse(raw)
// Prerendered routes need a rebuild — hit your host's deploy hook
await $fetch(process.env.BUILD_HOOK_URL!, { method: 'POST' })
return { triggered: true, event: payload.event }
})
Then wire up the CMS side:
- In the UnfoldCMS admin, open the webhooks settings (or manage subscriptions over the API at
/api/v1/admin/webhookswith a Sanctum admin token). - Add your endpoint URL —
https://yoursite.com/api/cms-webhook. - Subscribe to the
post.publishedevent. - Copy the generated signing secret into your Nuxt env as
NUXT_WEBHOOK_SECRET. - Hit the "Send test" button and watch your server route log a verified delivery before trusting the wiring.
Now the loop is closed: editor publishes, UnfoldCMS signs and POSTs, your Nitro route verifies and rebuilds, and the live site reflects the change without anyone touching a deploy button. Every delivery attempt is logged on the CMS side, so failed calls are visible, not silent.
If you're running
swrroutes only, you can skip the build-hook call entirely — the cache window handles freshness. The webhook still earns its keep for cache busting or pinging your search index.
Deploy Options
The Nuxt app and the CMS deploy separately, and that's a feature: the CMS stays put while you move the frontend anywhere Nitro runs.
- Vercel / Netlify — zero-config Nitro presets,
swrroute rules map onto their caching layers, deploy hooks for the webhook flow. - Cloudflare Pages/Workers — edge preset; note
node:cryptoin the webhook route works on Workers' Node compat mode. - Your own VPS —
node .output/server/index.mjsbehind Nginx; it can even sit on the same box as UnfoldCMS. - Static hosting —
nuxi generateand ship.output/publicanywhere, with the rebuild webhook keeping it current.
UnfoldCMS itself needs PHP and MySQL, so it lives on a PHP-capable host — a $5 VPS or shared hosting both work. Setup takes about five minutes.
Frequently Asked Questions
Does UnfoldCMS have a GraphQL API for Nuxt?
No. UnfoldCMS is REST-only. You query endpoints like /api/v1/posts with $fetch, which Nuxt ships built in. For a content site this is simpler than GraphQL — no schema layer, no codegen, no extra client in your bundle.
Is there an official Nuxt module or npm package like @unfoldcms/nuxt?
No SDK or Nuxt module exists today, and you don't need one. The ten-line cmsFetch helper in Step 1 is the whole integration layer. Everything else is standard Nuxt: useAsyncData, useFetch, routeRules.
Do I need an API key to read posts?
No. Published posts, pages, categories, search, menus, and public settings are open read endpoints, rate-limited to 60 requests per minute. Sanctum tokens exist only for write operations — admin CRUD and webhook management — which a public frontend never performs.
Can I host UnfoldCMS on Vercel or Netlify next to my Nuxt app?
No — UnfoldCMS is a PHP/Laravel app, and those platforms run JavaScript. Host the CMS on any PHP server (shared hosting works), deploy Nuxt wherever you like, and connect them over the API. They scale and deploy independently, which is the point of going headless.
Where to Go From Here
You now have Nuxt pulling content from UnfoldCMS, rendering it in the mode each route deserves, and refreshing on publish. If you're still choosing a CMS rather than wiring one you've picked, the CMS for Nuxt comparison weighs the options honestly. The same three-part pattern works on other frameworks too — see the Next.js guide (on-demand revalidation instead of rebuilds) and the Astro guide (pure static).
UnfoldCMS is a one-time license, self-hosted, with the full public API in every tier — compare tiers and pricing. You run your own server, which isn't for everyone, but it means no per-seat bills and full ownership of your content.
Sources & Notes
- Endpoint list and response shapes verified against the shipped
/api/v1/*surface — see the API v1 launch post and the docs. - Webhook signing:
spatie/laravel-webhook-server,Signatureheader, HMAC-SHA256 over the raw body. - Nuxt APIs referenced (
useAsyncData,useFetch,routeRules, Nitro server routes) are current as of Nuxt 3.x/4.x. - Rate limit on public reads: 60 requests/minute per the CMS
apithrottle.
Related: CMS for Nuxt · Next.js integration · Astro integration