
Using Sanity With the Next.js App Router
To use Sanity with the Next.js App Router, fetch content in async Server Components rather than in client components, and let next-sanity handle caching. The recommended setup calls defineLive() from next-sanity/live with your client and a Viewer token, which returns a sanityFetch function for pages, layouts and generateMetadata, and a <SanityLive /> component you render once in the root layout. Add Draft Mode routes for previews, use route handlers for webhooks and Server Actions for writes, and keep tokens in server-only code. If you enable Cache Components, run sanityFetch inside 'use cache' boundaries and pass draft settings in as props.
The App Router changed where and how data is fetched in Next.js, and Sanity's tooling has followed. Patterns from the Pages Router, like fetching in getStaticProps or querying from useEffect, are no longer the right default. This guide explains how Sanity fits each App Router building block: Server and Client Components, layouts, dynamic routes, metadata, Draft Mode, Cache Components, Route Handlers and Server Actions. It assumes you've already connected Sanity with Next.js.
Where Sanity Data Belongs in the App Router
The App Router gives you several places to run code, and each has a natural Sanity job:
| App Router feature | Sanity use | Runs on |
|---|---|---|
Server Component (page.tsx, layout.tsx) | Fetch and render content | Server |
generateStaticParams | List slugs to prerender | Server, at build time |
generateMetadata | Titles, descriptions, Open Graph images | Server |
Client Component ('use client') | Interactive UI that receives data as props | Browser (and server for the first render) |
Route Handler (route.ts) | Webhooks, Draft Mode toggles, feeds | Server |
Server Action ('use server') | Writing to Sanity, such as comments or likes | Server |
The rule of thumb is simple: fetch on the server, pass data down. A Server Component awaits the query and passes the result to any Client Components as props. Your queries, tokens and the Sanity client never reach the browser, and the page is rendered with content already in the HTML.
Set Up the Client and defineLive
Start with the base client. It's the same one you'd use anywhere, with stega.studioUrl added so Visual Editing can link back to your Studio later:
// src/sanity/lib/client.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2025-02-19',
useCdn: true,
stega: { studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || '/studio' },
})
Then wrap it with defineLive:
// src/sanity/lib/live.ts
import { defineLive } from 'next-sanity/live'
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,
browserToken: token,
})
The two exports do different jobs:
- sanityFetch: a server-side replacement for
client.fetch(). It tags cached results, picks the right perspective (published normally, drafts in Draft Mode), adds stega encoding during previews and chooses the CDN for published requests. - SanityLive: a component that listens to Sanity's Live Content API and revalidates the cached data when content changes.
The two tokens also differ. serverToken is used for server-side fetches, so drafts can be read in Draft Mode. browserToken is only sent to the browser when Draft Mode is on, to power live draft updates. Use a token with Viewer permissions, since anyone in Draft Mode can see the browser token. Set it in .env.local as SANITY_API_READ_TOKEN, without a NEXT_PUBLIC_ prefix.
Render SanityLive in the Root Layout
<SanityLive /> goes in the root layout once, so it's present on every page:
// src/app/layout.tsx
import { draftMode } from 'next/headers'
import { VisualEditing } from 'next-sanity/visual-editing'
import { SanityLive } from '@/sanity/lib/live'
import './globals.css'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled: isDraftMode } = await draftMode()
return (
<html lang="en">
<body>
{children}
<SanityLive />
{isDraftMode && <VisualEditing />}
</body>
</html>
)
}
<VisualEditing /> is optional and only renders in Draft Mode. The full click-to-edit setup, including the Presentation tool, is in setting up Sanity Visual Editing in Next.js.
A note on freshness: in next-sanity v13, a live event marks the affected cache tags as stale rather than forcing an immediate update for every visitor. Most visitors see the change on their next navigation or refresh. If you need guaranteed near-instant updates, the v13 migration guide describes an opt-in approach using a Sanity Function that calls a revalidation route in your app.
Fetch in Pages and Layouts
With sanityFetch in place, pages look like this:
// src/app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { defineQuery } from 'next-sanity'
import { sanityFetch } from '@/sanity/lib/live'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title,
excerpt,
body
}`)
const POST_SLUGS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]{
"slug": slug.current
}`)
type Props = { params: Promise<{ slug: string }> }
export async function generateStaticParams() {
const { data } = await sanityFetch({
query: POST_SLUGS_QUERY,
perspective: 'published',
stega: false,
})
return data
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const { data } = await sanityFetch({ query: POST_QUERY, params: { slug }, stega: false })
return { title: data?.title, description: data?.excerpt }
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const { data: post } = await sanityFetch({ query: POST_QUERY, params: { slug } })
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
</article>
)
}
Three details matter here:
- The result is an object:
sanityFetchreturns{ data }, unlikeclient.fetch(), which returns the result directly. - params is a Promise: in Next.js 16, route
paramsandsearchParamsmust be awaited. - stega: false outside visible content: stega hides invisible characters in strings during previews. That's what Visual Editing needs on the page, but it would corrupt
<title>tags, URLs and slugs. Turn it off ingenerateMetadataandgenerateStaticParams, and whenever you compare strings in code.
Layouts fetch the same way. A site header can query navigation and settings in layout.tsx, and because results are cached and tagged, the query isn't repeated per page. For shared settings documents, the singleton documents pattern fits well.
Pass Data to Client Components
Interactive pieces, such as a search box, a carousel or tabs, need 'use client'. They shouldn't fetch from Sanity themselves. Instead, the Server Component fetches and hands over plain data:
// src/app/products/page.tsx (Server Component)
import { defineQuery } from 'next-sanity'
import { ProductFilter } from '@/components/ProductFilter'
import { sanityFetch } from '@/sanity/lib/live'
const PRODUCTS_QUERY = defineQuery(`*[_type == "product"]{ _id, name, category }`)
export default async function ProductsPage() {
const { data } = await sanityFetch({ query: PRODUCTS_QUERY })
return <ProductFilter products={data} />
}
// src/components/ProductFilter.tsx
'use client'
import { useState } from 'react'
type Product = { _id: string; name: string | null; category: string | null }
export function ProductFilter({ products }: { products: Product[] }) {
const [category, setCategory] = useState<string | null>(null)
const visible = category ? products.filter((p) => p.category === category) : products
return (
<div>
<button onClick={() => setCategory(null)}>All</button>
<button onClick={() => setCategory('books')}>Books</button>
<ul>
{visible.map((p) => (
<li key={p._id}>{p.name}</li>
))}
</ul>
</div>
)
}
One trap: in Draft Mode, category values carry stega characters, so p.category === 'books' will fail during previews. Clean values before comparing them with stegaClean from next-sanity:
import { stegaClean } from 'next-sanity'
const isBook = stegaClean(p.category) === 'books'
If you genuinely need to query from the browser, for example for search-as-you-type, call a Route Handler or Server Action that runs the query, or use a client with useCdn: true and no token.
Draft Mode for Previews
Draft Mode is how the App Router shows unpublished content. When it's on, sanityFetch automatically switches to the drafts perspective and adds stega. You need a route that turns it on securely, and one that turns it off:
// src/app/api/draft-mode/enable/route.ts
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { client } from '@/sanity/lib/client'
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
})
// src/app/api/draft-mode/disable/route.ts
import { draftMode } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
;(await draftMode()).disable()
return NextResponse.redirect(new URL('/', request.url))
}
defineEnableDraftMode checks a short-lived secret that only an authenticated Studio session can create, so random visitors can't enable Draft Mode. The Presentation tool in the Studio calls this route automatically. The guide to live preview with Sanity looks at the preview experience itself.
Using Cache Components
Next.js 16 can run with cacheComponents: true, where caching is explicit through the 'use cache' directive. Sanity supports this in next-sanity v13, with one important constraint: request-time APIs like draftMode() and cookies() aren't allowed inside cached code. Sanity's answer is a strict mode and a three-layer pattern.
First, enable Cache Components and use Sanity's cache life preset, so cached content relies on live revalidation instead of a short timer:
// next.config.ts
import type { NextConfig } from 'next'
import { sanity } from 'next-sanity/live/cache-life'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: { default: sanity },
}
export default nextConfig
Then pass strict: true to defineLive. TypeScript will then require perspective and stega on every sanityFetch call, and includeDrafts on <SanityLive />, because nothing can be read from cookies automatically:
// src/sanity/lib/live.ts
import { cookies, draftMode } from 'next/headers'
import { defineLive, resolvePerspectiveFromCookies, type LivePerspective } from 'next-sanity/live'
import { client } from './client'
const token = process.env.SANITY_API_READ_TOKEN!
export const { sanityFetch, SanityLive } = defineLive({
client,
serverToken: token,
browserToken: token,
strict: true,
})
export type DynamicFetchOptions = { perspective: LivePerspective; stega: boolean }
export async function getDynamicFetchOptions(): Promise<DynamicFetchOptions> {
const { isEnabled } = await draftMode()
if (!isEnabled) return { perspective: 'published', stega: false }
const perspective = await resolvePerspectiveFromCookies({ cookies: await cookies() })
return { perspective: perspective ?? 'drafts', stega: true }
}
Each route then has three layers: the page, a dynamic component that resolves request-time values, and a cached component that fetches:
// src/app/posts/[slug]/page.tsx
import { Suspense } from 'react'
import { sanityFetch, getDynamicFetchOptions, type DynamicFetchOptions } from '@/sanity/lib/live'
import { POST_QUERY } from '@/sanity/lib/queries'
type Props = { params: Promise<{ slug: string }> }
export default function Page({ params }: Props) {
return (
<Suspense fallback={<p>Loading…</p>}>
<Dynamic params={params} />
</Suspense>
)
}
async function Dynamic({ params }: Props) {
const { slug } = await params
const options = await getDynamicFetchOptions()
return <Cached slug={slug} {...options} />
}
async function Cached({ slug, perspective, stega }: { slug: string } & DynamicFetchOptions) {
'use cache'
const { data } = await sanityFetch({ query: POST_QUERY, params: { slug }, perspective, stega })
return <h1>{data?.title}</h1>
}
In the root layout, render <SanityLive includeDrafts={isDraftMode} />. This is more code than the default mode, so only adopt Cache Components if you want its benefits. The default mode shown earlier works without it. The post on Sanity caching and performance compares the approaches.
Route Handlers for Webhooks and Feeds
Route Handlers are the App Router's API endpoints. With Sanity, they typically handle:
- Draft Mode toggles: shown above.
- Webhooks: verify the signature with
parseBodyfromnext-sanity/webhook, then callrevalidateTag(tag, 'max')orrevalidatePath(). The Next.js and Sanity blog guide has a complete example. - Feeds and sitemaps: fetch slugs and dates with GROQ and return XML, as covered in dynamic sitemaps with Sanity.
If you use defineLive, you usually don't need a webhook for basic freshness, because <SanityLive /> handles revalidation.
Server Actions for Writing to Sanity
Server Actions let a form write to Sanity without building a separate API. Use a separate client with a token that has write permissions, and only import it in server code:
// src/app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { client } from '@/sanity/lib/client'
const writeClient = client.withConfig({
token: process.env.SANITY_API_WRITE_TOKEN,
useCdn: false,
})
export async function addComment(postId: string, formData: FormData) {
const text = String(formData.get('text') ?? '').trim()
if (!text || text.length > 1000) return
await writeClient.create({
_type: 'comment',
text,
post: { _type: 'reference', _ref: postId },
approved: false,
})
revalidatePath('/posts')
}
Validate everything, because Server Actions are public endpoints. Never pass the write token to a Client Component, and consider storing user submissions as unapproved documents that editors moderate in the Studio. The mutation API guide covers patches and transactions.
Recommended File Layout
Keeping Sanity code in one folder makes it easy to find and to keep server-only:
src/
sanity/
env.ts # validated environment variables
lib/client.ts # base client
lib/live.ts # defineLive: sanityFetch and SanityLive
lib/image.ts # urlFor helper
lib/queries.ts # defineQuery queries
schemaTypes/ # only if the Studio lives in this repo
app/
api/draft-mode/enable/route.ts
api/draft-mode/disable/route.ts
If you embed the Studio in the same app, keep <SanityLive /> and <VisualEditing /> out of its layout with route groups. The guide to embedding Sanity Studio in Next.js shows how.
FAQ: Sanity and the Next.js App Router
Use sanityFetch from defineLive for pages, layouts and metadata, because it handles caching, perspectives and stega for you. client.fetch is still useful for scripts, build-time work and route handlers where you want direct control.
You can, using a client with useCdn set to true and no token, but it's rarely the best choice. Fetching in a Server Component and passing props keeps queries off the client, avoids loading states and puts content in the initial HTML.
Stega encodes source information as invisible characters inside strings during previews, so Visual Editing knows which field each piece of text came from. It's harmless in rendered text but breaks titles, URLs and string comparisons, so pass stega false for metadata and use stegaClean before comparing values.
No. defineLive works in the default mode without it. Cache Components is an optional Next.js 16 model, and next-sanity v13 supports it through strict mode and the three-layer pattern if you choose to adopt it.
It's only sent to the browser when Draft Mode is on, and Draft Mode can only be enabled through the secure Studio handshake. Even so, use a Viewer token with no write access, and never put a write token in defineLive.
With defineLive in v13, changes mark cached data as stale and visitors usually see the update on their next navigation or refresh. If content never updates, check that SanityLive is rendered in the root layout, that the CORS origin is configured and that you're using sanityFetch rather than an uncached or differently cached client.fetch.
Conclusion
Sanity fits the App Router best when you treat the server as the place where content is fetched. A base client plus defineLive gives you sanityFetch for Server Components, layouts and metadata, and a single <SanityLive /> in the root layout keeps cached content in step with the Content Lake. Client Components receive data as props, Route Handlers handle Draft Mode and webhooks, and Server Actions write with a separate, server-only token.
Start with the default caching mode, turn off stega anywhere strings aren't simply displayed, and adopt Cache Components with strict mode only when you need explicit cache boundaries. With those patterns in place, previews, Visual Editing and the rest of the next-sanity toolkit slot in without restructuring your app.


