Generated banner for Sanity Best Practices
Generated logo for Sanity Best Practices

Sanity Best Practices

SanityOfficialPublished by Sanity

  • Skill
  • Tool integration
  • Software development
  • MIT
  • No issues found

From the author

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, the App SDK, and Studios or apps running in the Sanity Dashboard (defineApplication). 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.
— the description by Sanity

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a comprehensive collection of best practices, architectural patterns, and integration guides for the Sanity ecosystem. It covers the full development lifecycle, including schema design, GROQ query optimization, TypeGen configuration, and framework-specific integrations like Next.js, Angular, and Remix. It also includes guidance on infrastructure management via Blueprints, content automation with Sanity Functions, and building custom applications with the Sanity App SDK.

What it does

  • Provides standardized patterns for Sanity schema design and content modeling.
  • Offers integration guides for major frontend frameworks and the Sanity App SDK.
  • Defines best practices for GROQ query performance and type safety with TypeGen.
  • Outlines rules for media handling, including video asset management.
  • Details configuration for Sanity Studio structure and Visual Editing.
  • Explains infrastructure-as-code workflows using Sanity Blueprints.

When to use it

  • When starting a new Sanity project or onboarding team members.
  • When integrating Sanity with frameworks like Next.js, Astro, or Angular.
  • When optimizing GROQ queries or implementing type-safe data fetching.
  • When designing complex content schemas or implementing localization.
  • When setting up live previews, Visual Editing, or custom Studio structures.
  • When automating content workflows or managing infrastructure via Blueprints.

Advantages

  • Maintained by Sanity, ensuring alignment with current platform standards.
  • Reduces technical debt by enforcing consistent schema and query patterns.
  • Provides clear decision matrices for complex topics like video and localization.
  • Includes framework-specific guidance to accelerate frontend integration.
  • Promotes maintainable project structures and type-safe development workflows.

Usage examples

“What are the best practices for handling video in Sanity?”
The skill advises against using standard file assets for production video due to bandwidth costs, recommending the Sanity Media Library with Mux for Enterprise plans or dedicated services like Mux, YouTube, or Vimeo for others.
“How do I set up TypeGen for an Angular monorepo?”
The skill provides a configuration example for studio/sanity.cli.ts to point TypeGen at the Angular source files, ensuring generated types are correctly integrated into the Angular project.
“What is the recommended way to structure singleton documents?”
The skill recommends using explicit document IDs for singleton documents and controlling them via the Studio Structure, while letting Sanity generate IDs for all other document types.
“How should I fetch data in a React app using the App SDK?”
The skill mandates wrapping data-fetching components in Suspense, using documentId as a React key, and preferring useDocuments and useDocumentProjection over raw GROQ hooks.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/sanity-io/sanity-best-practices/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip sanity-best-practices.zip -d .agents/skills

Documentation by Sanity

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

  • Developing or deploying a Studio or app in the Sanity Dashboard

  • 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

  • dashboard - Studios and apps in the Sanity Dashboard: defineApplication, local dev, deploys, Dashboard hooks

  • 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

Files

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, Media Library events, content-availability (sync tag) events, a schedule, or a direct call from another function.

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

  • Run recurring work on a schedule (cache expiry, digests, periodic sync)

  • Split a pipeline into small, separately-configured steps that call each other (PubSub + invoke)

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

DependencyVersion
Node.jsv24.x (matches deployed runtime)
Sanity CLIv4.12.0+
@sanity/blueprintsLatest
@sanity/functionsLatest
@sanity/clientv7.12.0+ (includes recursion protection)

Project Structure

Organize functions alongside your Sanity project, one level above the Studio directory:

my-project/
├── studio/
├── next-app/
├── functions/
│   ├── my-function/
│   │   ├── index.ts          # Handler code (entry point)
│   │   └── package.json      # (optional) function-level dependencies
│   └── another-function/
│       └── index.ts
├── sanity.blueprint.ts        # Blueprint configuration
├── package.json               # Project-level dependencies
└── node_modules/

