--- title: Next.js & Sanity Integration Rules description: Integration guide for Next.js App Router, Live Content API, and Sanity Studio (Embedded or Standalone). --- # 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 - Embedded Studio setup - Draft Mode setup - Error handling - Presentation queries - Pagination pattern ## 1. Architecture Patterns ### Option A: Embedded Studio (Recommended) **Best for:** Most Next.js projects. Unified deployment, simpler setup. The Studio lives inside your Next.js app at `/app/studio/[[...tool]]/page.tsx`. - **Config:** `sanity.config.ts` lives in the project root. - See `project-structure.md` rule for detailed structure. ### Option B: Monorepo (Alternative) **Best for:** Separation of concerns, multiple frontends, or strict dependency isolation. The Studio and Next.js app live in separate folders: ``` apps/ ├── studio/ # Sanity Studio (standalone) └── web/ # Next.js frontend ``` - **Config:** Add your Next.js app URL to **CORS Origins** in [Sanity Manage](https://www.sanity.io/manage). - See `project-structure.md` rule for detailed structure. ## 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' 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: Embedded Studio Mount the Studio on a Next.js route. **`src/app/studio/[[...tool]]/page.tsx`:** ```typescript import { NextStudio } from 'next-sanity/studio' import config from '../../../../sanity.config' export const dynamic = 'force-static' export { metadata, viewport } from 'next-sanity/studio' export default function StudioPage() { return } ``` ## 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. ### Queries ```typescript // Paginated listing export const ARTICLES_QUERY = defineQuery(` *[_type == "article" && defined(slug.current)] | order(date desc) [$start...$end] { _id, title, "slug": slug.current, date } `); // Total count for pagination UI export const ARTICLES_COUNT_QUERY = defineQuery(` count(*[_type == "article" && defined(slug.current)]) `); ``` ### 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 = parseInt(pageParam || "1"); const start = (page - 1) * ENTRIES_PER_PAGE; const end = start + ENTRIES_PER_PAGE; const [{ data: articles }, { data: total }] = await Promise.all([ sanityFetch({ query: ARTICLES_QUERY, params: { start, end } }), sanityFetch({ query: ARTICLES_COUNT_QUERY }) ]); const totalPages = Math.ceil(total / ENTRIES_PER_PAGE); return (
{articles.map(article => ( ))}
); } ```