add skills
Build and Deploy / build-and-deploy (push) Successful in 2m53s

This commit is contained in:
2026-05-18 08:39:42 +02:00
parent 92a80e8759
commit 57af0b8386
53 changed files with 10190 additions and 0 deletions
@@ -0,0 +1,634 @@
---
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).