Using Sanity.io with Nuxt 4
This site used to keep its blog posts as Markdown files and read them at build time. It now keeps them in Sanity and queries them at runtime. That migration is mostly plumbing, but a handful of things about it are genuinely counter-intuitive, and each one cost me time, so this post is about those rather than about how to install a module.
Verified against Sanity Studio v6.16.0 and @nuxtjs/sanity on Nuxt 4.5.2, Node 24. The project id in every snippet below is a placeholder — use your own.
Two packages, no root manifest
The Studio and the Nuxt app are independent npm packages with separate lockfiles. There is no root package.json and no workspace tooling, which means every command starts with cd nuxt or cd sanity.
This is not a style preference — it is what stops a Studio-only change from touching the frontend lockfile. It also has a consequence people forget: a build tool that assumes a root manifest will get the base directory wrong. That is not hypothetical, it is the first thing that broke the Netlify deploy.
The package names do not match their directories, so never derive a path from a package name.
Defining a schema
A document type is an ordinary defineType, and the useful part is composition. Field groups let you share one set of metadata fields across several document types without repeating them:
import {defineField} from 'sanity'
export const title = defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
})Then a document is the spread of those definitions, each tagged with a group:
export default defineType({
name: 'blog',
title: 'Blog Post',
type: 'document',
groups: [metadataGroup, contentGroup, seoGroup],
fields: [
...metadataFields,
...contentFields,
...relatedFields(RelatedBlogsInput, name, metadataGroup.name),
],
})A few Studio v6 specifics that differ from older tutorials: icons import from a subpath like @sanity/icons/DocumentText, Stack takes gap and not space, and Badge has no mode prop — the mode="ghost" you see in this repo is on Button.
Extract the schema, and commit it
Two generated files, both tracked:
cd sanity && npm run schema:extract && npm run typegenschema.json is what other tools and agents read, and it is committed so a schema change always lands with its regenerated copy. --enforce-required-fields matters: without it the extracted schema silently omits validation, and a required field stops looking required to anything consuming that file.
--force makes re-runs idempotent, which is what stops the committed file churning for no reason.
Typegen has a trap that fails silently
Typegen writes ../types/schema.ts, straight across the package boundary into the Nuxt app. It infers result types by finding your GROQ queries in source, which is where it gets fragile.
It only discovers a query assigned to a top-level `const`. A query nested inside an object literal is skipped with no warning at all — the run still exits 0 and cheerfully reports how many files it found. So this is invisible:
// Found: nothing. The query is a property, not a top-level const.
export const blogQueries = {
all: groq`*[_type == "blog"]`,
}And this works:
const allBlogsQuery = defineQuery(`*[_type == "blog"]`)
export const blogQueries = {
all: allBlogsQuery,
}The second trap is the tag. defineQuery preserves the literal type that inference needs; the default groq tag is typed as returning string, which collapses every result to unknown. There is a documented TypeScript issue behind it, but the practical rule is simpler: use `defineQuery`, never the bare `groq` tag.
And do not annotate the call site. useSanityQuery<Blog[]>(query) looks helpful and actively breaks things — an array is not a string, so it cannot match the typed overload and falls through to the untyped one. Let the result type resolve from the query.
One more: because the Studio has no src/ directory, the default glob matches nothing. Restore the list by hand and include the Nuxt app's composables, because that is where the queries actually live. When the glob is wrong, typegen does not fail — it emits degraded types with no literal unions, which is the worst possible outcome.
Wiring the module
The configuration is short. The important detail is that the values come from the environment rather than being hard-coded:
sanity: {
projectId: process.env.SANITY_PROJECT_ID || "",
dataset: process.env.SANITY_DATASET || "production",
apiVersion: process.env.SANITY_API_VERSION || "2026-09-27",
useCdn: true,
}Those || "" fallbacks are the source of a failure mode worth internalising: a missing project id does not fail the build. You get a green deploy with no content, because every query resolves to an empty result. So before debugging "the page is blank", check that the variable is set.
Keep the apiVersion fallback in nuxt.config.ts and the value in .env.example equal to each other, and be aware of which one you are actually reading — a local .env silently wins over the fallback in the config, so grep the env file rather than trusting the config to tell you the effective version.
Querying
Queries live in composables, and the contract is return await:
const getBlogBySlug = async (slug: string) => {
return await useSanityQuery(blogBySlugQuery, { slug })
}useSanityQuery is async underneath, so dropping the await hands you an unresolved object rather than an error — which surfaces somewhere unrelated and is hard to trace. Await at every call site too:
const { data, pending, error } = await useBlog().getBlogBySlug(slug)Surface pending and error. A composable that quietly returns nothing renders as an empty page, and empty is indistinguishable from broken.
Two query-writing habits that pay off immediately. Filter inside the brackets rather than after them — an array path on the left of a comparison does not flatten, so *[carousel[].asset._ref == $x] compares an array to a string and is silently always false. And quote projection keys: an unquoted key is a parse error at the current API version.
Images
Do not reach for @sanity/image-url. It is not installed here, and it does not need to be — the built-in provider works with <NuxtImg> straight out of the box:
image: {
sanity: {
projectId: process.env.SANITY_PROJECT_ID || "",
dataset: process.env.SANITY_DATASET || "production",
},
}The CDN cannot pad. Valid fit values are clip, crop, fill, fillmax, max, scale and min — there is no pad, and asking for one returns an error about a completely different parameter. Any aspect-ratio change that must not lose edge content has to be composited before upload.
Draft preview that is actually private
The Presentation tool gives you click-to-edit and a live preview of unpublished changes. Getting it to preview drafts without publishing them takes three pieces.
In the Studio, point the tool at your frontend and at the endpoint that enables preview mode:
presentationTool({
previewUrl: {
initial: 'https://example.com',
previewMode: {enable: '/preview/enable', shareAccess: false},
},
})Leave shareAccess off. Sharing hands the preview secret to anyone with the link, and a preview that bypasses your published-content boundary is not a preview.
In the app, the module needs a Viewer token — read-only, and read only on the server, where it swaps queries to the drafts perspective once the preview cookie is set:
visualEditing: {
token: process.env.SANITY_VIEWER_TOKEN,
studioUrl: 'https://example.sanity.studio',
}Without that token the module does not throw. It logs a warning and silently disables visual editing, so the preview routes are never registered and the tool reports a connection failure rather than a configuration problem.
Then keep the preview out of search results, and say so in robots.txt as well as in a meta tag — a crawler that honours one and not the other is enough to leak a draft URL:
Back up your content, and know what git cannot save
The worst day of this project so far was deleting the dataset. Every post, page and tag went at once. It was recoverable only because the pre-migration Markdown still existed in git history.
That is the whole lesson, and it has a precise edge: a draft lives only in the dataset. It is never in git, because git tracks code, and it is never rendered, because it was never published. Post-migration copy edits, regenerated teaser images and anything added through the Studio have no backup at all.
So treat a dataset export as a real artefact and take one deliberately, rather than assuming version control is covering it.
The other half of that lesson: read the prompt. The CLI takes a dataset name as a positional argument and has no --dataset flag, so a command that looks like it is deleting one document will cheerfully offer to delete the entire dataset instead. There was no undo, and no export had ever been taken.
A short checklist
Keep Studio and app as separate packages with separate lockfiles.
Regenerate schema.json and the frontend types after any schema change.
Give every query a top-level const and wrap it in defineQuery.
Await every fetch, and surface pending and error.
Check the environment before debugging empty content.
Preview with a Viewer token, shareAccess: false, and noindex.
Export the dataset on purpose. Git will not do it for you.
References
Sanity Studio v6 — defineConfig, defineType, field groups
GROQ query language
sanity-typegen — defineQuery and top-level const discovery
@nuxtjs/sanity — useSanityQuery, visual editing, preview mode
sanity/presentation — Presentation tool, preview URLs