The function directory name must match the name in the blueprint config. Each function exports a handler from its index.ts (or index.js).


Step-by-step: Creating a Function

1. Initialize a Blueprint
npx sanity@latest blueprints init . \
  --type ts \
  --stack-name production \
  --project-id <your-project-id>

This creates sanity.blueprint.ts and .sanity/blueprint.config.json (gitignored automatically; it links your Blueprint to a Stack and is not secret).

2. Scaffold a Function
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, pub-sub.

3. Configure the Blueprint
// sanity.blueprint.ts
import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'my-function',
      event: {
        on: ['create', 'update'],
        // 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
// 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() },
      })
      .commit()
    console.log(`Set firstPublished on ${data._id}`)
  } catch (error) {
    console.error('Failed to patch document:', error)
  }
})
5. Test Locally
# Visual dev playground
npx sanity@latest functions dev

# CLI testing
npx sanity@latest functions test my-function \
  --dataset production \
  --with-user-token

# With a specific document
npx sanity@latest functions test my-function \
  --document-id abc123 \
  --dataset production \
  --with-user-token
6. Deploy
npx sanity@latest blueprints deploy
7. View Logs
npx sanity@latest functions logs my-function
npx sanity@latest functions logs my-function --watch

Handler Reference

Every handler receives { context, event }. Sync tag invalidate handlers additionally receive done; scheduled handlers receive only { context } — see defineSyncTagInvalidateFunction and defineScheduledFunction below. PubSub handlers receive whatever the calling function passed to invoke.

context
PropertyTypeDescription
clientOptions.apiHoststringAPI host URL
clientOptions.projectIdstringSanity project ID
clientOptions.datasetstringDataset name
clientOptions.tokenstringRobot token (deployed only)
localboolean | undefinedtrue during local testing
eventResourceTypestring'dataset' or 'media-library'
eventResourceIdstringe.g., 'projectId.datasetName'
event
{
  data: {
    _id: string
    _type: string
    // ... rest of document (shaped by projection if set)
  }
}

For sync tag invalidate functions, event.data is { syncTags: string[] } instead. For PubSub functions, event.data is whatever the caller passed — no schema is enforced.

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
OptionTypeDefaultDescription
namestringrequiredMust match the directory name under functions/
displayNamestring—Human-readable display name
srcstringfunctions/<name>Path to function source directory
memorynumber1Memory in GB (max 10)
timeoutnumber10Timeout in seconds (max 900)
runtimestring'nodejs24.x''node', 'nodejs22.x', or 'nodejs24.x'
projectstring—Project ID. Required if blueprint is org-scoped.
robotTokenstring—Custom robot token name for the function
eventobjectrequiredEvent configuration (see below)
envRecord<string, string>—Environment variables via process.env
event Options
OptionTypeDefaultDescription
onstring[]required'create', 'update', 'delete'
filterstring—GROQ filter body (no *[...] wrapper)
projectionstring—GROQ projection to shape event.data. Wrap in {}.
includeDraftsbooleanfalseTrigger on draft changes
includeAllVersionsbooleanfalseTrigger on all document versions
resourceobject—Scope to dataset: { type: 'dataset', id: 'projectId.datasetName' }
defineMediaLibraryAssetFunction

For Media Library asset events. Requires @sanity/blueprints v0.4.0+ and @sanity/functions v1.1.0+.

import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineMediaLibraryAssetFunction({
      name: 'asset-handler',
      event: {
        on: ['delete'],
        filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
        projection: '{_id, versions, title}',
        resource: {
          type: 'media-library',
          id: 'mlYourLibraryId',
        },
      },
    }),
  ],
})
defineSyncTagInvalidateFunction

