Type something to search...
How to Use Sanity With Nuxt and Vue

How to Use Sanity With Nuxt and Vue

To use Sanity with Nuxt, install the official @nuxtjs/sanity module, add it to modules in nuxt.config.ts with your project ID, dataset and an apiVersion date, and fetch content in your pages with the auto-imported useSanityQuery() composable. It runs on the server during SSR, passes the result to the client without a second request, and returns reactive data like Nuxt's useAsyncData. The module also registers <SanityContent> for Portable Text and <SanityImage> for images, and adds preview routes and overlays for Visual Editing. In a plain Vue app without Nuxt, use @sanity/client, @portabletext/vue and @sanity/image-url directly.

Nuxt and Sanity work well together because Nuxt's server rendering keeps queries fast and indexable, while the module handles the wiring that you'd otherwise write by hand. This guide covers installing and configuring the module, fetching with useSanityQuery and useLazySanityQuery, rendering Portable Text in Vue, displaying images, caching pages, setting up preview and Visual Editing, and building the same thing in a plain Vue and Vite app.

What You Need Before You Start

You need a Sanity project with some content, such as a post type with title, slug, mainImage and body fields. If you're starting from scratch, set up your first Sanity project first.

On the Nuxt side, this guide uses Nuxt 4 and @nuxtjs/sanity v2.5, the current releases. Version 2 of the module is built on @nuxt/kit v4 and targets Nuxt 4. If you're still on Nuxt 3, either upgrade or stay on the 1.x line of the module (1.14 at the time of writing).

PackagePurpose
@nuxtjs/sanityNuxt module: client, composables, components, preview
@portabletext/vuePortable Text renderer (bundled with the module)
@sanity/image-urlImage URL builder for crops and hotspots
@nuxt/imageOptional, used by <SanityImage> when installed

Install and Configure @nuxtjs/sanity

Add the module with the Nuxt CLI, which installs it and registers it in your config:

npx nuxi@latest module add sanity

Then configure it in nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sanity'],
  sanity: {
    projectId: process.env.NUXT_PUBLIC_SANITY_PROJECT_ID,
    dataset: process.env.NUXT_PUBLIC_SANITY_DATASET || 'production',
    apiVersion: '2025-02-19',
    useCdn: true,
    perspective: 'published',
  },
})
# .env
NUXT_PUBLIC_SANITY_PROJECT_ID=abc123xy
NUXT_PUBLIC_SANITY_DATASET=production

Two settings deserve attention:

  • apiVersion: the module defaults to '1', an old API version that lacks newer GROQ features and isn't accepted when Visual Editing is enabled. Always set a recent date.
  • perspective: the module defaults to raw. Without a token that still returns only published documents, but setting published makes the intent explicit and keeps drafts out if you add a token later.

The module writes these values to runtimeConfig.public.sanity, so you can override them per environment at runtime with variables such as NUXT_PUBLIC_SANITY_DATASET. If you keep your Studio in a cms folder of the same repository, the module can also read projectId and dataset from ~~/cms/sanity.config.ts.

Finally, add your site's origins, such as http://localhost:3000, under API > CORS origins at sanity.io/manage. Client-side navigation can query Sanity from the browser, so the origin must be allowed.

Fetch Content With useSanityQuery

useSanityQuery is auto-imported, along with groq and defineQuery. On the first page load it runs on the server; after that, client-side navigations fetch from the browser. Here's a blog index:

<!-- app/pages/blog/index.vue -->
<script setup lang="ts">
const POSTS_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)] | order(publishedAt desc) [0...12] {
    _id,
    title,
    "slug": slug.current,
    publishedAt
  }
`)

const { data: posts } = await useSanityQuery(POSTS_QUERY)
</script>

<template>
  <ul>
    <li v-for="post in posts" :key="post._id">
      <NuxtLink :to="`/blog/${post.slug}`">{{ post.title }}</NuxtLink>
    </li>
  </ul>
</template>

And a dynamic post page that passes the slug as a parameter:

<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()

const POST_QUERY = defineQuery(`
  *[_type == "post" && slug.current == $slug][0] {
    title,
    publishedAt,
    mainImage { alt, asset },
    body
  }
