Craft CMS 5 + Nuxt 4: a production-ready headless setup
The headless Craft + Nuxt setup I wish the tutorials had shown me — the SSR/browser GraphQL split, the NUXT_ env-var trap, Suspense-swallowed errors, live preview, and the content model in git.
Most "headless CMS + modern frontend" tutorials stop at the happy path: spin up the CMS, expose an API, fetch it from the frontend, ship a screenshot. Then you deploy to production and spend a weekend discovering all the things the tutorial didn't mention — the environment variable that silently does nothing, the GraphQL call that works in the browser but not during SSR, the blank page with no error in the console.
I've been running Craft CMS 5 and Nuxt 4 together in production — on client projects, including large multi-site, multi-language marketing platforms, and on my own site — and the pairing is genuinely excellent once it's wired correctly. This is the setup I wish the tutorials had shown me, gotchas included. At the end I'll point you at a free starter that has all of it solved out of the box.
Why Craft + Nuxt
The appeal is that neither side has to compromise.
Editors get a CMS they actually like. Craft's content modelling is best-in-class — sections, matrix fields, live preview, granular permissions, and a control panel that non-developers can use without a training session. Its project config system version-controls the entire content model as YAML, so your schema lives in git alongside your code.
Developers get a modern frontend. Nuxt 4 brings server-side rendering, file-based routing, typed data fetching, and the whole Vue ecosystem. No Twig, no coupling the presentation layer to PHP.
The seam between them is a single GraphQL endpoint. Craft exposes a GraphQL API out of the box; Nuxt consumes it. Because the contract is just GraphQL, the two halves evolve independently — you can redesign the frontend without touching the CMS, or migrate the CMS later without rewriting the frontend.
The architecture
Four moving parts, one entry point:
Browser ──▶ nginx ──▶ Nuxt 4 (SSR) ──┐
└──▶ Craft CMS 5 ◀───┘ GraphQL
│
▼
MySQL
- nginx is the single public entry point. It routes
/adminand/api(GraphQL) to Craft's PHP-FPM, and everything else to the Nuxt server. - Craft runs in headless mode with the GraphQL API enabled. It never renders a page — it's a content API with a great editing UI.
- Nuxt server-renders every route, fetching content from Craft's GraphQL endpoint.
- MySQL backs Craft.
In development this is four Docker containers. In production it's the same shape, which is the whole point — dev/prod parity means the surprises happen on your machine, not on the server.
Two lines in Craft's general.php are what make it headless:
return GeneralConfig::create()
->headlessMode() // no Twig front-end routing
->enableGql() // GraphQL API on
;
The gotchas that actually cost time
Here's the part the tutorials skip — the things you only learn by shipping. Each is the kind of trap that quietly eats an afternoon the first time you meet it.
1. The SSR-vs-browser GraphQL split
Your Nuxt server renders pages by calling Craft's GraphQL API. Your browser also calls it (for client-side navigation and hydration). It is tempting to point both at the same public URL — https://yoursite.com/api. Don't.
- The browser should use the public URL. It's on the open internet; that's correct.
- The server (SSR) should call Craft over the internal network — in Docker, that's the nginx service name, e.g.
http://web/api, with no TLS.
Why? When your Nuxt container calls https://yoursite.com/api, that request leaves the box, hits your public IP, and tries to come back in — hairpin NAT. Depending on your host it's slow, flaky, or silently broken, and you get SSL-loopback headaches for zero benefit. Keep server-side traffic on the internal network:
function getGqlEndpoint(): string {
if (import.meta.server) {
return config.gqlApiHostServer // http://web/api — internal, no TLS
}
return config.public.gqlApiHost // https://yoursite.com/api — public
}
2. The NUXT_ prefix that silently does nothing
This one is pure "why isn't my config taking effect" agony. Nuxt's runtimeConfig lets you override values at runtime with environment variables — but the env var name must be prefixed with NUXT_, and the key path maps to SCREAMING_SNAKE_CASE.
If your config is:
runtimeConfig: {
gqlApiHostServer: 'http://web/api', // private, server-only
}
then the env var that overrides it is NUXT_GQL_API_HOST_SERVER. Setting plain GQL_API_HOST_SERVER does absolutely nothing — no warning, no error, it just keeps the default. It's an easy one to trip over in a Docker Compose file: everything looks configured, but the un-prefixed name is inert.
Rule of thumb: every runtime override is NUXT_ + the key path in SCREAMING_SNAKE_CASE.
3. The blank page with no error (Suspense swallows it)
You write a page that fetches content in <script setup>:
const data = await query(homeGql) // ❌ don't do this
It works locally. In production, when that query throws — a network blip, a schema change, a typo — you get a blank page and nothing in the console. The error was swallowed by Vue's <Suspense> boundary, which is what makes top-level await in setup work at all.
Always wrap Craft queries in useAsyncData:
const { data, error } = await useAsyncData('home', () => query(homeGql))
if (error.value) {
throw createError({ statusCode: 500, statusMessage: error.value.message })
}
Now errors surface as a proper Nuxt error page, and the fetched data is deduplicated into the hydration payload instead of being fetched twice.
4. Live preview is worth wiring up front
Editors expect to hit "Preview" in Craft and see their draft rendered — not saved, not published, just previewed. It's genuinely one of Craft's best features, and it costs about 15 lines to support:
- Craft appends a
?token=…to the preview URL. - A composable reads that token (and stashes it in a short-lived cookie so it survives client-side navigation).
- Your GraphQL client forwards it as the
X-Craft-Tokenheader, and Craft serves draft content for that request.
Retrofitting this later means threading a token through every query. Wire it on day one.
5. Content model in git, not just in the database
Craft's project config serialises your entire content model — sections, fields, entry types, GraphQL schema — to YAML files. Commit them. Now your schema is code-reviewed, version-controlled, and deployable: a teammate pulls, runs project-config/apply, and has your exact content model. No "click these 40 things in the control panel to match my setup."
This also means your CI/CD can apply schema changes on deploy, so the database and the code never drift.
What this looks like day to day
Once it's wired, the development loop is genuinely tight: edit an entry in Craft, hit save, refresh the Nuxt page, see the change. Add a field in the control panel, and the project config YAML updates for you to commit. Add a new page in a structure section and it's instantly routable by slug — no frontend change needed.
The content team works in a CMS they don't need you for. You work in Nuxt without PHP in your face. And the GraphQL contract between you means neither side blocks the other.
Skip the plumbing
Everything above — the SSR/browser split, the env-var naming, the useAsyncData pattern, live preview, project config, the four-container Docker setup — is exactly the kind of thing you shouldn't have to rebuild for every project.
So I packaged it: craft-nuxt-starter — a free, MIT-licensed starter with all of it solved. One command (make setup) and you have Craft in headless mode, a server-rendered Nuxt 4 frontend, live preview, seeded demo content, and every gotcha in this article already handled. TypeScript throughout, tested, CI green.
Clone it, delete the demo content, and you're building features instead of fighting infrastructure.
I'm a freelance web engineer specialising in headless Craft CMS and Nuxt builds. If you're an agency or SaaS team weighing a headless setup, I'm open to contract work.