All skills
sanity-io avatar

/sanity-best-practices

@fc8116b official
by Sanitysanity-io/agent-toolkit187 stars
30

Sanity development best practices for schema design, GROQ queries, TypeGen, Visual Editing, images, Portable Text, Studio structure, localization, migrations, Sanity Functions, webhooks, Blueprints, and framework integrations such as Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and the App SDK. Use this skill whenever working with Sanity schemas, defineType or defineField, GROQ or defineQuery, content modeling, Presentation or preview setups, Sanity-powered frontend integrations, event-driven content automation, documentEventHandler, defineDocumentFunction, defineMediaLibraryAssetFunction, @sanity/functions, @sanity/blueprints, sanity.blueprint.ts, event-driven content automation, or when reviewing and fixing a Sanity codebase.

Use this Skill: https://skilld.dev/gh/sanity-io/agent-toolkit/sanity-best-practices

This session only. Nothing lands on disk.

referencesnextjs.md

≈4.2k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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

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

npm create sanity@latest -- --project <projectId> --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 (<NextStudio /> 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:

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.

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

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

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

Source: SKILL.md on GitHub

2 warnings14d5 checks · Risk SAFE
  • Gen Agent Trust Hub14d

    This skill provides comprehensive Sanity.io development best practices, covering schema design, GROQ queries, and integration with major frontend frameworks. It promotes secure development habits, such as proper management of API tokens and environment variables.

  • Socket14d

    1 alert: gptAnomaly

  • Snyk14d

    Risk: LOW · No issues

  • Runlayer6mo

    8/24 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at fc8116b. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 months ago
  • Next.js
  • Nuxt
  • sanity
  • groq
  • schema
  • typegen
  • portable-text
  • visual-editing
  • astro
  • remix
  • sveltekit
  • migrations
  • localization
  • cms

README badge

README badge for sanity-io/agent-toolkit/sanity-best-practices

Provides guidelines and reference materials for Sanity schema design, GROQ queries, TypeGen, Visual Editing, Portable Text, Studio structure, localization, migrations, Sanity Functions, and framework integrations including Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, and Hydrogen. Use this skill when setting up Sanity projects, designing content models, writing queries, implementing live preview, or integrating Sanity with a frontend framework.

Generated from the current SKILL.md.

Does this skill cover framework integrations like Next.js, Nuxt, and Astro?
Yes. The skill includes integration guides for Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and standalone Studio patterns.
What does this skill cover for GROQ queries?
The skill provides GROQ query patterns, type safety approaches, and performance optimization guidelines, with reference materials for detailed examples.
Does this skill include guidance on Sanity Functions and event automation?
Yes. The skill covers Sanity Functions for automating content workflows, including documentEventHandler and defineDocumentFunction patterns.
Can I use this skill for schema design and content modeling?
Yes. The skill provides schema design best practices, field definitions, validation patterns, and content modeling guidance for different use cases.
Does this cover Visual Editing and live preview setup?
Yes. The skill includes guidance on the Presentation Tool, Stega, overlays, and live preview configuration for frontend frameworks.

Generated from the current SKILL.md. These answers refresh after source changes.