Files
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

14 KiB

title, description
title description
Next.js & Sanity Integration Rules 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

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.
  • 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)

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.

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:

// next.config.ts
const nextConfig: NextConfig = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
};

Console output shows cache status:

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:

// 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:

// 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.

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:

// 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:

// 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:

// 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.

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.

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.

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:

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:

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
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

'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:

// 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:

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:

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

// 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

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>
  );
}