Fires when updated content becomes available for querying — after a write has propagated to the query layer, not at mutation time. The event carries the sync tags affected by that update: the same tags the Live Content API returns alongside query results, so you can purge exactly the cached entries that went stale instead of guessing from document types.

Blueprint:

import { defineBlueprint, defineSyncTagInvalidateFunction } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineSyncTagInvalidateFunction({
      name: 'invalidate-tags',
      // Scope to one dataset so a shared blueprint doesn't fire against staging
      event: { resource: { type: 'dataset', id: 'myProjectId.production' } },
    }),
  ],
})

Scaffold with npx sanity@latest functions add --name invalidate-tags --type sync-tag-invalidate.

There is no on, filter, or projection — the function fires for every batch of invalidated tags on the dataset. event.resource is the only scoping mechanism.

Handler — uses syncTagInvalidateEventHandler, which passes a third argument, done:

// functions/invalidate-tags/index.ts
import { syncTagInvalidateEventHandler } from '@sanity/functions'

export const handler = syncTagInvalidateEventHandler(async ({ context, event, done }) => {
  const { syncTags } = event.data

  if (!context.local) {
    await fetch(process.env.CACHE_PURGE_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ tags: syncTags }),
    })
  }

  // Signals that invalidation finished. Clients waiting on the Live Content
  // API block until this resolves — skip it and they never see the update.
  await done(syncTags)
})

Rules:

  • Always call done. It is the completion signal, not a convenience. Call it on the error path too, otherwise a failed purge stalls every subscribed client.

  • One sync-tag-invalidate function per dataset. Several of them on the same dataset race each other and produce unpredictable invalidation.

  • Don't write content from this handler. A mutation makes new content queryable, which fires the function again — an immediate loop that burns through rate limits.

defineScheduledFunction

Runs on a clock instead of a content event — nightly cleanup, cache expiry, digest emails, periodic sync. No document triggers it, so there is no event.data.

Scheduled functions are organization-scoped: they carry no project or dataset context. The Stack must be org-scoped (blueprints init . --organization-id <id>, or blueprints promote an existing project Stack), and any dataset access needs an explicit robot token — context.clientOptions will not supply projectId or dataset for you.

Blueprint:

import { defineBlueprint, defineScheduledFunction, defineRobotToken } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineRobotToken({
      name: 'my-robot',
      label: 'My Robot',
      memberships: [
        { resourceType: 'project', resourceId: 'abc123', roleNames: ['editor'] },
      ],
    }),
    defineScheduledFunction({
      name: 'expire-cache',
      event: { expression: '0 0 * * *' },   // midnight daily
      timezone: 'America/New_York',          // IANA identifier; defaults to UTC
      robotToken: '$.resources.my-robot.token',
    }),
  ],
})

Scaffold with npx sanity@latest functions add --name expire-cache --type scheduled-function --language ts.

Schedule options:

FormExample
CRON expressionevent: { expression: '0 0 * * *' } — minute, hour, day-of-month, month, day-of-week
Explicit fieldsevent: { minute: '0', hour: '0', dayOfMonth: '*', month: '*', dayOfWeek: '*' }

Omit timezone and the schedule runs in UTC. Cadence limits are plan-dependent — check the Functions pricing tier before scheduling anything minutely.

Handler — uses scheduledEventHandler and receives only { context }:

