
Sanity Best Practices
- 16.6k installs
- 171 repo stars
- Updated July 29, 2026
- sanity-io/agent-toolkit
Comprehensive best practices guide for Sanity development including schema design, GROQ queries, framework integrations, Visual Editing, localization, migrations, and content automation.
About
Sanity Best Practices is a comprehensive reference guide covering schema design, GROQ query patterns, TypeGen configuration, Visual Editing setup, image optimization, Portable Text rendering, and framework integrations (Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen). Developers use it when building Sanity-powered applications, setting up new projects, optimizing content modeling, or implementing event-driven content automation via Sanity Functions. Key workflows include designing reference relationships, configuring Studio structure, establishing localization patterns, performing content migrations, and integrating Sanity with frontend frameworks using established patterns and decision matrices.
- Model relationships with reference fields resolved via GROQ lookups or source-key patterns
- Framework integration guides for Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen
- GROQ query optimization, TypeGen type safety, and Visual Editing with Stega overlays
- Localization patterns (field-level vs document-level) and content migration strategies
- Sanity Functions and Blueprints for event-driven content automation and infrastructure-as-code
Sanity Best Practices by the numbers
- 16,572 all-time installs (skills.sh)
- +1,571 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #30 of 1,879 Documentation skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
sanity-best-practices capabilities & compatibility
- Capabilities
- schema design patterns and validation · groq query optimization and type safety · visual editing and live preview setup · framework specific integration guides · content modeling with references and relationshi · localization and migration strategies · sanity functions and blueprints for automation
- Works with
- github · vercel
- Use cases
- api development · database · documentation · refactoring
- Platforms
- macOS · Windows · Linux · WSL
- Runs
- Remote server
- Pricing
- Free
npx skills add https://github.com/sanity-io/agent-toolkit --skill sanity-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16.6k |
|---|---|
| repo stars | ★ 171 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | sanity-io/agent-toolkit ↗ |
What it does
Reference best practices for Sanity schema design, GROQ queries, framework integrations, and content workflows.
Who is it for?
Developers building Sanity-powered applications, integrating Sanity with Next.js/Nuxt/Astro/Remix/SvelteKit/Angular/Hydrogen, optimizing GROQ queries, designing content schemas, implementing Visual Editing.
Skip if: Designers working purely in Sanity Studio UI, non-technical content editors, projects not using Sanity as headless CMS.
When should I use this skill?
Setting up new Sanity projects, onboarding to Sanity, integrating frontend frameworks, writing GROQ queries, designing content schemas, implementing Visual Editing, configuring TypeGen, setting up localization, performin
What you get
Developers can efficiently set up Sanity projects, design schemas following relationship patterns, optimize GROQ queries, integrate with frontend frameworks, and automate content workflows.
- Sanity client service configuration
- Portable Text rendering setup
By the numbers
- Covers 7+ framework integrations: Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen
- Includes 12+ topic guides: groq, schema, visual-editing, page-builder, portable-text, image, studio-structure, typegen,
- Supports content migration from HTML via @portabletext/block-tools
Files
Sanity Best Practices
Comprehensive best practices and integration guides for Sanity development, maintained by Sanity. Use the quick reference below to load only the one or two topic files that match the task.
When to Apply
Reference these guidelines when:
- Setting up a new Sanity project or onboarding
- Integrating Sanity with a frontend framework (Next.js, Nuxt, Astro, Remix, SvelteKit, Hydrogen)
- Writing GROQ queries or optimizing performance
- Designing content schemas
- Implementing Visual Editing and live preview
- Working with images, Portable Text, or page builders
- Configuring Sanity Studio structure
- Setting up TypeGen for type safety
- Implementing localization
- Migrating content from other systems
- Building custom apps with the Sanity App SDK
- Managing infrastructure with Blueprints
- Automating content workflows with Sanity Functions
Global Rules
- Let Sanity generate
_idvalues for ordinary documents. Do not create deterministic UUIDs, slug-derived IDs, or legacy-system IDs when creating documents. - Model relationships with
referencefields, then resolve related documents with GROQ lookups, source-key fields, or returned_idvalues from created documents. - Use explicit document IDs mainly for singleton documents controlled by Studio Structure, including localized singletons such as
homePage-en.
Quick Reference
Integration Guides
get-started- Interactive onboarding for new Sanity projectsnextjs- Next.js App Router, Live Content API, standalone Studionuxt- Nuxt integration with @nuxtjs/sanityangular- Angular integration with @sanity/client, signals, resource APIastro- Astro integration with @sanity/astroremix- React Router / Remix integrationsvelte- SvelteKit integration with @sanity/svelte-loaderhydrogen- Shopify Hydrogen with Sanityproject-structure- Standalone Studio and monorepo patternsapp-sdk- Custom applications with Sanity App SDKblueprints- Infrastructure as Code with Sanity Blueprintsfunctions- Automating content workflows with Sanity Functions
Topic Guides
groq- GROQ query patterns, type safety, performance optimizationschema- Schema design, field definitions, validation, deprecation patternsvisual-editing- Presentation Tool, Stega, overlays, live previewpage-builder- Page Builder arrays, block components, live editingportable-text- Rich text rendering and custom componentsimage- Image schema, URL builder, hotspots, LQIP, Next.js Imagestudio-structure- Desk structure, singletons, navigationtypegen- TypeGen configuration, workflow, type utilitiesseo- Metadata, sitemaps, Open Graph, JSON-LDlocalization- i18n patterns, document vs field-level, locale managementmigration- Content import overview (see alsomigration-html-import)migration-html-import- HTML to Portable Text with @portabletext/block-tools
How to Use
Start with the single framework or topic guide that best matches the request, then read additional references only when the task crosses concerns. Use these reference files for detailed explanations and code examples:
references/groq.md
references/schema.md
references/nextjs.mdEach reference file contains:
- Comprehensive topic or integration coverage
- Incorrect and correct code examples
- Decision matrices and workflow guidance
- Framework-specific patterns where applicable
Angular & Sanity Integration Rules
Jump to the section that matches your Angular version or integration task instead of reading this guide straight through.
Table of Contents
- Setup and configuration
- Client setup (service pattern)
- Data fetching patterns
- Routing
- Portable Text rendering
- Image optimization
- Modern Angular features
- SSR and prerendering
- Visual Editing
- Error handling
1. Setup & Configuration
Use the official template sanity-template-angular-clean as a starting point. It provides a monorepo structure:
project/
├── angular-app/ # Angular 19+ frontend
└── studio/ # Sanity StudioInstall dependencies in the Angular app:
npm install @sanity/client @sanity/image-url @portabletext/to-htmlConfigure environment files for Sanity credentials:
// environments/environment.ts
export const environment = {
production: false,
sanity: {
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-05-01',
},
}// environments/environment.production.ts
export const environment = {
production: true,
sanity: {
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-05-01',
},
}There is no Angular-specific Sanity SDK. Use @sanity/client directly, wrapped in an Angular service.TypeGen in a Monorepo
Sanity TypeGen generates TypeScript types from your schema and GROQ queries. In the Angular monorepo template, TypeGen runs from the Studio side but scans your Angular app's source files. Ensure studio/sanity.cli.ts points at the Angular app:
// studio/sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
enabled: true,
path: '../angular-app/src/**/*.ts',
generates: '../angular-app/sanity.types.ts',
},
})The remaining defaults (overloadClientMethods: true, schema: "schema.json") work as-is. Include the generated types file in angular-app/tsconfig.json (usually covered by "include": ["src/**/*.ts", "sanity.types.ts"]). See typegen.md for the full TypeGen workflow, git strategy, and configuration options.
2. Client Setup (Service Pattern)
Create an injectable service wrapping @sanity/client and @sanity/image-url:
import { Injectable } from '@angular/core'
import { createClient, type ClientReturn, type QueryParams, type SanityClient } from '@sanity/client'
import imageUrlBuilder, { type ImageUrlBuilder } from '@sanity/image-url'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { environment } from '../environments/environment'
@Injectable({ providedIn: 'root' })
export class SanityService {
private client: SanityClient
private builder: ImageUrlBuilder
constructor() {
this.client = createClient({
projectId: environment.sanity.projectId,
dataset: environment.sanity.dataset,
apiVersion: environment.sanity.apiVersion,
useCdn: true,
})
this.builder = imageUrlBuilder(this.client)
}
// ClientReturn resolves TypeGen's declaration-merged overloads for defineQuery strings
fetch<Query extends string>(query: Query, params?: QueryParams): Promise<ClientReturn<Query>> {
return this.client.fetch(query, params)
}
getImageUrlBuilder(source: SanityImageSource) {
return this.builder.image(source)
}
}For preview/draft content, create a second client instance with a token and useCdn: false. Never expose tokens in client-side bundles — use server-side rendering or a proxy endpoint for authenticated requests.
3. Data Fetching Patterns
A. resource API (Angular 19+, Recommended)
The resource API works natively with promises and integrates with Angular signals:
import { Component, input, resource, inject } from '@angular/core'
import { defineQuery } from 'groq'
import { SanityService } from '../sanity.service'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title, body, mainImage, publishedAt
}`)
@Component({
selector: 'app-post',
standalone: true,
template: `
@if (post.value(); as p) {
<h1>{{ p.title }}</h1>
<time>{{ p.publishedAt | date }}</time>
} @else if (post.isLoading()) {
<p>Loading…</p>
} @else if (post.error()) {
<p>Error loading post</p>
}
`,
})
export default class PostComponent {
slug = input.required<string>()
private sanity = inject(SanityService)
post = resource({
params: () => ({ slug: this.slug() }),
loader: ({ params }) => this.sanity.fetch(POST_QUERY, params),
})
}The resource automatically re-fetches when slug changes and exposes value(), isLoading(), and error() signals.
TypeGen: Wrapping queries indefineQueryenables Sanity TypeGen to infer return types automatically — no manual type imports needed. Seetypegen.mdfor the full workflow.
B. rxResource (Observable-based)
For teams using RxJS patterns or needing operators like retry and debounceTime:
import { Component, input, inject } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { defineQuery } from 'groq'
import { from } from 'rxjs'
import { SanityService } from '../sanity.service'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`)
@Component({ /* ... */ })
export default class PostComponent {
slug = input.required<string>()
private sanity = inject(SanityService)
post = rxResource({
params: () => ({ slug: this.slug() }),
loader: ({ params }) => from(this.sanity.fetch(POST_QUERY, params)),
})
}C. toSignal (Angular 17–18)
For apps not yet on Angular 19, convert observables to signals:
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { defineQuery } from 'groq'
import { from } from 'rxjs'
import { SanityService } from '../sanity.service'
const POSTS_QUERY = defineQuery(`*[_type == "post"] | order(publishedAt desc)`)
@Component({ /* ... */ })
export class HomeComponent {
private sanity = inject(SanityService)
posts = toSignal(from(this.sanity.fetch(POSTS_QUERY)), { initialValue: [] })
}Note:toSignaldoes not re-fetch on parameter changes. For dynamic queries, useresourceorrxResource.
Choosing a pattern
| Pattern | Angular Version | Reactivity | Best For |
|---|---|---|---|
resource | 19+ | Signal-based, auto re-fetch | New projects, dynamic queries |
rxResource | 19+ | RxJS + signals | Teams using RxJS operators |
toSignal | 17+ | One-shot conversion | Static queries, legacy apps |
4. Routing
Use lazy-loaded routes with withComponentInputBinding() so route params bind directly to component inputs:
// app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router'
import { routes } from './app.routes'
export const appConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
}// app.routes.ts
import { Routes } from '@angular/router'
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component'),
pathMatch: 'full',
},
{
path: 'post/:slug',
loadComponent: () => import('./post/post.component'),
},
]With withComponentInputBinding(), the :slug route param is automatically bound to slug = input.required<string>() on the component — no need to inject ActivatedRoute.
5. Portable Text Rendering
A. @portabletext/to-html with Angular Pipe (Recommended)
import { Pipe, PipeTransform, inject } from '@angular/core'
import { toHTML, type PortableTextComponents } from '@portabletext/to-html'
import type { PortableTextBlock } from '@portabletext/types'
import { SanityService } from '../sanity.service'
@Pipe({ name: 'portableTextToHTML', standalone: true })
export class PortableTextToHTMLPipe implements PipeTransform {
private sanity = inject(SanityService)
private components: PortableTextComponents = {
types: {
image: ({ value }) => {
const url = this.sanity.getImageUrlBuilder(value).width(800).auto('format').url()
return `<img src="${url}" alt="${value.alt || ''}" loading="lazy" />`
},
},
marks: {
link: ({ children, value }) =>
`<a href="${value.href}" rel="noopener noreferrer">${children}</a>`,
},
}
transform(value: PortableTextBlock[] | undefined): string {
if (!value) return ''
return toHTML(value, { components: this.components })
}
}Usage in templates:
<div [innerHTML]="post.body | portableTextToHTML"></div>B. @limitless-angular/sanity (Community, Component-based)
For full Angular component control over each block type, the community library @limitless-angular/sanity provides a component-based Portable Text renderer. This is useful when you need Angular-specific interactivity within rich text blocks.
See portable-text.md for Portable Text schema design and serialization rules.
6. Image Optimization
Create a pipe wrapping @sanity/image-url:
import { Pipe, PipeTransform, inject } from '@angular/core'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { SanityService } from '../sanity.service'
@Pipe({ name: 'sanityImage', standalone: true })
export class SanityImagePipe implements PipeTransform {
private sanity = inject(SanityService)
transform(value: SanityImageSource | undefined, width?: number): string | null {
if (!value) return null
const builder = this.sanity.getImageUrlBuilder(value)
if (width) return builder.width(width).auto('format').url()
return builder.auto('format').url()
}
}Combine with Angular's NgOptimizedImage for LCP images:
<!-- Priority image with NgOptimizedImage -->
<img [ngSrc]="post.mainImage | sanityImage: 1200" width="1200" height="630" priority />
<!-- Lazy-loaded image -->
<img [src]="post.mainImage | sanityImage: 600" [alt]="post.mainImage.alt" loading="lazy" />❌ Bad: Fetching full-size images without width constraints.
<img [src]="post.mainImage | sanityImage" />✅ Good: Specifying width and using auto('format') for WebP/AVIF delivery.
<img [src]="post.mainImage | sanityImage: 800" loading="lazy" />LQIP with NgOptimizedImage
Sanity provides a base64 LQIP (Low Quality Image Placeholder) per image asset — but you must query it explicitly:
mainImage {
// @sanity/image-url needs these to build URLs with hotspot/crop support
asset,
hotspot,
crop,
alt,
// NgOptimizedImage needs these for placeholder and layout
"lqip": asset->metadata.lqip,
"width": asset->metadata.dimensions.width,
"height": asset->metadata.dimensions.height
}Feed the LQIP directly into NgOptimizedImage's placeholder attribute:
<img
[ngSrc]="post.mainImage | sanityImage: 1200"
[width]="post.mainImage.width"
[height]="post.mainImage.height"
[placeholder]="post.mainImage.lqip"
[alt]="post.mainImage.alt"
priority
/>Angular applies a CSS blur to the LQIP and crossfades to the full image on load. No extra libraries needed.
Note: LQIP strings are small (~200 bytes) so they're safe to inline in SSR HTML andTransferState. Seeimage.mdfor the full image query patterns.
See image.md for image field schema patterns and hotspot/crop configuration.
7. Modern Angular Features
When building with Sanity, leverage these Angular 19+ features:
- Standalone components — Default in Angular 19. No
NgModuleboilerplate needed. - Signals and `resource` — Preferred over RxJS for data fetching. Simpler, less boilerplate.
- New control flow — Use
@if,@for,@switchwith@emptyfor cleaner templates:
@for (post of posts.value(); track post._id) {
<app-post-card [post]="post" />
} @empty {
<p>No posts found.</p>
}- `@defer` blocks — Lazy-load below-fold content:
@defer (on viewport) {
<app-comments [postId]="post._id" />
} @placeholder {
<p>Scroll to see comments…</p>
}- `inject()` function — Preferred over constructor injection for cleaner code.
- Zoneless change detection — Experimental in Angular 19. Works well with signals-based data fetching since signals automatically notify the framework of changes.
8. SSR & Prerendering
Angular 17+ includes built-in SSR support (replacing Angular Universal):
// app.config.server.ts
import { provideServerRendering } from '@angular/platform-server'
import { provideClientHydration } from '@angular/platform-browser'
export const serverConfig = {
providers: [
provideServerRendering(),
provideClientHydration(),
],
}Key considerations for Sanity + Angular SSR:
| Feature | Details |
|---|---|
| Hydration | provideClientHydration() preserves server-rendered DOM. The client reuses it instead of re-rendering. |
| HTTP Transfer Cache | Only works with Angular's HttpClient. Since @sanity/client uses its own HTTP transport, use TransferState manually (see below). |
| Prerendering | Use getPrerenderParams in route config to generate static pages at build time. |
Transfer State for @sanity/client
Angular's built-in HTTP Transfer Cache does not cover @sanity/client requests. Without manual transfer, the client re-fetches every query during hydration. Add TransferState to the service from Section 2:
+ async function hashQuery(query: string, params?: QueryParams): Promise<string> {
+ const input = query + JSON.stringify(params ?? {})
+ const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
+ return Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, '0')).join('')
+ }
import { Injectable, inject } from '@angular/core'
+ import { isPlatformBrowser, isPlatformServer } from '@angular/common'
+ import { PLATFORM_ID, makeStateKey, TransferState } from '@angular/core'
import { createClient, type ClientReturn, type QueryParams, type SanityClient } from '@sanity/client'
export class SanityService {
private client: SanityClient
+ private transferState = inject(TransferState)
+ private platformId = inject(PLATFORM_ID)
async fetch<Query extends string>(query: Query, params?: QueryParams): Promise<ClientReturn<Query>> {
+ const key = makeStateKey<ClientReturn<Query>>(await hashQuery(query, params))
+
+ if (isPlatformBrowser(this.platformId)) {
+ const cached = this.transferState.get(key, null)
+ if (cached !== null) {
+ this.transferState.remove(key)
+ return cached
+ }
+ }
+
const result = await this.client.fetch(query, params)
+
+ if (isPlatformServer(this.platformId)) {
+ this.transferState.set(key, result)
+ }
+
return result
}
}The hashQuery helper keeps TransferState keys short (SHA-256 hex) instead of embedding raw GROQ strings in the serialized HTML.
Prerendering dynamic routes:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{
path: 'post/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
// Fetch all slugs from Sanity at build time
const client = createClient({ projectId: '...', dataset: '...', apiVersion: '...', useCdn: true })
const slugs = await client.fetch<string[]>(`*[_type == "post"].slug.current`)
return slugs.map((slug) => ({ slug }))
},
},
{ path: '**', renderMode: RenderMode.Server },
]❌ Bad: Using isPlatformBrowser() in templates to conditionally render content — causes hydration mismatch.
✅ Good: Using @defer or afterNextRender() for browser-only code.
9. Visual Editing
Important: Angular does not have official Sanity Visual Editing support. There is no @sanity/visual-editing integration, no Stega encoding, and no click-to-edit overlay for Angular applications. This is unlike Next.js, Nuxt, and SvelteKit which have first-party support.Preview Mode (Basic)
For draft content preview, create a separate preview client with an API token:
@Injectable({ providedIn: 'root' })
export class SanityService {
private client: SanityClient
private previewClient: SanityClient
constructor() {
this.client = createClient({
projectId: environment.sanity.projectId,
dataset: environment.sanity.dataset,
apiVersion: environment.sanity.apiVersion,
useCdn: true,
})
this.previewClient = this.client.withConfig({
useCdn: false,
token: environment.sanity.previewToken, // Server-side only!
perspective: 'drafts',
})
}
fetch<Query extends string>(query: Query, params?: QueryParams, preview = false): Promise<ClientReturn<Query>> {
const client = preview ? this.previewClient : this.client
return client.fetch(query, params)
}
}Security: Never expose the preview token in client-side bundles. Use this pattern only with SSR where the token stays on the server, or proxy preview requests through a backend API.
Community Visual Editing
The community library @limitless-angular/sanity provides experimental Visual Editing support for Angular, including overlay click-to-edit functionality. Check its documentation for current status and limitations.
10. Error Handling
Common errors when integrating Angular with Sanity:
| Error | Cause | Solution |
|---|---|---|
401 Unauthorized | Invalid or missing API token | Verify token in Sanity Manage. Ensure it has correct permissions. |
403 Forbidden | CORS origin not allowed | Add your Angular dev/production URL to CORS origins in Sanity Manage. |
422 Invalid query | GROQ syntax error | Test queries in Vision plugin or Sanity's GROQ playground. See groq.md. |
| Hydration mismatch | Conditional rendering based on platform | Use @defer or afterNextRender() instead of isPlatformBrowser() checks. |
| Empty response | Missing dataset or wrong apiVersion | Verify environment config. Use a date-based apiVersion (e.g., '2025-05-01'). |
| Images not loading | Missing @sanity/image-url setup | Ensure getImageUrlBuilder is called with a valid image reference. See image.md. |
For GROQ query patterns and best practices, see groq.md. For schema design, see schema.md.
Sanity App SDK
Build custom React applications that interact with Sanity content in real-time.
Tech Stack
- Framework: React 19+, TypeScript
- Packages:
@sanity/sdk,@sanity/sdk-react - Optional UI:
@sanity/ui,styled-components - Runtime: Node.js 20+
Commands
# Basic quickstart
npx sanity@latest init --template app-quickstart --organization <your-org-id> --output-path . --typescript --skip-mcp
# With Sanity UI components
npx sanity@latest init --template app-sanity-ui --organization <your-org-id> --output-path . --typescript --skip-mcp
# Start development server
npm run dev
# Deploy to Sanity
npx sanity@latest deploy
# Install Sanity UI
npm install @sanity/ui styled-componentsProject Structure
my-app/
├── sanity.cli.ts # CLI config (org ID, entry point)
├── src/
│ ├── App.tsx # Root component with SanityApp provider
│ ├── App.css # Global styles
│ └── components/ # Your components
├── package.json
└── tsconfig.jsonBoundaries
- Always: Wrap data-fetching components in
<Suspense>, usedocumentIdas Reactkey, read/write directly to Content Lake (not local state) - Always: Use
useDocumentsfor lists,useDocumentProjectionfor display,useDocument+useEditDocumentfor editing - Ask first: Before using
useQuerywith raw GROQ (preferuseDocuments+useDocumentProjection) - Ask first: Before adding multiple data-fetching hooks in a single component
- Never: Use
useStatefor form values that should sync with Content Lake - Never: Use array index as React
keyfor document lists (breaks real-time updates) - Never: Forget the
fallbackprop on<SanityApp>and<Suspense>boundaries
---
Configuration
CLI Config (sanity.cli.ts)
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'your-org-id',
entry: './src/App.tsx',
},
})App Root (src/App.tsx)
import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
export default function App() {
const config: SanityConfig[] = [
{
projectId: 'your-project-id',
dataset: 'production',
},
]
return (
<SanityApp config={config} fallback={<div>Loading...</div>}>
<YourComponents />
</SanityApp>
)
}With Sanity UI
import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
import { ThemeProvider } from '@sanity/ui'
import { buildTheme } from '@sanity/ui/theme'
const theme = buildTheme()
export default function App() {
const config: SanityConfig[] = [
{ projectId: 'your-project-id', dataset: 'production' },
]
return (
<ThemeProvider theme={theme}>
<SanityApp config={config} fallback={<div>Loading...</div>}>
<YourComponents />
</SanityApp>
</ThemeProvider>
)
}Environment Variables
Prefix with SANITY_APP_ for automatic bundling:
SANITY_APP_PROJECT_ID=abc123
SANITY_APP_DATASET=productionAccess: process.env.SANITY_APP_PROJECT_ID
---
Document Handles
Lightweight references to documents. Fetch handles first, then load content as needed.
interface DocumentHandle {
documentId: string
documentType: string
projectId?: string
dataset?: string
}Creating Handles
// Best: From useDocuments hook
const { data: handles } = useDocuments({ documentType: 'article' })
// Good: With helper (preserves literal types for TypeGen)
import { createDocumentHandle } from '@sanity/sdk'
const handle = createDocumentHandle({
documentId: 'my-doc-id',
documentType: 'article',
})
// Good: With as const (preserves literal types)
const handle = {
documentId: 'my-doc-id',
documentType: 'article',
} as const---
Hook Selection
| Hook | Use Case | Returns |
|---|---|---|
useDocuments | List of documents (infinite scroll) | Document handles |
usePaginatedDocuments | Paginated lists with page controls | Document handles |
useDocument | Single document, real-time editing | Full document or field |
useDocumentProjection | Specific fields, display only | Projected data |
useQuery | Complex GROQ queries (use sparingly) | Raw query results |
---
Code Patterns
Fetching a Document List
// Good: Fetch handles, render items with Suspense
import { Suspense } from 'react'
import { useDocuments } from '@sanity/sdk-react'
function ArticleList() {
const { data, hasMore, loadMore, isPending } = useDocuments({
documentType: 'article',
batchSize: 10,
orderings: [{ field: '_updatedAt', direction: 'desc' }],
})
return (
<>
<ul>
{data.map((handle) => (
<Suspense key={handle.documentId} fallback={<li>Loading...</li>}>
<ArticleItem {...handle} />
</Suspense>
))}
</ul>
{hasMore && (
<button onClick={loadMore} disabled={isPending}>
Load More
</button>
)}
</>
)
}// Bad: Over-fetching with raw GROQ, no pagination
function BadArticleList() {
const { data } = useQuery(`*[_type == "article"]`)
return data?.map((doc, i) => <li key={i}>{doc.title}</li>)
}Projecting Content from a Handle
// Good: Project only needed fields
import { useDocumentProjection, type DocumentHandle } from '@sanity/sdk-react'
function ArticleItem(handle: DocumentHandle) {
const { data } = useDocumentProjection({
...handle,
projection: `{
title,
"authorName": author->name,
"imageUrl": image.asset->url
}`,
})
if (!data) return null
return (
<li>
<h2>{data.title}</h2>
<p>By {data.authorName}</p>
</li>
)
}Real-time Editing
// Good: Read and write directly to Content Lake
import { useDocument, useEditDocument, type DocumentHandle } from '@sanity/sdk-react'
function TitleInput(handle: DocumentHandle) {
const { data: title } = useDocument({ ...handle, path: 'title' })
const editTitle = useEditDocument({ ...handle, path: 'title' })
return (
<input
type="text"
value={title ?? ''}
onChange={(e) => editTitle(e.currentTarget.value)}
/>
)
}// Bad: Local state with submit button - causes stale data
function BadTitleForm(handle: DocumentHandle) {
const [value, setValue] = useState('')
const editTitle = useEditDocument({ ...handle, path: 'title' })
function handleSubmit(e: FormEvent) {
e.preventDefault()
editTitle(value) // Only writes on submit!
}
return (
<form onSubmit={handleSubmit}>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<button type="submit">Save</button>
</form>
)
}Document Actions
import {
useApplyDocumentActions,
publishDocument,
unpublishDocument,
deleteDocument,
} from '@sanity/sdk-react'
function DocumentActions({ handle }: { handle: DocumentHandle }) {
const apply = useApplyDocumentActions()
return (
<div>
<button onClick={() => apply(publishDocument(handle))}>Publish</button>
<button onClick={() => apply(unpublishDocument(handle))}>Unpublish</button>
<button onClick={() => apply(deleteDocument(handle))}>Delete</button>
</div>
)
}---
Suspense Patterns
The App SDK uses React Suspense. Every data-fetching component must be wrapped.
One Hook Per Component
// Good: Separate fetchers into separate components
function EventsAndVenues() {
return (
<>
<Suspense fallback="Loading events...">
<EventsList />
</Suspense>
<Suspense fallback="Loading venues...">
<VenuesList />
</Suspense>
</>
)
}
function EventsList() {
const { data } = useDocuments({ documentType: 'event' })
return <List items={data} />
}
function VenuesList() {
const { data } = useDocuments({ documentType: 'venue' })
return <List items={data} />
}// Bad: Multiple fetchers in one component
function BadComponent() {
const { data: events } = useDocuments({ documentType: 'event' })
const { data: venues } = useDocuments({ documentType: 'venue' })
// Both trigger Suspense together, causing unnecessary re-renders
}Prevent Layout Shift
// Good: Fallback matches final component dimensions
const BUTTON_TEXT = 'Open in Studio'
export function OpenInStudio({ handle }: { handle: DocumentHandle }) {
return (
<Suspense fallback={<Button text={BUTTON_TEXT} disabled />}>
<OpenInStudioButton handle={handle} />
</Suspense>
)
}
function OpenInStudioButton({ handle }: { handle: DocumentHandle }) {
const { navigateToStudioDocument } = useNavigateToStudioDocument(handle)
return <Button onClick={navigateToStudioDocument} text={BUTTON_TEXT} />
}---
Event Handling
import { useDocumentEvent, DocumentEvent } from '@sanity/sdk-react'
function DocumentWatcher(handle: DocumentHandle) {
useDocumentEvent({
...handle,
onEvent: (event) => {
switch (event.type) {
case 'edited':
console.log('Edited:', event.documentId)
break
case 'published':
console.log('Published:', event.documentId)
break
case 'deleted':
console.log('Deleted:', event.documentId)
break
}
},
})
return null
}---
Multi-Project Apps
const config: SanityConfig[] = [
{ projectId: 'project-1', dataset: 'production' },
{ projectId: 'project-2', dataset: 'staging' },
]
// Handles include project/dataset info
const handle: DocumentHandle = {
documentId: 'doc-123',
documentType: 'article',
projectId: 'project-1',
dataset: 'production',
}---
Lazy Loading with Refs
function LazyContent(handle: DocumentHandle) {
const ref = useRef(null)
const { data } = useDocumentProjection({
...handle,
ref, // Only loads when element enters viewport
projection: '{ title, body }',
})
return <div ref={ref}>{data?.title}</div>
}---
What's NOT Included
The App SDK provides hooks and data stores. You bring:
- UI components (use Sanity UI or your own)
- Router
- Form validation
- Schema validation
---
Troubleshooting
| Issue | Solution |
|---|---|
| Safari dev issues | Use Chrome or Firefox during development |
| Port 3333 in use | npm run dev -- --port 3334 |
| Auth errors | npx sanity@latest logout && npx sanity@latest login |
Astro & Sanity Integration Rules
1. Setup & Configuration
Scaffold a new Astro app
npm create astro@latest my-app -- --template with-tailwindcss --install --git --yes
cd my-app--yes accepts defaults non-interactively. --install runs npm install for you, --git initializes a repo.
Installation
Add the @sanity/astro integration and the renderer/helper packages used by the examples below.
npx astro add @sanity/astro
npm install astro-portabletext @sanity/image-url groq@sanity/astro provides the sanity:client virtual module. astro-portabletext renders Portable Text. @sanity/image-url builds image URLs. groq exports defineQuery for typed queries.
Configuration (astro.config.mjs)
Use the official @sanity/astro integration. astro.config.mjs runs at config time before Astro's env loading, so import.meta.env.PUBLIC_* is not available there — use Vite's loadEnv to read the same PUBLIC_ variables your pages will use.
import { defineConfig } from "astro/config";
import { loadEnv } from "vite";
import sanity from "@sanity/astro";
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
process.env.NODE_ENV ?? "development",
process.cwd(),
""
);
export default defineConfig({
integrations: [
sanity({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
useCdn: false, // False for static builds
studioBasePath: "/admin", // Optional — only if embedding the Studio
}),
],
});Inside .astro files and components you can keep using import.meta.env.PUBLIC_SANITY_* directly; the loadEnv shim above is config-only.
Client Type Safety
Enable types in tsconfig.json.
{
"compilerOptions": {
"types": ["@sanity/astro/module"]
}
}2. Data Fetching
Basic Fetching
Use sanityClient from sanity:client in the frontmatter of your .astro files.
---
import { sanityClient } from "sanity:client";
import { defineQuery } from "groq";
const POSTS_QUERY = defineQuery(`*[_type == "post"]{title, slug}`);
const posts = await sanityClient.fetch(POSTS_QUERY);
---
<ul>
{posts.map(post => <li>{post.title}</li>)}
</ul>Helper Functions
It's best practice to abstract queries into a utility file (e.g., src/utils/sanity.ts).
import { sanityClient } from "sanity:client";
import { defineQuery } from "groq";
const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]`);
export async function getPosts() {
return await sanityClient.fetch(POSTS_QUERY);
}Dynamic Routes ([slug].astro)
Astro hoists getStaticPaths() into a separate module context. Module-scope const declarations in the frontmatter are NOT accessible inside it — referencing them throws ReferenceError: <NAME> is not defined at request time. Define queries used by getStaticPaths inside the function, or import them from a utility module.
---
import { sanityClient } from "sanity:client";
import { defineQuery } from "groq";
import { PortableText } from "astro-portabletext";
// Module-scope queries are fine for module-scope code…
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{ title, body }`);
// …but anything used inside getStaticPaths must live inside it.
export async function getStaticPaths() {
const SLUGS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)]{ "params": { "slug": slug.current } }`
);
return await sanityClient.fetch(SLUGS_QUERY);
}
const { slug } = Astro.params;
const post = await sanityClient.fetch(POST_QUERY, { slug });
---
<article>
<h1>{post?.title}</h1>
{post?.body && <PortableText value={post.body} />}
</article>3. Portable Text
Use astro-portabletext for rendering rich text.
---
import { PortableText } from "astro-portabletext";
const { body } = Astro.props;
---
<div class="prose">
<PortableText value={body} />
</div>4. Image Handling
Use @sanity/image-url to generate optimized image URLs.
import imageUrlBuilder from "@sanity/image-url";
import { sanityClient } from "sanity:client";
const builder = imageUrlBuilder(sanityClient);
export function urlFor(source) {
return builder.image(source);
}5. Visual Editing (Live Preview)
Astro handles visual editing slightly differently depending on if you are using Hybrid or Static mode.
Setup
Ensure stega is enabled in your client configuration if you want clickable overlays.
For real-time updates in the presentation tool, you typically need a React component wrapper (since Astro components don't re-render on the client) or use the View Transitions API with a loader.
Note: The `@sanity/astro` integration is evolving. Check the latest docs for "Visual Editing" support.
Sanity Blueprints
Sanity's Infrastructure as Code (IaC) solution. Define resources declaratively in sanity.blueprint.ts, track in version control, deploy with a single command.
Mental Model
Blueprint (code) → Stack (deployed state) → Resources (real infrastructure)| Concept | What it is |
|---|---|
| Blueprint | A declarative configuration file (sanity.blueprint.ts) that describes your desired infrastructure |
| Stack | The deployed, real-world collection of resources managed by Blueprints |
| Resources | Individual Sanity components: CORS origins, webhooks, datasets, functions, roles, robots |
| Operation | A deployment execution that applies Blueprint changes to resources in a Stack |
How it works
1. Initialize and edit a Blueprint file describing desired resources 2. Run sanity blueprints deploy to apply changes to resources in a Stack 3. Blueprints creates/updates a Stack with your resources 4. The Stack persists — future deploys update it based on Blueprint changes
Key insight: The Blueprint is your intent. The Stack is reality. Blueprints reconciles the two.
Available Resources
Blueprints can manage these Sanity components:
- Document Functions
- Media Library Asset Functions
More resources are coming soon.
CLI Commands
sanity blueprints init <name> # Initialize a new blueprint project
sanity blueprints info # Show current stack status and resources
sanity blueprints plan # Preview changes before deploying
sanity blueprints deploy # Deploy the blueprint (creates/updates stack)
sanity blueprints config # Configure the blueprint (edit project and stack)
sanity blueprints logs # View deployment logs
sanity blueprints doctor # Check for potential issues
sanity blueprints stacks # List all stacks for the project
sanity blueprints destroy # Destroy all resources in the stackBasic Workflow
1. Initialize
sanity blueprints init my-infra
cd my-infra
sanity blueprints infoThis creates a sanity.blueprint.ts file and links it to a Sanity project.
2. Define resources
Edit sanity.blueprint.ts to add resources using typed helper functions from @sanity/blueprints.
3. Preview and deploy
sanity blueprints plan # See what will change
sanity blueprints deploy # Apply changes4. Iterate
Modify your Blueprint and redeploy. Blueprints handles creating, updating, or removing resources to match your definition.
Key Behaviors
- Additive by default — New resources in the Blueprint are created
- Updates in place — Changed resources are updated when possible
- Removal = destruction — Resources removed from the Blueprint are destroyed from the Stack
- References — Resources can reference each other (e.g., a webhook can reference a dataset)
- Rollback on failure — If a deployment fails partway through, Blueprints attempts to rollback
Sanity Functions
Serverless event handlers hosted on Sanity's infrastructure, configured via Blueprints and triggered by document lifecycle events.
Experimental feature: APIs may change. Always use npx sanity@latest.When to use
- Set computed/derived fields (timestamps, slugs, summaries)
- Enrich or validate content on publish
- Trigger external services (CDN purge, deploy hooks, notifications)
- Automate workflows (translation, tagging, cross-posting)
- Sync content to external systems
- Invoke Agent Actions in response to content events
When NOT to use
- Logic needs >900s execution or >200MB bundle — use an external worker
- High-throughput bulk operations that exceed rate limits (200/fn/30s, 4000/project/30s)
- A simple POST to an external URL on publish with no document data shaping — use a webhook
- Client-side or UI-driven logic (validation, conditional fields) — belongs in Studio schema config
Requirements
| Dependency | Version |
|---|---|
| Node.js | v24.x (matches deployed runtime) |
| Sanity CLI | v4.12.0+ |
@sanity/blueprints | Latest |
@sanity/functions | Latest |
@sanity/client | v7.12.0+ (includes recursion protection) |
Project Structure
Organize functions alongside your Sanity project, one level above the Studio directory:
my-project/
├── studio/
├── next-app/
├── functions/
│ ├── my-function/
│ │ ├── index.ts # Handler code (entry point)
│ │ └── package.json # (optional) function-level dependencies
│ └── another-function/
│ └── index.ts
├── sanity.blueprint.ts # Blueprint configuration
├── package.json # Project-level dependencies
└── node_modules/The function directory name must match the name in the blueprint config. Each function exports a handler from its index.ts (or index.js).
---
Step-by-step: Creating a Function
1. Initialize a Blueprint
npx sanity@latest blueprints init . \
--type ts \
--stack-name production \
--project-id <your-project-id>This creates sanity.blueprint.ts and .sanity/blueprint.config.json (add the latter to .gitignore).
2. Scaffold a Function
npx sanity@latest blueprints add function \
--name my-function \
--fn-type document-publish \
--installer npm--fn-type options: document-create, document-update, document-publish (deprecated), document-delete.
3. Configure the Blueprint
// sanity.blueprint.ts
import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'my-function',
event: {
on: ['create', 'update'],
filter: '_type == "post"',
},
}),
],
})4. Write the Handler
// functions/my-function/index.ts
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'
interface PostData {
_id: string
_type: string
title: string
}
export const handler = documentEventHandler<PostData>(async ({ context, event }) => {
const { data } = event
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
try {
await client.patch(data._id, {
setIfMissing: { firstPublished: new Date().toISOString() },
})
console.log(`Set firstPublished on ${data._id}`)
} catch (error) {
console.error('Failed to patch document:', error)
}
})5. Test Locally
# Visual dev playground
npx sanity@latest functions dev
# CLI testing
npx sanity@latest functions test my-function \
--dataset production \
--with-user-token
# With a specific document
npx sanity@latest functions test my-function \
--document-id abc123 \
--dataset production \
--with-user-token6. Deploy
npx sanity@latest blueprints deploy7. View Logs
npx sanity@latest functions logs my-function
npx sanity@latest functions logs my-function --watch---
Handler Reference
Every handler receives { context, event }:
context
| Property | Type | Description |
|---|---|---|
clientOptions.apiHost | string | API host URL |
clientOptions.projectId | string | Sanity project ID |
clientOptions.dataset | string | Dataset name |
clientOptions.token | string | Robot token (deployed only) |
local | `boolean \ | undefined` |
eventResourceType | string | 'dataset' or 'media-library' |
eventResourceId | string | e.g., 'projectId.datasetName' |
event
{
data: {
_id: string
_type: string
// ... rest of document (shaped by projection if set)
}
}When testing locally, context.clientOptions only has projectId and apiHost. Use --dataset and --with-user-token flags to supply the rest.
---
Blueprint Configuration
defineDocumentFunction Options
| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Must match the directory name under functions/ |
displayName | string | — | Human-readable display name |
src | string | functions/<name> | Path to function source directory |
memory | number | 1 | Memory in GB (max 10) |
timeout | number | 10 | Timeout in seconds (max 900) |
runtime | string | 'nodejs22.x' | 'node', 'nodejs22.x', or 'nodejs24.x' |
project | string | — | Project ID. Required if blueprint is org-scoped. |
robotToken | string | — | Custom robot token name for the function |
event | object | required | Event configuration (see below) |
env | Record<string, string> | — | Environment variables via process.env |
event Options
| Option | Type | Default | Description |
|---|---|---|---|
on | string[] | required | 'create', 'update', 'delete'. Legacy 'publish' is deprecated. |
filter | string | — | GROQ filter body (no *[...] wrapper) |
projection | string | — | GROQ projection to shape event.data. Wrap in {}. |
includeDrafts | boolean | false | Trigger on draft changes |
includeAllVersions | boolean | false | Trigger on all document versions |
resource | object | — | Scope to dataset: { type: 'dataset', id: 'projectId.datasetName' } |
defineMediaLibraryAssetFunction
For Media Library asset events. Requires @sanity/blueprints v0.4.0+ and @sanity/functions v1.1.0+.
import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineMediaLibraryAssetFunction({
name: 'asset-handler',
event: {
on: ['delete'],
filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
projection: '{_id, versions, title}',
resource: {
type: 'media-library',
id: 'mlYourLibraryId',
},
},
}),
],
})---
Event Types
| Event | Description |
|---|---|
create | New document created |
update | Existing document modified (for published docs, fires when a draft/version is published) |
delete | Document deleted |
publish | Deprecated. Equivalent to ['create', 'update']. Migrate to explicit events. |
Often best to use ['create', 'update'] together for published document triggers.
---
GROQ Filter Tips
- Only the filter body —
_type == 'post', not*[_type == 'post'] delta::changedAny(fieldName)— trigger only when specific fields changesanity::dataset() == 'production'— scope to a dataset withoutresourceconfig_id in path('drafts.**')withincludeDrafts: true— draft-only triggers- Combine conditions to prevent recursion:
_type == 'post' && !defined(processedAt)
---
Projections
- Shape the data passed to
event.data - Limited to the invoking document's scope (plus
→for references) - Nested filters in projections (like
*[references(^._id)]) will fail silently — query inside the function instead - Wrap in
{}:projection: '{title, _id, slug}'
---
Environment Variables
Three ways to set them:
1. Blueprint config: env: { MY_VAR: 'value' } 2. CLI: npx sanity functions env add my-function MY_VAR my-value 3. Local testing: MY_VAR=value npx sanity functions test my-function
Access in handler code via process.env.MY_VAR.
---
Critical Rules
Preventing Recursion
If your function mutates the same document type it listens to, you will create an infinite loop.
✅ Correct — use GROQ filters to exclude processed documents:
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
})✅ Correct — use `@sanity/client` v7.12.0+ for automatic lineage headers:
import { createClient } from '@sanity/client'
// Client automatically sets X-Sanity-Lineage header
// Recursive chains are limited to 16 invocations
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})❌ Incorrect — no recursion guard:
defineDocumentFunction({
name: 'update-post',
event: {
on: ['create', 'update'],
filter: "_type == 'post'", // Will re-trigger on its own writes!
},
})Local Testing Safety
Use context.local to prevent accidental mutations during testing:
// Skip mutations entirely in test
if (!context.local) {
await client.createOrReplace(someDoc)
}
// Or use dryRun
await client.patch(event.data._id, {
set: { processed: true },
}).commit({ dryRun: context.local })
// Or use noWrite for Agent Actions
await client.agent.action.generate({
schemaId: 'your-schema-id',
documentId: event.data._id,
instruction: 'Summarize this document',
target: { path: ['summary'] },
noWrite: context.local,
})Limits
- Max bundle size: 200MB (including dependencies). Prefer slim, platform-agnostic packages.
- Rate limits: 200 invocations/fn/30s, 4000/project/30s
- Max timeout: 900s. Larger functions = slower cold starts.
Cost
Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. Monitor usage at the organization level.
---
Common Patterns
Deploy hook / CDN invalidation
Blueprint:
defineDocumentFunction({
name: 'deploy-hook',
event: {
on: ['create', 'update'],
filter: '_type == "page"',
},
})Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const URL = process.env.DEPLOY_HOOK_URL
if (!URL) throw new Error('DEPLOY_HOOK_URL is not set')
await fetch(URL)
console.log('Deploy hook triggered')
})Set the env var: npx sanity functions env add deploy-hook DEPLOY_HOOK_URL https://...
Set a timestamp on first publish
Uses the same pattern as the step-by-step example above. The key insight: the !defined(firstPublished) GROQ filter prevents re-triggering after the field is set. The setIfMissing patch is a redundant safety net.
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: '_type == "post" && !defined(firstPublished)',
},
})Auto-translate with Agent Actions
Blueprint:
defineDocumentFunction({
name: 'translate',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && language == 'en-US'",
projection: '{_id}',
},
})Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })
await client.agent.action.translate({
schemaId: 'your-schema-id',
async: true,
documentId: event.data._id,
languageFieldPath: 'language',
targetDocument: {
operation: 'create',
},
fromLanguage: { id: 'en-US', title: 'English' },
toLanguage: { id: 'el-GR', title: 'Greek' },
})
})The GROQ filter ensures only English documents trigger the function. The translated document gets a different language value, preventing recursive triggers.
Let Sanity assign the translated document's _id for ordinary localized content. To find or update translations later, query by language, slug, or translation metadata instead of deriving IDs from the source document. Reserve explicit targetDocument._id values for singleton-style targets.
Auto-tag with Agent Actions
Blueprint:
defineDocumentFunction({
name: 'auto-tag',
event: {
on: ['create', 'update'],
filter: "_type == 'post'",
projection: '{_id, title, body}',
},
})Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })
await client.agent.action.generate({
schemaId: 'your-schema-id',
documentId: event.data._id,
instruction: 'Analyze the content and generate 3 relevant tags. Reuse existing tags when possible.',
target: { path: ['tags'] },
async: true,
})
})Slack notification on publish
export const handler = documentEventHandler(async ({ context, event }) => {
const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL
if (!WEBHOOK_URL) throw new Error('SLACK_WEBHOOK_URL not set')
await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📝 New content published: *${event.data.title || event.data._id}* (${event.data._type})`,
}),
})
})Scope to a specific dataset
Option A — `resource` config:
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post'",
resource: { type: 'dataset', id: 'myProjectId.production' },
},
})Option B — GROQ filter:
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post' && sanity::dataset() == 'production'",
},
})React to Media Library asset changes
Requires @sanity/blueprints v0.4.0+ and @sanity/functions v1.1.0+.
Blueprint:
import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineMediaLibraryAssetFunction({
name: 'asset-deleted',
event: {
on: ['delete'],
filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
projection: '{_id, versions, title}',
resource: { type: 'media-library', id: 'mlYourLibraryId' },
},
}),
],
})Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const { eventResourceId } = context // Media Library ID
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
const response = await client.request({
uri: `/media-libraries/${eventResourceId}/query`,
method: 'POST',
body: { query: `*[_type == 'sanity.imageAsset']` },
})
console.log('Assets:', response)
})Recursion control with custom HTTP clients
If not using @sanity/client, implement lineage tracking manually:
export const handler = documentEventHandler(async ({ context, event }) => {
const lineage = process.env.X_SANITY_LINEAGE
await fetch(`https://${context.clientOptions.projectId}.api.sanity.io/v2025-05-08/data/mutate/production`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${context.clientOptions.token}`,
...(lineage ? { 'X-Sanity-Lineage': lineage } : {}),
},
body: JSON.stringify({
mutations: [{ patch: { id: event.data._id, set: { processed: true } } }],
}),
})
})Multiple functions in one blueprint
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
}),
defineDocumentFunction({
name: 'notify-slack',
event: {
on: ['create', 'update'],
filter: "_type == 'post'",
projection: '{title, _id}',
},
}),
defineDocumentFunction({
name: 'sync-algolia',
timeout: 30,
event: {
on: ['create', 'update', 'delete'],
filter: "_type == 'product'",
},
}),
],
})---
CI/CD Deployment
Use the Blueprints GitHub Action
- uses: sanity-io/blueprints-actions/deploy@deploy-v3
with:
sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }}Only personal auth tokens are supported for deployment (not robot tokens).
Sanity Getting Started Guide
Overview
Getting started with Sanity follows three phases: 1. Studio & Schema — Set up Sanity Studio and define your content model 2. Content — Import existing content or generate placeholder content via MCP 3. Frontend — Integrate with your application (framework-specific)
Communication Style
Keep responses succinct:
- Tell the user what you did: "Created post schema with title, body, and slug"
- Ask direct questions: "What kind of content are you building?"
- Avoid verbose explanations of what you're about to do
- Don't explain every step unless the user asks
Examples:
- Good: "Schema deployed. Ready to add some content?"
- Bad: "I'm going to deploy your schema to the Content Lake so that the MCP server can recognize your new document types. This will allow..."
---
Get Started with Sanity (Interactive Guide)
TRIGGER PHRASE: When the user says "Get started with Sanity" or similar, follow these steps.
Before starting: Let the user know they can pause and resume anytime by saying "Continue Sanity setup".
RESUME TRIGGER: If the user says "Continue Sanity setup", check what's already configured:
- Does
sanity.config.tsexist (typically in astudio/folder)? → Studio is set up - Are there files in
schemaTypes/? → Schema exists - Is there a frontend framework in
package.json? → May need integration
Resume from where they left off.
---
Phase 1: Studio & Schema
Step 1: Check for Existing Studio
Look for `sanity.config.ts` or `sanity.cli.ts` across the workspace — in the recommended side-by-side layout the Studio lives in its own folder (studio/, or studio-* when created by the Sanity onboarding flow) next to the app folder:
If NO Studio found:
- Ask: "Want to create a new Sanity Studio?"
- If yes, run from the repo root — not inside a Next.js app folder, where the CLI would switch to its embedded flow (not recommended):
npm create sanity@latest -- --template clean --typescript --output-path studio- This creates a standalone Studio in
studio/, alongside your app folder (seeproject-structure.md)
If Studio exists:
- Read the config to get
projectIdanddataset - Proceed to Step 2
Step 2: Check for Existing Schema
Look in `schemaTypes/`, `schemas/`, or `src/sanity/schemaTypes/`:
If NO schema found:
- Ask: "What kind of content are you building? (e.g., Blog, E-commerce, Portfolio)"
- Create appropriate schema types based on their answer
- See
schema.mdfor patterns
If schema exists:
- Show them what you found
- Ask: "Want to add more content types or modify existing ones?"
If they want a quick example: Create a basic blog schema:
// schemaTypes/post.ts
import { defineType, defineField } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
],
})Step 3: Deploy Schema
Required before Phase 2:
npx sanity schema deployThis uploads your schema to the Content Lake so MCP tools can work with it.
---
Phase 2: Content
Step 1: Check for Existing Content
Use MCP `query_documents` to check:
*[_type == "post"][0...5]If content exists:
- Show them a summary
- Ask: "Want to add more content or move to frontend integration?"
If NO content:
- Ask: "Do you want to:
1. Import existing content (from another CMS, markdown, etc.) 2. Generate sample content with AI 3. Skip this and add content manually in the Studio"
Step 2a: Import Existing Content
If migrating from another CMS or files:
- See
migration.mdand thesanity-migrationskill for guidance - Use MCP content tools such as
create_documentsandpatch_documentsafter converting content to structured Sanity documents
Step 2b: Generate Sample Content (MCP)
Ask the agent to draft structured sample content, then create it with the Sanity MCP Server:
Tool: create_documents
Documents: [{ type: "post", content: { title: "Getting started with Sanity", body: [] } }]If MCP content tools cannot see new types or fields: Remind them to run npx sanity schema deploy first.
MCP Setup (If Not Configured)
Quick start via Sanity CLI:
npx sanity@latest mcp configureCursor: One-click install →
Or add to .cursor/mcp.json:
{
"mcpServers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}Claude Code:
claude mcp add Sanity -t http https://mcp.sanity.io --scope userVS Code: Command Palette → MCP: Open User Configuration → add:
{
"servers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}---
Phase 3: Frontend Integration
Client Bundle Warning (Vite-based frameworks)
React Router, SvelteKit, Astro, and Nuxt all run on Vite. Any module imported by a client component will be bundled to the browser. process.env doesn't exist there.
For publishable values (projectId, dataset, apiVersion, public studio URL), use the framework's client-safe env mechanism:
- React Router / Remix:
import.meta.env.VITE_* - SvelteKit:
$env/static/public - Astro:
import.meta.env.PUBLIC_* - Nuxt:
useRuntimeConfig().public
For secrets (read tokens, webhook secrets), read process.env.* (or the server equivalent) only from server-only modules — .server.ts, route handlers, API endpoints. Don't centralize them in a shared env.ts that anything else imports.
This trap is invisible at SSR — the page renders fine on first load. It surfaces on client-side route transitions, when a lazy-loaded route chunk pulls a shared client/image module into the browser.
Step 1: Find the App and Detect Framework
The working directory is often a parent folder with the Studio and the app side by side. Identify the app folder first: a sibling of the Studio folder with its own package.json (commonly web/). If several candidates exist, ask the user which app to integrate — never assume.
Check the app's `package.json` dependencies:
| Dependency | Framework | Rule File |
|---|---|---|
next | Next.js | nextjs.md |
@remix-run/react or react-router | React Router / Remix | remix.md |
svelte or @sveltejs/kit | SvelteKit | svelte.md |
nuxt | Nuxt | nuxt.md |
astro | Astro | astro.md |
If NO framework found:
- Ask: "Which framework are you using, or would you like to create a new app?"
- Guide them to create one or specify their choice
Step 2: Next.js Integration (Inline)
If Next.js is detected, follow these essential steps:
Scaffold a new app (if you don't have one yet):
Run from the repo root so the app sits alongside your studio/ folder:
npx create-next-app@latest web --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd webInstall dependencies:
npm install next-sanity @sanity/image-urlnext-sanity is the official Sanity toolkit for Next.js. It bundles @sanity/client, groq (with defineQuery), and @portabletext/react, plus dedicated subpath exports for Next.js-specific features:
next-sanity—createClient,defineQuery,PortableText,SanityDocument,stegaCleannext-sanity/live—defineLivefor live content with Next.js cache integrationnext-sanity/draft-mode— Draft Mode endpoint helpersnext-sanity/visual-editing—<VisualEditing />component for click-to-edit overlaysnext-sanity/image— Sanity-aware<Image />wrappingnext/imagenext-sanity/studio— embed the Sanity Studio at a route (legacy setups only — keep the Studio standalone, seenextjs.md)next-sanity/webhook— webhook signature verification
Don't also install @sanity/client, @portabletext/react, or groq directly — import them from next-sanity. @sanity/image-url is not bundled (yet), so add it separately.
Create the client (`src/sanity/client.ts`):
import { createClient } from "next-sanity";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2026-05-15", // Use current date for new projects
useCdn: false, // Use API directly for server-side rendering; set true for client-side reads
});Fetch content in a Server Component:
// src/app/page.tsx
import { client } from "@/sanity/client";
import { defineQuery, type SanityDocument } from "next-sanity";
const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){ _id, title, slug }`
);
const options = { next: { revalidate: 30 } };
export default async function PostsPage() {
const posts = await client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options);
return (
<ul>
{posts.map((post) => (
<li key={post._id}>
<a href={`/${(post.slug as { current?: string })?.current}`}>{post.title as string}</a>
</li>
))}
</ul>
);
}{ next: { revalidate: 30 } } opts the fetch into Next.js' ISR cache with a 30-second revalidation window. Tune to taste; omit options to use defaults.
Render an individual post (`src/app/[slug]/page.tsx`):
import { PortableText, defineQuery, type SanityDocument } from "next-sanity";
import { notFound } from "next/navigation";
import { client } from "@/sanity/client";
const POST_QUERY = defineQuery(
`*[_type == "post" && slug.current == $slug][0]{ _id, title, body }`
);
const options = { next: { revalidate: 30 } };
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await client.fetch<SanityDocument | null>(POST_QUERY, { slug }, options);
if (!post) return notFound();
return (
<article>
<h1>{post.title as string}</h1>
{Array.isArray(post.body) && <PortableText value={post.body} />}
</article>
);
}Add environment variables (`.env.local`):
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=productionFor advanced patterns (TypeGen, Visual Editing with next-sanity/visual-editing, live content with defineLive from next-sanity/live, standalone Studio architecture), see nextjs.md.
Step 3: Other Frameworks
For non-Next.js frameworks, read the corresponding rule file and follow its integration guide:
- React Router / Remix:
remix.md - SvelteKit:
svelte.md - Nuxt:
nuxt.md - Astro:
astro.md
Each rule file contains framework-specific patterns for data fetching, Portable Text rendering, and Visual Editing.
Step 4: Smoke Test
Before declaring integration done, exercise both render paths:
1. npm run dev (in the app folder) 2. Load the home page (lists posts). 3. Click through to a detail page via an in-app <Link> / <a> — do not paste the URL. 4. Open the browser console. It should be clean. No ReferenceError: process is not defined, no hard reload to /. 5. For good measure, reload the detail page directly (URL bar) — that exercises SSR.
Server-side rendering passing isn't enough. Client-side route transitions pull lazy chunks that exercise different code paths, and that's where env/bundling traps surface.
---
What's Next
Once setup is complete, let the user know:
"You're all set! Here are some things I can help with:
- Visual Editing — Click-to-edit in the Presentation tool (
visual-editing.md) - TypeGen — Type-safe queries with generated types (
typegen.md) - Studio Structure — Customize the Studio sidebar (
studio-structure.md) - SEO — Metadata, sitemaps, and Open Graph (
seo.md) - i18n — Multi-language content (
localization.md)
Just ask about any of these!"
---
Environment Variables
Framework-Specific Prefixes
| Framework | Client-Side Prefix | Example |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | NEXT_PUBLIC_SANITY_PROJECT_ID |
| React Router / Remix | VITE_ | VITE_SANITY_PROJECT_ID |
| SvelteKit | PUBLIC_ | PUBLIC_SANITY_PROJECT_ID |
| Nuxt | NUXT_PUBLIC_ | NUXT_PUBLIC_SANITY_PROJECT_ID |
| Astro | PUBLIC_ | PUBLIC_SANITY_PROJECT_ID |
Secrets (read tokens, webhook secrets) stay unprefixed and are read via process.env (or the framework's server-only equivalent) from server-only modules — *.server.ts, route handlers, API routes. Never re-export a secret from a module that a route component can import.
---
Common Commands
npx sanity@latest mcp configure # Configure MCP for your editor
npx sanity dev # Start Studio locally
npx sanity schema deploy # Deploy schema for MCP/editor access
npx sanity deploy # Deploy Studio to Sanity hosting
npx sanity manage # Open project settings
npm run typegen # Generate TypeScript types---
Important Notes
- Be succinct — Guide step-by-step without over-explaining
- Check context first — Read existing files before suggesting changes
- Don't give up — If something fails, give the user a way to complete manually
- Deploy schema early — MCP content tools need deployed schemas to see new types and fields
- One phase at a time — Complete each phase before moving to the next
GROQ Query Maintenance & Best Practices
Use this contents list to jump to the query concern you need to solve.
Table of Contents
- Query definition and imports
- Query fragments
- Expansion patterns
- Maintenance workflow
- Common patterns
- Performance rules
- API version best practices
1. Query Definition & Imports
The defineQuery Function
ALWAYS wrap GROQ queries in defineQuery for TypeGen support. The import location depends on your framework:
// Framework-agnostic (Angular, Remix, SvelteKit, Astro, vanilla)
import { defineQuery } from "groq";
// Next.js (re-exported for convenience)
import { defineQuery } from "next-sanity";Syntax Highlighting
For VS Code syntax highlighting, either: 1. Use the groq tagged template (recommended): groq\...\` 2. Or prefix with / groq / comment when using defineQuery`
import { defineQuery } from "groq";
// ✅ Option A: groq tag (provides highlighting automatically)
import groq from "groq";
const QUERY = defineQuery(groq`*[_type == "post"]`);
// ✅ Option B: Comment prefix (for plain template literals)
const QUERY = defineQuery(/* groq */ `*[_type == "post"]`);
// ✅ Also valid: Just defineQuery (TypeGen works, but no editor highlighting)
const QUERY = defineQuery(`*[_type == "post"]`);2. Query Fragments
Use string interpolation to reuse query logic and keep queries maintainable.
// src/sanity/fragments/image.ts
export const imageFragment = /* groq */ `
asset->{
_id,
url,
metadata { lqip, dimensions }
},
alt
`;
// src/sanity/queries/post.ts
import { defineQuery } from "groq";
import { imageFragment } from "../fragments/image";
export const POST_QUERY = defineQuery(/* groq */ `
*[_type == "post"][0] {
title,
mainImage {
${imageFragment}
}
}
`);3. Expansion Patterns (Page Builder)
When building a Page Builder query, expand all potential component types.
Best Practice: Use a pageFields fragment or similar strategy to keep the main query clean.
const pageBuilderExpansion = /* groq */ `
pageBuilder[] {
...,
_type == "hero" => {
...,
cta[] { link, label }
},
_type == "gallery" => {
images[] { ${imageFragment} }
}
}
`;4. Maintenance Workflow
When you add a new field or component to the Schema: 1. Update the Query: Add the new field/expansion to the relevant GROQ query immediately. 2. Run TypeGen: If you have typegen.enabled: true in sanity.cli.ts, types regenerate automatically during sanity dev/sanity build. Otherwise, run npm run typegen manually. 3. Verify: Ensure the new field is available in the generated types.
5. Common Patterns
Ordering
// Single field
*[_type == "post"] | order(publishedAt desc)
// Multiple fields (tiebreaker)
*[_type == "post"] | order(featured desc, publishedAt desc)
// ⚠️ Order BEFORE slice, not after!
*[_type == "post"] | order(publishedAt desc)[0...10] // ✅ Correct
*[_type == "post"][0...10] | order(publishedAt desc) // ❌ Wrong orderSlice Notation
*[_type == "post"][0] // Single document (object, not array)
*[_type == "post"][0...5] // First 5 (exclusive) ← Most common
*[_type == "post"][$start...$end] // Pagination with paramsDefault Values with coalesce()
*[_type == "page"]{
"title": coalesce(seoTitle, title, "Untitled"),
"image": coalesce(ogImage, mainImage, defaultImage)
}Conditionals with select()
*[_type == "product"]{
title,
"badge": select(
stock == 0 => "Out of Stock",
stock < 5 => "Low Stock",
"In Stock"
)
}Aggregation with count()
// Total count
count(*[_type == "post" && defined(slug.current)])
// Count per document
*[_type == "category"]{
title,
"postCount": count(*[_type == "post" && references(^._id)])
}Reverse References
*[_type == "author"]{
name,
"posts": *[_type == "post" && references(^._id)]{ title, slug }
}Array Filtering
*[_type == "movie"]{
title,
"mainCast": castMembers[role == "lead"]->{name}
}
// Check if value exists in array
*[_type == "post" && "tech" in categories[]->slug.current]Special Variables
// ^ = parent document (in nested queries)
*[_type == "author"]{
name,
"posts": *[_type == "post" && author._ref == ^._id]
}
// @ = current item (in array operations)
*[_type == "post"]{
"tagCount": count(tags[@ != null])
}6. Performance Rules
Optimizable vs Non-Optimizable Filters
GROQ uses indexes for optimizable filters. Non-optimizable filters scan ALL documents.
| Pattern | Optimizable | Example |
|---|---|---|
_type == "x" | ✅ Yes | *[_type == "post"] |
_id == "x" | ✅ Yes | *[_id == "abc123"] |
slug.current == $slug | ✅ Yes | *[slug.current == "hello"] |
defined(field) | ✅ Yes | *[defined(publishedAt)] |
references($id) | ✅ Yes | *[references("author-123")] |
field->attr == x | ❌ No | Resolves reference for every doc |
fieldA < fieldB | ❌ No | Compares two attributes |
Fix non-optimizable filters by stacking:
// Stack optimizable filters FIRST to reduce search space
*[_type == "product" && defined(salePrice) && salePrice < displayPrice]Avoid Joins in Filters
Reference resolution (->) in filters is expensive. Use _ref instead:
// ❌ Slow: Resolves reference for every document
*[_type == "post" && author->name == "Bob Woodward"]
// ✅ Fast: Direct _ref comparison
*[_type == "post" && author._ref == "author-bob-woodward-id"]When you need dynamic lookups (don't know the ID upfront):
// Two-step approach:
// 1. Get the reference ID first
*[_type == "author" && name == "Bob Woodward"][0]._id
// 2. Use that ID in your main query
*[_type == "post" && author._ref == $authorId]
// Or use a subquery (still better than -> in filter):
*[_type == "post" && author._ref in *[_type == "author" && name == "Bob Woodward"]._id]Merge Repeated Reference Resolutions
Each -> is a subquery. Don't repeat it:
// ❌ Slow: Two separate subqueries
*[_type == "category"]{
"parentTitle": parent->title,
"parentSlug": parent->slug.current
}
// ✅ Fast: Single subquery, merged
*[_type == "category"]{
...(parent->{ "parentTitle": title, "parentSlug": slug.current })
}Cursor-Based Pagination (Not Deep Slicing)
Deep slices are slow because all skipped docs must be sorted first.
// ❌ Slow: Must sort and skip 10,000 docs
*[_type == "article"] | order(_id)[10000...10020]
// ✅ Fast: Cursor-based, only fetches 20
*[_type == "article" && _id > $lastId] | order(_id)[0...20]For custom sort orders, include the sort field in the cursor:
// Compound cursor: publishedAt + _id for deterministic pagination
*[_type == "article" && (
publishedAt < $lastDate ||
(publishedAt == $lastDate && _id > $lastId)
)] | order(publishedAt desc, _id)[0...20]Always Project Fields
Always use projections to return only the fields your application needs. Fetching entire documents wastes bandwidth and processing time.
// ❌ Returns ALL fields including unused ones, metadata, revisions
*[_type == "post"]
// ✅ Only fetch what the component needs
*[_type == "post"]{
_id,
title,
"slug": slug.current,
publishedAt,
excerpt
}Apply projections at every level, including nested references:
*[_type == "post"]{
title,
author->{ name, "avatar": image.asset->url },
categories[]->{ title, "slug": slug.current }
}Use conditional projections for different contexts:
*[_type == "post"]{
title,
slug,
// Only include body for single post view
$includeBody == true => { body }
}Don't Filter/Sort on Projected Values
Computed attributes can't use indexes:
// ❌ Not optimizable (computed attribute)
*[_type == "person"]{
"fullName": firstName + " " + lastName
} | order(fullName)
// ✅ Optimizable (original attribute)
*[_type == "person"] | order(firstName, lastName)Quick Checklist
| Rule | Why |
|---|---|
Always project { fields } | Reduces data returned |
Use defined() checks | Filters use indexes |
Use $params not interpolation | Prevents query manipulation + enables caching |
| Order BEFORE slice | order()[0...N] not [0...N] order() |
Use _ref not ->field in filters | Avoids expensive joins |
Merge repeated -> calls | Single subquery vs many |
| Cursor pagination for deep pages | Avoids sorting entire dataset |
7. API Version Best Practices
Always use dated versions (YYYY-MM-DD) for consistent behavior:
const client = createClient({
apiVersion: '2026-02-01', // Use current date for new projects
})- New projects: Use current date (e.g.,
2026-02-01) - Existing projects: Keep current version unless you need new features
- Dated versions lock behavior;
v1orvXmay change unexpectedly
Sanity + Shopify + Hydrogen Rules
Package: `hydrogen-sanity` — requires @shopify/hydrogen >= 2025.5.0
1. Architecture Overview
| Component | Purpose |
|---|---|
| Shopify | Product catalog, inventory, checkout (source of truth for commerce) |
| Sanity Connect | Syncs Shopify data to Sanity in real-time |
| Sanity Studio | Editorial content, rich descriptions, media (enhances Shopify data) |
| Hydrogen | React Router 7 front-end optimized for Shopify |
Project Structure:
./
├── /studio # Sanity Studio
└── /web # Hydrogen front-end2. Environment Variables
# web/.env
PUBLIC_STOREFRONT_API_TOKEN="your-public-storefront-token"
PRIVATE_STOREFRONT_API_TOKEN="your-private-storefront-token"
PUBLIC_STORE_DOMAIN="your-store.myshopify.com"
SESSION_SECRET="your-random-session-secret"
# Sanity
SANITY_PROJECT_ID="your-project-id"
SANITY_DATASET="production"
SANITY_API_VERSION="2026-02-01"
SANITY_PREVIEW_TOKEN="your-sanity-viewer-token" # Viewer token for previews3. Sanity Client Setup
Vite Config
// web/vite.config.ts
import {hydrogen} from '@shopify/hydrogen/vite'
import {sanity} from 'hydrogen-sanity/vite'
export default defineConfig({
plugins: [hydrogen(), sanity()],
})Context Setup
// web/app/lib/context.ts
import {createSanityContext, type SanityContext} from 'hydrogen-sanity'
import {PreviewSession} from 'hydrogen-sanity/preview/session'
import {isPreviewEnabled} from 'hydrogen-sanity/preview'
const sanity = await createSanityContext({
request,
cache,
waitUntil,
client: {
projectId: env.SANITY_PROJECT_ID,
dataset: env.SANITY_DATASET,
apiVersion: env.SANITY_API_VERSION || '2026-02-01',
useCdn: true,
stega: {
enabled: isPreviewEnabled(env.SANITY_PROJECT_ID, previewSession),
studioUrl: 'http://localhost:3333',
}
},
preview: {
token: env.SANITY_PREVIEW_TOKEN,
session: previewSession,
}
})Provider Setup (entry.server.tsx)
const {SanityProvider} = context.sanity
const body = await renderToReadableStream(
<NonceProvider>
<SanityProvider>
<ServerRouter context={reactRouterContext} url={request.url} nonce={nonce} />
</SanityProvider>
</NonceProvider>,
)Root Layout (root.tsx)
import {Sanity} from 'hydrogen-sanity'
export function Layout({children}) {
const nonce = useNonce()
return (
<html>
<body>
{children}
<Sanity nonce={nonce} /> {/* Required for client-side */}
<Scripts nonce={nonce} />
</body>
</html>
)
}4. Data Fetching
Fetch from both Shopify (GraphQL) and Sanity (GROQ). Use defineQuery for TypeGen support.
Recommended: query + Query component
import {defineQuery} from 'groq'
import {Query} from 'hydrogen-sanity'
const PRODUCT_QUERY = defineQuery(`*[_type == "product" && store.slug.current == $handle][0]{ body }`)
// Loader
export async function loader({params, context: {sanity}}: LoaderFunctionArgs) {
const initial = await context.sanity.query(PRODUCT_QUERY, params)
return {initial}
}
// Component - auto-enables live preview when active
export default function ProductPage({loaderData}) {
return (
<Query query={PRODUCT_QUERY} options={{initial: loaderData.initial}}>
{(data) => <div>{data?.body}</div>}
</Query>
)
}Alternative methods
| Method | Use Case |
|---|---|
sanity.query() + Query | Recommended - auto preview mode |
sanity.loadQuery() | Manual loader integration |
sanity.fetch() | No preview needed, lightweight |
sanity.client | Mutations in actions |
Images
import {useImageUrl} from 'hydrogen-sanity'
function Hero({image}) {
const imageUrl = useImageUrl(image)
return <img src={imageUrl.width(1200).height(600).url()} />
}Key Insight: Shopify fields synced via Sanity Connect are readOnly. Use Sanity for editorial enhancements only.
5. Visual Editing Setup
Root Layout
// web/app/root.tsx
import {usePreviewMode} from 'hydrogen-sanity/preview'
import {VisualEditing} from 'hydrogen-sanity/visual-editing'
export function Layout({children}: {children?: React.ReactNode}) {
const previewMode = usePreviewMode()
return (
<html>
<body>
{children}
{previewMode ? <VisualEditing action="/api/preview" /> : null}
</body>
</html>
)
}Preview Route
// web/app/routes/api.preview.ts
export {action, loader} from 'hydrogen-sanity/preview/route'Content Security Policy
// web/entry.server.tsx
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
frameAncestors: isPreviewEnabled ? [studioHostname] : [],
connectSrc: [
`https://${projectId}.api.sanity.io`,
`wss://${projectId}.api.sanity.io`,
],
})6. Studio: Presentation Tool
// studio/sanity.config.ts
import {presentationTool} from 'sanity/presentation'
export default defineConfig({
plugins: [
presentationTool({
resolve: {
locations: {
product: defineLocations({
select: { title: 'store.title', slug: 'store.slug.current' },
resolve: (doc) => ({
locations: [
{ title: doc?.title || 'Untitled', href: `/products/${doc?.slug}` },
{ title: 'Products', href: `/collections/all` },
],
}),
}),
},
},
previewUrl: {
origin: 'http://localhost:3000',
previewMode: { enable: '/api/preview' },
},
}),
],
})7. Commands
# Install dependencies
pnpm add hydrogen-sanity @sanity/client @portabletext/react
# Development (run in separate terminals)
cd studio && pnpm dev # Studio at localhost:3333
cd web && pnpm dev # Hydrogen at localhost:3000
# Sanity Manage (CORS, tokens): https://www.sanity.io/manage
pnpm dlx sanity manage8. Boundaries
- Always:
- Query Shopify for commerce data (price, inventory, variants)
- Query Sanity for editorial content (rich text, custom fields)
- Use
hydrogen-sanitypackage for Visual Editing - Add Hydrogen URL to CORS origins in Sanity Manage
- Ask First:
- Before modifying Sanity Connect sync settings
- Before changing CSP configuration
- Never:
- Edit Shopify-synced fields in Sanity (they're
readOnly) - Expose
SANITY_API_TOKENto client-side code - Query Sanity for commerce data that should come from Shopify
Sanity Image Rules
1. Schema Definition
Always enable hotspot: true. This allows editors to control cropping and the focal point.
defineField({
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: {
hotspot: true // CRITICAL
},
fields: [
defineField({
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: rule => rule.required().warning('Alt text is important for SEO')
})
]
})2. URL Builder (urlFor)
Use the Sanity Image URL Builder to generate optimized URLs (resize, crop, format).
Setup (`sanity/lib/image.ts`):
import createImageUrlBuilder from '@sanity/image-url'
import { dataset, projectId } from '../env'
const builder = createImageUrlBuilder({ projectId, dataset })
export const urlFor = (source: any) => {
return builder.image(source)
}Usage: The URL builder automatically uses hotspot/crop data when available:
const imageUrl = urlFor(mainImage)
.width(800)
.height(600)
.fit('crop') // Respects hotspot when cropping
.url()3. Next.js Image Component Pattern
Create a reusable SanityImage component that handles the urlFor logic and next/image props.
import Image from 'next/image'
import { urlFor } from '@/sanity/lib/image'
interface SanityImageProps {
value: any // SanityImageSource
width?: number
height?: number
className?: string
priority?: boolean
}
export function SanityImage({ value, width = 800, height, className, priority }: SanityImageProps) {
if (!value?.asset) return null
return (
<Image
className={className}
src={urlFor(value)
.width(width)
.height(height || Math.round(width / 1.5)) // Default aspect ratio if no height
.url()}
alt={value.alt || ''}
width={width}
height={height || Math.round(width / 1.5)}
priority={priority}
// Optional: Use LQIP (Low Quality Image Placeholder)
placeholder={value.asset.metadata?.lqip ? 'blur' : 'empty'}
blurDataURL={value.asset.metadata?.lqip}
/>
)
}4. Querying Images
Critical: LQIP (Low Quality Image Placeholder) is not automatic. You must explicitly query it via asset->{ metadata { lqip } }.
Minimal Query (No LQIP)
mainImage {
asset->{ _id, url },
alt
}Full Query (With LQIP & Dimensions)
mainImage {
asset->{
_id,
url,
metadata {
lqip, // Base64 blur placeholder
dimensions { width, height } // For aspect ratio
}
},
alt,
hotspot, // Include if using hotspot cropping
crop // Include if using cropping
}Why this matters: Without querying metadata.lqip, the blurDataURL in your component will be undefined and the blur effect won't work.
5. Performance Tips
- Auto Format: Sanity CDN automatically serves WebP/AVIF if the browser supports it (no need to specify
.format('webp')manually in most cases, butnext/imagehandles this too). - Sizing: Always request the exact size you need using
.width()and.height()inurlFor. Don't download a 4000px image for a thumbnail.
Sanity Localization Rules
Use the contents list to jump directly to the localization pattern you need.
Table of Contents
- Guiding principles
- Terminology
- Locale content type
- Choosing document-level vs field-level localization
- Document-level localization
- Localized singletons
- Field-level localization
- AI-powered translation
- UI enhancement
- Frontend URL best practices
1. Guiding Principles
Priority: Easy Authoring Experience
The structured nature of Sanity schemas and GROQ make it easy to parse localized content for your frontend. Never let frontend architecture dictate your localization approach — prioritize the editor experience.
Avoid Content Duplication
Don't create nearly identical copies with slight differences (e.g., US vs British English). Use Portable Text marks and custom blocks to swap out words or sections as needed.
2. Terminology
| Term | Definition |
|---|---|
| Internationalization (i18n) | Designing your frontend to support multiple languages |
| Localization | Adapting content for a specific language/region |
| Language Tag | Code like en, en-US, zh-Hant-TW (per IETF RFC 5646) |
| Locale | A language tag with region info (e.g., en-US) |
3. Create a Locale Content Type
Best Practice: Store locales in Sanity, not just in code. This allows sharing between Studio and frontend.
// schemaTypes/locale.ts
import { TranslateIcon } from '@sanity/icons'
import { defineField, defineType } from 'sanity'
export const localeType = defineType({
name: 'locale',
icon: TranslateIcon,
type: 'document',
fields: [
defineField({ name: 'name', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'tag', type: 'string', description: 'IANA tag (en, en-US)', validation: (r) => r.required() }),
defineField({ name: 'fallback', type: 'reference', to: [{ type: 'locale' }] }),
defineField({ name: 'default', type: 'boolean' }),
],
preview: { select: { title: 'name', subtitle: 'tag' } },
})Tip: Restrict locale editing to admins via Structure by filtering locale from non-admin users.
4. Choose Your Localization Method
| Content Type | Examples | Recommended Method |
|---|---|---|
| Structured (things) | Products, People, Locations, Categories | Field-level |
| Presentation (UI) | Pages, Posts, Components | Document-level |
Decision Questions
1. Are fields shared across languages? → Field-level 2. Should changes be "global" for all locales? (e.g., reordering components) → Field-level 3. Is content mostly the same except regional differences? → Field-level with PT marks 4. Need to publish language versions independently? → Document-level
5. Document-Level Localization
Use the @sanity/document-internationalization plugin.
npm install @sanity/document-internationalizationConfiguration
// sanity.config.ts
import { documentInternationalization } from '@sanity/document-internationalization'
export default defineConfig({
plugins: [
documentInternationalization({
// Fetch from Content Lake
supportedLanguages: (client) =>
client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
// Document types to localize
schemaTypes: ['post', 'page'],
}),
],
})Add Language Field to Schema
// In each schema type listed in schemaTypes
defineField({
name: 'language',
type: 'string',
readOnly: true,
hidden: true,
})Initial Value Templates
Pre-set language when creating documents outside the translation UI:
// sanity.config.ts
import { template } from 'sanity'
export default defineConfig({
// ...
document: {
newDocumentOptions: (prev, { creationContext }) => {
// Filter to only show base language in "New document" menu
// The plugin handles creating translations from there
return prev.filter((item) =>
!['post', 'page'].includes(item.templateId) ||
item.parameters?.language === 'en'
)
},
},
// Initial value templates for each language
templates: (prev) => [
...prev,
template.initial({
id: 'post-en',
title: 'Post (English)',
schemaType: 'post',
parameters: [{name: 'language', type: 'string'}],
value: ({language}) => ({language}),
}),
],
})Querying Translated Documents
// Get document in specific language
*[_type == "post" && language == $locale && slug.current == $slug][0]
// Get all translations via metadata document
*[_type == "translation.metadata" && references($docId)][0] {
translations[] {
_key,
value-> { title, slug, language }
}
}6. Localized Singletons (Homepage per Locale)
For singletons like homepages that need a separate document per locale, combine document-level localization with the singleton pattern.
Schema Definition
// schemaTypes/homePage.ts
import { HomeIcon } from '@sanity/icons'
import { defineType, defineField } from 'sanity'
export const homePageType = defineType({
name: 'homePage',
title: 'Home Page',
type: 'document',
icon: HomeIcon,
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
hidden: true,
}),
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'pageBuilder', type: 'pageBuilder' }),
// ... other fields
],
preview: {
select: { language: 'language' },
prepare({ language }) {
return {
title: 'Home Page',
subtitle: language?.toUpperCase() || 'No language',
}
},
},
})Initial Value Templates
Create templates that pre-set the language for each locale:
// sanity.config.ts
import { defineConfig, Template } from 'sanity'
// Define your supported locales
const LOCALES = [
{ id: 'en', title: 'English' },
{ id: 'fr', title: 'French' },
{ id: 'de', title: 'German' },
]
export default defineConfig({
// ...
templates: (prev) => {
// Create a template for each locale
const homePageTemplates: Template[] = LOCALES.map((locale) => ({
id: `homePage-${locale.id}`,
title: `Home Page (${locale.title})`,
schemaType: 'homePage',
parameters: [{ name: 'language', type: 'string' }],
value: { language: locale.id },
}))
return [...prev, ...homePageTemplates]
},
})Structure: Localized Singleton Helper
Create a helper to show one singleton per locale in the Structure:
// src/structure/index.ts
import { StructureBuilder, StructureResolver } from 'sanity/structure'
import { HomeIcon } from '@sanity/icons'
const LOCALES = ['en', 'fr', 'de']
function createLocalizedSingleton(
S: StructureBuilder,
typeName: string,
title: string,
icon?: React.ComponentType
) {
return S.listItem()
.title(title)
.icon(icon)
.child(
S.list()
.title(title)
.items(
LOCALES.map((locale) =>
S.listItem()
.title(`${title} (${locale.toUpperCase()})`)
.icon(icon)
.child(
S.document()
.schemaType(typeName)
.documentId(`${typeName}-${locale}`) // Fixed ID per locale
.title(`${title} (${locale.toUpperCase()})`)
)
)
)
)
}
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// Localized singletons
createLocalizedSingleton(S, 'homePage', 'Home Page', HomeIcon),
S.divider(),
// Filter localized singletons from default list
...S.documentTypeListItems().filter(
(item) => !['homePage'].includes(item.getId() as string)
),
])Querying Localized Singletons
// Get homepage for specific locale
*[_type == "homePage" && language == $locale][0]{
title,
pageBuilder[]{...}
}
// Or by fixed document ID
*[_id == "homePage-" + $locale][0]{...}Key Points
- Fixed IDs: Use
${typeName}-${locale}only for localized singletons; let Sanity generate IDs for ordinary localized content - Initial Value Templates: Essential for the "New document" menu to work correctly
- Structure: Group all locale versions under one list item for cleaner navigation
- See also:
studio-structure.mdfor more singleton patterns
7. Field-Level Localization
Use sanity-plugin-internationalized-array (NOT localized objects — they hit attribute limits).
npm install sanity-plugin-internationalized-arrayConfiguration
// sanity.config.ts
import { internationalizedArray } from 'sanity-plugin-internationalized-array'
export default defineConfig({
plugins: [
internationalizedArray({
languages: (client) =>
client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
fieldTypes: ['string', 'text', 'simpleBlockContent'],
}),
],
})Usage in Schema
// The plugin creates types like `internationalizedArrayString`
defineField({
name: 'jobTitle',
type: 'internationalizedArrayString', // Localized string field
})Portable Text Localization
Create a reusable block content type, then add it to fieldTypes:
// schemaTypes/simpleBlockContent.ts
export default defineType({
name: 'simpleBlockContent',
type: 'array',
of: [
{
type: 'block',
styles: [{ title: 'Normal', value: 'normal' }],
lists: [],
},
],
})
// sanity.config.ts
fieldTypes: ['string', 'simpleBlockContent']
// In your schema
defineField({
name: 'bio',
type: 'internationalizedArraySimpleBlockContent',
})Querying Internationalized Arrays
// Get specific locale value
*[_type == "author"][0] {
"jobTitle": jobTitle[_key == $locale][0].value
}
// With fallback
*[_type == "author"][0] {
"jobTitle": coalesce(
jobTitle[_key == $locale][0].value,
jobTitle[_key == "en"][0].value
)
}8. AI-Powered Translation
Use @sanity/assist for automated translations.
npm install @sanity/assist// sanity.config.ts
import { assist } from '@sanity/assist'
export default defineConfig({
plugins: [
assist({
translate: {
// For document-level localization
document: {
languageField: 'language',
},
// For field-level localization
field: {
languages: (client) =>
client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
documentTypes: ['author', 'category'],
},
},
}),
],
})9. UI Enhancement
Use @sanity/language-filter to let editors show/hide locales:
npm install @sanity/language-filter10. Frontend URL Best Practices
Always include locale in the URL for SEO:
yoursite.com/en/my-page→yoursite.com/fr/my-pageyoursite.com/my-page→ redirects to default locale
Avoid: Having the default locale at root without prefix — causes SEO edge cases.
Use Next.js middleware (or framework equivalent) to redirect paths missing a locale prefix to the default locale.
Import HTML to Portable Text
Use @portabletext/block-tools with JSDOM to convert HTML from legacy CMSs to Portable Text.
Setup
npm install @portabletext/block-tools jsdomBasic Conversion
import { htmlToBlocks } from '@portabletext/block-tools'
import { JSDOM } from 'jsdom'
// Get block content type from your schema
const blockContentType = schema.get('blockContent')
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
})Custom Deserializers
Handle specific HTML patterns:
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
rules: [
{
deserialize(el, next, block) {
// Custom link handling
if (el.tagName.toLowerCase() === 'a') {
return {
_type: 'link',
href: el.getAttribute('href'),
blank: el.getAttribute('target') === '_blank'
}
}
// Custom image handling
if (el.tagName.toLowerCase() === 'img') {
return {
_type: 'image',
// Upload image separately, store reference
_sanityAsset: `image@${el.getAttribute('src')}`
}
}
return undefined // Fall through to default handling
}
}
]
})Pre-Processing HTML
Clean HTML before conversion:
function cleanHtml(html) {
const dom = new JSDOM(html)
const doc = dom.window.document
// Remove layout elements
doc.querySelectorAll('header, footer, nav, .sidebar').forEach(el => el.remove())
// Extract metadata before processing body
const title = doc.querySelector('title')?.textContent
const description = doc.querySelector('meta[name="description"]')?.content
return {
body: doc.body.innerHTML,
metadata: { title, description }
}
}Image Upload
Don't just link external images—upload them:
async function uploadImage(client, imageUrl) {
const response = await fetch(imageUrl)
const buffer = await response.arrayBuffer()
const asset = await client.assets.upload('image', Buffer.from(buffer), {
filename: imageUrl.split('/').pop()
})
return {
_type: 'image',
asset: { _type: 'reference', _ref: asset._id }
}
}Using in a Migration
Wrap this in defineMigration for controlled imports:
// migrations/import-wordpress-posts/index.ts
import {defineMigration, create} from 'sanity/migrate'
import {htmlToBlocks} from '@portabletext/block-tools'
export default defineMigration({
title: 'Import WordPress posts',
async *migrate(documents, context) {
const posts = await fetchWordPressPosts() // Your import source
for (const post of posts) {
const blocks = htmlToBlocks(post.content, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
})
yield create({
_type: 'post',
title: post.title,
slug: {_type: 'slug', current: post.slug},
legacyId: String(post.id),
body: blocks,
})
}
}
})Let Sanity generate document IDs for ordinary imported content. Add schema fields for legacy identifiers or slugs, then use GROQ lookups against those fields when you need to rerun an import, patch existing documents, or create references between imported records. Set _id directly only for singleton documents.
Run with: sanity migration run import-wordpress-posts --no-dry-run
Reference: Schema and Content Migrations
Sanity Content Migration Rules
Document Identity During Import (Critical)
Let Sanity generate _id values for imported documents unless you are intentionally creating a singleton. Do not derive deterministic UUIDs or document IDs from slugs, file paths, legacy IDs, or related document IDs.
- Store legacy identifiers in fields such as
legacyId,externalId, orslug. - Make imports idempotent by looking up existing documents with GROQ before creating or patching them.
- Create relationships by querying the target document and using its real
_idin areference; do not predict_refvalues from naming conventions. - Reserve explicit
_idvalues for singleton documents such assettings,homePage, or localized singleton IDs likehomePage-en.
1. HTML Import (Legacy CMS)
Use @portabletext/block-tools with JSDOM to convert HTML to Portable Text. This covers setup, custom deserializers, pre-processing, image uploads, and wrapping in defineMigration.
See `migration-html-import.md` for the full guide with working examples.
2. Markdown Import (Static Sites)
Use @portabletext/markdown for direct, schema-aware Markdown ↔ Portable Text conversion.
Recommended: Direct Conversion with `@portabletext/markdown`
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText(markdownString)This handles headings, lists, bold, italic, code, links, images, and tables. Use @portabletext/sanity-bridge to pass your Sanity schema so only valid types are produced.
Alternative: Markdown → HTML → Portable Text For complex Markdown with non-standard extensions, convert to HTML first, then use htmlToBlocks (see above).
1. Parse: marked or remark to convert MD to HTML. 2. Convert: Use htmlToBlocks from @portabletext/block-tools.
Note:@sanity/block-content-to-markdownand@sanity/block-toolsare deprecated. Use@portabletext/markdownand@portabletext/block-toolsinstead.
3. Image Handling (Universal)
Don't just link to external images. Download them and upload to Sanity Asset Pipeline.
1. Extract: Find <img> tags or Markdown image syntax. 2. Download: Fetch the image buffer. 3. Upload: client.assets.upload('image', buffer) 4. Replace: Return a Sanity Image block with the new asset reference.
4. Schema Validation
Ensure your destination schema allows the structures you are importing.
- Tables: Need a
tabletype (HTML<table>or GFM tables). - Code: Need a
codetype (HTML<pre><code>or MD code fences).
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 frontendWhy standalone instead of embedding the Studio in the Next.js app:
- Faster dev and builds:
sanity devandsanity buildrun on Vite and are dramatically faster (10-30x) than compiling the Studio throughnext 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 intonext dev, so you must re-runsanity typegen generatemanually 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) andsanity 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.mdrule 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'
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_SECRETto 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 studioRun 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.
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>
);
}Related skills
How it compares
Use sanity-best-practices for opinionated Angular-to-Sanity wiring instead of generic CMS tutorials without signals, Portable Text, or Visual Editing detail.
FAQ
Should I create deterministic UUIDs or slug-derived IDs for Sanity documents?
No - let Sanity generate _id values for ordinary documents. Use explicit document IDs only for singleton documents controlled by Studio Structure, such as homePage-en for localized singletons.
How should I model relationships between documents in Sanity?
Use reference fields to link documents, then resolve related documents with GROQ lookups, source-key fields, or returned _id values from created documents.
Which framework integration guides does this reference cover?
Next.js (App Router, Live Content API, standalone Studio), Nuxt, Astro, Remix, SvelteKit, Angular, Shopify Hydrogen, plus custom apps with App SDK.
Is Sanity Best Practices safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.