---
title: Next.js & Sanity Integration Rules
description: Integration guide for Next.js App Router, Live Content API, and a standalone Sanity Studio.
---
# Next.js & Sanity Integration Rules
Jump to the section that matches the task instead of reading this guide end-to-end.
## Table of Contents
- Architecture patterns
- Data fetching (Live Content API)
- Caching and revalidation
- Visual Editing and clean data
- Studio setup (standalone)
- Draft Mode setup
- Error handling
- Presentation queries
- Pagination pattern
## 1. Architecture Patterns
### Option A: Standalone Studio (Recommended)
**Best for:** All new Next.js projects.
The Studio is its own app, living alongside the Next.js app in the same repo:
```
your-project/
├── studio/ # Sanity Studio (standalone)
└── web/ # Next.js frontend
```
Why standalone instead of embedding the Studio in the Next.js app:
- **Faster dev and builds:** `sanity dev` and `sanity build` run on Vite and are dramatically faster (10-30x) than compiling the Studio through `next dev` / `next build`.
- **Auto-updates:** Standalone Studios receive bugfixes and new features automatically, with no dependency bump or redeploy. Embedded Studios can't auto-update (Next.js does not support ESM with import maps), so every update means bump + deploy.
- **TypeGen watch mode:** With `sanity dev`, TypeGen regenerates types as queries change. Embedded Studios can't hook into `next dev`, so you must re-run `sanity typegen generate` manually after every query edit.
- **Content model independence:** A separate Studio keeps the content model from becoming website-centric and makes collaboration easier.
**Setup:**
- Run both apps side by side in separate terminals: `next dev` (localhost:3000) and `sanity dev` (localhost:3333).
- Add your Next.js app URL to **CORS Origins**: `npx sanity cors add http://localhost:3000 --credentials` (repeat for your production URL), or via [Sanity Manage](https://www.sanity.io/manage).
- See `project-structure.md` rule for detailed structure.
### Option B: Embedded Studio (Not Recommended)
The Studio can be mounted inside the Next.js app at `/app/studio/[[...tool]]/page.tsx` via `next-sanity/studio`. Avoid this for new projects: it slows builds, ties every Studio update to an app deploy, and rules out auto-updates and TypeGen watch mode. For maintaining or migrating an existing embedded Studio, see section 5.
## 2. Data Fetching (Live Content API)
We use `defineLive` (next-sanity v11+) to enable real-time content updates and Visual Editing automatically.
### Setup (`src/sanity/lib/live.ts`)
```typescript
import { defineLive } from 'next-sanity/live'
import { client } from './client'
export const { sanityFetch, SanityLive } = defineLive({
client: client.withConfig({
apiVersion: '2026-02-01'
}),
serverToken: process.env.SANITY_API_READ_TOKEN,
browserToken: process.env.SANITY_API_READ_TOKEN,
})
```
### Rendering (`src/app/layout.tsx`)
You **must** render `` in the root layout to enable real-time updates.
```typescript
import { SanityLive } from '@/sanity/lib/live'
import { VisualEditing } from 'next-sanity/visual-editing'
import { draftMode } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
{(await draftMode()).isEnabled && }
)
}
```
## 3. Caching & Revalidation
### Prefer Live Content API (Default)
**Use `defineLive` by default.** It handles fetching, caching, and invalidation automatically. Only implement manual caching when you need fine-grained control.
### When to Use Manual Caching
| Scenario | Approach |
|----------|----------|
| Real-time updates, Visual Editing | `defineLive` (default) |
| Static marketing pages, rarely updated | Time-based revalidation |
| Blog posts, products with frequent edits | Tag-based revalidation |
| Critical accuracy (stock levels, prices) | Path-based + short revalidation |
### Debugging: Enable Fetch Logging
See every fetch with cache HIT/MISS status:
```typescript
// next.config.ts
const nextConfig: NextConfig = {
logging: {
fetches: {
fullUrl: true,
},
},
};
```
Console output shows cache status:
```text
GET /posts 200 in 39ms
│ GET https://...apicdn.sanity.io/... 200 in 5ms (cache hit)
```
### Sanity CDN vs API
| Setting | Speed | Freshness | Use When |
|---------|-------|-----------|----------|
| `useCdn: true` | Fast | May have brief delay | Default for all runtime fetches |
| `useCdn: false` | Slower | Guaranteed fresh | `generateStaticParams`, webhooks |
Override per-request:
```typescript
// For static generation, use API directly
export async function generateStaticParams() {
const slugs = await client
.withConfig({ useCdn: false })
.fetch(SLUGS_QUERY);
return slugs;
}
```
### Manual `sanityFetch` Helper (Advanced)
For manual caching control, create a wrapper:
```typescript
// src/sanity/lib/client.ts
export async function sanityFetch({
query,
params = {},
revalidate = 60,
tags = [],
}: {
query: QueryString;
params?: QueryParams;
revalidate?: number | false;
tags?: string[];
}) {
return client.fetch(query, params, {
next: {
revalidate: tags.length ? false : revalidate,
tags,
},
});
}
```
### Time-Based Revalidation
Simple and predictable. Good for content that changes infrequently.
```typescript
const posts = await sanityFetch({
query: POSTS_QUERY,
revalidate: 3600, // Revalidate every hour
});
```
**The "Typo Problem":** With time-based only, content authors may wait up to an hour to see changes. Use webhooks for instant updates.
### Path-Based Revalidation
Surgically revalidate specific routes when documents change.
**1. Create API Route:**
```typescript
// src/app/api/revalidate/path/route.ts
import { revalidatePath } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
type WebhookPayload = { path?: string };
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody(
req,
process.env.SANITY_REVALIDATE_SECRET,
true // Add delay to allow CDN to update
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!body?.path) {
return new Response('Missing path', { status: 400 });
}
revalidatePath(body.path);
return NextResponse.json({ revalidated: body.path });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
```
**2. Create GROQ-Powered Webhook:**
- URL: `https://yoursite.com/api/revalidate/path`
- Filter: `_type in ["post"]`
- Projection: `{ "path": "/posts/" + slug.current }`
- Add `SANITY_REVALIDATE_SECRET` to webhook and `.env.local`
### Tag-Based Revalidation
"Update once, revalidate everywhere" — best for referenced content.
**1. Tag Your Queries:**
```typescript
// Posts index - revalidate when ANY post, author, or category changes
const posts = await sanityFetch({
query: POSTS_QUERY,
tags: ['post', 'author', 'category'],
});
// Individual post - more granular, includes slug-specific tag
const post = await sanityFetch({
query: POST_QUERY,
params,
tags: [`post:${params.slug}`, 'author', 'category'],
});
```
**2. Create API Route:**
```typescript
// src/app/api/revalidate/tag/route.ts
import { revalidateTag } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
type WebhookPayload = { tags: string[] };
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody(
req,
process.env.SANITY_REVALIDATE_SECRET,
true
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!Array.isArray(body?.tags) || !body.tags.length) {
return new Response('Missing tags', { status: 400 });
}
body.tags.forEach((tag) => revalidateTag(tag));
return NextResponse.json({ revalidated: body.tags });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
```
**3. Create GROQ-Powered Webhook:**
- URL: `https://yoursite.com/api/revalidate/tag`
- Filter: `_type in ["post", "author", "category"]`
- Projection: `{ "tags": [_type, _type + ":" + slug.current] }`
### Stale Data After Webhook?
Webhooks fire *before* Sanity CDN updates. If you see stale data:
1. **Add delay** — Pass `true` as third arg to `parseBody`
2. **Or bypass CDN** — Set `useCdn: false` in client config (use sparingly)
## 4. Visual Editing (Stega) & Clean Data
Visual Editing injects invisible characters into strings to enable click-to-edit.
### A. The Golden Rule of Stega
If a string field controls logic (alignment, colors, IDs), you **must** clean it before comparing.
```typescript
import { stegaClean } from "@sanity/client/stega";
export function Layout({ align }: { align: string }) {
// ❌ Bad: Will fail in Edit Mode due to invisible chars
// if (align === 'center') ...
// ✅ Good: Clean the value first
const cleanAlign = stegaClean(align);
return
}
```
### B. Metadata & SEO (Critical)
**Never** let Stega characters leak into `` tags. Always set `stega: false` for metadata fetching.
```typescript
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({
query: SEO_QUERY,
params: await params,
stega: false // 👈 Critical for SEO
})
return { title: data?.title }
}
```
### C. Static Params
When generating static params, fetch only published content and disable stega.
```typescript
export async function generateStaticParams() {
const { data } = await sanityFetch({
query: SLUGS_QUERY,
perspective: 'published', // 👈 No drafts
stega: false
})
return data
}
```
## 5. Setup: Studio (Standalone)
Create the Studio as its own app from the repo root — **not inside the Next.js app folder**, where the CLI would switch to its embedded flow:
```bash
npm create sanity@latest -- --project --dataset production --template clean --typescript --output-path studio
```
Run it with `npm run dev` inside `studio/` (defaults to http://localhost:3333). For Visual Editing, point the Presentation Tool's `previewUrl.origin` at the Next.js app (see `visual-editing.md`).
### Migrating an Existing Embedded Studio
Embedded Studios (`` mounted at a route like `/app/studio/[[...tool]]/page.tsx`) keep working, but migrating to a standalone Studio is recommended:
1. Create a standalone Studio folder as above, reusing your existing `projectId` and dataset.
2. Move `sanity.config.ts`, `sanity.cli.ts`, and your schema types into it.
3. Delete the `/app/studio/[[...tool]]/` route from the Next.js app. Keep `next-sanity` — the app still needs it for fetching, Live Content, and Visual Editing.
4. Add the app's URLs to CORS origins and set the Presentation Tool's `previewUrl.origin` to the app's URL.
## 6. Setup: Draft Mode
Enable Presentation Tool and Visual Editing by setting up a draft mode route.
**`src/app/api/draft-mode/enable/route.ts`:**
```typescript
import { client } from '@/sanity/lib/client'
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { token } from '@/sanity/lib/token' // Helper to get token
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token }),
})
```
## 7. Error Handling
Use `notFound()` for missing documents. Common errors:
| Error | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Invalid/missing token | Check `SANITY_API_READ_TOKEN` |
| 403 Forbidden | CORS not configured | Add URL to CORS origins |
| Query syntax error | Invalid GROQ | Test in Vision plugin first |
| Empty result | Wrong filter/params | Log params, check `_type` spelling |
```typescript
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: Props) {
const { data } = await sanityFetch({ query: POST_QUERY, params: await params })
if (!data) notFound()
return
}
```
## 8. Presentation Queries (`usePresentationQuery`)
For faster live editing in the Presentation Tool, use `usePresentationQuery` to fetch only the specific block being edited, rather than re-rendering the entire page.
### Why Use This
- **Without:** Editing a hero title re-fetches the whole page, re-renders all blocks
- **With:** Only the hero block re-fetches and re-renders
This is especially valuable for pages with many Page Builder blocks or complex Portable Text.
### Basic Pattern
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
type HeroProps = {
_key: string
documentId: string
title: string
subtitle?: string
// ... other initial props from page query
}
export function Hero({ _key, documentId, title, subtitle, ...rest }: HeroProps) {
// Fetch block-specific data for faster updates in Presentation Tool
const { data } = usePresentationQuery({
query: HERO_PRESENTATION_QUERY,
params: { documentId, blockKey: _key },
})
// Use presentation data if available, fallback to initial server props
const blockData = data?.heroBlock || { title, subtitle, ...rest }
return (
{blockData.title}
{blockData.subtitle && {blockData.subtitle}
}
)
}
```
### The Presentation Query
Create a query that targets the specific block by `_key`:
```typescript
// queries.ts
export const HERO_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title,
subtitle,
image,
theme,
// Include all fields the component needs
}
}
`)
```
### Passing Document Context
Your PageBuilder component needs to pass `documentId` to each block:
```typescript
export function PageBuilder({ content, documentId }: { content: Block[]; documentId: string }) {
return (
{content.map((block) => {
switch (block._type) {
case "hero":
return
// ... other blocks
}
})}
)
}
```
### For Portable Text Blocks
The same pattern works for custom blocks inside Portable Text:
```typescript
export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
"pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
image,
caption,
alt
}
}
`)
```
**See also:** `visual-editing.md` for the conceptual overview and `page-builder.md` for full Page Builder patterns.
## 9. Pagination Pattern
For listing pages with many entries, use offset-based pagination with a count query.
GROQ slice bounds (`[start...end]`) must be constant numbers, not `$params`. Validate the page bounds in application code and interpolate them directly into the query string.
### Queries
```typescript
// Total count for pagination UI
export const ARTICLES_COUNT_QUERY = defineQuery(`
count(*[_type == "article" && defined(slug.current)])
`);
// Paginated listing — validated integers interpolated into the slice
function articlesQuery(start: number, end: number) {
return defineQuery(`
*[_type == "article" && defined(slug.current)]
| order(date desc) [${start}...${end}] {
_id, title, "slug": slug.current, date
}
`);
}
```
### Listing Page
```typescript
const ENTRIES_PER_PAGE = 10;
export default async function BlogPage({
searchParams
}: {
searchParams: Promise<{ page?: string }>
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam || "1") || 1);
const start = (page - 1) * ENTRIES_PER_PAGE;
const end = start + ENTRIES_PER_PAGE;
const [{ data: articles }, { data: total }] = await Promise.all([
sanityFetch({ query: articlesQuery(start, end) }),
sanityFetch({ query: ARTICLES_COUNT_QUERY })
]);
const totalPages = Math.ceil(total / ENTRIES_PER_PAGE);
return (
{articles.map(article => (
))}
);
}
```