// functions/expire-cache/index.ts
import { scheduledEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = scheduledEventHandler(async ({ context }) => {
  // projectId and dataset are NOT in context here — set them explicitly
  const client = createClient({
    projectId: 'abc123',
    dataset: 'production',
    apiVersion: '2025-05-08',
    token: context.clientOptions?.token,   // from the robotToken above
  })

  const stale = await client.fetch(
    `*[_type == "cacheEntry" && expiresAt < now()]._id`,
  )

  if (!context.local && stale.length) {
    await stale
      .reduce((tx, id) => tx.delete(id), client.transaction())
      .commit()
  }

  console.log(`Expired ${stale.length} entries`)
})

Deploying an org-scoped Stack requires the organization admin role, the blueprint deployer role, or a token with sanity.blueprints.deploy. Test with npx sanity@latest functions dev — playground runs don't count against usage quotas.

definePubSubFunction

A function with no trigger of its own — it runs only when another function calls it with invoke. Use it to break a pipeline into separately-configured steps instead of chaining them through document mutations.

Before invoke, the only way for one function to reach another was to write a document and let the resulting change event fire the next function. That forced every step to be modeled as a mutation, even steps that had nothing to do with the document (posting to Slack, calling an external API). A PubSub function is called directly, so the intermediate write disappears.

Blueprint — name is the only required option:

import { defineBlueprint, definePubSubFunction } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    definePubSubFunction({ name: 'slack-post' }),
  ],
})

Scaffold with npx sanity@latest functions add --name slack-post --type pub-sub --installer npm.

There is no event block — no on, filter, projection, or resource. The other defineDocumentFunction options (memory, timeout, runtime, env, robotToken) still apply, which is the point: each step gets its own resource budget and its own permissions.

Handler — uses pubSubEventHandler:

// functions/slack-post/index.ts
import { pubSubEventHandler } from '@sanity/functions'

export const handler = pubSubEventHandler(async ({ context, event }) => {
  // event.data is whatever the caller passed — validate it, it is not typed
  // or validated by the platform the way a document event is
  const { text } = event.data

  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  })
})

The callee can't tell it was invoked by another function rather than by a document event — it just receives the context and event it was handed.

Calling it with invoke
// functions/on-publish/index.ts
import { documentEventHandler, invoke } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  await invoke('slack-post', {
    context,
    event: { data: { text: `Published ${event.data.title}` } },
  })
})

invoke(name, { context, event }, options?) takes an optional third argument, { sync: boolean }, defaulting to false.

Async (default, sync: false)Sync (sync: true)
Waits for completion?No — only for acceptanceYes
Returns the callee's response?NoYes
Best forFan-out, chaining steps, privilege separationSteps that genuinely can't proceed without the callee's result
Use liberally?Yes — this is the default patternNo — reserve for what async can't do

Async resolving means "accepted", not "done". invoke throws if the request itself is rejected (bad function name, malformed payload), but a resolved promise says only that the invocation was queued.

❌ Incorrect — treating an async invoke as if it returned the callee's output:

const result = await invoke('slack-post', { context, event })
if (result.ok) { /* never runs as expected — result is not the callee's return value */ }

// Same mistake, sequenced: this read happens right after the invocation is
// accepted, not after resize-image has resized anything.
await invoke('resize-image', { context, event })
const resized = await client.fetch(`*[_id == $id][0].resizedUrl`, { id: event.data._id })

✅ Correct — fire-and-forget, catching only acceptance errors:

try {
  await invoke('slack-post', { context, event: { data: event.data } })
} catch (err) {
  // Only failures to *accept* the invocation land here
  console.error('Failed to trigger slack-post:', err)
}

✅ Correct — sync: true for a real dependency:

const response = await invoke(
  'validate-content',
  { context, event: { data: event.data } },
  { sync: true },
)

if (!response.valid) return { skipped: true, reason: response.reason }

await invoke('publish-content', { context, event: { data: event.data } })

Rules:

  • Default to async. sync: true ties up the caller's timeout and memory budget for as long as the callee runs, and serializes work that should be parallel. Reaching for it on most calls usually means the logic belongs in one function, not two.

  • Never sync: true in a fan-out loop. await invoke(..., { sync: true }) inside a for loop runs batches one at a time and blocks the caller until the last one finishes — the opposite of what fan-out is for.

  • Validate event.data in the callee. Nothing between the two functions checks its shape.

  • Recursion limits still apply. A chain of invocations counts toward the same rate limits as event-triggered runs; two PubSub functions invoking each other loop just as fast as a self-triggering document function.