`)

const { data: post } = await useSanityQuery(POST_QUERY, {
  slug: route.params.slug as string,
})

if (!post.value) {
  throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}

useSeoMeta({ title: () => post.value?.title })
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
  </article>
</template>

Always pass values as parameters rather than building the query string, as explained in GROQ parameters. The return value includes everything from useAsyncData, such as status, error and refresh, plus Sanity-specific sourceMap and encodeDataAttribute for Visual Editing.

useLazySanityQuery

useSanityQuery blocks navigation until the data arrives. useLazySanityQuery doesn't: the route changes immediately and you render a loading state while the query runs:

<script setup lang="ts">
const { data: related, status } = useLazySanityQuery(
  `*[_type == "post" && slug.current != $slug][0...3]{ _id, title, "slug": slug.current }`,
  { slug: useRoute().params.slug as string },
)
</script>

<template>
  <p v-if="status === 'pending'">Loading related posts…</p>
  <RelatedPosts v-else :posts="related ?? []" />
</template>

Use the lazy version for secondary content like related posts or comments, and the standard version for the main content of a page, so it's in the server-rendered HTML.

Typed Results

The module includes optional programmatic type generation. Enable typegen with the path to your schema types and the folders that contain queries, and useSanityQuery infers result types from defineQuery strings:

// nuxt.config.ts
sanity: {
  // ...
  typegen: {
    enabled: true,
    schemaTypesPath: 'cms/schemaTypes/index.ts',
    queryPaths: ['app/**/*.{ts,vue}'],
  },
},

If your Studio lives in another repository, you can use the standard Sanity CLI workflow from Sanity TypeGen instead.

Using the Client Directly

For anything other than page data, such as a server route or a plugin, useSanity() returns a helper with the configured client and a fetch shortcut:

const sanity = useSanity()
const total = await sanity.fetch<number>(`count(*[_type == "post"])`)

Render Portable Text in Vue

The module registers a <SanityContent> component that wraps @portabletext/vue. Pass it a Portable Text array and, optionally, your own components:

<!-- app/components/PostBody.vue -->
<script setup lang="ts">
import { h } from 'vue'
import type { PortableTextComponents } from '@portabletext/vue'
import CodeBlock from './CodeBlock.vue'

defineProps<{ value: any[] }>()

const components: PortableTextComponents = {
  types: {
    code: CodeBlock,
  },
  marks: {
    link: ({ value }, { slots }) => {
      const external = !value?.href?.startsWith('/')
      return h(
        'a',
        { href: value?.href, rel: external ? 'noopener noreferrer' : undefined },
        slots.default?.(),
      )
    },
  },
}
</script>

<template>
  <div class="prose">
    <SanityContent :value="value" :components="components" />
  </div>
</template>

Custom block types can be single-file components. They receive value, index and isInline props, and PortableTextComponentProps types them:

<!-- app/components/CodeBlock.vue -->
<script setup lang="ts">
import type { PortableTextComponentProps } from '@portabletext/vue'

defineProps<PortableTextComponentProps<{ code: string; language?: string }>>()
</script>

<template>
  <pre :data-language="value.language"><code>{{ value.code }}</code></pre>
</template>

Unknown types log a warning rather than breaking the page, so you can add renderers gradually. If you prefer, import PortableText from @portabletext/vue yourself; it takes the same props. The structure of blocks, marks and annotations is explained in what is Portable Text, and designing custom types is covered in Portable Text custom blocks.

Display Sanity Images

<SanityImage> builds a Sanity CDN URL from an asset ID and transformation props. If @nuxt/image is installed, the module uses it under the hood:

<SanityImage
  v-if="post.mainImage?.asset"
  :asset-id="post.mainImage.asset._ref"
  :w="1200"
  :h="630"
  fit="crop"
  auto="format"
  :alt="post.mainImage.alt ?? ''"
/>

The component works from the asset ID alone, so it doesn't apply the crop and hotspot an editor set in the Studio. When those matter, use @sanity/image-url in a small composable:

// app/composables/useImageUrl.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url'

export function useImageUrl() {
  const { projectId, dataset } = useSanityConfig()
  const builder = createImageUrlBuilder({ projectId: projectId!, dataset: dataset! })
  return (source: SanityImageSource) => builder.image(source).auto('format')
}
<script setup lang="ts">
const urlFor = useImageUrl()
</script>

<template>
  <img :src="urlFor(post.mainImage).width(1200).height(630).url()" :alt="post.mainImage.alt" />
</template>

Project the whole image object, including crop and hotspot, for the builder to use them. The options are covered in Sanity's image URL builder.

Cache Rendered Pages

useCdn: true makes Sanity's API CDN cache query results, but Nuxt still renders each request. Use route rules to cache the rendered HTML too:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { swr: 600 },
    '/blog/**': { swr: 3600 },
    '/about': { prerender: true },
  },
})

swr serves a cached page and regenerates it in the background after the given number of seconds, while prerender builds the page once at build time. On Vercel and Netlify, isr works the same way using the platform's cache. For fully static output, run nuxt generate and trigger a rebuild from a webhook when content is published, as described in rebuilding a site with Sanity webhooks.

Preview and Visual Editing

The module includes preview mode and Visual Editing, built on Sanity's loaders and overlays. Add a visualEditing block with a Viewer token and your Studio's URL:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sanity'],
  sanity: {
    projectId: process.env.NUXT_PUBLIC_SANITY_PROJECT_ID,
    dataset: process.env.NUXT_PUBLIC_SANITY_DATASET,
    apiVersion: '2025-02-19',
    visualEditing: {
      token: process.env.NUXT_SANITY_VISUAL_EDITING_TOKEN,
      studioUrl: process.env.NUXT_SANITY_VISUAL_EDITING_STUDIO_URL,
      stega: true,
    },
  },
})

This adds two server routes, /preview/enable and /preview/disable, which you can rename with the previewMode option. The enable route validates a secret from the Studio before turning preview on, so visitors can't enable it themselves. While preview is on, useSanityQuery fetches drafts through a server proxy that holds the token, so it never reaches the browser.

The default mode is 'live-visual-editing', which re-renders queries as editors type and adds click-to-edit overlays. 'visual-editing' shows the overlays but refreshes the page on change instead, and 'custom' leaves the wiring to you.

In the Studio, point the Presentation tool at your Nuxt site and its enable route:

// cms/sanity.config.ts
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'

export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        origin: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
        previewMode: { enable: '/preview/enable' },
      },
    }),
  ],
})

Stega makes text fields clickable automatically. For images and other non-text elements, use encodeDataAttribute from useSanityQuery:

<script setup lang="ts">
const { data: post, encodeDataAttribute } = await useSanityQuery(POST_QUERY, { slug })
</script>

<template>
  <img
    :src="urlFor(post.mainImage).width(1200).url()"
    :data-sanity="encodeDataAttribute(['mainImage'])"
    alt=""
  />
</template>

Remember that stega adds invisible characters to strings. Don't use stega-encoded values in comparisons, class names or URLs. The concepts behind overlays, document locations and stega are explained in depth in Sanity Visual Editing in Next.js, and they apply equally to Nuxt. For the editor experience of drafts updating live, see live preview with Sanity.

Using Sanity in Plain Vue

Without Nuxt, for example in a Vite single-page app, use the underlying packages directly:

npm install @sanity/client @portabletext/vue @sanity/image-url

Create a client. Because it runs in the browser, keep useCdn: true, don't use a token, and add the app's origin to your CORS settings:

// src/sanity.ts
import { createClient } from '@sanity/client'

export const client = createClient({
  projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
  dataset: import.meta.env.VITE_SANITY_DATASET,
  apiVersion: '2025-02-19',
  useCdn: true,
})

Then fetch in a small composable:

// src/composables/useQuery.ts
import { ref, watchEffect, type Ref } from 'vue'
import type { QueryParams } from '@sanity/client'
import { client } from '../sanity'

export function useQuery<T>(query: string, params: () => QueryParams = () => ({})) {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(true)
  const error = ref<Error | null>(null)

  watchEffect(async () => {
    loading.value = true
    try {
      data.value = await client.fetch<T>(query, params())
      error.value = null
    } catch (err) {
      error.value = err as Error
    } finally {
      loading.value = false
    }
  })

  return { data, loading, error }
}
<script setup lang="ts">
import { PortableText } from '@portabletext/vue'
import { useRoute } from 'vue-router'
import { useQuery } from '../composables/useQuery'

const route = useRoute()
const { data: post, loading } = useQuery<{ title: string; body: any[] }>(
  `*[_type == "post" && slug.current == $slug][0]{ title, body }`,
  () => ({ slug: route.params.slug as string }),
)
</script>

<template>
  <p v-if="loading">Loading…</p>
  <article v-else-if="post">
    <h1>{{ post.title }}</h1>
    <PortableText :value="post.body" />
  </article>
</template>

A client-rendered app is fine for dashboards and logged-in tools, but content isn't in the initial HTML, which hurts SEO and first paint. For public content sites, Nuxt's server rendering is the better default. For previews in plain Vue, @sanity/core-loader and @sanity/visual-editing provide the same building blocks the module uses.


FAQ: Sanity With Nuxt and Vue

It's the official Nuxt module for Sanity, maintained in the nuxt-modules organisation on GitHub and documented at sanity.nuxtjs.org. Version 2 targets Nuxt 4, while the 1.x line supports Nuxt 3.

useSanityQuery waits for the data before the page renders or the route changes, so the content is in the server-rendered HTML. useLazySanityQuery lets navigation happen immediately and fills the data in when it arrives, which suits secondary content like related posts.

The module defaults apiVersion to 1, which is too old for Visual Editing and the Content Source Maps it relies on. Set apiVersion to a recent date, such as 2025-02-19, in your sanity config block.

No. It builds the URL from the asset ID and the props you pass, so editor crops and hotspots are ignored. Use @sanity/image-url with the full image object when those settings matter.

The Studio is a React application, so it can't run as a Nuxt page directly. Most Nuxt projects keep the Studio in a separate folder or repository and deploy it on its own, then connect the two through the Presentation tool.

No. @sanity/client, @portabletext/vue and @sanity/image-url all work in any Vue 3 app. Nuxt adds server rendering, caching and the preview tooling, which matter most for public sites.


Conclusion

The @nuxtjs/sanity module makes Sanity feel native in Nuxt. Configure it once with a recent apiVersion, fetch page data with useSanityQuery, use the lazy variant for secondary content, render rich text with <SanityContent> and images with <SanityImage> or the URL builder, and cache rendered pages with route rules.

When editors want previews, add the visualEditing block and point the Presentation tool at the module's enable route, and you get live, click-to-edit previews without writing the plumbing yourself. If you're building a plain Vue app instead, the same three packages, @sanity/client, @portabletext/vue and @sanity/image-url, cover fetching, rich text and images.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper