SKILL DETAIL
sanity-best-practices
sanity-io/agent-toolkit/sanity-best-practices
This skill provides comprehensive best practices and integration guides for Sanity development, maintained by Sanity. It is applicable when setting up a new Sanity project, integrating Sanity with frontend frameworks, writing GROQ queries, designing content schemas, implementing Visual Editing and live preview, working with images and Portable Text, configuring Studio structure, setting up TypeGen, implementing localization, migrating content from other systems, building custom apps with the Sanity App SDK, managing infrastructure with Blueprints, and automating content workflows with Sanity Functions or webhooks. Global rules include letting Sanity generate `_id` values for ordinary documents, avoiding deterministic UUIDs or slug-derived IDs; modeling relationships with `reference` fields and resolving related documents via GROQ lookups; and using explicit document IDs mainly for singleton documents controlled by Studio Structure. For video, it is not recommended to store or serve video from Sanity `file` assets for production playback, as file assets are delivered as raw downloads with no transcoding or adaptive streaming, leading to high bandwidth usage and large bills. On Enterprise plans with the video add-on, use Sanity Media Library for transcoded and adaptive streaming via Mux; otherwise, use a dedicated video service like `sanity-plugin-mux-input` or host on YouTube/Vimeo and store the embed URL.
Installation
npx skills add https://github.com/sanity-io/agent-toolkit --skill sanity-best-practices
Skill files
SKILL.md
Last synced · Aug 29, 2026
references/angular.md›
---
title: Angular & Sanity Integration Rules
description: Integration guide for Angular, including @sanity/client setup, data fetching with signals and resource API, Portable Text rendering, and image optimization.
---
# 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 Studio
```
Install dependencies in the Angular app:
```bash
npm install @sanity/client @sanity/image-url @portabletext/to-html
```
Configure environment files for Sanity credentials:
```typescript
// environments/environment.ts
export const environment = {
production: false,
sanity: {
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-05-01',
},
}
```
```typescript
// 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:
```typescript
// 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`:
```typescript
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:
```typescript
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 in `defineQuery` enables Sanity TypeGen to infer return types automatically — no manual type imports needed. See `typegen.md` for the full workflow.
### B. `rxResource` (Observable-based)
For teams using RxJS patterns or needing operators like `retry` and `debounceTime`:
```typescript
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:
```typescript
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:** `toSignal` does not re-fetch on parameter changes. For dynamic queries, use `resource` or `rxResource`.
### 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:
```typescript
// app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router'
import { routes } from './app.routes'
export const appConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
}
```
```typescript
// 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)
```typescript
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:
```html
<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`:
```typescript
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:
```html
<!-- 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.
```html
<img [src]="post.mainImage | sanityImage" />
```
✅ **Good:** Specifying width and using `auto('format')` for WebP/AVIF delivery.
```html
<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:
```groq
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:
```html
<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 and `TransferState`. See `image.md` for 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 `NgModule` boilerplate needed.
- **Signals and `resource`** — Preferred over RxJS for data fetching. Simpler, less boilerplate.
- **New control flow** — Use `@if`, `@for`, `@switch` with `@empty` for cleaner templates:
```html
@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:
```html
@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):
```typescript
// 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:
```typescript
import { Injectable, inject, PLATFORM_ID, makeStateKey, TransferState } from '@angular/core'
import { isPlatformBrowser, isPlatformServer } from '@angular/common'
import { createClient, type ClientReturn, type QueryParams, type SanityClient } from '@sanity/client'
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('')
}
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>> {
// The key type includes `null` so `get(key, null)` type-checks against
// Angular's `get<T>(key: StateKey<T>, defaultValue: T): T` signature.
const key = makeStateKey<ClientReturn<Query> | null>(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:
```typescript
// 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:
```typescript
@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](https://www.sanity.io/manage). Ensure it has correct permissions. |
| `403 Forbidden` | CORS origin not allowed | Add your Angular dev/production URL to CORS origins in [Sanity Manage](https://www.sanity.io/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`.
references/app-sdk.md›
---
title: Sanity App SDK
description: Rules for building custom applications with the Sanity App SDK, including React hooks, document handles, real-time patterns, and Suspense best practices.
---
# 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
```bash
# 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-components
```
## Project 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.json
```
## Boundaries
- **Always:** Wrap data-fetching components in `<Suspense>`, use `documentId` as React `key`, read/write directly to Content Lake (not local state)
- **Always:** Use `useDocuments` for lists, `useDocumentProjection` for display, `useDocument` + `useEditDocument` for editing
- **Ask first:** Before using `useQuery` with raw GROQ (prefer `useDocuments` + `useDocumentProjection`)
- **Ask first:** Before adding multiple data-fetching hooks in a single component
- **Never:** Use `useState` for form values that should sync with Content Lake
- **Never:** Use array index as React `key` for document lists (breaks real-time updates)
- **Never:** Forget the `fallback` prop on `<SanityApp>` and `<Suspense>` boundaries
- **Never:** Set `app.visibility: 'disabled'` on an SDK app — it makes the app unreachable (hidden from the sidebar *and* 404 on the direct link). Use `'unlisted'` to hide it while keeping the link openable.
---
## Configuration
### CLI Config (`sanity.cli.ts`)
```typescript
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'your-org-id',
entry: './src/App.tsx',
},
})
```
### App Visibility
`app.visibility` controls whether the app appears in the Dashboard sidebar. Applied on deploy; change it and redeploy to update. Requires the `sanity` package v6.6.0+.
```typescript
export default defineCliConfig({
app: {
organizationId: 'your-org-id',
entry: './src/App.tsx',
visibility: 'unlisted', // 'default' | 'unlisted'
},
})
```
- `default` — listed in the Dashboard sidebar (the default when omitted).
- `unlisted` — hidden from the sidebar, but still opens via a direct link. **Not private:** anyone with the link can open it.
`sanity.cli.ts` is the source of truth: a redeploy re-applies `app.visibility`, so change it in config and redeploy rather than patching the deployed app out of band.
### App Root (`src/App.tsx`)
```typescript
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
```typescript
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:
```bash
SANITY_APP_PROJECT_ID=abc123
SANITY_APP_DATASET=production
```
Access: `process.env.SANITY_APP_PROJECT_ID`
---
## Document Handles
Lightweight references to documents. Fetch handles first, then load content as needed.
```typescript
interface DocumentHandle {
documentId: string
documentType: string
projectId?: string
dataset?: string
}
```
### Creating Handles
```typescript
// 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
```typescript
// 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>
)}
</>
)
}
```
```typescript
// 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
```typescript
// 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
```typescript
// 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)}
/>
)
}
```
```typescript
// 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
```typescript
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
```typescript
// 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} />
}
```
```typescript
// 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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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` |
references/astro.md›
---
title: Astro & Sanity Integration Rules
description: Integration guide for Astro, including @sanity/astro, visual editing, and data fetching.
---
# Astro & Sanity Integration Rules
## 1. Setup & Configuration
### Scaffold a new Astro app
```bash
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.
```bash
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.
```javascript
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`.
```json
{
"compilerOptions": {
"types": ["@sanity/astro/module"]
}
}
```
## 2. Data Fetching
### Basic Fetching
Use `sanityClient` from `sanity:client` in the frontmatter of your `.astro` files.
```astro
---
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`).
```typescript
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.
```astro
---
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.
```astro
---
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.
```typescript
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.*
references/blueprints.md›
---
title: Sanity Blueprints
description: Rules for Sanity Blueprints, the Infrastructure as Code solution for managing Sanity resources declaratively. Covers blueprint files, stacks and scope, the plan/deploy workflow, deletion policies, error recovery, and CI deployment.
---
# Sanity Blueprints
Sanity's Infrastructure as Code (IaC) solution. Define resources declaratively in `sanity.blueprint.ts`, track the file in version control, preview changes with `plan`, apply them with `deploy`. Blueprints is the only way to deploy Sanity Functions.
The CLI is built for unattended use: every command accepts `--json`, all IDs can be supplied via flags or environment variables, and `--help` on any command is the authoritative reference. Always run via `npx sanity@latest blueprints <command>`.
## Mental Model
```
Blueprint file (code, intent) → Stack (deployed state) → Resources (real infrastructure)
```
| Concept | What it is |
|---------|------------|
| **Blueprint file** | `sanity.blueprint.ts`, the declarative manifest of desired resources. The source of truth: if it's not in the file, it's not deployed |
| **Resource** | One managed thing (function, CORS origin, webhook, role, robot token, dataset). Identified by a unique `name` |
| **Definer** | Typed helper from `@sanity/blueprints` (e.g. `defineCorsOrigin`) that declares a resource and validates input as you write |
| **Stack** | The deployed counterpart of the file: the live resources on Sanity's side. One file can deploy to many Stacks (e.g. `staging`, `production`) |
| **Config file** | `.sanity/blueprint.config.json`, links the local file to a Stack and records scope. Gitignored by `init` automatically; not secret |
| **Scope** | `project` or `organization`. Determines which resource types a Stack can manage |
### Key behaviors
- Resources are matched **by `name`** between file and Stack. `plan` buckets every resource as create, update, no-op, or destroy.
- **There is no rename.** Changing a resource's `name` destroys the old resource and creates a new one under the new name.
- **Removal is destruction.** Deleting a resource from the file destroys it on the next deploy (subject to its deletion policy). `blueprints destroy` is equivalent to deploying an empty file.
- Deploys apply in dependency order. On partial failure, completed changes are rolled back best-effort in reverse order. Rollback is **not atomic**; some actions may be irreversible.
## Three different "deploy" commands
Do not confuse these:
| Command | What it deploys |
|---------|-----------------|
| `sanity blueprints deploy` | Infrastructure resources defined in `sanity.blueprint.ts` |
| `sanity schema deploy` | Studio schema to Content Lake (for MCP and editor access) |
| `sanity deploy` | The Studio application to Sanity hosting |
## The Blueprint File
`sanity.blueprint.ts` default-exports a `defineBlueprint` call. TypeScript is idiomatic (definers validate inline and provide editor help); `.js` and hand-written `.json` also work but new files should use TypeScript.
```typescript
import {defineBlueprint, defineCorsOrigin, defineDocumentFunction} from '@sanity/blueprints'
export default defineBlueprint({
values: {
corsOrigin: 'https://studio.example.com',
},
resources: [
defineCorsOrigin({
name: 'studio-cors',
origin: '$.values.corsOrigin',
}),
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
}),
],
})
```
- `resources` is an array of definer outputs. Every resource requires a unique `name`.
- `values` holds reusable file-level constants (always strings), referenced as `$.values.<key>`.
- Resource-type-specific fields are documented in the typed reference: https://reference.sanity.io/_sanity/blueprints/
### Discovering available resources
The definers exported by `@sanity/blueprints` are the canonical list of what Blueprints can manage. Discover them by inspecting the package's exports (every `define*` function is a resource definer) and reading their TSDoc for fields, defaults, and examples, or browse the typed reference at https://reference.sanity.io/_sanity/blueprints/. Do not rely on a memorized list; new definers ship additively.
When the Stack is organization-scoped, project-contained resources (CORS origins, webhooks, datasets, functions) must set their `project` field explicitly.
For the semantics of an individual resource type (what a CORS origin, webhook, or role does and which values make sense), consult that feature's own Sanity documentation. This reference covers how Blueprints manages resources, not the resources themselves.
## References and the Resource Graph
Resources reference values and each other with `$` string paths:
| Syntax | Resolves | When |
|--------|----------|------|
| `$.values.<key>` | A constant from the `values` block | At file evaluation |
| `$.resources.<name>` | Another resource in the file | At deploy time; creates a dependency edge |
| `$.resources.<name>.id` | The generated ID of that resource, as a string | At deploy time |
Blueprints builds the dependency graph from these references and orders work so dependencies are created first. Unresolved references and cycles fail validation before anything is deployed. To force ordering without a data reference, use `lifecycle: {dependsOn: '$.resources.<name>'}`.
### Referencing an existing project
The recommended pattern is an environment variable surfaced through `values`:
```typescript
import 'dotenv/config' // the CLI does not read .env files itself
export default defineBlueprint({
values: {
projectId: process.env.SANITY_PROJECT_ID ?? '',
},
resources: [
defineCorsOrigin({
name: 'app-cors',
project: '$.values.projectId',
origin: 'https://www.example.com',
}),
],
})
```
## Deletion Policies
Set `lifecycle.deletionPolicy` on a resource to control what removal means:
| Policy | Removed from file | Stack destroyed | Notes |
|--------|-------------------|-----------------|-------|
| `allow` | Resource destroyed | Resource destroyed | Default for stateless resources |
| `retain` | **Deployment fails** | Resource detached, kept alive | Default for stateful resources like datasets |
| `replace` | Resource destroyed | Resource destroyed | Updates become destroy + recreate |
| `protect` | **Deployment fails** | **Deployment fails** | Resource is never updated or destroyed |
Use `protect` for production-critical stateful resources:
```typescript
defineDataset({
name: 'production',
lifecycle: {deletionPolicy: 'protect'},
})
```
A deletion-policy violation is a pre-deploy validation failure, not a partial deploy: fix the file (or consciously change the policy) and re-run `plan`.
## Stacks, Environments, and Scope
One blueprint file deploys to many Stacks. The `--stack <name-or-id>` flag (on `deploy`, `plan`, `info`, `logs`) selects the target; without it, the Stack recorded in `.sanity/blueprint.config.json` is used.
- **Only `init` creates Stacks.** `--stack` never creates a Stack on a miss, so CI cannot accidentally provision infrastructure. Re-run `blueprints init .` to add another Stack for the same file.
- Accounts are currently limited to **three Stacks per project scope**.
- For per-environment differences, read an environment variable inside the file (e.g. `process.env.SANITY_ENV`) and deploy with the env var and `--stack` together. `SANITY_ENV` is a convention your file reads, not a CLI feature.
### Project vs organization scope
| | Project scope | Organization scope |
|---|--------------|--------------------|
| Stored in config as | `projectId` | `organizationId` |
| Resources default to | The project | Nothing; each project-contained resource must name its `project` |
| Org-scoped resource types | Not available | Available |
Organization scope is recommended for anything beyond a single-project experiment. Convert an existing Stack with `blueprints promote` (safe, idempotent, additive, but **one-way**; requires `organization-update` admin permission).
## CLI Commands
```bash
npx sanity@latest blueprints <command>
```
| Command | Purpose |
|---------|---------|
| `init [dir]` | Create a blueprint file and provision a remote Stack. The only command that creates Stacks |
| `plan` | Preview the diff against the Stack. Read-only, always safe |
| `deploy` | Apply the file to the Stack (`-m` message, `--no-wait` to queue and return) |
| `info` | Show Stack status and deployed resources. Remote read; works without local files |
| `stacks` | List Stacks (`--include-projects` with `--organization-id` for org-wide audit) |
| `config` | View or edit `.sanity/blueprint.config.json` (`--edit` with ID flags for scripting) |
| `logs` | Deployment logs (`--watch`, `--limit 1-500`, `--since`/`--before` ISO timestamps) |
| `promote` | Convert a Stack from project to organization scope. One-way (`--force` to skip confirmation) |
| `destroy` | Destroy the Stack and its resources (`--force` to skip confirmation). Local files remain |
| `doctor` | Diagnose local/remote configuration issues (`--fix` to resolve interactively) |
| `mint-deploy-token` | Mint a long-lived robot token for CI deploys (`--print` for shell pipelines) |
Running `npx sanity@latest blueprints --help` will show the latest available commands.
Use `npx sanity@latest blueprints <command> --help` to see options and examples for a specific command.
Function scaffolding, local testing, secrets, and function logs live under `npx sanity@latest functions <command>` (`add`, `dev`, `test`, `env add|list|remove`, `logs`). See the `functions` reference.
### Standard workflow
```bash
npx sanity@latest blueprints init . # once: create file + Stack
# edit sanity.blueprint.ts
npx sanity@latest blueprints plan # preview the diff, catch validation errors safely
npx sanity@latest blueprints deploy # apply
npx sanity@latest blueprints info # verify deployed state
```
Always run `plan` before `deploy`. It is read-only and surfaces validation errors, reference problems, scope mismatches, and deletion-policy violations without touching anything.
### Exit codes
`deploy` and `destroy` return: **`0`** succeeded, **`2`** failed, **`75`** accepted but completion could not be confirmed.
**✅ Correct — treat exit 75 as unknown, then verify:**
```bash
npx sanity@latest blueprints deploy || status=$?
if [ "${status:-0}" -eq 75 ]; then
npx sanity@latest blueprints info --json # confirm actual Stack state
fi
```
**❌ Incorrect — treating any nonzero exit as failure and retrying.** Exit 75 means the deployment was accepted; blindly redeploying can queue duplicate operations (deploy also refuses to start while another operation is in progress).
### Scope resolution and environment variables
For `organizationId`, `projectId`, and `stackId`, the first source that resolves wins: CLI flags → environment variables → the blueprint file module → the local config file.
| Variable | Purpose |
|----------|---------|
| `SANITY_AUTH_TOKEN` | Auth token (in CI, the minted deploy token) |
| `SANITY_ORGANIZATION_ID` / `SANITY_PROJECT_ID` | Scope |
| `SANITY_BLUEPRINT_STACK_ID` | Target Stack |
| `SANITY_BLUEPRINT_PATH` | Path to the blueprint file or its directory |
| `SANITY_ASSET_TIMEOUT` | Seconds to wait for resource asset processing (default 60) |
The CLI does not load `.env` files. Export variables in the shell or import `dotenv/config` inside the blueprint file.
## Errors and Recovery
Errors include a name, a human-readable message, and the **resource path**, which maps to a specific resource in your file. Five failure kinds:
| Failure | Caught | What to do |
|---------|--------|------------|
| Validation error (missing/invalid field, malformed file) | Before deploy | Fix the file at the reported resource path |
| Unresolved reference or dependency cycle | Before deploy | Fix the `$.resources.<name>` reference or break the cycle |
| Scope mismatch (org-scoped resource on project Stack, or no resolvable `project`) | Before deploy | Set the resource's `project` field, or `promote` the Stack |
| Deletion-policy violation (change would remove a `retain`/`protect` resource) | Before deploy | Restore the resource, or deliberately relax its policy |
| Execution failure (underlying service rejected the change) | During deploy | Read `blueprints logs`, fix the cause, redeploy |
Recovery procedure after any failure:
1. Read the error message and resource path (`blueprints logs` re-reads failure output).
2. Fix the blueprint file (or the external condition).
3. Run `blueprints plan` to confirm the corrected diff.
4. Run `blueprints deploy` again.
Retrying without changing anything only makes sense for transient execution failures; the first four failure kinds are deterministic and will fail identically until the file changes. If local and remote state seem inconsistent, run `blueprints doctor` (and `doctor --fix`) before attempting anything destructive. After a partial-deploy rollback, `blueprints info` shows what is actually live; resources owned by other Stacks are never touched by rollback.
## CI/CD
Recommended workflow: `plan` on pull requests, `deploy` on merge to main. Official GitHub Actions: https://github.com/sanity-io/blueprints-actions
```bash
# one-time, by a human with access:
npx sanity@latest blueprints mint-deploy-token --label "ci-deploy" --print
```
The minted robot token has exactly the role needed to plan, deploy, and destroy (`blueprints-deployer` for project scope, `blueprints-deployer-robot` for organization scope) and is revocable under Robots in Sanity Manage. Store it as a CI secret, then in the pipeline:
```bash
export SANITY_AUTH_TOKEN=<the minted token>
export SANITY_ORGANIZATION_ID=<orgId> # or SANITY_PROJECT_ID
export SANITY_BLUEPRINT_STACK_ID=<stackId>
npx sanity@latest blueprints deploy --json --message "CI deploy"
```
**✅ Correct — secrets via server-side function env vars:**
```bash
npx sanity@latest functions env add my-function API_KEY sk-...
```
**❌ Incorrect — secrets in the blueprint file's `env` block, which is committed to git:**
```typescript
defineDocumentFunction({
name: 'my-function',
env: {API_KEY: 'sk-...'}, // committed to version control!
})
```
references/functions.md›
---
title: Sanity Functions
description: Rules for Sanity Functions — serverless event handlers that react to content changes in Sanity's Content Lake. Covers blueprint configuration, handler patterns, testing, deployment, and recursion control.
---
# Sanity Functions
Serverless event handlers hosted on Sanity's infrastructure, configured via **Blueprints** and triggered by document lifecycle events.
> Always use `npx sanity@latest` so CLI and runtime versions stay current.
## 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
```bash
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` (gitignored automatically; it links your Blueprint to a Stack and is not secret).
### 2. Scaffold a Function
```bash
npx sanity@latest functions add \
--name my-function \
--type document-create --type document-update \
--installer npm
```
`--type` options: `document-create`, `document-update`, `document-delete`, `media-library-asset-create`, `media-library-asset-update`, `media-library-asset-delete`, `scheduled-function`, `sync-tag-invalidate`.
### 3. Configure the Blueprint
```typescript
// sanity.blueprint.ts
import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'my-function',
event: {
on: ['create', 'update'],
// The handler patches the same document, which emits another update
// event. Guard with !defined(firstPublished) so the function stops
// matching once it has run — see "Recursion control" below.
filter: '_type == "post" && !defined(firstPublished)',
},
}),
],
})
```
### 4. Write the Handler
```typescript
// 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
```bash
# 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-token
```
### 6. Deploy
```bash
npx sanity@latest blueprints deploy
```
### 7. View Logs
```bash
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` | `true` during local testing |
| `eventResourceType` | `string` | `'dataset'` or `'media-library'` |
| `eventResourceId` | `string` | e.g., `'projectId.datasetName'` |
### `event`
```typescript
{
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` | `'nodejs24.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'` |
| `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+.
```typescript
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 |
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 change
- `sanity::dataset() == 'production'` — scope to a dataset without `resource` config
- `_id in path('drafts.**')` with `includeDrafts: 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@latest 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:**
```typescript
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
})
```
**✅ Correct — use `@sanity/client` v7.12.0+ for automatic lineage headers:**
```typescript
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:**
```typescript
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:
```typescript
// 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](https://www.sanity.io/manage).
---
## Common Patterns
### Deploy hook / CDN invalidation
**Blueprint:**
```typescript
defineDocumentFunction({
name: 'deploy-hook',
event: {
on: ['create', 'update'],
filter: '_type == "page"',
},
})
```
**Handler:**
```typescript
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@latest 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.
```typescript
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: '_type == "post" && !defined(firstPublished)',
},
})
```
### Auto-translate with Agent Actions
**Blueprint:**
```typescript
defineDocumentFunction({
name: 'translate',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && language == 'en-US'",
projection: '{_id}',
},
})
```
**Handler:**
```typescript
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:**
```typescript
defineDocumentFunction({
name: 'auto-tag',
event: {
on: ['create', 'update'],
// Only fire while tags are missing. The handler writes to `tags`, which
// emits another `update` event — without this guard the function would
// re-trigger itself in a loop. Once tags exist, the filter stops matching.
filter: "_type == 'post' && !defined(tags)",
projection: '{_id, title, body}',
},
})
```
**Handler:**
```typescript
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
```typescript
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:**
```typescript
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post'",
resource: { type: 'dataset', id: 'myProjectId.production' },
},
})
```
**Option B — GROQ filter:**
```typescript
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:**
```typescript
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:**
```typescript
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:
```typescript
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/${context.clientOptions.dataset}`, {
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
```typescript
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](https://github.com/sanity-io/blueprints-actions)
```yaml
- uses: sanity-io/blueprints-actions/deploy@deploy-v3
with:
sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }}
```
Mint a long-lived deploy token with `npx sanity@latest blueprints mint-deploy-token` (creates a robot token with the role required to plan, deploy, and destroy) and store it as a CI secret. Recommended workflow: `blueprints plan` on pull requests, `blueprints deploy` on merge to main. See the `blueprints` reference for CI environment variables and exit codes.
references/get-started.md›
---
title: Sanity Getting Started Guide
description: Use these rules when users ask to 'Get started with Sanity' or need help setting up a new Sanity project.
---
# 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.ts` exist (typically in a `studio/` folder)? → Studio is set up
- Are one or more custom schema types registered in the Studio config (often through a non-empty `schemaTypes` export)? → Schema exists
- Is there a frontend framework in `package.json`? → May need integration
Do not treat files in `schemaTypes/` as proof that a schema exists. The clean
Studio template includes `schemaTypes/index.ts` with an empty
`schemaTypes` array.
Resume from where they left off.
### Step 0: Check Sanity MCP
Check whether Sanity MCP tools are already available before creating files.
- If a local Studio exists, keep it as the source of truth. Update its schema
files first, then deploy that schema before using MCP content tools.
- If no local Studio exists and the user wants an MCP-managed setup, first
select or create the project and dataset. Ask what content they are building,
define the schema with the `schema.md` patterns, run `deploy_schema`, then
`deploy_studio`. Skip Phase 1 below and continue with Phase 2.
- Do not mix a code-managed Studio and an MCP-managed Studio without explaining
which schema is authoritative.
- If MCP is not configured, use the setup instructions below. The current
Sanity initializer may also offer to configure MCP and install Sanity skills.
### Run an authentication preflight
From the intended workspace root, run:
```bash
npx sanity@latest debug
```
If the `User` result identifies a logged-in user, prefer the account-owned
`sanity init` path in Step 1. Otherwise, continue directly with `sanity new`
below.
### Start without an account with [`sanity.new`](https://sanity.new)
Without an account, `sanity new` creates a working full-stack app with Sanity as
the content backend in seconds. The project works immediately; claim it within
72 hours to keep it. First run this from the intended workspace root:
```bash
npx sanity@latest new --instructions
```
This prints the current agent guide and creates nothing. Read and follow it
before creating the project because this flow is rolling out and the CLI guide
is the source of truth.
From an empty workspace, the batteries-included command is:
```bash
npx sanity@latest new "<project name>" --yes
```
Choose the setup that fits:
- **New full-stack app:** Use the command above. It creates a project, a Studio
in `sanity/`, and a Next.js app in `web/`, all connected and ready to run.
- **Existing frontend:** Run the same command from the app root. The CLI detects
the app and adds the `sanity/` Studio without replacing the frontend.
- **Project only:** Add `--no-scaffold` when only the project and credentials are
needed, such as for a custom setup or a framework other than Next.js. This
creates no Studio or frontend files.
Tell the user the claim link and expiry immediately. Treat the claim link and
robot token as secrets: never commit them or paste them into issues, PRs, or
shared channels, and keep the token server-only. Do not run the authenticated
initializer in Step 1 afterward.
If the robot token, claim link, or other project details are not readily
available, recover the existing project instead of creating a duplicate:
```bash
npx sanity@latest projects unclaimed
npx sanity@latest projects unclaimed --project-id <projectId>
```
The project-specific command returns full details, including the robot token.
Keep its output private.
---
## 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/`, `sanity/` when created by `sanity new`, or `studio-*` in some onboarding flows) next to the app folder:
**If NO Studio found:**
- Ask: "Want to create a new Sanity Studio?"
- If yes, first run `node --version`. Current Sanity Studio and CLI releases
require Node.js 22.12 or newer.
- If the authentication preflight did not identify a logged-in user, follow the
`sanity.new` flow above and then continue at Step 2 with the generated
`sanity/` Studio. The remaining bullets are for the account-owned
`sanity init` path.
- Use `sanity init` to create or select the project and dataset. If the project,
organization, or dataset choice is unclear, ask the user rather than guessing.
- When the values are known, run `sanity init` unattended from the repo root —
**not inside a Next.js app folder**, where the CLI would switch to its
embedded flow (not recommended):
```bash
npx sanity@latest init --yes --project <projectId> --dataset <dataset> --template clean --typescript --output-path studio
```
- If authentication is incomplete, ask the user to finish `sanity login`, then
retry. If a project, organization, or dataset choice is still missing, ask
the user to provide it. Do not fall back to an interactive initializer flow.
- This creates a standalone Studio in `studio/`, alongside your app folder (see `project-structure.md`)
**If Studio exists:**
- Read the config to get `projectId` and `dataset`
- Proceed to Step 2
### Step 2: Check for Existing Schema
**Inspect the types registered by `sanity.config.ts`**, usually through
`schemaTypes/index.ts`, `schemas/index.ts`, or
`src/sanity/schemaTypes/index.ts`:
**If NO custom types are registered:**
- Ask: "What kind of content are you building? (e.g., Blog, E-commerce, Portfolio)"
- Create appropriate schema types based on their answer
- See `schema.md` for patterns
**If custom types are registered:**
- Show them what you found
- Ask: "Want to add more content types or modify existing ones?"
Before moving to Phase 2, keep track of the primary document type and the
fields needed to list and render it. Carry that choice through content checks,
sample content, queries, routes, and components. Never fall back to `post`
unless the registered primary type is actually `post`.
**If they want a quick example:**
Create a basic blog schema:
```typescript
// schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } 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: [defineArrayMember({ type: 'block' })],
}),
],
})
```
Register the type in the schema entry point:
```typescript
// schemaTypes/index.ts
import { post } from './post'
export const schemaTypes = [post]
```
Creating the file is not enough. Only types included in the array passed to
`schema.types` are part of the Studio schema and available to schema
deployment.
### Step 3: Deploy Schema
**Required before Phase 2:**
Run schema commands with the detected Studio folder as the working directory.
For the default side-by-side layout:
```bash
cd studio
npx sanity schemas deploy
```
This 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 == "<primaryDocumentType>"][0...5]
```
Replace `<primaryDocumentType>` with the registered type selected in Phase 1,
such as `post`, `product`, or `project`.
**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.md` and the `sanity-migration` skill for guidance
- Use MCP content tools such as `create_documents` and `patch_documents` after converting content to structured Sanity documents
### Step 2b: Generate Sample Content (MCP)
Ask the agent to draft structured sample content that matches the selected
primary document type, then create it with the Sanity MCP Server.
For the quick blog example above:
```
Tool: create_documents
Documents: [{
type: "post",
content: {
title: "Getting started with Sanity",
slug: { _type: "slug", current: "getting-started-with-sanity" },
body: []
}
}]
```
The content tool creates a draft. Show the draft to the user and ask whether to
publish it so the public frontend can read it. If yes, call
`publish_documents` with the returned document ID before starting frontend
integration.
**If MCP content tools cannot see new types or fields:** Remind them to run `npx sanity schemas deploy` first.
### MCP Setup (If Not Configured)
**Quick start via Sanity CLI:**
```bash
npx sanity@latest mcp configure
```
This command detects Codex, Cursor, Claude Code, VS Code, and other supported
editors. Prefer it over editing client configuration by hand.
**Codex (manual fallback):** Register the server globally and authenticate in one command:
```bash
codex mcp add sanity --url https://mcp.sanity.io
```
**Cursor:** [One-click install →](cursor://anysphere.cursor-deeplink/mcp/install?name=Sanity&config=eyJ1cmwiOiJodHRwczovL21jcC5zYW5pdHkuaW8iLCJ0eXBlIjoiaHR0cCJ9Cg==)
Or add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}
```
**Claude Code:**
```bash
claude mcp add Sanity -t http https://mcp.sanity.io --scope user
```
**VS Code:** Command Palette → `MCP: Open User Configuration` → add:
```json
{
"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:
The inline implementation below continues the quick **Blog** example. If the
primary document type is not `post`, adapt the type filter, projection, sample
document, route, component names, and renderer to the fields selected in Phase
1. Do not create or query `post` as a fallback for E-commerce or Portfolio
setups.
**Scaffold a new app (if you don't have one yet):**
Run from the repo root so the app sits alongside your `studio/` folder:
```bash
npx create-next-app@latest web --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd web
```
**Install dependencies:**
```bash
npm install next-sanity @sanity/image-url
```
`next-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`, `stegaClean`
- `next-sanity/live` — `defineLive` for live content with Next.js cache integration
- `next-sanity/draft-mode` — Draft Mode endpoint helpers
- `next-sanity/visual-editing` — `<VisualEditing />` component for click-to-edit overlays
- `next-sanity/image` — Sanity-aware `<Image />` wrapping `next/image`
- `next-sanity/studio` — embed the Sanity Studio at a route (legacy setups only — keep the Studio standalone, see `nextjs.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`):**
```typescript
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: "YYYY-MM-DD", // Replace with today's UTC date and keep it hard-coded
useCdn: true, // Fast, cached published-content reads
});
```
**Fetch content in a Server Component:**
```typescript
// src/app/page.tsx
import { client } from "@/sanity/client";
import { defineQuery, type SanityDocument } from "next-sanity";
import Link from "next/link";
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}>
<Link href={`/${(post.slug as { current?: string })?.current}`}>{post.title as string}</Link>
</li>
))}
</ul>
);
}
```
`{ next: { revalidate: 30 } }` opts the fetch into Next.js' ISR cache with a
30-second revalidation window. This is a minimal published-content path for a
first smoke test. Tune to taste; omit `options` to use defaults.
**Render an individual post (`src/app/[slug]/page.tsx`):**
```typescript
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=production
```
**Configure TypeGen before calling the Next.js setup complete:**
Merge the TypeGen settings into the existing `studio/sanity.cli.ts`. For the
side-by-side `studio/` and `web/` layout:
```typescript
typegen: {
enabled: true,
path: '../web/src/**/*.{ts,tsx,js,jsx}',
schema: 'schema.json',
generates: '../web/sanity.types.ts',
overloadClientMethods: true,
},
```
Add a repeatable script to `studio/package.json`:
```json
"typegen": "sanity schemas extract --force && sanity typegen generate"
```
Then run it from the Studio folder:
```bash
cd studio
npm run typegen
```
Confirm TypeGen found the frontend queries, then remove the `SanityDocument`
import, broad generic arguments, and casts. Run TypeGen after schema or query
changes. For other layouts, use `typegen.md` to adjust the paths.
For the recommended production path—live content with `defineLive`, Visual
Editing, and the standalone Studio architecture—follow `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 the selected content type).
3. **Click through to a detail page** via the in-app Next.js `<Link>` — 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
```bash
npx sanity@latest new --instructions # Print the current no-write sanity.new guide
npx sanity@latest debug # Check the current CLI user without logging in
npx sanity@latest init # Initialize an account-owned project or Studio
npx sanity@latest mcp configure # Configure MCP for your editor
npx sanity dev # Start Studio locally
npx sanity schemas 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 types (run in Studio after adding the script above)
```
---
## 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
references/groq.md›
---
title: GROQ Query Maintenance & Best Practices
description: Guidelines for GROQ queries, type safety, performance optimization, and syntax highlighting.
---
# 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:
```typescript
// 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`
```typescript
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.
```typescript
// 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.
```typescript
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
```groq
// 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 order
```
### Slice Notation
```groq
*[_type == "post"][0] // Single document (object, not array)
*[_type == "post"][0...5] // First 5 (exclusive) ← Most common
```
Slice bounds must be constant numbers — `$params` aren't allowed. For dynamic pagination, validate the numbers in application code and interpolate them directly into the query string:
```typescript
const start = Number.isInteger(page) && page >= 0 ? page * pageSize : 0
const end = start + pageSize
const query = `*[_type == "post"] | order(publishedAt desc)[${start}...${end}]`
```
### Default Values with `coalesce()`
```groq
*[_type == "page"]{
"title": coalesce(seoTitle, title, "Untitled"),
"image": coalesce(ogImage, mainImage, defaultImage)
}
```
### Conditionals with `select()`
```groq
*[_type == "product"]{
title,
"badge": select(
stock == 0 => "Out of Stock",
stock < 5 => "Low Stock",
"In Stock"
)
}
```
### Aggregation with `count()`
```groq
// Total count
count(*[_type == "post" && defined(slug.current)])
// Count per document
*[_type == "category"]{
title,
"postCount": count(*[_type == "post" && references(^._id)])
}
```
### Reverse References
```groq
*[_type == "author"]{
name,
"posts": *[_type == "post" && references(^._id)]{ title, slug }
}
```
### Array Filtering
```groq
*[_type == "movie"]{
title,
"mainCast": castMembers[role == "lead"]->{name}
}
// Check if value exists in array
*[_type == "post" && "tech" in categories[]->slug.current]
```
### Special Variables
```groq
// ^ = 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:**
```groq
// 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:
```groq
// ❌ 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):
```groq
// 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:
```groq
// ❌ 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.
```groq
// ❌ 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:
```groq
// 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.
```groq
// ❌ 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:
```groq
*[_type == "post"]{
title,
author->{ name, "avatar": image.asset->url },
categories[]->{ title, "slug": slug.current }
}
```
Use conditional projections for different contexts:
```groq
*[_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:
```groq
// ❌ 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 (exception: slice bounds — see Slice Notation) |
| 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:
```typescript
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; `v1` or `vX` may change unexpectedly
references/hydrogen.md›
---
title: "Sanity + Shopify + Hydrogen Rules"
description: Integration guide for Sanity with Shopify using the Hydrogen framework (React Router 7).
---
# Sanity + Shopify + Hydrogen Rules
**Package:** [`hydrogen-sanity`](https://github.com/sanity-io/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-end
```
## 2. Environment Variables
```bash
# 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 previews
```
## 3. Sanity Client Setup
### Vite Config
```typescript
// web/vite.config.ts
import {hydrogen} from '@shopify/hydrogen/vite'
import {sanity} from 'hydrogen-sanity/vite'
export default defineConfig({
plugins: [hydrogen(), sanity()],
})
```
### Context Setup
```typescript
// 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 previewSession = await PreviewSession.init(request, [env.SESSION_SECRET])
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)
```typescript
const {SanityProvider} = context.sanity
const body = await renderToReadableStream(
<NonceProvider>
<SanityProvider>
<ServerRouter context={reactRouterContext} url={request.url} nonce={nonce} />
</SanityProvider>
</NonceProvider>,
)
```
### Root Layout (root.tsx)
```typescript
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
```typescript
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 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
```typescript
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
```typescript
// 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
```typescript
// web/app/routes/api.preview.ts
export {action, loader} from 'hydrogen-sanity/preview/route'
```
### Content Security Policy
```typescript
// 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
```typescript
// 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
```bash
# 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 manage
```
## 8. Boundaries
- Always:
- Query Shopify for commerce data (price, inventory, variants)
- Query Sanity for editorial content (rich text, custom fields)
- Use `hydrogen-sanity` package for Visual Editing
- Add Hydrogen URL to CORS origins in [Sanity Manage](https://www.sanity.io/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_TOKEN` to client-side code
- Query Sanity for commerce data that should come from Shopify
references/image.md›
---
title: "Sanity Image Rules"
description: "Best practices for handling images in Sanity: Schema, URL generation, and Next.js Image integration."
---
# Sanity Image Rules
## 1. Schema Definition
**Always** enable `hotspot: true`. This allows editors to control cropping and the focal point.
```typescript
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`):**
```typescript
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:
```typescript
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.
```typescript
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)
```groq
mainImage {
asset->{ _id, url },
alt
}
```
### Full Query (With LQIP & Dimensions)
```groq
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, but `next/image` handles this too).
- **Sizing:** Always request the exact size you need using `.width()` and `.height()` in `urlFor`. Don't download a 4000px image for a thumbnail.
references/localization.md›
---
title: Sanity Localization Rules
description: Localization patterns for Sanity using official plugins and best practices.
---
# 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.
```typescript
// schemaTypes/locale.ts
import { TranslateIcon } from '@sanity/icons/Translate'
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.
```bash
npm install @sanity/document-internationalization
```
### Configuration
```typescript
// 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
```typescript
// 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:
```typescript
// sanity.config.ts
import { defineConfig } from 'sanity'
import type { Template } from 'sanity'
const LOCALIZED_TYPES = ['post', 'page']
const BASE_LANGUAGE = 'en'
export default defineConfig({
// ...
document: {
newDocumentOptions: (prev) => [
// Drop the auto-generated entries for localized types — they create
// documents with no `language` set
...prev.filter((item) => !LOCALIZED_TYPES.includes(item.templateId)),
// Offer the base-language templates instead
// The plugin handles creating translations from the document itself
...LOCALIZED_TYPES.map((schemaType) => ({
templateId: `${schemaType}-${BASE_LANGUAGE}`,
parameters: {language: BASE_LANGUAGE},
})),
],
},
schema: {
// A base-language template per localized type
templates: (prev): Template[] => [
...prev,
...LOCALIZED_TYPES.map((schemaType) => ({
id: `${schemaType}-${BASE_LANGUAGE}`,
title: `${schemaType} (${BASE_LANGUAGE})`,
schemaType,
parameters: [{name: 'language', type: 'string'}],
value: ({language}: {language: string}) => ({language}),
})),
],
},
})
```
A template that declares `parameters` is left out of the auto-generated "New
document" list, so it only appears if `newDocumentOptions` adds it explicitly,
with the parameter values supplied on the item. The auto-generated items carry
no `parameters` of their own, so a filter that tests `item.parameters` matches
nothing and empties the menu.
### Querying Translated Documents
```groq
// 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
```typescript
// schemaTypes/homePage.ts
import { HomeIcon } from '@sanity/icons/Home'
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:
```typescript
// sanity.config.ts
import { defineConfig } from 'sanity'
import type { Template } from 'sanity'
// Define your supported locales
const LOCALES = [
{ id: 'en', title: 'English' },
{ id: 'fr', title: 'French' },
{ id: 'de', title: 'German' },
]
export default defineConfig({
// ...
schema: {
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:
```typescript
// src/structure/index.ts
import { StructureBuilder, StructureResolver } from 'sanity/structure'
import { HomeIcon } from '@sanity/icons/Home'
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
```groq
// 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.md` for more singleton patterns
## 7. Field-Level Localization
Use **sanity-plugin-internationalized-array** (NOT localized objects — they hit attribute limits).
```bash
npm install sanity-plugin-internationalized-array
```
### Configuration
```typescript
// 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
```typescript
// 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`:
```typescript
// 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
```groq
// 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.
```bash
npm install @sanity/assist
```
```typescript
// 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:
```bash
npm install @sanity/language-filter
```
## 10. Frontend URL Best Practices
**Always include locale in the URL** for SEO:
- `yoursite.com/en/my-page` → `yoursite.com/fr/my-page`
- `yoursite.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.
references/migration-html-import.md›
---
title: Import HTML to Portable Text
description: Use @portabletext/block-tools with JSDOM to convert HTML content
---
## Import HTML to Portable Text
Use `@portabletext/block-tools` with `JSDOM` to convert HTML from legacy CMSs to Portable Text.
### Setup
```bash
npm install @portabletext/block-tools jsdom
```
### Basic Conversion
```typescript
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:
```javascript
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
rules: [
{
deserialize(el, next, block) {
// Custom link handling — links are inline annotations, not blocks.
// Return an `__annotation` with a `markDef`, and recurse into the
// child nodes via `next()` so the link text is preserved.
if (el.tagName?.toLowerCase() === 'a') {
const href = el.getAttribute('href')
// An anchor with no `href` (named anchors, JS-driven links) isn't a
// link. Fall through so the text survives without a dangling markDef.
if (!href) return undefined
return {
_type: '__annotation',
markDef: {
_type: 'link',
href,
blank: el.getAttribute('target') === '_blank'
},
children: next(el.childNodes)
}
}
// Custom image handling — block-level types are wrapped with `block()`
if (el.tagName?.toLowerCase() === 'img') {
const src = el.getAttribute('src')
// Skip sourceless images rather than emitting `image@null`, which
// the importer reports as a failed asset with no pointer to the node.
if (!src) return undefined
return block({
_type: 'image',
// NDJSON + `sanity datasets import` only — see the note below.
_sanityAsset: `image@${src}`
})
}
return undefined // Fall through to default handling
}
}
]
})
```
> **`_sanityAsset` is only resolved by `sanity datasets import`.** The NDJSON
> importer fetches each `image@<url>` and swaps in a real asset reference. The
> mutation API does not interpret the directive, so the same blocks written
> through `@sanity/client`, `sanity exec`, or `defineMigration` are stored
> verbatim — leaving an image field with a stray `_sanityAsset` string and no
> `asset` reference. On those paths, upload the image first and emit an asset
> reference instead, as in [Image Upload](#image-upload) below.
### Pre-Processing HTML
Clean HTML before conversion:
```javascript
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:
```javascript
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. This path writes through
the mutation API, so any custom rules used here must emit uploaded asset
references rather than `_sanityAsset` directives:
```typescript
// 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 migrations run import-wordpress-posts --no-dry-run`
Reference: [Schema and Content Migrations](https://www.sanity.io/docs/content-lake/schema-and-content-migrations)
references/migration.md›
---
title: Sanity Content Migration Rules
description: Best practices for migrating content (HTML, Markdown) into Sanity Portable Text.
---
# 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`, or `slug`.
- 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 `_id` in a `reference`; do not predict `_ref` values from naming conventions.
- Reserve explicit `_id` values for singleton documents such as `settings`, `homePage`, or localized singleton IDs like `homePage-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`**
```typescript
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-markdown` and `@sanity/block-tools` are deprecated. Use `@portabletext/markdown` and `@portabletext/block-tools` instead.
## 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 `table` type (HTML `<table>` or GFM tables).
- **Code:** Need a `code` type (HTML `<pre><code>` or MD code fences).
references/nextjs.md›
---
title: Next.js & Sanity Integration Rules
description: Integration guide for Next.js App Router, Live Content API, and a standalone Sanity Studio.
---
# Next.js & Sanity Integration Rules
Jump to the section that matches the task instead of reading this guide end-to-end.
## Table of Contents
- Architecture patterns
- Data fetching (Live Content API)
- Caching and revalidation
- Visual Editing and clean data
- Studio setup (standalone)
- Draft Mode setup
- Error handling
- Presentation queries
- Pagination pattern
## 1. Architecture Patterns
### Option A: Standalone Studio (Recommended)
**Best for:** All new Next.js projects.
The Studio is its own app, living alongside the Next.js app in the same repo:
```
your-project/
├── studio/ # Sanity Studio (standalone)
└── web/ # Next.js frontend
```
Why standalone instead of embedding the Studio in the Next.js app:
- **Faster dev and builds:** `sanity dev` and `sanity build` run on Vite and are dramatically faster (10-30x) than compiling the Studio through `next dev` / `next build`.
- **Auto-updates:** Standalone Studios receive bugfixes and new features automatically, with no dependency bump or redeploy. Embedded Studios can't auto-update (Next.js does not support ESM with import maps), so every update means bump + deploy.
- **TypeGen watch mode:** With `sanity dev`, TypeGen regenerates types as queries change. Embedded Studios can't hook into `next dev`, so you must re-run `sanity typegen generate` manually after every query edit.
- **Content model independence:** A separate Studio keeps the content model from becoming website-centric and makes collaboration easier.
**Setup:**
- Run both apps side by side in separate terminals: `next dev` (localhost:3000) and `sanity dev` (localhost:3333).
- Add your Next.js app URL to **CORS Origins**: `npx sanity cors add http://localhost:3000 --credentials` (repeat for your production URL), or via [Sanity Manage](https://www.sanity.io/manage).
- See `project-structure.md` rule for detailed structure.
### Option B: Embedded Studio (Not Recommended)
The Studio can be mounted inside the Next.js app at `/app/studio/[[...tool]]/page.tsx` via `next-sanity/studio`. Avoid this for new projects: it slows builds, ties every Studio update to an app deploy, and rules out auto-updates and TypeGen watch mode. For maintaining or migrating an existing embedded Studio, see section 5.
## 2. Data Fetching (Live Content API)
We use `defineLive` (next-sanity v11+) to enable real-time content updates and Visual Editing automatically.
### Setup (`src/sanity/lib/live.ts`)
```typescript
import { defineLive } from 'next-sanity/live'
import { client } from './client'
export const { sanityFetch, SanityLive } = defineLive({
client: client.withConfig({
apiVersion: '2026-02-01'
}),
serverToken: process.env.SANITY_API_READ_TOKEN,
browserToken: process.env.SANITY_API_READ_TOKEN,
})
```
### Rendering (`src/app/layout.tsx`)
You **must** render `<SanityLive />` in the root layout to enable real-time updates.
```typescript
import { SanityLive } from '@/sanity/lib/live'
import { VisualEditing } from 'next-sanity/visual-editing'
import { draftMode } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<SanityLive />
{(await draftMode()).isEnabled && <VisualEditing />}
</body>
</html>
)
}
```
## 3. Caching & Revalidation
### Prefer Live Content API (Default)
**Use `defineLive` by default.** It handles fetching, caching, and invalidation automatically. Only implement manual caching when you need fine-grained control.
### When to Use Manual Caching
| Scenario | Approach |
|----------|----------|
| Real-time updates, Visual Editing | `defineLive` (default) |
| Static marketing pages, rarely updated | Time-based revalidation |
| Blog posts, products with frequent edits | Tag-based revalidation |
| Critical accuracy (stock levels, prices) | Path-based + short revalidation |
### Debugging: Enable Fetch Logging
See every fetch with cache HIT/MISS status:
```typescript
// next.config.ts
const nextConfig: NextConfig = {
logging: {
fetches: {
fullUrl: true,
},
},
};
```
Console output shows cache status:
```text
GET /posts 200 in 39ms
│ GET https://...apicdn.sanity.io/... 200 in 5ms (cache hit)
```
### Sanity CDN vs API
| Setting | Speed | Freshness | Use When |
|---------|-------|-----------|----------|
| `useCdn: true` | Fast | May have brief delay | Default for all runtime fetches |
| `useCdn: false` | Slower | Guaranteed fresh | `generateStaticParams`, webhooks |
Override per-request:
```typescript
// For static generation, use API directly
export async function generateStaticParams() {
const slugs = await client
.withConfig({ useCdn: false })
.fetch(SLUGS_QUERY);
return slugs;
}
```
### Manual `sanityFetch` Helper (Advanced)
For manual caching control, create a wrapper:
```typescript
// src/sanity/lib/client.ts
export async function sanityFetch<const QueryString extends string>({
query,
params = {},
revalidate = 60,
tags = [],
}: {
query: QueryString;
params?: QueryParams;
revalidate?: number | false;
tags?: string[];
}) {
return client.fetch(query, params, {
next: {
revalidate: tags.length ? false : revalidate,
tags,
},
});
}
```
### Time-Based Revalidation
Simple and predictable. Good for content that changes infrequently.
```typescript
const posts = await sanityFetch({
query: POSTS_QUERY,
revalidate: 3600, // Revalidate every hour
});
```
**The "Typo Problem":** With time-based only, content authors may wait up to an hour to see changes. Use webhooks for instant updates.
### Path-Based Revalidation
Surgically revalidate specific routes when documents change.
**1. Create API Route:**
```typescript
// src/app/api/revalidate/path/route.ts
import { revalidatePath } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
type WebhookPayload = { path?: string };
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true // Add delay to allow CDN to update
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!body?.path) {
return new Response('Missing path', { status: 400 });
}
revalidatePath(body.path);
return NextResponse.json({ revalidated: body.path });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
```
**2. Create GROQ-Powered Webhook:**
- URL: `https://yoursite.com/api/revalidate/path`
- Filter: `_type in ["post"]`
- Projection: `{ "path": "/posts/" + slug.current }`
- Add `SANITY_REVALIDATE_SECRET` to webhook and `.env.local`
### Tag-Based Revalidation
"Update once, revalidate everywhere" — best for referenced content.
**1. Tag Your Queries:**
```typescript
// Posts index - revalidate when ANY post, author, or category changes
const posts = await sanityFetch({
query: POSTS_QUERY,
tags: ['post', 'author', 'category'],
});
// Individual post - more granular, includes slug-specific tag
const post = await sanityFetch({
query: POST_QUERY,
params,
tags: [`post:${params.slug}`, 'author', 'category'],
});
```
**2. Create API Route:**
```typescript
// src/app/api/revalidate/tag/route.ts
import { revalidateTag } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
type WebhookPayload = { tags: string[] };
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!Array.isArray(body?.tags) || !body.tags.length) {
return new Response('Missing tags', { status: 400 });
}
body.tags.forEach((tag) => revalidateTag(tag));
return NextResponse.json({ revalidated: body.tags });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
```
**3. Create GROQ-Powered Webhook:**
- URL: `https://yoursite.com/api/revalidate/tag`
- Filter: `_type in ["post", "author", "category"]`
- Projection: `{ "tags": [_type, _type + ":" + slug.current] }`
### Stale Data After Webhook?
Webhooks fire *before* Sanity CDN updates. If you see stale data:
1. **Add delay** — Pass `true` as third arg to `parseBody`
2. **Or bypass CDN** — Set `useCdn: false` in client config (use sparingly)
## 4. Visual Editing (Stega) & Clean Data
Visual Editing injects invisible characters into strings to enable click-to-edit.
### A. The Golden Rule of Stega
If a string field controls logic (alignment, colors, IDs), you **must** clean it before comparing.
```typescript
import { stegaClean } from "@sanity/client/stega";
export function Layout({ align }: { align: string }) {
// ❌ Bad: Will fail in Edit Mode due to invisible chars
// if (align === 'center') ...
// ✅ Good: Clean the value first
const cleanAlign = stegaClean(align);
return <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
}
```
### B. Metadata & SEO (Critical)
**Never** let Stega characters leak into `<head>` tags. Always set `stega: false` for metadata fetching.
```typescript
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({
query: SEO_QUERY,
params: await params,
stega: false // 👈 Critical for SEO
})
return { title: data?.title }
}
```
### C. Static Params
When generating static params, fetch only published content and disable stega.
```typescript
export async function generateStaticParams() {
const { data } = await sanityFetch({
query: SLUGS_QUERY,
perspective: 'published', // 👈 No drafts
stega: false
})
return data
}
```
## 5. Setup: 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:
```bash
npm create sanity@latest -- --project <projectId> --dataset production --template clean --typescript --output-path studio
```
Run it with `npm run dev` inside `studio/` (defaults to http://localhost:3333). For Visual Editing, point the Presentation Tool's `previewUrl.origin` at the Next.js app (see `visual-editing.md`).
### Migrating an Existing Embedded Studio
Embedded Studios (`<NextStudio />` mounted at a route like `/app/studio/[[...tool]]/page.tsx`) keep working, but migrating to a standalone Studio is recommended:
1. Create a standalone Studio folder as above, reusing your existing `projectId` and dataset.
2. Move `sanity.config.ts`, `sanity.cli.ts`, and your schema types into it.
3. Delete the `/app/studio/[[...tool]]/` route from the Next.js app. Keep `next-sanity` — the app still needs it for fetching, Live Content, and Visual Editing.
4. Add the app's URLs to CORS origins and set the Presentation Tool's `previewUrl.origin` to the app's URL.
## 6. Setup: Draft Mode
Enable Presentation Tool and Visual Editing by setting up a draft mode route.
**`src/app/api/draft-mode/enable/route.ts`:**
```typescript
import { client } from '@/sanity/lib/client'
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { token } from '@/sanity/lib/token' // Helper to get token
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token }),
})
```
## 7. Error Handling
Use `notFound()` for missing documents. Common errors:
| Error | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Invalid/missing token | Check `SANITY_API_READ_TOKEN` |
| 403 Forbidden | CORS not configured | Add URL to CORS origins |
| Query syntax error | Invalid GROQ | Test in Vision plugin first |
| Empty result | Wrong filter/params | Log params, check `_type` spelling |
```typescript
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: Props) {
const { data } = await sanityFetch({ query: POST_QUERY, params: await params })
if (!data) notFound()
return <Post data={data} />
}
```
## 8. Presentation Queries (`usePresentationQuery`)
For faster live editing in the Presentation Tool, use `usePresentationQuery` to fetch only the specific block being edited, rather than re-rendering the entire page.
### Why Use This
- **Without:** Editing a hero title re-fetches the whole page, re-renders all blocks
- **With:** Only the hero block re-fetches and re-renders
This is especially valuable for pages with many Page Builder blocks or complex Portable Text.
### Basic Pattern
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
type HeroProps = {
_key: string
documentId: string
title: string
subtitle?: string
// ... other initial props from page query
}
export function Hero({ _key, documentId, title, subtitle, ...rest }: HeroProps) {
// Fetch block-specific data for faster updates in Presentation Tool
const { data } = usePresentationQuery({
query: HERO_PRESENTATION_QUERY,
params: { documentId, blockKey: _key },
})
// Use presentation data if available, fallback to initial server props
const blockData = data?.heroBlock || { title, subtitle, ...rest }
return (
<section>
<h1>{blockData.title}</h1>
{blockData.subtitle && <p>{blockData.subtitle}</p>}
</section>
)
}
```
### The Presentation Query
Create a query that targets the specific block by `_key`:
```typescript
// queries.ts
export const HERO_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title,
subtitle,
image,
theme,
// Include all fields the component needs
}
}
`)
```
### Passing Document Context
Your PageBuilder component needs to pass `documentId` to each block:
```typescript
export function PageBuilder({ content, documentId }: { content: Block[]; documentId: string }) {
return (
<main>
{content.map((block) => {
switch (block._type) {
case "hero":
return <Hero key={block._key} documentId={documentId} {...block} />
// ... other blocks
}
})}
</main>
)
}
```
### For Portable Text Blocks
The same pattern works for custom blocks inside Portable Text:
```typescript
export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
"pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
image,
caption,
alt
}
}
`)
```
**See also:** `visual-editing.md` for the conceptual overview and `page-builder.md` for full Page Builder patterns.
## 9. Pagination Pattern
For listing pages with many entries, use offset-based pagination with a count query.
GROQ slice bounds (`[start...end]`) must be constant numbers, not `$params`. Validate the page bounds in application code and interpolate them directly into the query string.
### Queries
```typescript
// Total count for pagination UI
export const ARTICLES_COUNT_QUERY = defineQuery(`
count(*[_type == "article" && defined(slug.current)])
`);
// Paginated listing — validated integers interpolated into the slice
function articlesQuery(start: number, end: number) {
return defineQuery(`
*[_type == "article" && defined(slug.current)]
| order(date desc) [${start}...${end}] {
_id, title, "slug": slug.current, date
}
`);
}
```
### Listing Page
```typescript
const ENTRIES_PER_PAGE = 10;
export default async function BlogPage({
searchParams
}: {
searchParams: Promise<{ page?: string }>
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam || "1") || 1);
const start = (page - 1) * ENTRIES_PER_PAGE;
const end = start + ENTRIES_PER_PAGE;
const [{ data: articles }, { data: total }] = await Promise.all([
sanityFetch({ query: articlesQuery(start, end) }),
sanityFetch({ query: ARTICLES_COUNT_QUERY })
]);
const totalPages = Math.ceil(total / ENTRIES_PER_PAGE);
return (
<main>
{articles.map(article => (
<ArticleCard key={article._id} article={article} />
))}
<Pagination current={page} total={totalPages} />
</main>
);
}
```
references/nuxt.md›
---
title: Nuxt & Sanity Integration Rules
description: Integration guide for Nuxt, including @nuxtjs/sanity, visual editing, and data fetching.
---
# Nuxt & Sanity Integration Rules
## 1. Setup & Configuration
### Scaffold a new Nuxt app
```bash
npm create nuxt@latest my-app -- -t ui -M "" --packageManager npm --no-gitInit
cd my-app
```
`-t ui` selects the Nuxt UI starter. `-M ""` skips the interactive module-selection prompt (empty string = no extra modules). `--packageManager npm` and `--no-gitInit` suppress the other two prompts so the scaffold runs end-to-end without input.
### Installation
```bash
npx nuxi@latest module add sanity
```
`nuxi module add sanity` resolves to the official `@nuxtjs/sanity` module and registers it in `nuxt.config.ts` automatically. The module bundles `@sanity/client`, `@sanity/visual-editing`, `@portabletext/vue`, and `groq` as direct dependencies — no separate installs needed.
`groq` and `defineQuery` are also **auto-imported** by the module, so you can use them in `.vue` files without an `import` statement.
For manual image-URL building (an alternative to the auto-registered `<SanityImage>` component), add `@sanity/image-url`:
```bash
npm install @sanity/image-url
```
### What the module auto-imports
**Composables** (use directly in `<script setup>`, no imports needed):
- `useSanity()` — get the client and its config
- `useSanityQuery()` / `useLazySanityQuery()` — reactive query helpers
- `useSanityConfig()` — read the resolved module config
- `useSanityPerspective()`, `useSanityPreviewPerspective()`, `useSanityPreviewEnvironment()` — perspective helpers for drafts/preview
- `useSanityVisualEditingState()`, `useIsSanityLivePreview()`, `useIsSanityPresentationTool()` — visual-editing state helpers
**GROQ helpers** (template tags): `groq`, `defineQuery`
**Components** (use directly in `<template>`):
- `<SanityContent>` — Portable Text renderer (uses `@portabletext/vue` internally; prop is `:value`)
- `<SanityImage>` — image renderer; takes an `assetId` (the image asset's `_ref`); upgrades to `<NuxtImg>` automatically when `@nuxt/image` is installed
- `<SanityFile>` — file renderer
### Configuration (`nuxt.config.ts`)
```typescript
export default defineNuxtConfig({
modules: ['@nuxtjs/sanity'],
sanity: {
projectId: process.env.NUXT_SANITY_PROJECT_ID,
dataset: process.env.NUXT_SANITY_DATASET,
apiVersion: '2026-05-15',
// Live Visual Editing Configuration
visualEditing: {
studioUrl: process.env.NUXT_SANITY_STUDIO_URL,
token: process.env.NUXT_SANITY_API_READ_TOKEN, // Required for fetching drafts
stega: true, // Enable stega for visual editing
mode: 'live-visual-editing', // Default: enables live updates
},
},
});
```
**Important:** Don't enable the `minimal` client if you want the full feature set (composables, components, visual editing).
## 2. Data Fetching
### `useSanityQuery`
Use the composable for reactive fetching. It handles preview state automatically when `visualEditing` is configured. `groq` and `defineQuery` are auto-imported — use either.
```vue
<!-- app/pages/posts.vue -->
<script setup lang="ts">
const query = groq`*[_type == "post" && defined(slug.current)]{ _id, title, slug }`
const { data: posts } = await useSanityQuery<Array<{ _id: string; title?: string; slug?: { current?: string } }>>(query)
</script>
<template>
<ul>
<li v-for="post in posts || []" :key="post._id">
<NuxtLink :to="`/${post.slug?.current}`">{{ post.title }}</NuxtLink>
</li>
</ul>
</template>
```
### Dynamic Routes (`[slug].vue`)
Pull the slug off `useRoute()` and pass it as a query parameter. The `<SanityContent>` component renders Portable Text — note the prop is `value`, not `blocks` (renamed in v2).
```vue
<!-- app/pages/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const query = groq`*[_type == "post" && slug.current == $slug][0]{ _id, title, body }`
const { data: post } = await useSanityQuery<{ _id: string; title?: string; body?: unknown[] }>(
query,
{ slug: route.params.slug }
)
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<SanityContent v-if="post.body" :value="post.body" />
</article>
</template>
```
## 3. Visual Editing (Live Preview)
### Automatic Setup
When `visualEditing` is configured in `nuxt.config.ts`, the module handles:
1. Injecting the Visual Editing overlays.
2. Refreshing data when content changes in the Studio.
3. Enabling Stega encoding.
### Handling Stega in Logic
If you use stega-encoded strings in logic (e.g. `v-if="post.layout === 'full'"`), you must clean them. `stegaClean` is exported from `@sanity/client/stega` (a transitive of `@nuxtjs/sanity`, so no separate install).
```typescript
import { stegaClean } from '@sanity/client/stega'
const layout = computed(() => stegaClean(props.layout))
```
## 4. Components
### Portable Text — `<SanityContent>`
The module auto-registers `<SanityContent>`. Don't install `@portabletext/vue` separately; it's a direct dep of the module.
```vue
<SanityContent :value="post.body" />
```
For custom blocks/marks, pass `:components`:
```vue
<SanityContent :value="post.body" :components="{ block: { h2: MyH2 } }" />
```
### Images — two options
**Option A — `<SanityImage>` (recommended).** Auto-registered. Takes the asset's `_ref` (the `assetId`) and builds the URL via the module's resolved projectId/dataset. If `@nuxt/image` is installed, it transparently upgrades to `<NuxtImg>` for responsive sizing.
```vue
<SanityImage :asset-id="post.mainImage.asset._ref" width="800" />
```
**Option B — `@sanity/image-url` builder.** Install `@sanity/image-url` separately and build URLs manually. Useful when you need fine-grained control (hotspot/crop, format negotiation, srcset).
```typescript
import imageUrlBuilder from '@sanity/image-url'
const builder = imageUrlBuilder(useSanity().client)
// builder.image(source).width(800).url()
```
references/page-builder.md›
---
title: "Sanity Page Builder Patterns"
description: Patterns for Sanity Page Builder arrays, block components, and live editing.
---
# Sanity Page Builder Patterns
This guide covers **Page Builder** patterns—arrays of block objects that allow content teams to compose flexible page layouts. For Portable Text (rich text within documents), see `portable-text.md`.
## 1. What is a Page Builder?
A page builder is an **array of objects** (`pageBuilder[]`) that allows content teams to compose pages from reusable blocks without developer intervention.
**When to use:**
- Flexible layouts needed (marketing pages, landing pages)
- Content can be reordered
- Different components on different pages
**When NOT to use:**
- Rigid, formulaic content (blog posts, product pages)
- Highly structured data that doesn't change layout
- Rich text within a document body—use Portable Text instead
## 2. Schema Organization
### Directory Structure
```
schemaTypes/
├── blocks/ # Page builder blocks (objects)
│ ├── heroType.ts
│ ├── featuresType.ts
│ └── faqsType.ts
├── pageBuilderType.ts # The array definition
└── pageType.ts # Document using the page builder
```
### Objects vs References
| Use **Objects** | Use **References** |
|-----------------|-------------------|
| Content is unique to this page | Content reused across many pages |
| Simpler queries | Needs central management |
| Default choice | FAQs, CTAs, testimonials |
**Rule:** Use references sparingly. Most blocks should be objects.
### Page Builder Array
```typescript
// pageBuilderType.ts
import { defineType, defineArrayMember } from "sanity";
export const pageBuilderType = defineType({
name: "pageBuilder",
type: "array",
of: [
defineArrayMember({ type: "hero" }),
defineArrayMember({ type: "splitImage" }),
defineArrayMember({ type: "features" }),
defineArrayMember({ type: "faqs" }),
],
options: {
insertMenu: {
views: [
// Optional: Show visual thumbnails in the insert menu grid
{ name: "grid", previewImageUrl: (type) => `/block-previews/${type}.png` },
],
},
},
});
```
### Block Preview Pattern
Every block should have consistent previews:
```typescript
import { defineType } from "sanity";
import { BlockContentIcon } from "@sanity/icons/BlockContent";
export const splitImageType = defineType({
name: "splitImage",
type: "object",
icon: BlockContentIcon,
fields: [/* ... */],
preview: {
select: { title: "title", media: "image" },
prepare({ title, media }) {
return {
title: title || "Untitled",
subtitle: "Split Image", // Block type name
media: media ?? BlockContentIcon, // Fallback to icon
};
},
},
});
```
## 3. Querying Page Builders
Expand references only for blocks that need them:
```groq
*[_type == "page" && slug.current == $slug][0]{
...,
content[]{
...,
_type == "faqs" => {
...,
faqs[]-> // Expand only FAQ references
}
}
}
```
## 4. Rendering Page Builders
### TypeScript Typing
Use `Extract` to type individual blocks from the query result:
```typescript
import { PAGE_QUERYResult } from "@/sanity/types";
type HeroProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "hero" }
>;
export function Hero({ title, image }: HeroProps) {
// Fully typed!
}
```
### Switch-Based Rendering
```typescript
export function PageBuilder({ content }: { content: Block[] }) {
if (!Array.isArray(content)) return null;
return (
<main>
{content.map((block) => {
switch (block._type) {
case "hero":
return <Hero key={block._key} {...block} />;
case "features":
return <Features key={block._key} {...block} />;
case "splitImage":
return <SplitImage key={block._key} {...block} />;
default:
return <div key={block._key}>Unknown: {block._type}</div>;
}
})}
</main>
);
}
```
**Always use `_key` for React keys:**
```typescript
// Breaks Visual Editing and causes hydration issues
{items.map((item, i) => <Component key={i} {...item} />)}
// Always use Sanity's _key
{items.map((item) => <Component key={item._key} {...item} />)}
```
### Cleaning Values for Logic
Use `stegaClean` when block fields control rendering logic:
```typescript
import { stegaClean } from "next-sanity";
function SplitImage({ orientation, title, image }) {
return (
<section data-orientation={stegaClean(orientation) || "imageLeft"}>
{/* ... */}
</section>
);
}
```
## 5. Presentation Queries for Live Editing (Next.js)
For faster live updates in the Presentation Tool, use **presentation queries** that fetch only the specific block being edited, rather than re-fetching the entire page.
> **Note:** This pattern uses `usePresentationQuery` from `next-sanity/hooks`. For other frameworks, check your loader package for equivalent functionality.
### The Pattern
1. **Create a block-specific presentation query:**
```typescript
// queries.ts
export const HERO_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title,
subtitle,
image,
// ... all fields the component needs
}
}
`)
```
2. **Use `usePresentationQuery` in your component:**
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
type HeroProps = {
_key: string
documentId: string
// ... initial props from page query
}
export function Hero({ _key, documentId, ...initialProps }: HeroProps) {
// Fetch block-specific data for faster updates
const { data } = usePresentationQuery({
query: HERO_PRESENTATION_QUERY,
params: { documentId, blockKey: _key },
})
// Use presentation data if available, fallback to initial props
const blockData = data?.heroBlock || initialProps
return (
<section>
<h1>{blockData.title}</h1>
{/* ... */}
</section>
)
}
```
### Why This Is Faster
- **Without:** Editing a field triggers a full page re-render with all blocks
- **With:** Only the specific block re-renders with its targeted query
This pattern is especially valuable for pages with many blocks or complex nested data.
**Note:** See `nextjs.md` for more details on `usePresentationQuery` and `visual-editing.md` for the conceptual overview.
## 6. Page Builder Pitfalls
| Pitfall | Solution |
|---------|----------|
| Too many block variations | Split into separate blocks if >2 variants |
| Paradox of choice | Limit blocks per document type |
| Overusing references | Default to objects; references only for truly shared content |
| Unused blocks accumulate | Prune regularly; see deprecation patterns |
| Inconsistent previews | Always set title, subtitle (block name), and media/icon |
## 7. Component Alignment Pattern
Map Sanity "alignment" fields (usually string/select) to CSS classes using utility functions.
**Schema:**
```typescript
defineField({
name: 'align',
type: 'string',
options: { list: ['left', 'center', 'right'], layout: 'radio' }
})
```
**Implementation (Utility):**
```typescript
import { stegaClean } from "@sanity/client/stega";
export function getTextAlign(align?: string) {
// CLEAN the value before switching!
switch (stegaClean(align)) {
case 'left': return 'text-left';
case 'right': return 'text-right';
default: return 'text-center';
}
}
```
## 8. Semantic Heading Levels
**Rule:** Do NOT store heading levels (h1, h2) in Sanity schema options. Determine them dynamically in the frontend to ensure accessibility.
**Bad Schema:**
```typescript
// Don't do this
{ name: 'level', type: 'string', options: { list: ['h1', 'h2'] } }
```
**Good Component:**
Pass a `semanticLevel` prop based on the component's context/nesting.
```typescript
type Props = {
block: HeroBlock;
level?: 'h1' | 'h2' | 'h3'; // Default to h2 if undefined
}
export default function Section({ block, level = 'h2' }: Props) {
const Tag = level;
return <Tag>{block.title}</Tag>;
}
```
*Note: For Image patterns, see `image.md`. For Portable Text patterns, see `portable-text.md`.*
references/portable-text.md›
---
title: "Sanity Portable Text Rules"
description: Portable Text (Rich Text) rendering and custom component creation for React/Next.js.
---
# Sanity Portable Text Rules
Portable Text is Sanity's rich text format, used for content like article bodies (`body[]`). This guide covers rendering and creating custom PTE components.
**Note:** For page-level layout blocks (`pageBuilder[]`), see `page-builder.md`.
## 1. The Component
Use the `PortableText` component from `next-sanity` (or `@portabletext/react`).
```typescript
import { PortableText } from "next-sanity";
// or import { PortableText } from "@portabletext/react";
export function Content({ value }: { value: any }) {
return <PortableText value={value} components={components} />;
}
```
## 2. Custom Components (`components` prop)
**Always** define a typed components object to handle custom blocks, marks, and list styles.
```typescript
import { PortableTextComponents } from "next-sanity";
const components: PortableTextComponents = {
// 1. Block styles (paragraphs, headings)
block: {
h1: ({ children }) => <h1 className="text-4xl font-bold">{children}</h1>,
h2: ({ children }) => <h2 className="text-3xl font-bold">{children}</h2>,
blockquote: ({ children }) => <blockquote className="border-l-4 pl-4">{children}</blockquote>,
},
// 2. Custom types (non-text blocks like images, videos)
types: {
image: ({ value }) => <SanityImage value={value} />,
callToAction: ({ value }) => <Button href={value.url}>{value.text}</Button>,
},
// 3. Marks (inline decorators and annotations)
marks: {
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
link: ({ children, value }) => {
const rel = !value.href.startsWith("/") ? "noreferrer noopener" : undefined;
return <a href={value.href} rel={rel} className="underline text-blue-600">{children}</a>;
},
},
// 4. Lists
list: {
bullet: ({ children }) => <ul className="list-disc ml-4">{children}</ul>,
number: ({ children }) => <ol className="list-decimal ml-4">{children}</ol>,
},
};
```
## 3. Component Categories
Portable Text has three types of custom components, each with different patterns:
| Type | Examples | Pattern |
|------|----------|---------|
| **Block styles** | h1, h2, blockquote, normal | Text blocks with `children` prop |
| **Custom types** | image, video, callToAction | Non-text blocks with `value` prop |
| **Marks** | link, strong, productRef | Inline annotations wrapping text |
## 4. Creating Block Style Components
Block styles are text blocks like headings and paragraphs. For simple styling, inline components work fine:
```typescript
block: {
h2: ({ children }) => <h2 className="mt-8 mb-4 text-3xl font-bold">{children}</h2>,
normal: ({ children }) => <p className="mb-4 leading-relaxed">{children}</p>,
}
```
### With Visual Editing Support
For live editing in the Presentation Tool, block style components may need **both** a client and server version:
```typescript
// Heading2.tsx (Server - simple SSR for production)
export function Heading2({ children }: { children: React.ReactNode }) {
return <h2 className="mt-8 mb-4 text-3xl font-bold">{children}</h2>;
}
// Heading2Client.tsx (Client - for visual editing context)
'use client'
export function Heading2Client({ children, value }: { children: React.ReactNode; value: any }) {
// Can access block data via `value` for advanced patterns
return <h2 className="mt-8 mb-4 text-3xl font-bold">{children}</h2>;
}
```
Use `useIsPresentationTool` to conditionally render the client version:
```typescript
import { useIsPresentationTool } from 'next-sanity/hooks'
function Heading2Wrapper(props) {
const isPresentationTool = useIsPresentationTool()
if (isPresentationTool) {
return <Heading2Client {...props} />
}
return <Heading2 {...props} />
}
```
## 5. Creating Custom Type Components
Custom types are non-text blocks like images, videos, or CTAs embedded in rich text.
### Schema Definition
```typescript
// schemaTypes/blocks/pteImageBlock.ts
import { defineType, defineField } from 'sanity'
export const pteImageBlock = defineType({
name: 'pteImage',
title: 'Image',
type: 'object',
fields: [
defineField({ name: 'image', type: 'image', options: { hotspot: true } }),
defineField({ name: 'caption', type: 'string' }),
defineField({ name: 'alt', type: 'string', validation: (r) => r.required() }),
],
preview: {
select: { title: 'caption', media: 'image' },
},
})
```
### Register in Body Schema
```typescript
defineField({
name: 'body',
type: 'array',
of: [
{ type: 'block' }, // Standard text
{ type: 'pteImage' }, // Custom image block
{ type: 'pteVideo' }, // Custom video block
],
})
```
### Frontend Component
```typescript
// PteImageComponent.tsx
'use client'
type PteImageProps = {
value: {
_key: string
image: any
caption?: string
alt: string
}
}
export function PteImageComponent({ value }: PteImageProps) {
if (!value.image) return null
return (
<figure className="my-8">
<SanityImage value={value.image} alt={value.alt} />
{value.caption && (
<figcaption className="text-sm text-gray-600 mt-2">{value.caption}</figcaption>
)}
</figure>
)
}
// Register in components
const components: PortableTextComponents = {
types: {
pteImage: PteImageComponent,
},
}
```
## 6. Creating Mark Components
Marks are inline annotations that wrap text—links, highlights, or custom references.
### Schema Definition (Annotation)
```typescript
// In your block configuration
defineField({
name: 'body',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Highlight', value: 'highlight' },
],
annotations: [
{
name: 'link',
type: 'object',
title: 'Link',
fields: [
{ name: 'href', type: 'url', title: 'URL' },
{ name: 'openInNewTab', type: 'boolean', title: 'Open in new tab' },
],
},
{
name: 'productRef',
type: 'object',
title: 'Product Reference',
fields: [
{ name: 'product', type: 'reference', to: [{ type: 'product' }] },
],
},
],
},
},
],
})
```
### Frontend Component
```typescript
// LinkMark.tsx
type LinkMarkProps = {
children: React.ReactNode
value: {
href: string
openInNewTab?: boolean
}
}
export function LinkMark({ children, value }: LinkMarkProps) {
const { href, openInNewTab } = value
const target = openInNewTab ? '_blank' : undefined
const rel = openInNewTab ? 'noopener noreferrer' : undefined
return (
<a href={href} target={target} rel={rel} className="text-blue-600 underline">
{children}
</a>
)
}
// Register in components
const components: PortableTextComponents = {
marks: {
link: LinkMark,
highlight: ({ children }) => <mark className="bg-yellow-200">{children}</mark>,
},
}
```
## 7. Presentation Queries for PTE Blocks
For faster live editing of custom PTE blocks, use presentation queries that fetch only the specific block:
```typescript
// queries.ts
export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
_key,
image,
caption,
alt
}
}
`)
```
Then in your component:
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
export function PteImageComponent({ value, documentId }: { value: any; documentId?: string }) {
const { data } = usePresentationQuery({
query: PTE_IMAGE_PRESENTATION_QUERY,
params: { documentId, blockKey: value._key },
})
const blockData = data?.pteImageBlock || value
// ... render with blockData
}
```
**Note:** You'll need to pass `documentId` through to your PTE components. See `visual-editing.md` for context patterns.
## 8. GROQ Fragment for PTE
When querying documents with Portable Text, expand custom blocks:
```groq
*[_type == "article" && slug.current == $slug][0]{
...,
body[]{
...,
_type == "pteImage" => {
...,
"imageUrl": image.asset->url
},
_type == "pteVideo" => {
...,
video->{ title, url }
}
}
}
```
## 9. Stega and Visual Editing
When Visual Editing is enabled, text content contains invisible stega characters for click-to-edit functionality.
**For text rendering:** Let stega characters pass through—they enable overlays:
```typescript
// Good - stega preserved for click-to-edit
<h2>{children}</h2>
```
**For logic/comparisons:** Clean the values first:
```typescript
import { stegaClean } from '@sanity/client/stega'
// Clean before using in logic
const cleanedStyle = stegaClean(block.style)
if (cleanedStyle === 'h2') { ... }
```
## 10. Type Safety
When using TypeGen, the Portable Text value usually has a complex generated type. You can often use `any` or `PortableTextBlock[]` for the *prop*, but cast specific blocks if needed.
```typescript
import { PortableTextBlock } from "next-sanity";
type Props = {
value: PortableTextBlock[];
};
```
## 11. Best Practices
- **Tailwind Typography:** For simple blogs, wrap `<PortableText />` in a `<div className="prose">` (from `@tailwindcss/typography`) instead of manually styling every block.
- **Handling Nulls:** Always check if `value` exists and is an array before rendering.
- **Keys:** The `PortableText` component handles React keys automatically using the `_key` from Sanity. Do not add keys manually.
- **Separate from Page Builder:** PTE blocks live in `body[]` (rich text fields), not `pageBuilder[]` (page layout). Keep these patterns separate.
references/project-structure.md›
---
title: Sanity Project Structure
description: Project structure patterns for Sanity projects including standalone Studio and monorepo setups.
---
# Sanity Project Structure
## Standalone Studio
Best for content-only projects, API-first architectures, or when frontend is managed separately.
```
your-project/
├── schemaTypes/
│ ├── index.ts
│ ├── documents/
│ ├── objects/
│ └── blocks/
├── sanity.config.ts
├── sanity.cli.ts
└── package.json
```
**Use cases:**
- Content modeling with MCP/AI tools (no frontend needed)
- Headless CMS with external consumers
- Prototyping and content design
## Monorepo (Recommended with a frontend)
Best for most projects pairing Sanity with a Next.js (or other framework) app. The Studio stays standalone — Vite-based dev/builds, auto-updates, TypeGen watch mode — while living in the same repo as the frontend.
```
your-project/
├── studio/ # Sanity Studio (standalone)
│ ├── schemaTypes/
│ │ ├── index.ts
│ │ ├── documents/
│ │ ├── objects/
│ │ └── blocks/
│ ├── sanity.config.ts
│ ├── sanity.cli.ts # CLI + TypeGen configuration
│ └── package.json
└── web/ # Next.js (or other framework)
├── src/
│ ├── app/
│ └── sanity/
│ ├── client.ts
│ ├── live.ts # defineLive setup
│ └── queries.ts
├── sanity.types.ts # Generated types (from TypeGen)
└── package.json
```
No workspace tooling is required — each app manages its own dependencies. For larger repos, the same shape works under `apps/` with npm or pnpm workspaces.
**Setup:**
1. Add the web app URL to CORS origins: `npx sanity cors add http://localhost:3000 --credentials` (or via [Sanity Manage](https://www.sanity.io/manage))
2. Configure `typegen` in `studio/sanity.cli.ts` to read queries from `../web` and output types to `../web/sanity.types.ts` (see `typegen.md`)
3. Optionally add a root `package.json` with scripts that run both dev servers
## Embedded Studio (Legacy — Not Recommended)
Older Next.js projects may mount the Studio inside the app at `src/app/studio/[[...tool]]/page.tsx`, with `sanity.config.ts` in the app root. This still works but is no longer recommended: it slows builds, ties Studio updates to app deploys, and rules out auto-updates and TypeGen watch mode. See `nextjs.md` for the rationale and migration steps.
## File Naming Conventions
- **kebab-case** for all files: `user-profile.ts`, `hero-block.ts`
- `.ts` for schemas/utilities, `.tsx` for React components
- Each schema exports a named const matching filename
## Schema Directory Structure
```
schemaTypes/
├── index.ts # Exports all types
├── documents/ # Standalone content types
│ ├── post.ts
│ └── author.ts
├── objects/ # Embeddable/reusable types
│ ├── seo.ts
│ └── link.ts
├── blocks/ # Portable Text blocks
│ ├── hero.ts
│ └── callout.ts
└── shared/ # Shared field definitions
└── seoFields.ts
```
## Key Files
| File | Purpose |
|------|---------|
| `sanity.config.ts` | Studio configuration (plugins, schema, structure) |
| `sanity.cli.ts` | CLI configuration (project ID, dataset, TypeGen config) |
| `structure.ts` | Custom desk structure |
references/remix.md›
---
title: React Router (Remix) & Sanity Integration Rules
description: Integration guide for React Router v7 (and Remix v2) with Sanity, including loaders and visual editing.
---
# React Router (Remix) & Sanity Integration Rules
## Version Note
The primary examples below use **React Router v7** (the current shape — Remix v2 was renamed to React Router v7 starting with the v7 release). Import paths and the route-types file (`./+types/<route>`) come from the `react-router` package and the framework's typegen.
If you are on the older **Remix v2** stack, the integration shape is identical; only the import paths differ:
| React Router v7 | Remix v2 |
|-----------------|----------|
| `react-router` | `@remix-run/node` / `@remix-run/react` |
| `import type { Route } from "./+types/<route>"` | `import type { LoaderFunctionArgs } from "@remix-run/node"` + `useLoaderData<typeof loader>()` |
| `react-router.config.ts` | `remix.config.js` |
## 1. Setup & Client Pattern
### Scaffold a new React Router v7 app
```bash
npx create-react-router@latest my-app -y
cd my-app
npm install @sanity/client @sanity/react-loader @sanity/visual-editing @portabletext/react groq
```
`-y` accepts defaults. The Sanity packages cover server loaders (`@sanity/react-loader`, `@sanity/client`), live preview (`@sanity/visual-editing`), Portable Text rendering (`@portabletext/react`), and typed queries (`groq`).
To support both server-side fetching and client-side live previews, use the **Split Loader Pattern**.
### A. Environment Variables
React Router runs on Vite. **Any module reachable from a route component gets bundled into the client** — `process.env` doesn't exist there and will throw `ReferenceError: process is not defined` on client-side route transitions (SSR will still work, which makes this trap easy to miss).
Split publishable values from secrets:
- **Publishable** (`projectId`, `dataset`, `apiVersion`, `studioUrl`): prefix with `VITE_` and read via `import.meta.env`. Safe to import from anywhere.
- **Secrets** (read tokens, webhook secrets): keep unprefixed and read via `process.env` **only inside `*.server.ts` files**. Never re-export them from a shared module.
`.env`:
```
VITE_SANITY_PROJECT_ID=your-project-id
VITE_SANITY_DATASET=production
VITE_SANITY_API_VERSION=2026-02-01
VITE_SANITY_STUDIO_URL=http://localhost:3333
SANITY_API_READ_TOKEN=your-read-token
```
`app/sanity/env.ts` — browser-safe, publishable values only:
```typescript
export const projectId = import.meta.env.VITE_SANITY_PROJECT_ID!
export const dataset = import.meta.env.VITE_SANITY_DATASET!
export const apiVersion = import.meta.env.VITE_SANITY_API_VERSION ?? '2026-02-01'
export const studioUrl = import.meta.env.VITE_SANITY_STUDIO_URL
```
### B. Shared Loader (`app/sanity/loader.ts`)
Defines the store config (SSR enabled, client deferred).
```typescript
import { createQueryStore } from '@sanity/react-loader'
export const {
loadQuery,
setServerClient,
useQuery,
useLiveMode,
} = createQueryStore({ client: false, ssr: true })
```
### C. Server Loader (`app/sanity/loader.server.ts`)
Initializes the server client. Read the token directly from `process.env` here — do **not** import it from `env.ts`, or it will leak into the client bundle the moment any client-reachable module touches `env.ts`.
```typescript
import { createClient } from '@sanity/client'
import { loadQuery, setServerClient } from './loader'
import { projectId, dataset, apiVersion, studioUrl } from './env'
const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
token: process.env.SANITY_API_READ_TOKEN,
stega: {
// Stega encodes invisible markers into string fields for click-to-edit
// overlays in the Presentation tool. Those markers can leak into copy/paste,
// screen readers, and some downstream renderers, so only enable when actually
// previewing — gate on an env var that's only set in preview environments.
enabled: Boolean(studioUrl),
studioUrl,
},
})
setServerClient(client)
export { loadQuery }
```
### D. Browser-safe Client + Image URL Builder (`app/sanity/client.ts`, `app/sanity/image.ts`)
Anything used by a route component runs in the browser too. Build a separate publishable-only client for things like the image URL builder:
```typescript
// app/sanity/client.ts
import { createClient } from '@sanity/client'
import { projectId, dataset, apiVersion } from './env'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
```
```typescript
// app/sanity/image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './client'
const builder = imageUrlBuilder(client)
export const urlFor = (source: Parameters<typeof builder.image>[0]) => builder.image(source)
```
Install `@sanity/image-url` if you'll render images:
```bash
npm install @sanity/image-url
```
### E. Queries (`app/sanity/queries.ts`)
Keep query definitions in one place so route loaders, components, and TypeGen all read the same source.
```typescript
import { defineQuery } from "groq";
export const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){
_id, title, slug
}`
);
export const POST_QUERY = defineQuery(
`*[_type == "post" && slug.current == $slug][0]{
_id, title, body, image
}`
);
```
## 2. Data Fetching (Loaders)
Use `loadQuery` from your **server** file in route loaders. Import the generated `Route` type from `./+types/<route>` — React Router writes one type module per route file.
```typescript
// app/routes/home.tsx
import type { Route } from "./+types/home";
import { loadQuery } from "~/sanity/loader.server";
import { POSTS_QUERY } from "~/sanity/queries";
export async function loader() {
const initial = await loadQuery(POSTS_QUERY, {});
return { initial, query: POSTS_QUERY, params: {} };
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { initial } = loaderData;
// …pass to component
}
```
For Remix v2: replace `Route.ComponentProps` / `Route.LoaderArgs` with `useLoaderData<typeof loader>()` and `LoaderFunctionArgs` from `@remix-run/node`.
## 3. Dynamic Routes (`:slug`)
Register the dynamic route in `app/routes.ts`:
```typescript
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route(":slug", "routes/post.tsx"),
] satisfies RouteConfig;
```
Then in `app/routes/post.tsx`:
```typescript
import type { Route } from "./+types/post";
import { PortableText } from "@portabletext/react";
import { loadQuery } from "~/sanity/loader.server";
import { useQuery } from "~/sanity/loader";
import { urlFor } from "~/sanity/image";
import { POST_QUERY } from "~/sanity/queries";
export async function loader({ params }: Route.LoaderArgs) {
const initial = await loadQuery(POST_QUERY, { slug: params.slug });
return { initial, query: POST_QUERY, params: { slug: params.slug } };
}
export default function Post({ loaderData }: Route.ComponentProps) {
const { initial, query, params } = loaderData;
const { data: post } = useQuery(query, params, { initial });
return (
<article>
<h1>{post?.title}</h1>
{post?.image && (
<img src={urlFor(post.image).width(1200).url()} alt={post.title ?? ""} />
)}
{post?.body && <PortableText value={post.body} />}
</article>
);
}
```
This route is the canonical shape that exposes the env trap: `urlFor` → `client.ts` → `env.ts`. If `env.ts` reads `process.env`, the route works under SSR (curl returns HTML) but the client-side `<Link>` navigation will throw `ReferenceError: process is not defined` in the browser console and React Router will hard-reload back to `/`.
## 4. Real-time Preview & Visual Editing
### A. Use `useQuery` in Components
Import `useQuery` from your **shared** loader file.
```typescript
import { useQuery } from "~/sanity/loader";
export default function Page({ loaderData }: Route.ComponentProps) {
const { initial, query, params } = loaderData;
const { data, encodeDataAttribute } = useQuery(query, params, { initial });
return (
<h1 data-sanity={encodeDataAttribute("title")}>
{data?.title}
</h1>
);
}
```
### B. Enable Live Mode (`VisualEditing.tsx`)
Create a component to handle the connection.
```typescript
import { enableVisualEditing } from '@sanity/visual-editing'
import { useLiveMode } from '~/sanity/loader'
import { client } from '~/sanity/client' // Your browser-safe client
import { useEffect } from 'react'
export default function VisualEditing() {
useEffect(() => enableVisualEditing(), [])
useLiveMode({ client })
return null
}
```
Render this component in `root.tsx` only when valid (e.g., check env vars or user session).
## 5. Stega Cleaning
When using data for logic (routing, classNames), use `stegaClean`.
```typescript
import { stegaClean } from "@sanity/client/stega"
// ...
if (stegaClean(slug) === 'home') { ... }
```
references/schema.md›
---
title: Sanity Schema Best Practices
description: Rules for defining Sanity Content Models (Schemas), including field definitions, strict typing, and validation patterns.
---
# Sanity Schema Best Practices
Use this contents list to jump to the schema design decision you are making.
## Table of Contents
- Core philosophy: data over presentation
- Strict definition syntax
- Shared fields pattern
- Field patterns
- References vs nested objects
- Document creation and IDs
- Safe schema updates
- Validation patterns
## 1. Core Philosophy: Data > Presentation
Model **what things are**, not **what they look like**.
- ❌ **Bad:** `bigHeroText`, `redButton`, `threeColumnRow`, `color`, `fontSize`
- ✅ **Good:** `heroStatement`, `callToAction`, `featuresSection`, `status`, `role`
**The test:** "If we redesigned the site, would this field name still make sense?"
- `threeColumnLayout` → ❌ Fails (what if we go to 2 columns?)
- `features` → ✅ Passes (features are features regardless of layout)
## 2. Strict Definition Syntax
Always use the helper functions from `sanity` for type safety and autocompletion.
- **ALWAYS** use `defineType` for the root export.
- **ALWAYS** use `defineField` for fields.
- **ALWAYS** use `defineArrayMember` for items inside arrays.
```typescript
import { defineType, defineField, defineArrayMember } from 'sanity'
import { TagIcon } from '@sanity/icons/Tag'
export const article = defineType({
name: 'article',
title: 'Article',
type: 'document',
icon: TagIcon,
fields: [
defineField({
name: 'title',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'tags',
type: 'array',
of: [
// ALWAYS use defineArrayMember for array items
defineArrayMember({ type: 'reference', to: [{ type: 'tag' }] })
]
})
]
})
```
## 3. Shared Fields Pattern
Export arrays of fields to reuse common patterns (e.g., SEO, standard page headers).
```typescript
// src/schemaTypes/shared/seoFields.ts
export const seoFields = [
defineField({ name: 'seoTitle', type: 'string', title: 'SEO Title' }),
defineField({ name: 'seoDesc', type: 'text', title: 'SEO Description' })
]
// Usage
defineType({
name: 'page',
fields: [
defineField({ name: 'title', type: 'string' }),
...seoFields // Spread shared fields
]
})
```
## 4. Field Patterns
### A. Array Keys (`_key`)
Every item in a Sanity array automatically gets a `_key` property. This is **critical** for:
- React reconciliation (use as `key` prop)
- Visual Editing overlays (click-to-edit)
- Portable Text rendering
**Schema:** Sanity auto-generates `_key` for array items. You don't define it.
**Frontend:** Always use `_key` as React's `key`:
```typescript
// ✅ Correct
{items.map((item) => <Component key={item._key} {...item} />)}
// ❌ Wrong - index keys break Visual Editing
{items.map((item, i) => <Component key={i} {...item} />)}
```
**Querying:** Always include `_key` in array projections:
```groq
*[_type == "page"][0]{
pageBuilder[]{
_key, // Always include _key in queries
_type,
...
}
}
```
### B. Icons
Always assign an icon from `@sanity/icons` to documents and objects. This improves the Studio UX significantly. Browse all icons at [icons.sanity.build](https://icons.sanity.build/all).
```typescript
// ✅ Correct — import each icon from its own subpath
import { DocumentTextIcon } from '@sanity/icons/DocumentText'
// ❌ Wrong — root named exports were removed in v5.
// Type-checks clean, then fails at bundle time.
import { DocumentTextIcon } from '@sanity/icons'
```
| Content Type | Icon | Import |
|--------------|------|--------|
| Article, Post | `DocumentTextIcon` | `@sanity/icons/DocumentText` |
| Author, Person | `UserIcon` | `@sanity/icons/User` |
| Category, Tag | `TagIcon` | `@sanity/icons/Tag` |
| Settings | `CogIcon` | `@sanity/icons/Cog` |
| Page | `DocumentIcon` | `@sanity/icons/Document` |
| Image block | `ImageIcon` | `@sanity/icons/Image` |
| Video block | `PlayIcon` | `@sanity/icons/Play` |
| FAQ | `HelpCircleIcon` | `@sanity/icons/HelpCircle` |
| Link | `LinkIcon` | `@sanity/icons/Link` |
### C. Boolean vs. List
Avoid boolean fields for binary states that might expand later.
- **Prefer:** `options.list` with "radio" layout.
```typescript
defineField({
name: 'status',
type: 'string',
options: {
list: [
{ title: 'Draft', value: 'draft' },
{ title: 'Published', value: 'published' }
],
layout: 'radio'
}
})
```
### D. The "Toggle" Pattern (Conditional Fields)
Use a radio/boolean field to toggle visibility of other fields (often grouped in fieldsets).
```typescript
defineField({
name: 'linkType',
type: 'string',
options: { list: ['internal', 'external'], layout: 'radio' }
}),
defineField({
name: 'internalLink',
type: 'reference',
hidden: ({ parent }) => parent?.linkType !== 'internal'
}),
defineField({
name: 'externalUrl',
type: 'url',
hidden: ({ parent }) => parent?.linkType !== 'external'
})
```
## 5. References vs Nested Objects
A **critical modeling decision**: when to use `reference` vs embedding an `object`.
### Use References When:
- Content is **reusable** across documents (authors, categories, products)
- Content needs its **own editing interface** in Studio
- You need to query/filter by the related content independently
- Multiple documents should share the **same instance** (update once, reflect everywhere)
```typescript
// ✅ Author is reusable and independently editable
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }]
})
```
### Use Nested Objects When:
- Content is **specific to this document** (not shared)
- Content doesn't make sense on its own (address, SEO metadata)
- You want **simpler editing** (all fields in one place)
- You need the data to be **copied** not linked
```typescript
// ✅ SEO is document-specific, not shared
defineField({
name: 'seo',
type: 'object',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'description', type: 'text' })
]
})
```
### Quick Decision Matrix
| Scenario | Use |
|----------|-----|
| Blog post author | `reference` (reusable) |
| Product category | `reference` (shared taxonomy) |
| Page SEO fields | `object` (page-specific) |
| Hero section content | `object` (page-specific) |
| Team member on About page | `reference` (might be used elsewhere) |
| Call-to-action button | `object` (usually page-specific) |
### Querying Differences
```groq
// Reference requires expansion
*[_type == "post"]{ author->{ name, bio } }
// Object is already inline
*[_type == "post"]{ seo { title, description } }
```
## 6. Document Creation and IDs
Sanity document `_id` values are implementation identifiers, not a content modeling tool.
- **Prefer generated IDs:** Let Sanity assign `_id` values for ordinary content documents. Avoid deterministic UUIDs, slug-derived IDs, and IDs copied from legacy systems.
- **Use relationships, not ID conventions:** Connect documents with `reference` fields and set `_ref` from an actual lookup or from the `_id` returned after creating the related document.
- **Store source identity as content:** For imports, put legacy IDs, external IDs, or stable slugs in explicit fields such as `legacyId`, `externalId`, or `slug`, then query by those fields when you need to find or upsert content.
- **Keep explicit IDs rare:** Directly setting `_id` is mainly useful for singleton documents managed through Studio Structure, such as `settings` or localized singletons like `homePage-en`.
```typescript
// ✅ Correct - relationship comes from a lookup
import {defineQuery} from 'groq'
const AUTHOR_BY_EXTERNAL_ID_QUERY = defineQuery(`
*[_type == "author" && externalId == $externalId][0]{_id}
`)
const author = await client.fetch(AUTHOR_BY_EXTERNAL_ID_QUERY, {
externalId: post.authorId,
})
if (!author?._id) throw new Error(`Missing author for ${post.authorId}`)
await client.create({
_type: 'post',
title: post.title,
slug: {_type: 'slug', current: post.slug},
legacyId: post.id,
author: {_type: 'reference', _ref: author._id},
})
// ❌ Wrong - IDs encode relationships and source data
await client.createOrReplace({
_id: `post-${post.id}`,
_type: 'post',
author: {_type: 'reference', _ref: `author-${post.authorId}`},
})
```
## 7. Safe Schema Updates (The Deprecation Pattern)
**NEVER** delete a field that contains production data. It will cause data loss or Studio crashes. Instead, follow the **ReadOnly -> Hidden -> Deprecated** lifecycle.
### The Pattern
1. **`deprecated`**: Adds a visual warning and reason.
2. **`readOnly: true`**: Prevents new edits but keeps data visible.
3. **`hidden`**: Hides it from *new* documents (where value is undefined).
4. **`initialValue: undefined`**: Ensures new documents don't get this field.
```typescript
defineField({
name: 'oldTitle', // The field you want to remove
title: 'Article Title (Deprecated)',
type: 'string',
deprecated: {
reason: 'Use the new "seoTitle" field instead. This will be removed in v2.'
},
readOnly: true,
hidden: ({ value }) => value === undefined,
initialValue: undefined
})
```
### Migration Workflow
**Phase 1: Deprecate** — Apply the deprecation pattern above. Deploy.
**Phase 2: Migrate** — Update frontend to use new fields (with `coalesce()` fallbacks). Create a migration:
```typescript
// migrations/rename-oldTitle-to-newTitle/index.ts
import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'
export default defineMigration({
title: 'Rename oldTitle to newTitle',
documentTypes: ['article'],
filter: 'defined(oldTitle) && !defined(newTitle)',
migrate: {
document(doc) {
if (!doc.oldTitle || doc.newTitle) return
return [
at('newTitle', setIfMissing(doc.oldTitle)),
at('oldTitle', unset())
]
}
}
})
```
```bash
# Dry run first (default)
sanity migrations run rename-oldTitle-to-newTitle
# Execute when ready
sanity migrations run rename-oldTitle-to-newTitle --no-dry-run
```
**Phase 3: Remove** — Once `oldTitle` is undefined for all documents, delete the field definition.
## 8. Validation Patterns
Beyond `rule.required()`, Sanity offers powerful validation options.
### Common Patterns
```typescript
// Email validation
defineField({
name: 'email',
type: 'string',
validation: (rule) => rule.email().required()
})
// URL validation (with custom message)
defineField({
name: 'website',
type: 'url',
validation: (rule) => rule.uri({
scheme: ['http', 'https']
}).error('Must be a valid URL starting with http:// or https://')
})
// Length constraints
defineField({
name: 'excerpt',
type: 'text',
validation: (rule) => rule.max(200).warning('Keep it under 200 characters for best SEO')
})
// Regex pattern
defineField({
name: 'slug',
type: 'slug',
validation: (rule) => rule.required().custom((slug) => {
if (!slug?.current) return 'Required'
if (!/^[a-z0-9-]+$/.test(slug.current)) {
return 'Slug must be lowercase with hyphens only'
}
return true
})
})
```
### Cross-Field Validation
```typescript
defineField({
name: 'endDate',
type: 'datetime',
validation: (rule) => rule.custom((endDate, context) => {
const startDate = context.document?.startDate
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
return 'End date must be after start date'
}
return true
})
})
```
### Array Validation
```typescript
defineField({
name: 'tags',
type: 'array',
of: [{ type: 'string' }],
validation: (rule) => rule
.min(1).error('Add at least one tag')
.max(10).warning('Too many tags may hurt SEO')
.unique()
})
```
### Async Validation (Uniqueness Check)
```typescript
defineField({
name: 'slug',
type: 'slug',
validation: (rule) => rule.required().custom(async (slug, context) => {
if (!slug?.current) return true
const client = context.getClient({ apiVersion: '2026-02-01' })
const id = context.document?._id?.replace(/^drafts\./, '')
const existing = await client.fetch(
`count(*[_type == "post" && slug.current == $slug && _id != $id])`,
{ slug: slug.current, id }
)
return existing === 0 || 'Slug already exists'
})
})
```
references/seo.md›
---
title: Sanity SEO Best Practices
description: SEO best practices for Sanity with Next.js, including metadata, Open Graph, sitemaps, redirects, and JSON-LD structured data.
---
# Sanity SEO Best Practices
## 1. Core Philosophy
SEO doesn't require complex configurations. A few core principles, applied consistently:
- **Smart defaults with optional overrides** — Don't require SEO fields; use existing content as fallback
- **Use GROQ for fallback logic** — Move conditional logic into queries, not components
- **Leverage Next.js APIs** — Use `generateMetadata`, `sitemap.ts`, not manual `<meta>` tags
- **Structured content = structured data** — Your content model is already SEO-ready
## 2. SEO Schema Type (Reusable)
Create a reusable SEO object type for consistent metadata across document types.
```typescript
// schemaTypes/seoType.ts
import { defineField, defineType } from "sanity";
export const seoType = defineType({
name: "seo",
title: "SEO",
type: "object",
fields: [
defineField({
name: "title",
description: "Overrides the page title if provided",
type: "string",
}),
defineField({
name: "description",
type: "text",
rows: 3,
}),
defineField({
name: "image",
description: "Image for social sharing (1200x630 recommended)",
type: "image",
options: { hotspot: true },
}),
defineField({
name: "noIndex",
description: "Hide this page from search engines",
type: "boolean",
initialValue: false,
}),
],
});
```
**Usage in document types:**
```typescript
defineField({
name: "seo",
type: "seo",
})
```
## 3. GROQ Queries with Fallbacks
Use `coalesce()` to provide fallback values. This keeps frontend logic clean.
```groq
*[_type == "page" && slug.current == $slug][0]{
...,
"seo": {
// Use SEO field if provided, otherwise fall back to main title
"title": coalesce(seo.title, title, ""),
"description": coalesce(seo.description, ""),
"image": seo.image,
"noIndex": seo.noIndex == true
}
}
```
**Key principle:** `seo.title` will never be `null` — it contains either the SEO override, the page title, or empty string.
## 4. Next.js Metadata (The Right Way)
Use `generateMetadata` — never render `<title>` or `<meta>` tags directly in components.
```typescript
// app/(frontend)/[slug]/page.tsx
import type { Metadata } from "next";
import { urlFor } from "@/sanity/lib/image";
type RouteProps = {
params: Promise<{ slug: string }>;
};
// Extract fetch to reuse in both functions
const getPage = async (params: RouteProps["params"]) =>
sanityFetch({
query: PAGE_QUERY,
params: await params,
stega: false, // Critical for SEO!
});
export async function generateMetadata({ params }: RouteProps): Promise<Metadata> {
const { data: page } = await getPage(params);
if (!page) return {};
const metadata: Metadata = {
title: page.seo.title,
description: page.seo.description,
};
// Open Graph image
if (page.seo.image) {
metadata.openGraph = {
images: {
url: urlFor(page.seo.image).width(1200).height(630).url(),
width: 1200,
height: 630,
},
};
}
// noIndex robots directive
if (page.seo.noIndex) {
metadata.robots = "noindex";
}
return metadata;
}
export default async function Page({ params }: RouteProps) {
const { data: page } = await getPage(params);
// ... render page
}
```
**Critical:** Always set `stega: false` when fetching for metadata. Stega characters in `<title>` destroy SEO.
## 5. Dynamic Sitemap
Use Next.js `sitemap.ts` convention to auto-generate from Sanity content.
### GROQ Query
```groq
*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true] {
"href": select(
_type == "page" => "/" + slug.current,
_type == "post" => "/posts/" + slug.current,
slug.current
),
_updatedAt
}
```
### Route Implementation
```typescript
// app/sitemap.ts
import { MetadataRoute } from "next";
import { client } from "@/sanity/lib/client";
import { SITEMAP_QUERY } from "@/sanity/lib/queries";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
try {
const paths = await client.fetch(SITEMAP_QUERY);
if (!paths) return [];
return paths.map((path) => ({
url: new URL(path.href!, baseUrl).toString(),
lastModified: new Date(path._updatedAt),
changeFrequency: "weekly",
priority: 1,
}));
} catch (error) {
console.error("Sitemap generation failed:", error);
return [];
}
}
```
**Note:** Sitemap limit is 50,000 URLs per file. For larger sites, use sitemap index.
## 6. Redirects (Managed in Sanity)
Create a redirect document type for content team management.
### Schema
```typescript
// schemaTypes/redirectType.ts
import { defineField, defineType, SanityDocumentLike } from "sanity";
import { LinkIcon } from "@sanity/icons/Link";
function isValidPath(value: string | undefined) {
if (!value) return "Required";
if (!value.startsWith("/")) return "Must start with /";
if (/[^a-zA-Z0-9\-_/:]/.test(value)) return "Invalid characters";
return true;
}
export const redirectType = defineType({
name: "redirect",
title: "Redirect",
type: "document",
icon: LinkIcon,
validation: (Rule) =>
Rule.custom((doc: SanityDocumentLike | undefined) => {
if (doc?.source === doc?.destination) {
return "Source and destination cannot be the same";
}
return true;
}),
fields: [
defineField({
name: "source",
type: "string",
validation: (Rule) => Rule.required().custom(isValidPath),
}),
defineField({
name: "destination",
type: "string",
validation: (Rule) => Rule.required(),
}),
defineField({
name: "permanent",
description: "301 (permanent) or 302 (temporary)",
type: "boolean",
initialValue: true,
}),
defineField({
name: "isEnabled",
type: "boolean",
initialValue: true,
}),
],
});
```
### Next.js Config
```typescript
// next.config.ts
import { fetchRedirects } from "@/sanity/lib/fetchRedirects";
const nextConfig: NextConfig = {
async redirects() {
return await fetchRedirects();
},
};
```
**Limits:** Vercel allows max 1,024 redirects in `next.config`. For more, use middleware.
## 7. Dynamic Open Graph Images
Generate OG images on-the-fly using Next.js Edge Runtime at `/api/og`.
```typescript
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get("id");
if (!id) return new Response("Missing id", { status: 400 });
const data = await client.fetch(`*[_id == $id][0]{ title }`, { id });
return new ImageResponse(
<div tw="flex w-full h-full bg-blue-500 text-white p-10">
<h1 tw="text-6xl font-bold">{data?.title || "Untitled"}</h1>
</div>,
{ width: 1200, height: 630 }
);
}
```
Use as fallback in metadata: `url: page.seo.image ? urlFor(page.seo.image).url() : \`/api/og?id=\${page._id}\``
## 8. JSON-LD Structured Data
Use `schema-dts` for type-safe structured data.
```bash
npm install schema-dts
```
### FAQ Example
```typescript
import { FAQPage, WithContext } from "schema-dts";
const generateFaqData = (faqs: FAQ[]): WithContext<FAQPage> => ({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.title,
acceptedAnswer: {
"@type": "Answer",
text: faq.text, // Use pt::text() in GROQ to get plain text
},
})),
});
// In component
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateFaqData(faqs)) }}
/>
```
### GROQ for Plain Text
```groq
faqs[]->{
_id,
title,
body,
"text": pt::text(body) // Convert Portable Text to plain string
}
```
## 9. Testing Tools
- **Open Graph:** [opengraph.ing](https://opengraph.ing/)
- **Facebook:** [Sharing Debugger](https://developers.facebook.com/tools/debug/)
- **Twitter:** [Card Validator](https://cards-dev.twitter.com/validator)
- **LinkedIn:** [Post Inspector](https://www.linkedin.com/post-inspector/)
- **Sitemap:** [XML Sitemaps Validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html)
references/studio-structure.md›
---
title: "Sanity Studio Structure Rules"
description: Rules for customizing the Sanity Studio Structure (S.structure).
---
# Sanity Studio Structure Rules
## 1. Setup
Custom structure is defined in `sanity.config.ts` using the `structureTool`.
```typescript
import { structureTool } from 'sanity/structure'
import { structure } from './src/structure'
export default defineConfig({
// ...
plugins: [
structureTool({ structure })
]
})
```
## 2. Structure Definition
**Location:** `src/structure/index.ts`
Use a function that receives `S` (StructureBuilder).
```typescript
import type { StructureResolver } from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// ... items
])
```
## 3. Organization Principles
1. **Singletons First:** Place critical site-wide settings (Global Settings, Homepage) at the top.
2. **Dividers:** Use `S.divider()` to visually separate logical groups.
3. **Filtered Lists:** Always exclude Singleton documents from generic `documentTypeList` items to avoid duplication.
## 4. Singleton Pattern (Critical)
**Singletons are enforced via Structure, NOT schema options.** There is no `singleton: true` schema option.
This is the main case where explicit document IDs are appropriate. For ordinary content documents, let Sanity generate `_id` values and use references or GROQ lookups to connect records.
### How Singletons Work
1. Use `S.document().documentId('fixed-id')` to lock the document to a specific ID.
2. Filter the type from generic lists to prevent duplicate entries.
### Singleton Helper Function
```typescript
// Helper to create singleton list items
function createSingleton(S: StructureBuilder, typeName: string, title: string, icon?: ComponentType) {
return S.listItem()
.title(title)
.icon(icon)
.child(
S.document()
.schemaType(typeName)
.documentId(typeName) // Fixed ID = singleton
.title(title)
)
}
// Usage
createSingleton(S, 'settings', 'Site Settings', CogIcon)
```
### Querying Singletons
```groq
// By fixed ID (most efficient)
*[_id == "settings"][0]
// By type (works but slower)
*[_type == "settings"][0]
```
**For localized singletons** (e.g., homepage per language), see `localization.md` Section 6.
## 5. Implementation Pattern
```typescript
// Define singleton types to exclude from generic lists
const SINGLETONS = ['settings', 'homePage']
export const structure: StructureResolver = (S) =>
S.list()
.title('Website Content')
.items([
// 1. Singletons
S.listItem()
.title('Site Settings')
.icon(CogIcon)
.child(S.document().schemaType('settings').documentId('settings')),
S.divider(),
// 2. Content Verticals
S.listItem()
.title('Blog')
.child(
S.list()
.title('Blog Content')
.items([
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('author').title('Authors'),
])
),
S.divider(),
// 3. Remaining Documents (Filtered)
...S.documentTypeListItems().filter(
(listItem) => !SINGLETONS.includes(listItem.getId() as string)
)
])
```
## 6. Views (Split Pane)
Add "Web Preview" or other views to documents.
```typescript
export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, { schemaType }) => {
switch (schemaType) {
case `post`:
return S.document().views([
S.view.form(), // Default form
S.view.component(PreviewComponent).title('Preview') // Custom view
])
default:
return S.document().views([S.view.form()])
}
}
```
references/svelte.md›
---
title: "SvelteKit & Sanity Integration Rules"
description: Integration guide for SvelteKit with Sanity using @sanity/sveltekit, including Visual Editing and Preview Mode.
---
# SvelteKit & Sanity Integration Rules
This guide uses the official **`@sanity/sveltekit`** package (Svelte 5 + SvelteKit 2). The older `@sanity/svelte-loader` does not work with Svelte 5 — its `useQuery` store returns empty on the client. Use `@sanity/sveltekit` instead.
## 1. Setup & Configuration
### Scaffold a new SvelteKit app
```bash
npx sv@latest create my-app --template minimal --types ts --no-add-ons --install npm
cd my-app
```
`--template minimal` is the bare app. `--types ts` enables TypeScript. `--no-add-ons` skips the add-on picker. `--install <pm>` chooses the package manager (`npm`, `pnpm`, `yarn`, or `bun`).
### Installation
```bash
npm install @sanity/sveltekit @sanity/image-url @portabletext/svelte
```
`@sanity/sveltekit` is the one-stop integration: it bundles `@sanity/client`, `@sanity/visual-editing`, `@sanity/core-loader`, `groq`, and friends, and re-exports `createClient`, `defineQuery`, `groq`, and `stegaClean`. **Do not** also install `@sanity/client`, `@sanity/visual-editing`, or `groq` directly — import them from `@sanity/sveltekit`. `@sanity/image-url` and `@portabletext/svelte` are not bundled, so add them separately.
### Environment variables (`.env.local`)
```bash
PUBLIC_SANITY_PROJECT_ID=your-project-id
PUBLIC_SANITY_DATASET=production
PUBLIC_SANITY_API_VERSION=2026-05-15
PUBLIC_SANITY_STUDIO_URL=http://localhost:3333
SANITY_API_READ_TOKEN=
```
SvelteKit's `$env/static/public` requires the `PUBLIC_` prefix for any var read on the client. `SANITY_API_READ_TOKEN` must be declared (even empty) if any file imports it from `$env/static/private`, otherwise Vite throws at build time.
## 2. Files
### `src/lib/sanity/api.ts` — env var resolution
```ts
import {
PUBLIC_SANITY_DATASET,
PUBLIC_SANITY_PROJECT_ID,
PUBLIC_SANITY_API_VERSION,
PUBLIC_SANITY_STUDIO_URL,
} from '$env/static/public'
function assertEnvVar<T>(value: T | undefined, name: string): T {
if (value === undefined || value === '') {
throw new Error(`Missing environment variable: ${name}`)
}
return value
}
export const dataset = assertEnvVar(PUBLIC_SANITY_DATASET, 'PUBLIC_SANITY_DATASET')
export const projectId = assertEnvVar(PUBLIC_SANITY_PROJECT_ID, 'PUBLIC_SANITY_PROJECT_ID')
export const apiVersion = PUBLIC_SANITY_API_VERSION || '2026-05-15'
export const studioUrl = PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333'
```
### `src/lib/sanity/client.ts` — public client
```ts
import {createClient} from '@sanity/sveltekit'
import {apiVersion, projectId, dataset, studioUrl} from '$lib/sanity/api'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
stega: {studioUrl},
})
```
Import `createClient` from `@sanity/sveltekit`, not `@sanity/client`. `useCdn: true` is for production reads; the server (preview) client below overrides to `false`.
### `src/lib/sanity/client.server.ts` — server (preview) client
```ts
import {SANITY_API_READ_TOKEN} from '$env/static/private'
import {client} from '$lib/sanity/client'
export const serverClient = client.withConfig({
token: SANITY_API_READ_TOKEN,
useCdn: false,
stega: true,
})
```
### `src/lib/sanity/queries.ts` — queries + types
```ts
import {groq} from '@sanity/sveltekit'
export const postsQuery = groq`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){
_id, _createdAt, title, slug, excerpt, mainImage, body
}`
export const postQuery = groq`*[_type == "post" && slug.current == $slug][0]{
_id, _createdAt, title, slug, excerpt, mainImage, body
}`
export interface Post {
_id: string
_createdAt: string
title?: string
slug: {current: string}
excerpt?: string
mainImage?: unknown
body?: unknown[]
}
```
Use `defineQuery` instead of `groq` if you want TypeGen-friendly query definitions; both are re-exported from `@sanity/sveltekit`.
### `src/lib/sanity/image.ts` — image URL builder
```ts
import {createImageUrlBuilder} from '@sanity/image-url'
import {client} from './client'
const builder = createImageUrlBuilder(client)
export function urlFor(source: unknown) {
return builder.image(source as never)
}
```
Use the named `createImageUrlBuilder` export; the default export logs a deprecation warning at runtime.
## 3. Hooks & Locals
### `src/hooks.server.ts` — wire preview + query loader
```ts
import {handlePreviewMode, handleQueryLoader, setServerClient} from '@sanity/sveltekit'
import {redirect} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {serverClient} from '$lib/sanity/client.server'
setServerClient(serverClient)
export const handle = sequence(
handlePreviewMode({
client: serverClient,
preview: {redirect},
}),
handleQueryLoader(),
)
```
`handlePreviewMode` installs `/preview/enable` and `/preview/disable` endpoints, reads the preview cookie, and populates `locals.sanity` with `{client, fetch, loadQuery, previewEnabled, previewPerspective, browserToken}`. `handleQueryLoader` attaches `loadQuery` to `locals.sanity` for use in `+page.server.ts` / `+layout.server.ts`.
### `src/app.d.ts` — typed locals
```ts
import type {SanityLocals} from '@sanity/sveltekit'
declare global {
namespace App {
interface Locals extends SanityLocals {}
}
}
export {}
```
## 4. Layout: Preview + Visual Editing Providers
### `src/routes/+layout.server.ts` — propagate previewEnabled
```ts
import type {LayoutServerLoad} from './$types'
export const load: LayoutServerLoad = (event) => {
const {previewEnabled} = event.locals.sanity
return {previewEnabled}
}
```
### `src/routes/+layout.svelte` — wrap children in providers (Svelte 5)
```svelte
<script lang="ts">
import {PreviewMode, QueryLoader, VisualEditing} from '@sanity/sveltekit'
import type {LayoutProps} from './$types'
import {client} from '$lib/sanity/client'
const {children, data}: LayoutProps = $props()
// svelte-ignore state_referenced_locally
const {previewEnabled} = data
</script>
<PreviewMode enabled={previewEnabled}>
<VisualEditing enabled={previewEnabled}>
<QueryLoader enabled={previewEnabled} {client}>
{@render children()}
</QueryLoader>
</VisualEditing>
</PreviewMode>
```
Svelte 5 idioms here are mandatory:
- `const {children, data} = $props()` — not `export let data`.
- `{@render children()}` — not `<slot />`.
- The `svelte-ignore state_referenced_locally` comment silences a warning about destructuring reactive props at module scope.
`<VisualEditing>` dynamically imports its component only when `enabled === true`, so a preview-off app never loads the React-Compiler-runtime chunk.
## 5. Data Fetching (Loaders + `useQuery`)
### Posts list
`src/routes/+page.server.ts`:
```ts
import {postsQuery as query, type Post} from '$lib/sanity/queries'
import type {PageServerLoad} from './$types'
export const load: PageServerLoad = async ({locals}) => {
const {loadQuery} = locals.sanity
const initial = await loadQuery<Post[]>(query)
return {query, options: {initial}}
}
```
The return shape `{query, params?, options: {initial}}` is what `useQuery(data)` on the client expects — don't change the field names.
`src/routes/+page.svelte`:
```svelte
<script lang="ts">
import {useQuery} from '@sanity/sveltekit'
import type {Post} from '$lib/sanity/queries'
import type {PageProps} from './$types'
const {data}: PageProps = $props()
const query = $derived(useQuery<Post[]>(data))
const posts = $derived($query.data)
</script>
<h1>Posts</h1>
{#if posts?.length}
<ul>
{#each posts as post (post._id)}
<li><a href={`/post/${post.slug.current}`}>{post.title}</a></li>
{/each}
</ul>
{:else}
<p>No posts yet.</p>
{/if}
```
Critical Svelte 5 pattern:
- `useQuery` returns a Svelte Readable store. Wrap in `$derived(useQuery(data))` so the store reference stays current across reactive updates.
- Subscribe via `$query` (Svelte's auto-subscription) and read `.data`.
- Works on both SSR and client.
### Post detail (`[slug]`)
`src/routes/post/[slug]/+page.server.ts`:
```ts
import {postQuery as query, type Post} from '$lib/sanity/queries'
import type {PageServerLoad} from './$types'
export const load: PageServerLoad = async ({locals, params}) => {
const {loadQuery} = locals.sanity
const {slug} = params
const initial = await loadQuery<Post>(query, {slug})
return {query, params: {slug}, options: {initial}}
}
```
`src/routes/post/[slug]/+page.svelte`:
```svelte
<script lang="ts">
import {useQuery} from '@sanity/sveltekit'
import {PortableText} from '@portabletext/svelte'
import {urlFor} from '$lib/sanity/image'
import type {Post} from '$lib/sanity/queries'
import type {PageProps} from './$types'
const {data}: PageProps = $props()
const query = $derived(useQuery<Post>(data))
const post = $derived($query.data)
</script>
{#if post}
<article>
<h1>{post.title}</h1>
{#if post.mainImage}
<img src={urlFor(post.mainImage).width(800).url()} alt={post.title ?? ''} />
{/if}
{#if post.body}
<PortableText value={post.body} />
{/if}
</article>
{:else}
<p>Post not found.</p>
{/if}
```
## 6. Stega Cleaning
When using fetched strings for logic (routing, classNames), strip the stega markers first.
```ts
import {stegaClean} from '@sanity/sveltekit'
// …
if (stegaClean(slug) === 'home') { /* … */ }
```
## 7. Caveats
- **Yarn classic + Visual Editing.** `@sanity/visual-editing` lazy-loads a chunk that imports `react/compiler-runtime`. Yarn classic doesn't auto-install peer deps, so users who flip preview mode on with yarn classic also need `yarn add react react-dom`. (Other package managers handle this automatically.) `<VisualEditing>` only loads this chunk when `enabled === true`, so a default preview-off app is unaffected.
- **No `<slot />`.** Svelte 5 layouts use `{@render children()}`.
- **No `export let`.** Pages and components use `const {data} = $props()`.
- **`@sanity/image-url` default export.** Use the named `createImageUrlBuilder`; the default export still works but logs a runtime deprecation warning.
references/typegen.md›
---
title: Sanity TypeGen Rules
description: Workflow for generating TypeScript types from Sanity Schema and GROQ queries.
---
# Sanity TypeGen Rules
## 1. The Workflow
Sanity TypeGen generates TypeScript types from your schema and GROQ queries. Types can be generated automatically or manually.
### Automatic (Recommended)
Enable in `sanity.cli.ts` — types regenerate during `sanity dev` and `sanity build`:
```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
enabled: true,
},
})
```
### Manual
Run the extract + generate cycle whenever schema or queries change:
1. **Extract:** Converts your Schema (TS/JS) into a static JSON representation.
2. **Generate:** Scans your codebase for GROQ queries and generates TypeScript types.
```bash
npx sanity schemas extract --force && npx sanity typegen generate
```
### Watch Mode (for separate frontends)
If your frontend is in a separate repo from the Studio, use watch mode:
```bash
npx sanity typegen generate --watch
```
## 2. The "Update Types" Pattern
For manual workflows, implement a single script:
**package.json:**
```json
"scripts": {
"typegen": "sanity schemas extract --force && sanity typegen generate"
}
```
### Git Strategy for Generated Files
**Option A: Commit generated types (Recommended for most teams)**
- Types available immediately after `git pull`
- CI/CD doesn't need to run typegen
- Can cause merge conflicts
**Option B: Generate in CI (Recommended for larger teams)**
Add to `.gitignore`:
```gitignore
# Sanity TypeGen (generated)
sanity.types.ts
schema.json
```
Then ensure CI runs typegen before build:
```yaml
# Example GitHub Actions
- run: npm run typegen
- run: npm run build
```
## 3. Configuration (`sanity.cli.ts`)
> **Note:** `sanity-typegen.json` is deprecated. Move your configuration to `sanity.cli.ts`.
```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
enabled: true, // Auto-generate during sanity dev/build
path: "./src/**/*.{ts,tsx,js,jsx,astro,svelte,vue}", // Glob to find queries
schema: "schema.json", // Schema file from extract
generates: "./sanity.types.ts", // Output file
overloadClientMethods: true, // Auto-type client.fetch() calls
},
})
```
### Project Structure Examples
**Monorepo (recommended)** (Studio in `studio/`, Frontend in `web/` — same config works under `apps/`):
```typescript
export default defineCliConfig({
typegen: {
path: "../web/src/**/*.{ts,tsx,js,jsx}",
schema: "schema.json",
generates: "../web/sanity.types.ts",
},
})
```
**Single Repo / Embedded Studio (legacy):**
Use defaults — no extra config needed.
**Separate Repos:**
Use `--watch` mode in your frontend: `sanity typegen generate --watch`
## 4. Usage in Code
### Automatic Type Inference (Recommended)
With `overloadClientMethods: true` (default), `client.fetch()` automatically returns typed results when you use `defineQuery`:
```typescript
import { defineQuery } from "groq";
import { createClient } from "@sanity/client";
const client = createClient({...});
const POSTS_QUERY = defineQuery(`*[_type == "post"]{ title, slug }`);
// Return type is automatically inferred — no manual type import needed!
const posts = await client.fetch(POSTS_QUERY);
```
### Manual Type Import (Alternative)
You can also import generated types directly:
```typescript
import { defineQuery } from "groq";
// Next.js re-exports defineQuery for convenience:
// import { defineQuery } from "next-sanity";
const AUTHOR_QUERY = defineQuery(`*[_type == "author" && slug.current == $slug][0]{ name, bio }`);
import type { AUTHOR_QUERY_RESULT } from "@/sanity.types";
export default function Author({ data }: { data: AUTHOR_QUERY_RESULT }) {
return <h1>{data.name}</h1>
}
```
### Required Fields
Use `--enforce-required-fields` during extraction to translate `validation: rule => rule.required()` into non-optional types:
```bash
npx sanity schemas extract --force --enforce-required-fields
npx sanity typegen generate
```
> **Warning:** If you use draft previews, fields may still be `undefined` even with required validation, since drafts can be in an invalid state.
### Type Utilities
TypeGen provides utilities for working with complex types:
```typescript
import type { Get, FilterByType } from 'sanity'
import type { Page, PageBuilder } from './sanity.types'
// Extract deeply nested type (up to 20 levels)
type HeroSection = Get<Page, 'sections', number, 'hero'>
// Filter specific types from unions using _type discriminator
type HeroBlock = FilterByType<PageBuilder, 'hero'>
```
### Unique Query Names
All queries must have unique variable names. Duplicate names across files will cause TypeGen to silently overwrite types. Use descriptive, scoped names:
```typescript
// Unique names
const POSTS_INDEX_QUERY = defineQuery(`*[_type == "post"]{ title }`)
const POST_DETAIL_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`)
// Duplicate names will conflict
const QUERY = defineQuery(`*[_type == "post"]`) // file-a.ts
const QUERY = defineQuery(`*[_type == "author"]`) // file-b.ts — overwrites!
```
### Supported Query Formats
Queries must be assigned to a variable using `groq` or `defineQuery`:
```typescript
// Works — groq template tag
const query = groq`*[_type == "post"]`
// Works — defineQuery
const query = defineQuery(`*[_type == "post"]`)
// Won't work — inline query
await client.fetch(groq`*[_type == "post"]`)
```
### Supported File Types
TypeGen parses queries from: `.ts`, `.tsx`, `.js`, `.jsx`, `.astro`, `.svelte`, `.vue`
### tsconfig Requirements
Ensure `sanity.types.ts` is included in your `tsconfig.json`'s `include` array. If your config restricts includes (e.g., `["src/**/*"]`) and the types file is at the project root, TypeScript won't pick up the generated types:
```json
{
"include": ["src/**/*", "sanity.types.ts"]
}
```
### Skipping Individual Queries
Add `@sanity-typegen-ignore` in a comment before a query to skip type generation:
```typescript
// @sanity-typegen-ignore
const debugQuery = groq`*[_type == "debug"]`
```
references/visual-editing.md›
---
title: "Sanity Visual Editing Rules"
description: Comprehensive guide for Sanity Visual Editing, including Presentation Tool, Stega (Content Source Maps), and Overlays.
---
# Sanity Visual Editing Rules
## 1. Concepts
### Presentation Tool
The Studio plugin (`sanity/presentation`) that renders your front-end application inside an iframe in the Studio. It enables the "Edit" overlay and bidirectional navigation.
### Content Source Maps (Stega)
Invisible characters embedded in strings that tell the Presentation Tool which field in which document the content comes from.
- **Mechanism:** Sanity encodes document ID, field path, and dataset info into string values.
- **Result:** Click-to-edit functionality in the preview.
### Loaders
Framework-agnostic or specific libraries that handle:
1. Fetching data (production vs. preview).
2. Subscribing to real-time updates (Live Content API).
3. Encoding Stega strings (if not handled by the Content Lake automatically).
## 2. The Golden Rule of Stega (Clean Data)
When Visual Editing is enabled, string fields will contain invisible characters. You **MUST** clean them before using the value for logic.
| Scenario | Clean? | Why |
|----------|--------|-----|
| Comparing strings (`if (x === 'y')`) | ✅ Yes | Stega breaks equality |
| Using as object keys | ✅ Yes | Keys won't match |
| Using as HTML IDs | ✅ Yes | Invalid characters |
| Passing to third-party libraries | ✅ Yes | May validate input |
| Rendering text (`<h1>{title}</h1>`) | ❌ No | Breaks click-to-edit |
| Passing to `<PortableText />` | ❌ No | Handles internally |
| Passing to image helpers | ❌ No | Handles internally |
```typescript
import { stegaClean } from "@sanity/client/stega";
export function Layout({ align }: { align: string }) {
// Good: Clean before comparison
const cleanAlign = stegaClean(align);
return <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
}
```
## 3. Token Handling (Security)
Store your read token in a dedicated file that throws if missing:
```typescript
// src/sanity/lib/token.ts
export const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
```
**Never** expose tokens in client bundles. Pass to `defineLive` for server/browser use only when Draft Mode is enabled.
## 4. Setup: Presentation Tool
**File:** `sanity.config.ts`
```typescript
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'
import { resolve } from '@/sanity/presentation/resolve'
export default defineConfig({
// ...
plugins: [
presentationTool({
resolve, // Document locations (see below)
previewUrl: {
// The front-end origin — required when the Studio runs standalone
origin: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
})
```
### Document Locations
Show where documents appear in the front-end — enables quick navigation between Structure and Presentation tools.
```typescript
// src/sanity/presentation/resolve.ts
import { defineLocations, PresentationPluginOptions } from 'sanity/presentation'
export const resolve: PresentationPluginOptions['resolve'] = {
locations: {
post: defineLocations({
select: { title: 'title', slug: 'slug.current' },
resolve: (doc) => ({
locations: [
{ title: doc?.title || 'Untitled', href: `/posts/${doc?.slug}` },
{ title: 'Posts index', href: `/posts` },
],
}),
}),
// Add more document types as needed
},
}
```
## 5. Visual Editing Overlays
Render `<VisualEditing />` in Draft Mode for click-to-edit overlays.
**Next.js (App Router):**
```typescript
// layout.tsx
import { VisualEditing } from 'next-sanity/visual-editing'
import { draftMode } from 'next/headers'
import { DisableDraftMode } from '@/components/disable-draft-mode'
export default async function RootLayout({ children }) {
return (
<html>
<body>
{children}
{(await draftMode()).isEnabled && (
<>
<DisableDraftMode />
<VisualEditing />
</>
)}
</body>
</html>
)
}
```
### Disable Draft Mode Button
Useful for content authors to exit preview and see published content:
```typescript
// src/components/disable-draft-mode.tsx
'use client'
import { useDraftModeEnvironment } from 'next-sanity/hooks'
export function DisableDraftMode() {
const environment = useDraftModeEnvironment()
// Only show outside of Presentation Tool
if (environment !== 'live' && environment !== 'unknown') return null
return (
<a href="/api/draft-mode/disable" className="fixed bottom-4 right-4 bg-gray-50 px-4 py-2">
Disable Draft Mode
</a>
)
}
```
**Remix/Svelte:** See framework-specific rules for `useLiveMode` and `enableVisualEditing` patterns.
## 6. SEO & Metadata (Critical)
**NEVER** allow Stega strings in `<head>` tags (Title, Description, Canonical URLs). It destroys SEO rankings and looks broken in search results.
- **Next.js:** Set `stega: false` in `generateMetadata`.
- **General:** Explicitly clean fields used in `<title>` or `<meta>`.
```typescript
// Next.js Example — disable stega at fetch level
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({
query: SEO_QUERY,
stega: false // Critical
})
return { title: data.title }
}
```
**Alternative:** If you can't disable stega at the fetch level, clean explicitly:
```typescript
import { stegaClean } from "@sanity/client/stega";
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({ query: PAGE_QUERY })
return {
title: stegaClean(data.title),
description: stegaClean(data.description),
openGraph: { url: stegaClean(data.canonicalUrl) }
}
}
```
## 7. Drag-and-Drop Reordering (Advanced)
For arrays (e.g., "Related Posts"), enable drag-and-drop in the preview using `data-sanity` attributes and `useOptimistic`:
```typescript
import { createDataAttribute } from 'next-sanity'
import { useOptimistic } from 'next-sanity/hooks'
// Add data-sanity to array container
<ul data-sanity={createDataAttribute({ id: documentId, type: 'post', path: 'relatedPosts' }).toString()}>
{items.map((item) => (
<li key={item._key} data-sanity={createDataAttribute({
id: documentId, type: 'post', path: `relatedPosts[_key=="${item._key}"]`
}).toString()}>
{item.title}
</li>
))}
</ul>
```
**Key requirements:**
- Query must include `_key` for array items
- Use `useOptimistic` hook for instant UI updates during mutations
## 8. Optimistic Updates for Faster Editing
By default, editing a field in the Presentation Tool triggers a full page re-render. For pages with many components, this can feel sluggish. **Presentation queries** solve this by fetching only the specific block being edited.
### The Concept
Instead of:
1. User edits a field -> Full page query re-runs -> All components re-render
You get:
1. User edits a field -> Block-specific query runs -> Only that component re-renders
### How It Works
1. **Create a targeted query** that fetches just the block data using `_key`:
```groq
*[_id == $documentId][0]{
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title, subtitle, image
}
}
```
2. **Use a presentation query hook** in your component (e.g., `usePresentationQuery` in Next.js)
3. **Fall back to initial props** when not in presentation mode
This pattern works for both Page Builder blocks (`pageBuilder[]`) and Portable Text blocks (`body[]`).
**See framework-specific rules for implementation:**
- Next.js: `nextjs.md` (Section 9)
- Page Builder: `page-builder.md` (Section 5)
- Portable Text: `portable-text.md` (Section 7)
## 9. Framework Specifics
| Framework | Loader Package | Key Components |
| :--- | :--- | :--- |
| **Next.js** | `next-sanity` | `<VisualEditing />`, `defineLive`, `usePresentationQuery` |
| **Remix** | `@sanity/react-loader` | `createQueryStore`, `useLiveMode`, `enableVisualEditing` |
| **Svelte** | `@sanity/svelte-loader` | `createRequestHandler`, `useLiveMode`, `enableVisualEditing` |
| **Nuxt** | `@nuxtjs/sanity` | Automatic via module config (`visualEditing: {}`) |
| **Astro** | `@sanity/astro` | `sanity({ useCdn: false, stega: true })` |
SKILL.md›
---
name: sanity-best-practices
description: Sanity development best practices for schema design, GROQ queries, TypeGen, Visual Editing, images, Portable Text, Studio structure, localization, migrations, Sanity Functions, webhooks, Blueprints, and framework integrations such as Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and the App SDK. Use this skill whenever working with Sanity schemas, defineType or defineField, GROQ or defineQuery, content modeling, Presentation or preview setups, Sanity-powered frontend integrations, event-driven content automation, documentEventHandler, defineDocumentFunction, defineMediaLibraryAssetFunction, @sanity/functions, @sanity/blueprints, sanity.blueprint.ts, event-driven content automation, or when reviewing and fixing a Sanity codebase.
---
# 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 or webhooks
## Global Rules
- Let Sanity generate `_id` values for ordinary documents. Do not create deterministic UUIDs, slug-derived IDs, or legacy-system IDs when creating documents.
- Model relationships with `reference` fields, then resolve related documents with GROQ lookups, source-key fields, or returned `_id` values from created documents.
- Use explicit document IDs mainly for singleton documents controlled by Studio Structure, including localized singletons such as `homePage-en`.
## Video
- Do not store or serve video from Sanity `file` assets for production playback. File assets are delivered as raw downloads with no transcoding or adaptive streaming, and video traffic drives very high bandwidth usage and unexpectedly large bills.
- On Enterprise plans with the video add-on, use Sanity Media Library for video: uploads are transcoded and streamed adaptively via Mux. Model video fields with `defineVideoField()` from `sanity/media-library` and play them with `@mux/mux-player-react` using the asset's playback ID.
- On other plans, use a dedicated video service: install `sanity-plugin-mux-input` to upload and manage videos in your Mux account from the Studio, or host video on a platform such as YouTube or Vimeo and store only the embed URL in Sanity.
- Small clips and short previews in a `file` field are acceptable, but any user-facing video at scale must go through Media Library or a streaming service.
## Quick Reference
### Integration Guides
- `get-started` - Interactive onboarding for new Sanity projects
- `nextjs` - Next.js App Router, Live Content API, standalone Studio
- `nuxt` - Nuxt integration with @nuxtjs/sanity
- `angular` - Angular integration with @sanity/client, signals, resource API
- `astro` - Astro integration with @sanity/astro
- `remix` - React Router / Remix integration
- `svelte` - SvelteKit integration with @sanity/svelte-loader
- `hydrogen` - Shopify Hydrogen with Sanity
- `project-structure` - Standalone Studio and monorepo patterns
- `app-sdk` - Custom applications with Sanity App SDK
- `blueprints` - Infrastructure as Code: blueprint files, stacks, plan/deploy workflow, error recovery, CI deploys
- `functions` - Automating content workflows with Sanity Functions and webhooks
### Topic Guides
- `groq` - GROQ query patterns, type safety, performance optimization
- `schema` - Schema design, field definitions, validation, deprecation patterns
- `visual-editing` - Presentation Tool, Stega, overlays, live preview
- `page-builder` - Page Builder arrays, block components, live editing
- `portable-text` - Rich text rendering and custom components
- `image` - Image schema, URL builder, hotspots, LQIP, Next.js Image
- `studio-structure` - Desk structure, singletons, navigation
- `typegen` - TypeGen configuration, workflow, type utilities
- `seo` - Metadata, sitemaps, Open Graph, JSON-LD
- `localization` - i18n patterns, document vs field-level, locale management
- `migration` - Content import overview (see also `migration-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.md
```
Each reference file contains:
- Comprehensive topic or integration coverage
- Incorrect and correct code examples
- Decision matrices and workflow guidance
- Framework-specific patterns where applicable