Event Types

EventDescription
createNew document created
updateExisting document modified (for published docs, fires when a draft/version is published)
deleteDocument 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:

defineDocumentFunction({
  name: 'first-published',
  event: {
    on: ['create', 'update'],
    filter: "_type == 'post' && !defined(firstPublished)",
  },
})

✅ Correct — use @sanity/client v7.12.0+ for automatic lineage headers:

import { createClient } from '@sanity/client'

// Client automatically sets X-Sanity-Lineage header
// Recursive chains are limited to 16 invocations
const client = createClient({
  ...context.clientOptions,
  apiVersion: '2025-05-08',
})

❌ Incorrect — no recursion guard:

defineDocumentFunction({
  name: 'update-post',
  event: {
    on: ['create', 'update'],
    filter: "_type == 'post'",  // Will re-trigger on its own writes!
  },
})
Local Testing Safety

Use context.local to prevent accidental mutations during testing:

// Skip mutations entirely in test
if (!context.local) {
  await client.createOrReplace(someDoc)
}

// Or use dryRun
await client.patch(event.data._id, {
  set: { processed: true },
}).commit({ dryRun: context.local })

// Or use noWrite for Agent Actions
await client.agent.action.generate({
  schemaId: 'your-schema-id',
  documentId: event.data._id,
  instruction: 'Summarize this document',
  target: { path: ['summary'] },
  noWrite: context.local,
})
Limits
  • Max bundle size: 200MB (including dependencies). Prefer slim, platform-agnostic packages.

  • Rate limits: 200 invocations/fn/30s, 4000/project/30s

  • Max timeout: 900s. Larger functions = slower cold starts.

Cost

Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. Monitor usage at the organization level.


Common Patterns

Deploy hook / CDN invalidation

Blueprint:

defineDocumentFunction({
  name: 'deploy-hook',
  event: {
    on: ['create', 'update'],
    filter: '_type == "page"',
  },
})

Handler:

export const handler = documentEventHandler(async ({ context, event }) => {
  const URL = process.env.DEPLOY_HOOK_URL
  if (!URL) throw new Error('DEPLOY_HOOK_URL is not set')

  await fetch(URL)
  console.log('Deploy hook triggered')
})

Set the env var: npx sanity@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.

defineDocumentFunction({
  name: 'first-published',
  event: {
    on: ['create', 'update'],
    filter: '_type == "post" && !defined(firstPublished)',
  },
})
Auto-translate with Agent Actions

Blueprint:

defineDocumentFunction({
  name: 'translate',
  event: {
    on: ['create', 'update'],
    filter: "_type == 'post' && language == 'en-US'",
    projection: '{_id}',
  },
})

Handler:

export const handler = documentEventHandler(async ({ context, event }) => {
  const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })

  await client.agent.action.translate({
    schemaId: 'your-schema-id',
    async: true,
    documentId: event.data._id,
    languageFieldPath: 'language',
    targetDocument: {
      operation: 'create',
    },
    fromLanguage: { id: 'en-US', title: 'English' },
    toLanguage: { id: 'el-GR', title: 'Greek' },
  })
})

The GROQ filter ensures only English documents trigger the function. The translated document gets a different language value, preventing recursive triggers.

Let Sanity assign the translated document's _id for ordinary localized content. To find or update translations later, query by language, slug, or translation metadata instead of deriving IDs from the source document. Reserve explicit targetDocument._id values for singleton-style targets.

Auto-tag with Agent Actions

Blueprint:

