Sanity Live with Next.js Cache Components
Configure next-sanity v13 for Next.js Cache Components (cacheComponents: true). Covers the three-layer component pattern, defineLive setup, draft mode handling, and manual migration from earlier versions.
This guide requires next-sanity v13 and Next.js v16. We recommend Next.js v16.2 or later.
This guide shows how to configure next-sanity for cacheComponents: true. The important difference from traditional Sanity Live usage is that sanityFetch must run inside cached boundaries, while request-time values such as draftMode() and cookies must be resolved outside those boundaries and passed in as props.
Automate the migration with an agent
First install the skill: npx skills add https://github.com/sanity-io/next-sanity --skill sanity-live-cache-components and then give your agent this prompt:
Use the /sanity-live-cache-components skill to migrate this app to use Cache Components. When verifying with next dev, test both draft mode enabled and draft mode disabled because each mode has different rendering rules.
Setup
Install next-sanity@13:
npm install --save-exact next-sanity@^13
pnpm add --save-exact next-sanity@^13
yarn add --exact next-sanity@^13
bun add --exact next-sanity@^13
1. Configure next.config.ts
In your next.config.ts, enable cacheComponents and add the Sanity cacheLife preset. Sanity Live handles on-demand revalidation, so cached Sanity data should not rely on the default 15-minute time-based revalidation.
import type {NextConfig} from 'next'
import {sanity} from 'next-sanity/live/cache-life'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {default: sanity},
} satisfies NextConfig
export default nextConfig2. Configure the Sanity client
Projects typically have a src/sanity/lib/client.ts file. It should use a modern apiVersion, default to the published perspective, and configure stega.studioUrl for Visual Editing:
import {createClient} from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
useCdn: true,
apiVersion: '2026-02-27',
perspective: 'published',
stega: {studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333'},
})If this file already exists, extend it rather than overwriting it. Changing apiVersion or removing existing stega.* options can break an app.
3. Configure defineLive
Create a live.ts file next to client.ts. Use strict: true so TypeScript requires every sanityFetch call to pass perspective and stega, and every <SanityLive /> render to pass includeDrafts. You also need helpers for the places where Sanity data is fetched outside normal React Server Component rendering.
import {type QueryParams} from 'next-sanity'
import {defineLive, resolvePerspectiveFromCookies, type LivePerspective} from 'next-sanity/live'
import {cookies, draftMode} from 'next/headers'
import {client} from './client'
const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
export const {sanityFetch, SanityLive} = defineLive({
client,
serverToken: token,
// The browser token is exposed to browsers in draft/live preview.
// It must be read-only and scoped to the minimum required permissions.
browserToken: token,
strict: true,
})
export interface DynamicFetchOptions {
perspective: LivePerspective
stega: boolean
}
export async function getDynamicFetchOptions(): Promise<DynamicFetchOptions> {
const {isEnabled: isDraftMode} = await draftMode()
if (!isDraftMode) {
return {perspective: 'published', stega: false}
}
const jar = await cookies()
const perspective = await resolvePerspectiveFromCookies({cookies: jar})
return {perspective: perspective ?? 'drafts', stega: true}
}
// For usage within generateStaticParams
export async function sanityFetchStaticParams<const QueryString extends string>({
query,
params = {},
}: {
query: QueryString
params?: QueryParams
}) {
'use cache'
const {data} = await sanityFetch({query, params, perspective: 'published', stega: false})
return {data}
}
// For usage within generateMetadata and generateViewport
export async function sanityFetchMetadata<const QueryString extends string>({
query,
params = {},
perspective,
}: {
query: QueryString
params?: QueryParams
perspective: LivePerspective
}) {
'use cache'
const {data} = await sanityFetch({query, params, perspective, stega: false})
return {data}
}4. Render <SanityLive /> in a root layout
Render <SanityLive /> once in a root layout and pass includeDrafts={isDraftMode}. Render <VisualEditing /> only in draft mode.
import {SanityLive} from '@/sanity/lib/live'
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
export default async function RootLayout({children}: LayoutProps<'/'>) {
const {isEnabled: isDraftMode} = await draftMode()
return (
<html lang="en">
<body>
{children}
<SanityLive includeDrafts={isDraftMode} />
{isDraftMode && <VisualEditing />}
</body>
</html>
)
}If the app has an embedded Sanity Studio route (for example app/studio/[[...index]]/page.tsx), put <SanityLive /> in a route-group layout that the Studio route does not use, such as src/app/(website)/layout.tsx.
5. Fetching data with sanityFetch
Cache Components introduce a layered caching system, so you need to define cache boundaries yourself depending on your application needs and how dynamic or cacheable the data you are fetching is.
Key difference from cacheComponents: false
When cacheComponents: false, sanityFetch can read draftMode() to set perspective and stega for you. When cacheComponents: true, Next.js does not allow request-time APIs like draftMode() and cookies() inside use cache boundaries.
To handle this, use a three-layer structure:
- Page/layout component: branches on
draftMode()when the route can be prerendered. - Dynamic component: resolves
params,cookies(), andgetDynamicFetchOptions()outside the cache boundary. - Cached component: has use cache and receives serializable props, including perspective and stega.
Under the hood, sanityFetch automatically calls the cacheTag() API and the cacheLife() API, so you can focus on defining your query and params.
Keep these rules in mind:
- Any async function that calls
sanityFetchshould have a use cache or use cache: remote directive. - Do not hardcode perspective: published or stega: false inside cached components that render page content. Resolve those values outside the cache boundary and pass them in as props.
- Do not take perspective or stega as server action input. Server action inputs are untrusted; resolve them inside the server action and pass them to a cached helper.
- In route.ts handlers, use stega: false unless the response is rendered into the same DOM as
<VisualEditing />.
Static routes
import {draftMode} from 'next/headers'
import {defineQuery} from 'next-sanity'
import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {Suspense} from 'react'
const PRODUCTS_QUERY = defineQuery(
`*[_type == "product" && defined(slug.current)][0...$limit]{_id,slug,title}`,
)
export default async function Page() {
const {isEnabled: isDraftMode} = await draftMode()
if (isDraftMode) {
return (
<Suspense fallback={<section>Loading…</section>}>
<DynamicProductsList />
</Suspense>
)
}
return <CachedProductsList perspective="published" stega={false} />
}
async function DynamicProductsList() {
const {perspective, stega} = await getDynamicFetchOptions()
return <CachedProductsList perspective={perspective} stega={stega} />
}
async function CachedProductsList({perspective, stega}: DynamicFetchOptions) {
'use cache'
const {data: products} = await sanityFetch({
query: PRODUCTS_QUERY,
params: {limit: 10},
perspective,
stega,
})
return (
<section>
{products.map((product) => (
<article key={product._id}>
<a href={`/product/${product.slug}`}>{product.title}</a>
</article>
))}
</section>
)
}Dynamic routes with params
In Next.js 16+, params is a Promise. For routes where params is used as input to sanityFetch, implement generateStaticParams() and use sanityFetchStaticParams(). The dynamic layer unwraps both params and the fetch options before passing plain, serializable values to the cached component:
import {draftMode} from 'next/headers'
import {defineQuery} from 'next-sanity'
import {
getDynamicFetchOptions,
sanityFetch,
sanityFetchStaticParams,
type DynamicFetchOptions,
} from '@/sanity/lib/live'
import {Suspense} from 'react'
const SLUGS_BY_TYPE_QUERY = defineQuery(`
*[_type == $type && defined(slug.current)]{"slug": slug.current}
`)
const PRODUCT_QUERY = defineQuery(
`*[_type == "product" && slug.current == $slug][0]{_id,slug,title,description}`,
)
export async function generateStaticParams() {
const {data} = await sanityFetchStaticParams({
query: SLUGS_BY_TYPE_QUERY,
params: {type: 'product'},
})
return data
}
export default async function ProductPage({params}: PageProps<'/product/[slug]'>) {
const {isEnabled: isDraftMode} = await draftMode()
if (isDraftMode) {
return (
<Suspense fallback={<section>Loading product…</section>}>
<DynamicProductPage params={params} />
</Suspense>
)
}
const {slug} = await params
return <CachedProductPage slug={slug} perspective="published" stega={false} />
}
async function DynamicProductPage({params}: Pick<PageProps<'/product/[slug]'>, 'params'>) {
const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
return <CachedProductPage slug={slug} perspective={perspective} stega={stega} />
}
async function CachedProductPage({
slug,
perspective,
stega,
}: Awaited<PageProps<'/product/[slug]'>['params']> & DynamicFetchOptions) {
'use cache'
const {data: product} = await sanityFetch({
query: PRODUCT_QUERY,
params: {slug},
perspective,
stega,
})
return (
<article>
<h1>{product?.title}</h1>
</article>
)
}PageProps<"/product/[slug]"> is provided by Next.js next typegen output, so the params are typed from the route segment without having to define a Props type by hand.
Caching generateMetadata
Metadata should not use stega encoding, but it should still resolve perspective so Presentation Tool can preview draft content and content releases in a new preview window. Use sanityFetchMetadata() and pass the resolved perspective.
import type {Metadata, ResolvingMetadata} from 'next'
import {getDynamicFetchOptions, sanityFetchMetadata} from '@/sanity/lib/live'
export async function generateMetadata(
{params}: PageProps<'/product/[slug]'>,
parent: ResolvingMetadata,
): Promise<Metadata> {
const [{slug}, {perspective}] = await Promise.all([params, getDynamicFetchOptions()])
const {data: product} = await sanityFetchMetadata({
query: PRODUCT_QUERY,
params: {slug},
perspective,
})
return {
title: product?.title,
description: product?.description ?? (await parent).description,
}
}Routes with loading.tsx
If a dynamic route has a sibling loading.tsx, the route can rely on that fallback instead of adding its own Suspense boundary. In that case it can await params and getDynamicFetchOptions() directly in the page component before rendering a cached component:
export default async function ProductPage({params}: PageProps<'/product/[slug]'>) {
const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
return <CachedProductPage slug={slug} perspective={perspective} stega={stega} />
}Without a sibling loading.tsx, keep request-time work in a dynamic component wrapped by Suspense.
Migrating an existing Sanity Live setup
If the app is already using defineLive, this is a refactor, not a rewrite. The five-step sequence above still applies, but watch for these specific differences:
- Do not overwrite
client.tsorlive.tsif they exist. Append missing options. Preserve any existing token and stega.* settings. - Search for hardcoded
perspective: publishedandstega: falseinsanityFetchcall sites and refactor them to source perspective/stega viagetDynamicFetchOptionsand the three-layer pattern. - Search for
sanityFetchcalls insidegenerateStaticParamsand swap forsanityFetchStaticParams. - Search for
sanityFetchcalls insidegenerateMetadata, sitemap.ts, andopengraph-image.tsxand swap for sanityFetchMetadata. - Search for
sanityFetchcalls directly inside a use server function and split into a separate use cache helper. - Verify there is exactly one
<SanityLive>and one<VisualEditing>in the tree. Multiple renders are undefined behavior.
Verify both modes
Run the app with next dev and test both draft mode enabled and draft mode disabled. next build --debug-prerender can catch prerendering issues, but it does not prove that draft mode, Presentation Tool perspective switching, or Visual Editing overlays work correctly.