---
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 `<SanityLive />` 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 (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
        {(await draftMode()).isEnabled && <VisualEditing />}
      </body>
    </html>
  )
}
```

## 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<const QueryString extends string>({
  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<WebhookPayload>(
      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<WebhookPayload>(
      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 <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
}
```

### B. Metadata & SEO (Critical)

**Never** let Stega characters leak into `<head>` 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 <NextStudio config={config} />
}
```

## 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 <Post data={data} />
}
```

## 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 (
    <section>
      <h1>{blockData.title}</h1>
      {blockData.subtitle && <p>{blockData.subtitle}</p>}
    </section>
  )
}
```

### 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 (
    <main>
      {content.map((block) => {
        switch (block._type) {
          case "hero":
            return <Hero key={block._key} documentId={documentId} {...block} />
          // ... other blocks
        }
      })}
    </main>
  )
}
```

### 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 (
    <main>
      {articles.map(article => (
        <ArticleCard key={article._id} article={article} />
      ))}
      <Pagination current={page} total={totalPages} />
    </main>
  );
}
```