defineDocumentFunction({
  name: 'auto-tag',
  event: {
    on: ['create', 'update'],
    // 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:

export const handler = documentEventHandler(async ({ context, event }) => {
  const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })

  await client.agent.action.generate({
    schemaId: 'your-schema-id',
    documentId: event.data._id,
    instruction: 'Analyze the content and generate 3 relevant tags. Reuse existing tags when possible.',
    target: { path: ['tags'] },
    async: true,
  })
})
Slack notification on publish
export const handler = documentEventHandler(async ({ context, event }) => {
  const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL
  if (!WEBHOOK_URL) throw new Error('SLACK_WEBHOOK_URL not set')

  await fetch(WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `📝 New content published: *${event.data.title || event.data._id}* (${event.data._type})`,
    }),
  })
})
Fan out to several PubSub functions on publish

One document event, many independent side effects — each in its own function with its own timeout, memory, and permissions.

Blueprint:

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'on-publish',
      event: { on: ['create', 'update'], filter: "_type == 'post'" },
    }),
    definePubSubFunction({ name: 'post-to-bluesky' }),
    definePubSubFunction({ name: 'post-to-linkedin' }),
    definePubSubFunction({ name: 'post-to-mastodon' }),
  ],
})

Handler:

import { documentEventHandler, invoke } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  await Promise.all([
    invoke('post-to-bluesky', { context, event }),
    invoke('post-to-linkedin', { context, event }),
    invoke('post-to-mastodon', { context, event }),
  ])
})

Promise.all here resolves once every invocation is accepted — not once every post is live. If one social API is slow, that slowness stays inside its own function instead of eating this handler's timeout.

Scope to a specific dataset

Option A — resource config:

defineDocumentFunction({
  name: 'production-only',
  event: {
    on: ['update'],
    filter: "_type == 'post'",
    resource: { type: 'dataset', id: 'myProjectId.production' },
  },
})

Option B — GROQ filter:

defineDocumentFunction({
  name: 'production-only',
  event: {
    on: ['update'],
    filter: "_type == 'post' && sanity::dataset() == 'production'",
  },
})
React to Media Library asset changes

Requires @sanity/blueprints v0.4.0+ and @sanity/functions v1.1.0+.

Blueprint:

import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineMediaLibraryAssetFunction({
      name: 'asset-deleted',
      event: {
        on: ['delete'],
        filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
        projection: '{_id, versions, title}',
        resource: { type: 'media-library', id: 'mlYourLibraryId' },
      },
    }),
  ],
})

Handler:

export const handler = documentEventHandler(async ({ context, event }) => {
  const { eventResourceId } = context  // Media Library ID
  const client = createClient({
    ...context.clientOptions,
    apiVersion: '2025-05-08',
  })

  const response = await client.request({
    uri: `/media-libraries/${eventResourceId}/query`,
    method: 'POST',
    body: { query: `*[_type == 'sanity.imageAsset']` },
  })

  console.log('Assets:', response)
})
Recursion control with custom HTTP clients

If not using @sanity/client, implement lineage tracking manually:

export const handler = documentEventHandler(async ({ context, event }) => {
  const lineage = process.env.X_SANITY_LINEAGE

  await fetch(`https://${context.clientOptions.projectId}.api.sanity.io/v2025-05-08/data/mutate/${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
export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'first-published',
      event: {
        on: ['create', 'update'],
        filter: "_type == 'post' && !defined(firstPublished)",
      },
    }),
    defineDocumentFunction({
      name: 'notify-slack',
      event: {
        on: ['create', 'update'],
        filter: "_type == 'post'",
        projection: '{title, _id}',
      },
    }),
    defineDocumentFunction({
      name: 'sync-algolia',
      timeout: 30,
      event: {
        on: ['create', 'update', 'delete'],
        filter: "_type == 'product'",
      },
    }),
  ],
})

CI/CD Deployment

Use the Blueprints GitHub Action

- uses: sanity-io/blueprints-actions/deploy@deploy-v3
  with:
    sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }}

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.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Sanity
Publisher
Sanity (Official)
Source
sanity-io/agent-toolkit · skills/sanity-best-practices · commit 3ed3a14
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Sanity or Sanity.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download sanity-best-practices.zip