--- 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 => ( ))}
); } ```