635 lines
17 KiB
Markdown
635 lines
17 KiB
Markdown
---
|
||
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.
|
||
|
||
> **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
|
||
|
||
```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` (add the latter to `.gitignore`).
|
||
|
||
### 2. Scaffold a Function
|
||
|
||
```bash
|
||
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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```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` | `'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+.
|
||
|
||
```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 |
|
||
| `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 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 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 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: '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:**
|
||
```typescript
|
||
defineDocumentFunction({
|
||
name: 'auto-tag',
|
||
event: {
|
||
on: ['create', 'update'],
|
||
filter: "_type == 'post'",
|
||
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/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
|
||
|
||
```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 }}
|
||
```
|
||
|
||
Only personal auth tokens are supported for deployment (not robot tokens).
|