From Zero to a Static, CMS-Managed Marketing Site in an Afternoon
A practical walkthrough of standing up Nuxt 4 and Sanity together — one-command setup, demo content, CMS-driven theming, and a static build you can drop on any host.

From Zero to a Static, CMS-Managed Marketing Site in an Afternoon
Most "getting started" guides for a headless stack stop right where the work begins. You get a Nuxt app talking to a CMS, and then you're on your own for theming, SEO, redirects, forms, and the actual deployment.
This walkthrough goes the other way: start from a working stack, and spend the time on the parts that decide whether the site is maintainable six months from now.
1. Get both apps running
There are two apps in a headless setup — the frontend and the Studio — and the friction is usually in wiring them together. Two .env files, a project ID, a dataset name, an API token, and some content to look at.
That's all scriptable, so it is a script:
npm run setup:projectIt installs dependencies for the frontend and the Studio, creates both .env files, prompts for your Sanity credentials, optionally imports a demo dataset, and generates the font and theme CSS. Then:
npm run dev:all
# Nuxt → http://localhost:3000
# Studio → http://localhost:3333One terminal, both apps. This matters more than it sounds — when the Studio lives in a second terminal tab you forget to start, you end up editing content against stale data.
If you'd rather do it by hand, it's the same steps: npm install in root and studio/, copy .example.env to .env in both, fill in SANITY_STUDIO_PROJECT_ID, SANITY_STUDIO_DATASET, SANITY_STUDIO_API_TOKEN, and DOMAIN. Then verify:
npm run env:check2. Fetch content with typed queries
Data comes from GROQ. The important discipline is keeping queries in one place instead of scattering query strings through components:
const { data } = await useSanityQuery<PageQueryResult>(PAGE_QUERY, { slug })Types come from Sanity TypeGen, which reads your schemas and writes studio/sanity.types.ts. Any time you change a schema:
cd studio && npm run schema:typesSkip this, and TypeScript will happily let you read a field that no longer exists. Run it, and the mismatch is a compile error.
The queries file also holds shared projections for images, SEO objects, and portable text — so a field added to the image projection lands everywhere at once, not in the eleven components that each rolled their own.
3. Let the CMS own the brand
This is the step most teams skip, and the one that pays off every time a client asks for "a slightly different blue."
Colour themes and fonts are documents in Sanity, not constants in the codebase. At build time they compile to CSS variables:
npm run cssvars:generate # colour themes → CSS variables
npm run fonts:generate # fonts.js → font CSS variablesTwo rules keep it clean:
- No hex codes in Vue files. Every colour resolves through a theme reference.
- One owner for theme application. In this starter that's
app/layouts/default.vue, which watches the page's theme reference and applies it. Loaders stay pure data fetchers.
The payoff: a rebrand is an editor changing documents in the Studio, then a rebuild. No pull request.
One sharp edge worth knowing about — teleported overlays (dropdowns, date pickers) render outside your app's DOM tree, so a CSS variable defined on :root in your app may not resolve there and the panel paints transparent. Resolve those to concrete colour values before applying them inline. It's the kind of bug that takes an afternoon to find and one helper to fix.
4. The SEO work you'd otherwise defer
Marketing sites live or die on this, and it is always the thing pushed to "phase two."
Wire it once, at the CMS level:
- Per-page meta, OG, and Twitter tags as editable fields, not hardcoded defaults
- Canonical URLs, so pagination and filtered views don't fragment your ranking
- Sitemap and robots, generated at build (
routes.jshandles dynamic route discovery) - JSON-LD structured data — Organization, WebPage, Article — driven from the same CMS fields
Structured data is the one people leave out. It's what gets you article cards and rich results instead of a plain blue link, and once it's generated from fields editors already fill in, it costs nothing per page.
Redirects belong here too. When a client restructures their URLs, someone needs to add 301s. If that's a code change, it won't happen consistently. Make redirects documents in the Studio, applied as route rules at build time (and Apache rules for static hosts), and the marketing team can handle it themselves.
5. Build it static
For a marketing site, static generation is nearly always the right default. No server to keep alive, no runtime cost per visitor, and a CDN in front of it is genuinely fast rather than fast-on-a-good-day.
npm run generate # static site
npm run staticnuxt # full pipeline: setup → generate → .htaccessBefore shipping, run the suite:
npm run test:run # unit + Nuxt component tests
npm run test:e2e # builds and boots the appThe e2e smoke test is the one that earns its keep. It catches the class of failure unit tests never see — a bad import, a missing env var, a build-time query that throws — before your client does.
Drafts still need to be reviewable, which is what the Live Content API handles: editors see real-time draft updates in preview without a rebuild, while production stays fully static. You don't have to trade one for the other.
What's left to you
The stack above is scaffolding. The actual work is the part no starter can do: the content model that matches how this particular client thinks about their business, the blocks their pages actually need, and the copy.
Which is the point. The plumbing is solved and identical across every project you'll ever build. Spending a week on it again per client is the expensive habit worth breaking.
Related: Stop Rebuilding the Same Landing Page (opens in new tab) — why block-based composition beats bespoke templates.
Written by
Jason Reid
Founder / Content Writer
Builds Nuxion. Ex-agency lead who got tired of rebuilding the same CMS plumbing on every project and decided to ship it once