17 KiB
title, description
| title | description |
|---|---|
| Sanity Functions | 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.
Experimental feature: APIs may change. Always use
npx sanity@latest.
When to use
- Set computed/derived fields (timestamps, slugs, summaries)
- Enrich or validate content on publish
- Trigger external services (CDN purge, deploy hooks, notifications)
- Automate workflows (translation, tagging, cross-posting)
- Sync content to external systems
- Invoke Agent Actions in response to content events
When NOT to use
- Logic needs >900s execution or >200MB bundle — use an external worker
- High-throughput bulk operations that exceed rate limits (200/fn/30s, 4000/project/30s)
- A simple POST to an external URL on publish with no document data shaping — use a webhook
- Client-side or UI-driven logic (validation, conditional fields) — belongs in Studio schema config
Requirements
| Dependency | Version |
|---|---|
| Node.js | v24.x (matches deployed runtime) |
| Sanity CLI | v4.12.0+ |
@sanity/blueprints |
Latest |
@sanity/functions |
Latest |
@sanity/client |
v7.12.0+ (includes recursion protection) |
Project Structure
Organize functions alongside your Sanity project, one level above the Studio directory:
my-project/
├── studio/
├── next-app/
├── functions/
│ ├── my-function/
│ │ ├── index.ts # Handler code (entry point)
│ │ └── package.json # (optional) function-level dependencies
│ └── another-function/
│ └── index.ts
├── sanity.blueprint.ts # Blueprint configuration
├── package.json # Project-level dependencies
└── node_modules/
The function directory name must match the name in the blueprint config. Each function exports a handler from its index.ts (or index.js).
Step-by-step: Creating a Function
1. Initialize a Blueprint
npx sanity@latest blueprints init . \
--type ts \
--stack-name production \
--project-id <your-project-id>
This creates sanity.blueprint.ts and .sanity/blueprint.config.json (add the latter to .gitignore).
2. Scaffold a Function
npx sanity@latest blueprints add function \
--name my-function \
--fn-type document-publish \
--installer npm
--fn-type options: document-create, document-update, document-publish (deprecated), document-delete.
3. Configure the Blueprint
// sanity.blueprint.ts
import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'my-function',
event: {
on: ['create', 'update'],
filter: '_type == "post"',
},
}),
],
})
4. Write the Handler
// functions/my-function/index.ts
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'
interface PostData {
_id: string
_type: string
title: string
}
export const handler = documentEventHandler<PostData>(async ({ context, event }) => {
const { data } = event
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
try {
await client.patch(data._id, {
setIfMissing: { firstPublished: new Date().toISOString() },
})
console.log(`Set firstPublished on ${data._id}`)
} catch (error) {
console.error('Failed to patch document:', error)
}
})
5. Test Locally
# Visual dev playground
npx sanity@latest functions dev
# CLI testing
npx sanity@latest functions test my-function \
--dataset production \
--with-user-token
# With a specific document
npx sanity@latest functions test my-function \
--document-id abc123 \
--dataset production \
--with-user-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 }:
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
{
data: {
_id: string
_type: string
// ... rest of document (shaped by projection if set)
}
}
When testing locally, context.clientOptions only has projectId and apiHost. Use --dataset and --with-user-token flags to supply the rest.
Blueprint Configuration
defineDocumentFunction Options
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
required | Must match the directory name under functions/ |
displayName |
string |
— | Human-readable display name |
src |
string |
functions/<name> |
Path to function source directory |
memory |
number |
1 |
Memory in GB (max 10) |
timeout |
number |
10 |
Timeout in seconds (max 900) |
runtime |
string |
'nodejs22.x' |
'node', 'nodejs22.x', or 'nodejs24.x' |
project |
string |
— | Project ID. Required if blueprint is org-scoped. |
robotToken |
string |
— | Custom robot token name for the function |
event |
object |
required | Event configuration (see below) |
env |
Record<string, string> |
— | Environment variables via process.env |
event Options
| Option | Type | Default | Description |
|---|---|---|---|
on |
string[] |
required | 'create', 'update', 'delete'. Legacy 'publish' is deprecated. |
filter |
string |
— | GROQ filter body (no *[...] wrapper) |
projection |
string |
— | GROQ projection to shape event.data. Wrap in {}. |
includeDrafts |
boolean |
false |
Trigger on draft changes |
includeAllVersions |
boolean |
false |
Trigger on all document versions |
resource |
object |
— | Scope to dataset: { type: 'dataset', id: 'projectId.datasetName' } |
defineMediaLibraryAssetFunction
For Media Library asset events. Requires @sanity/blueprints v0.4.0+ and @sanity/functions v1.1.0+.
import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineMediaLibraryAssetFunction({
name: 'asset-handler',
event: {
on: ['delete'],
filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
projection: '{_id, versions, title}',
resource: {
type: 'media-library',
id: 'mlYourLibraryId',
},
},
}),
],
})
Event Types
| Event | Description |
|---|---|
create |
New document created |
update |
Existing document modified (for published docs, fires when a draft/version is published) |
delete |
Document deleted |
publish |
Deprecated. Equivalent to ['create', 'update']. Migrate to explicit events. |
Often best to use ['create', 'update'] together for published document triggers.
GROQ Filter Tips
- Only the filter body —
_type == 'post', not*[_type == 'post'] delta::changedAny(fieldName)— trigger only when specific fields changesanity::dataset() == 'production'— scope to a dataset withoutresourceconfig_id in path('drafts.**')withincludeDrafts: true— draft-only triggers- Combine conditions to prevent recursion:
_type == 'post' && !defined(processedAt)
Projections
- Shape the data passed to
event.data - Limited to the invoking document's scope (plus
→for references) - Nested filters in projections (like
*[references(^._id)]) will fail silently — query inside the function instead - Wrap in
{}:projection: '{title, _id, slug}'
Environment Variables
Three ways to set them:
- Blueprint config:
env: { MY_VAR: 'value' } - CLI:
npx sanity functions env add my-function MY_VAR my-value - Local testing:
MY_VAR=value npx sanity functions test my-function
Access in handler code via process.env.MY_VAR.
Critical Rules
Preventing Recursion
If your function mutates the same document type it listens to, you will create an infinite loop.
✅ Correct — use GROQ filters to exclude processed documents:
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
})
✅ Correct — use @sanity/client v7.12.0+ for automatic lineage headers:
import { createClient } from '@sanity/client'
// Client automatically sets X-Sanity-Lineage header
// Recursive chains are limited to 16 invocations
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
❌ Incorrect — no recursion guard:
defineDocumentFunction({
name: 'update-post',
event: {
on: ['create', 'update'],
filter: "_type == 'post'", // Will re-trigger on its own writes!
},
})
Local Testing Safety
Use context.local to prevent accidental mutations during testing:
// Skip mutations entirely in test
if (!context.local) {
await client.createOrReplace(someDoc)
}
// Or use dryRun
await client.patch(event.data._id, {
set: { processed: true },
}).commit({ dryRun: context.local })
// Or use noWrite for Agent Actions
await client.agent.action.generate({
schemaId: 'your-schema-id',
documentId: event.data._id,
instruction: 'Summarize this document',
target: { path: ['summary'] },
noWrite: context.local,
})
Limits
- Max bundle size: 200MB (including dependencies). Prefer slim, platform-agnostic packages.
- Rate limits: 200 invocations/fn/30s, 4000/project/30s
- Max timeout: 900s. Larger functions = slower cold starts.
Cost
Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. Monitor usage at the organization level.
Common Patterns
Deploy hook / CDN invalidation
Blueprint:
defineDocumentFunction({
name: 'deploy-hook',
event: {
on: ['create', 'update'],
filter: '_type == "page"',
},
})
Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const URL = process.env.DEPLOY_HOOK_URL
if (!URL) throw new Error('DEPLOY_HOOK_URL is not set')
await fetch(URL)
console.log('Deploy hook triggered')
})
Set the env var: npx sanity functions env add deploy-hook DEPLOY_HOOK_URL https://...
Set a timestamp on first publish
Uses the same pattern as the step-by-step example above. The key insight: the !defined(firstPublished) GROQ filter prevents re-triggering after the field is set. The setIfMissing patch is a redundant safety net.
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: '_type == "post" && !defined(firstPublished)',
},
})
Auto-translate with Agent Actions
Blueprint:
defineDocumentFunction({
name: 'translate',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && language == 'en-US'",
projection: '{_id}',
},
})
Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })
await client.agent.action.translate({
schemaId: 'your-schema-id',
async: true,
documentId: event.data._id,
languageFieldPath: 'language',
targetDocument: {
operation: 'createOrReplace',
_id: `${event.data._id}-el-GR`,
},
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.
Auto-tag with Agent Actions
Blueprint:
defineDocumentFunction({
name: 'auto-tag',
event: {
on: ['create', 'update'],
filter: "_type == 'post'",
projection: '{_id, title, body}',
},
})
Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })
await client.agent.action.generate({
schemaId: 'your-schema-id',
documentId: event.data._id,
instruction: 'Analyze the content and generate 3 relevant tags. Reuse existing tags when possible.',
target: { path: ['tags'] },
async: true,
})
})
Slack notification on publish
export const handler = documentEventHandler(async ({ context, event }) => {
const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL
if (!WEBHOOK_URL) throw new Error('SLACK_WEBHOOK_URL not set')
await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📝 New content published: *${event.data.title || event.data._id}* (${event.data._type})`,
}),
})
})
Scope to a specific dataset
Option A — resource config:
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post'",
resource: { type: 'dataset', id: 'myProjectId.production' },
},
})
Option B — GROQ filter:
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post' && sanity::dataset() == 'production'",
},
})
React to Media Library asset changes
Requires @sanity/blueprints v0.4.0+ and @sanity/functions v1.1.0+.
Blueprint:
import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineMediaLibraryAssetFunction({
name: 'asset-deleted',
event: {
on: ['delete'],
filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
projection: '{_id, versions, title}',
resource: { type: 'media-library', id: 'mlYourLibraryId' },
},
}),
],
})
Handler:
export const handler = documentEventHandler(async ({ context, event }) => {
const { eventResourceId } = context // Media Library ID
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
const response = await client.request({
uri: `/media-libraries/${eventResourceId}/query`,
method: 'POST',
body: { query: `*[_type == 'sanity.imageAsset']` },
})
console.log('Assets:', response)
})
Recursion control with custom HTTP clients
If not using @sanity/client, implement lineage tracking manually:
export const handler = documentEventHandler(async ({ context, event }) => {
const lineage = process.env.X_SANITY_LINEAGE
await fetch(`https://${context.clientOptions.projectId}.api.sanity.io/v2025-05-08/data/mutate/production`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${context.clientOptions.token}`,
...(lineage ? { 'X-Sanity-Lineage': lineage } : {}),
},
body: JSON.stringify({
mutations: [{ patch: { id: event.data._id, set: { processed: true } } }],
}),
})
})
Multiple functions in one blueprint
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
}),
defineDocumentFunction({
name: 'notify-slack',
event: {
on: ['create', 'update'],
filter: "_type == 'post'",
projection: '{title, _id}',
},
}),
defineDocumentFunction({
name: 'sync-algolia',
timeout: 30,
event: {
on: ['create', 'update', 'delete'],
filter: "_type == 'product'",
},
}),
],
})
CI/CD Deployment
Use the Blueprints GitHub Action
- uses: sanity-io/blueprints-actions/deploy@deploy-v3
with:
sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }}
Only personal auth tokens are supported for deployment (not robot tokens).