# Manage Sanity with code

#### Get started

[Introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

[Your first Blueprint](https://www.sanity.io/docs/blueprints/your-first-blueprint)
Set up a blueprint, run a plan, and deploy your first resource (a CORS origin) to a real Sanity stack.

[Create a Sanity Function](https://www.sanity.io/docs/functions/function-quickstart)
Use Blueprints to create a function

[Deploy Blueprints with GitHub Actions](https://www.sanity.io/docs/blueprints/blueprint-action)
Use the official action to deploy your blueprints with GitHub Actions.

#### Concepts

[Stacks and scope](https://www.sanity.io/docs/blueprints/stacks-and-scope)
How one blueprint file maps to many stacks, and what project versus organization scope means.

[The resource graph](https://www.sanity.io/docs/blueprints/resource-graph)
How resources reference each other with the $ syntax, and why a blueprint file is a connected system.

[Manage environments with Blueprints](https://www.sanity.io/docs/blueprints/manage-environments)
Deploy one blueprint file to staging, production, and other stacks.

[Errors and rollbacks](https://www.sanity.io/docs/blueprints/errors-and-rollbacks)
How Blueprints catches mistakes early and what happens when a deploy fails.

#### Reference documentation

[Configuration reference](https://www.sanity.io/docs/blueprints/blueprint-config)
Reference documentation for the Blueprint configuration files.

[Blueprints CLI command reference](https://www.sanity.io/docs/cli-reference/cli-blueprints)
Reference documentation for the Sanity CLI Blueprints command.

[Blueprints scopes reference](https://www.sanity.io/docs/blueprints/scopes)
Project versus organization scope, which resource types need which scope, and how the CLI resolves scope.

[Blueprints glossary](https://www.sanity.io/docs/blueprints/glossary)
Definitions of the core Blueprints terms: stacks, scopes, values, deletion policies, and more.



# Introduction

Blueprints enable infrastructure-as-code level management of Sanity resources.

Blueprints replaces one-off changes in the web interface with a **declarative** workflow. You describe the resources you want in one file, the **blueprint file** (`sanity.blueprint.ts`, sometimes called the manifest), and keep it in your repository alongside your application code. When you deploy, Sanity compares the file against what already exists and makes reality match it. If it's not in the blueprint, it's not deployed: the file is the source of truth.

Managing resources this way has a few compounding benefits:

- **Auditable.** Your setup lives in version control next to your code. Changes go through pull requests and have a history.
- **Reproducible.** Create staging or per-developer environments from the same file, so environments stay consistent instead of drifting apart.
- **Deterministic.** A plan shows the exact changes before you apply them.
- **Faster onboarding.** A teammate runs `blueprints init`, points at a stack, and deploys.

## Requirements

- The latest version of Sanity CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Permissions:- Project-scoped blueprints require an admin role within the project, or a role or robot token with the `sanity.project.blueprints.deploy` permission.
- Organization-scoped blueprints require an admin role, or an organization robot token with the `sanity.blueprints.deploy` permission.



## Core concepts

### Blueprint

Like a configuration file, a blueprint lets you define and customize Sanity resources.

[Blueprint configuration reference](https://www.sanity.io/docs/blueprints/blueprint-config)
Reference documentation for the Blueprint configuration files.

### Resource

Core Sanity components are resources. You can create and update resources by defining them in Blueprints.

#### Define resources with Blueprints

[Functions](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

[Define a webhook with Blueprints](https://www.sanity.io/docs/blueprints/blueprints-webhook)
Blueprints allow you to define and manage your webhooks in code, then deploy them in a predictable manner.

[Define a CORS origin with Blueprints](https://www.sanity.io/docs/blueprints/blueprints-cors)
Blueprints allow you to define and manage your CORS origins in code, then deploy them in a predictable manner. 

[Define a robot token with Blueprints](https://www.sanity.io/docs/blueprints/blueprints-robot-tokens)
Blueprints allow you to create robot tokens alongside other resources for use in the blueprint.

[Define a role with Blueprints](https://www.sanity.io/docs/blueprints/blueprints-role)
Use Blueprints to define a custom role in code alongside your project's other resources.

### Stack

<div style="display:none">Unknown block type "mermaidDiagram", specify a component for it in the `components.types` option</div>A stack is a collection of resources that are managed as a single unit. These are linked to a project and can be multiple deployments of the same `sanity.blueprint.ts` configuration, or deployments for different blueprint configurations entirely. 

For example, marketing might have a `sanity.blueprint.ts` that defines resources deployed to the `marketing` stack, while the commerce team may have their own `sanity.blueprint.ts` that deploys resources to the `commerce` stack.

You can view stacks with the `sanity blueprints stacks` command, and switch stacks by running `sanity blueprints init` or `sanity blueprints config --edit` in an existing blueprints project 

#### Stack scopes

You can scope stacks to a project (the default) or to an organization.

- For project-scoped stacks, all resources default to the stack’s project. 
- For organization-scoped stacks, you must explicitly set a resource’s project (when applicable) as part of the resource’s blueprint configuration.

Some resources, like [Scheduled Functions](https://www.sanity.io/docs/functions/scheduled-function-quickstart), require an organization-scoped stack.

### Definer

A definer is a typed function such as `defineCorsOrigin` or `defineRole` that you call to declare a resource. It checks your input as you write it, so mistakes surface in your editor instead of at deploy time. A blueprint file is a list of resources, each created with a definer:

**sanity.blueprint.ts**

```typescript
import { defineBlueprint, defineCorsOrigin } from '@sanity/blueprints'

export default defineBlueprint({
  values: {
    projectId: process.env.PROJECT_ID,
  },
  resources: [
    defineCorsOrigin({
      name: 'studio-cors',
      project: '$.values.projectId',
      origin: 'https://my-studio.sanity.studio',
      allowCredentials: true,
    }),
  ],
})
```

The `values` block holds reusable string constants, referenced with `$.values.<key>`. The example reads the project ID from the environment, so the file carries no hard-coded IDs.

> [!NOTE]
> Make changes in the blueprint file
> Blueprints manages the resources it knows about. If you change a Blueprint-managed resource in another Sanity interface, Blueprints won't see the change until the next `plan` shows the difference. Treat the blueprint file as the source of truth.

## Limitations

### Stack limit

Projects have a limit of 3 stacks. If you reach your limit and want to remove a stack, see the *Remove a stack* steps below.

### No nested blueprints

When creating multiple blueprints in a single project, you cannot nest blueprints in subdirectories of a directory containing a `sanity.blueprint.ts` file.

❌ For example, don't do this:

**Don't do this**

```text
.
└── some-project/
    ├── sanity.blueprint.ts
    └── another-project/
        └── sanity.blueprint.ts
```

✅ Instead, do this:

**Do this**

```text
.
└── some-project/
|   └── sanity.blueprint.ts
└── another-project/
    └── sanity.blueprint.ts
```

## Troubleshooting

### View stacks for a project

If you're unsure which stacks are deployed, run the `blueprints stacks` command.

**npm**

```shell
npx sanity@latest blueprints stacks
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints stacks
```

**yarn**

```shell
yarn dlx sanity@latest blueprints stacks
```

**bun**

```shell
bunx sanity@latest blueprints stacks
```

### View current stack

To view the currently selected stack, run the `blueprints info` command.

**npm**

```shell
npx sanity@latest blueprints info
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints info
```

**yarn**

```shell
yarn dlx sanity@latest blueprints info
```

**bun**

```shell
bunx sanity@latest blueprints info
```

### Remove a stack

To remove a deployed stack, run the following commands from a directory containing a configured blueprint for the same project as the stack you want to delete.

First, retrieve the stack identifier (it starts with `ST-`):

**npm**

```shell
npx sanity@latest blueprints info
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints info
```

**yarn**

```shell
yarn dlx sanity@latest blueprints info
```

**bun**

```shell
bunx sanity@latest blueprints info
```

Next, run the following command with the stack identifier from the previous step.

**CLI**

```sh
blueprints destroy --stack-id <ST-someid>
```



# Stacks and scope

## Blueprint files and stacks

The [blueprint file](https://www.sanity.io/docs/blueprints/blueprint-config) is what you edit. A **stack** is what it becomes once deployed: the live set of resources on Sanity's side. The relationship is one-to-many. A single file can deploy to several stacks, which is what makes environments straightforward: the same file produces a `staging` stack and a `production` stack, selected with the `--stack` flag at deploy time. See [Manage environments with Blueprints](https://www.sanity.io/docs/blueprints/manage-environments).

The link between your local file and a particular stack lives in `.sanity/blueprint.config.json`, the **blueprint config file**. It records the scope and the stack. It's gitignored by default and isn't secret, so a team can commit it to share a default.

## Two scopes

Every stack has a scope: **project** or **organization**. The blueprint file doesn't encode scope; the config file does. A `projectId` makes the stack project-scoped; an `organizationId` makes it organization-scoped.

##### Project vs organization scope

|  | Project scope | Organization scope |
| --- | --- | --- |
| Boundary | One Sanity project | An entire organization |
| Resource's project | Defaults to the stack's project | Set project on each project-scoped resource |
| Org-scoped resource types | Not available | Available (for example, scheduled functions) |

> [!TIP]
> Recommendation
> Organization scope is the recommended path. It unlocks organization-scoped resource types and scales cleanly to multi-project setups. On an org-scoped stack, give each project-scoped resource an explicit `project`; the common pattern is to supply `$.values.projectId` from an environment variable.
> Starting with project scope is completely fine, too. You can move to organization scope at any time with `blueprints promote`. See [Promote a stack to organization scope](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope).

## Why scope matters

Some resource types only make sense above a single project. Scheduled (cron) functions, for example, need an organization-scoped stack. If a stack is project-scoped and you add an organization-scoped resource type, the deploy tells you to promote the stack first.

The [scopes reference](https://www.sanity.io/docs/blueprints/scopes) covers which resource types need which scope.

## Promotion: from project to organization

`blueprints promote` converts a project-scoped stack to organization scope in place, so you never have to start over. It's a safe, additive change: it unlocks new capabilities without changing how your existing resources behave. Blueprints assigns the stack a default project, so project-scoped resources that don't name a `project` keep resolving to the project they always used.

Promotion is one-way (there's no demote), but nothing about your stack stops working. For the full steps, see [Promote a stack to organization scope](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope).

#### Related

[Manage environments with Blueprints](https://www.sanity.io/docs/blueprints/manage-environments)
Deploy one blueprint file to staging, production, and other stacks.

[Blueprints scopes reference](https://www.sanity.io/docs/blueprints/scopes)
Project versus organization scope, which resource types need which scope, and how the CLI resolves scope.

[Promote a stack to organization scope](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope)
Learn how to promote an existing project-scoped blueprint stack to organization scope to unlock scheduled functions.



# Manage environments with Blueprints

## The pattern

Keep one blueprint file. Read an environment variable inside it to select per-environment configuration, then deploy each stack with the environment variable and the `--stack` flag traveling together. The file stays the source of truth; the variable picks which values to apply.

**sanity.blueprint.ts**

```typescript
import { defineBlueprint, defineCorsOrigin } from '@sanity/blueprints'

const env = process.env.SANITY_ENV ?? 'production'

const config = {
  staging: { origin: 'https://staging.example.com' },
  production: { origin: 'https://www.example.com' },
}[env]

export default defineBlueprint({
  values: {
    projectId: process.env.PROJECT_ID,
  },
  resources: [
    defineCorsOrigin({
      name: 'web-cors',
      project: '$.values.projectId',
      origin: config.origin,
      allowCredentials: false,
    }),
  ],
})
```

## Add a second stack

Re-run `init` in the existing directory. It detects the existing blueprint file, skips scaffolding, and goes straight to configuration so you can create or select a stack. Pass `.` to target the current directory directly:

**npm**

```shell
npx sanity@latest blueprints init .
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init .
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init .
```

**bun**

```shell
bunx sanity@latest blueprints init .
```

## Deploy each environment

Pass the environment variable and the matching `--stack` on the same command:

**CLI**

```sh
SANITY_ENV=staging    npx sanity@latest blueprints deploy --stack staging
SANITY_ENV=production npx sanity@latest blueprints deploy --stack production
```

List your stacks at any time:

**npm**

```shell
npx sanity@latest blueprints stacks
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints stacks
```

**yarn**

```shell
yarn dlx sanity@latest blueprints stacks
```

**bun**

```shell
bunx sanity@latest blueprints stacks
```

## How targeting works

##### How targeting works

| Mechanism | What it does |
| --- | --- |
| --stack <name-or-id> | Selects which deployed stack to act on. Available on deploy, plan, info, and logs. |
| SANITY_ENV | A convention read by your file, not by the CLI. Name it whatever you like; the example above reads it to pick config. |

> [!WARNING]
> --stack never creates a stack
> `--stack` does not create a stack on a miss. It fails and points you to create one with `init` (or the interactive `config --edit`). This is deliberate, so CI cannot accidentally provision stacks.

> [!NOTE]
> Environment files are not auto-loaded
> The CLI does not auto-load `.env` files. Pass environment variables explicitly on the command, as shown above.

## Other ways to split stacks

Staging and production are the most common reason to run multiple stacks, but not the only one. The same one-file, many-stacks pattern applies when you split by:

- **Content division**: one stack per brand or property (`artist-1`, `artist-2`).
- **Business area**: `marketing`, `sales`.
- **Region**: a stack per locale or data region.

Pick the dimension that matches how your team is organized, and select it with `--stack` the same way.

#### Related

[Stacks and scope](https://www.sanity.io/docs/blueprints/stacks-and-scope)
How one blueprint file maps to many stacks, and what project versus organization scope means.

[Deploy Blueprints from CI](https://www.sanity.io/docs/blueprints/deploy-blueprints-from-ci)
How to deploy your Blueprint automatically from any CI system using a deploy token and environment variables.

[Blueprints CLI command reference](https://www.sanity.io/docs/cli-reference/cli-blueprints)
Reference documentation for the Sanity CLI Blueprints command.



# The resource graph

## Two kinds of reference

Inside a blueprint file you address other values with a small `$` syntax, passed as a plain string. There are two forms you'll use most:

##### Reference syntax

| Syntax | Resolved | Use it for |
| --- | --- | --- |
| $.values.<key> | When the file runs (eval time) | A reusable constant from the values block, such as a project ID. |
| $.resources.<name> | At deploy time | A reference to another resource in the same file. This creates a dependency edge. |
| $.resources.<name>.id | At deploy time | The generated ID of another resource, usable as a string once it exists. |

## Values: constants you reuse

A value is **always a string** (not an object or array). Define it once in the `values` block and reference it with `$.values.<key>` wherever it's needed. The most common use is a project ID drawn from the environment, so the file carries no hard-coded IDs:

**sanity.blueprint.ts**

```typescript
import { defineBlueprint, defineCorsOrigin, defineDocumentWebhook } from '@sanity/blueprints'

export default defineBlueprint({
  values: {
    projectId: process.env.PROJECT_ID,
  },
  resources: [
    defineCorsOrigin({
      name: 'studio-cors',
      project: '$.values.projectId',
      origin: 'https://my-studio.sanity.studio',
      allowCredentials: true,
    }),
    defineDocumentWebhook({
      name: 'revalidate',
      project: '$.values.projectId',
      dataset: 'production',
      apiVersion: 'v2025-02-19',
      url: 'https://example.com/api/revalidate',
      on: ['create', 'update', 'delete'],
    }),
  ],
})
```

Values resolve when the file runs, so they can't be built from things that only exist at deploy time. If you need to compose a string, do it in plain JavaScript at the top of the file and assign the result to a value.

## Resource references: edges in the graph

When one resource names another with `$.resources.<name>`, Blueprints records a dependency: the referenced resource is resolved before the resource that points at it. A robot token that uses a role defined in the same file is a clear example:

**sanity.blueprint.ts**

```typescript
import { defineBlueprint, defineRole, defineRobotToken } from '@sanity/blueprints'

export default defineBlueprint({
  values: {
    projectId: process.env.PROJECT_ID,
  },
  resources: [
    defineRole({
      name: 'ci-deploy-role',
      title: 'CI Deploy Role',
      appliesToUsers: false,
      appliesToRobots: true,
      permissions: [{ name: 'sanity-project-cors', action: 'create' }],
    }),
    defineRobotToken({
      name: 'ci-robot',
      memberships: [
        {
          resourceType: 'project',
          resourceId: '$.values.projectId',
          // references the role above, so it's created first
          roleNames: ['$.resources.ci-deploy-role'],
        },
      ],
    }),
  ],
})
```

You don't order resources yourself. Blueprints reads the references, builds the graph, and arranges the work so each dependency exists before it's needed.

> [!WARNING]
> References are validated
> A reference to a resource that doesn't exist, or a cycle where two resources depend on each other, fails validation before anything is deployed.

## Why this matters

The graph is what makes a blueprint file more than a pile of settings. It records how your resources relate (this token uses that role, this webhook watches that dataset) and makes those relationships explicit, reviewable, and reproducible. Change one resource and the connected ones come along with it.

## Referencing across stacks

A resource in one stack can hold a read-only reference to a resource in another stack in the same scope. This is an advanced, evolving capability. For the common case of pointing resources at an existing project, define the project ID as a value and supply it from the environment, as shown above.

#### Related

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

[Blueprint configuration reference](https://www.sanity.io/docs/blueprints/blueprint-config)
Reference documentation for the Blueprint configuration files.



# Errors and rollbacks

## Most errors happen before deploy

Validation runs at more than one point, so problems surface as early as possible:

- **As you write.** The [definers](https://www.sanity.io/docs/blueprints/blueprint-config) check their input when you call them, so a missing required field or a malformed value is flagged in your editor and at build time.
- **Before applying.** When you run `plan` or `deploy`, the whole file is validated (structurally and per resource) before any change is made.

Because `plan` is read-only, running it is the safe way to surface most errors without touching anything.

## Kinds of failure

##### Kinds of failure

| Failure | What it means |
| --- | --- |
| Validation error | A resource is missing a required field, has an invalid value, or the file is structurally wrong. Caught before deploy. |
| Unresolved reference or cycle | A $.resources.<name> points at something that doesn't exist, or two resources depend on each other. See The resource graph. |
| Scope mismatch | An organization-scoped resource type on a project-scoped stack, or a project-scoped resource with no resolvable project. See Stacks and scope. |
| Deletion-policy violation | A change would remove a resource whose deletion policy forbids it (for example a retain or protect resource). |
| Execution failure | A change passed validation but failed while being applied; for instance, an underlying service rejected it. |

Errors come back with a clear name, a human-readable message, and the path to the resource involved, so you can map the failure straight to a line in your file.

## What rollback does

If a deploy fails after some changes have already been applied, Blueprints attempts to undo the completed changes in reverse order and marks the operation as failed. The goal is to leave the stack consistent rather than half-applied.

> [!WARNING]
> Rollback is best-effort
> Rollback unwinds the actions it can, in reverse. It isn't a guaranteed atomic restore of the entire stack: some actions may not be reversible, and references to resources owned by other stacks are never touched. After a failed deploy, run `blueprints info` to see the current state, fix the cause, and deploy again.

## Reading what happened

A deploy streams its progress until the operation finishes. To review a past deploy after it has stopped tailing, including the messages from a failure, use the logs:

**npm**

```shell
# Inspect current state
npx sanity@latest blueprints info

# Re-read logs from earlier operations
npx sanity@latest blueprints logs
```

**pnpm**

```shell
# Inspect current state
pnpm dlx sanity@latest blueprints info

# Re-read logs from earlier operations
pnpm dlx sanity@latest blueprints logs
```

**yarn**

```shell
# Inspect current state
yarn dlx sanity@latest blueprints info

# Re-read logs from earlier operations
yarn dlx sanity@latest blueprints logs
```

**bun**

```shell
# Inspect current state
bunx sanity@latest blueprints info

# Re-read logs from earlier operations
bunx sanity@latest blueprints logs
```

## Recovering

1. Read the error message and the resource path it points to.
2. Fix the blueprint file (or the underlying resource, if something external changed).
3. Run `blueprints plan` to confirm the diff is now what you expect.
4. Run `blueprints deploy` again.

#### Related

[The resource graph](https://www.sanity.io/docs/blueprints/resource-graph)
How resources reference each other with the $ syntax, and why a blueprint file is a connected system.

[Stacks and scope](https://www.sanity.io/docs/blueprints/stacks-and-scope)
How one blueprint file maps to many stacks, and what project versus organization scope means.

[Blueprints CLI command reference](https://www.sanity.io/docs/cli-reference/cli-blueprints)
Reference documentation for the Sanity CLI Blueprints command.



# Your first Blueprint

You'll set up a blueprint, run a plan, and deploy your first resource (a CORS origin) to a real Sanity stack. By the end you'll have run the full edit, plan, deploy loop you use for everything else.

## Before you begin

- **Node.js and npm** installed.
- **A Sanity account, logged in.** If you're not sure, run `npx sanity@latest login`.
- **An existing Sanity project**, and its **project ID** (from the project's settings).

New to the ideas behind this? Read the [Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction) first. Otherwise, start in an empty directory.

## Step 1: Initialize a blueprint

Run `init` and follow the prompts:

**npm**

```shell
npx sanity@latest blueprints init
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init
```

**bun**

```shell
bunx sanity@latest blueprints init
```

The interactive setup walks you through a few choices:

- Confirm the directory to create the blueprint in.
- Choose your **organization** as the scope.
- Name the new stack `production`.
- Choose **TypeScript** as the format.

When it finishes, you'll have:

- `sanity.blueprint.ts`, the blueprint file you'll edit.
- `.sanity/blueprint.config.json`, which links the file to the new stack (gitignored by default).
- `@sanity/blueprints` added to your `package.json`.

## Step 2: Install dependencies

The blueprint file is real TypeScript, so install the package it imports:

**npm**

```shell
npm install
```

**pnpm**

```shell
pnpm install
```

**yarn**

```shell
yarn install
```

**bun**

```shell
bun install
```

## Step 3: Look at the empty stack

`init` created a stack with no resources yet. Confirm that:

**npm**

```shell
npx sanity@latest blueprints info
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints info
```

**yarn**

```shell
yarn dlx sanity@latest blueprints info
```

**bun**

```shell
bunx sanity@latest blueprints info
```

You'll see the stack you just created, with zero resources. `info` shows what's live and which stack you're connected to.

## Step 4: Declare a resource

Open `sanity.blueprint.ts` and replace its contents with the blueprint file below. It declares one CORS origin so a hosted Studio can call your project's API. Swap in your own project ID:

**sanity.blueprint.ts**

```typescript
import { defineBlueprint, defineCorsOrigin } from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineCorsOrigin({
      name: 'studio-cors',
      project: 'your-project-id',
      origin: 'https://my-studio.sanity.studio',
      allowCredentials: true,
    }),
  ],
})
```

What you just wrote:

- `defineCorsOrigin` is a **definer**, a typed function that checks your input as you write it.
- `name` is the resource's identity in the stack.
- `project` is the project the CORS origin belongs to.
- `allowCredentials: true` lets authenticated requests through, which a Studio needs.

## Step 5: Run a plan

Before changing anything, see what a deploy would do. `plan` is read-only:

**npm**

```shell
npx sanity@latest blueprints plan
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints plan
```

**yarn**

```shell
yarn dlx sanity@latest blueprints plan
```

**bun**

```shell
bunx sanity@latest blueprints plan
```

The output shows a single **create** action for `studio-cors`. Nothing has been created yet:

**blueprints plan**

```text
Deployment Plan
  + create  studio-cors  sanity.project.cors

  1 create
```

## Step 6: Deploy

Apply the plan:

**npm**

```shell
npx sanity@latest blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints deploy
```

**yarn**

```shell
yarn dlx sanity@latest blueprints deploy
```

**bun**

```shell
bunx sanity@latest blueprints deploy
```

`deploy` applies the change and streams progress until the stack finishes updating. When it's done, your CORS origin is live.

## Step 7: Confirm it's live

**npm**

```shell
npx sanity@latest blueprints info
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints info
```

**yarn**

```shell
yarn dlx sanity@latest blueprints info
```

**bun**

```shell
bunx sanity@latest blueprints info
```

This time `studio-cors` appears under the stack's resources. The deployed state matches your file.

> [!TIP]
> Reading past logs
> `deploy` stops tailing once it finishes. To re-read the logs of that deployment, run `npx sanity@latest blueprints logs`.

## What you did

You ran the core Blueprints loop end to end:

- Initialized a stack with `init`.
- Declared a resource in the blueprint file.
- Ran `plan` (read-only) to see the change.
- Applied it with `deploy` and confirmed with `info`.

Every future change is a variation on this loop: edit the file, plan, deploy. Ready to deploy the same file to more than one environment? See [Manage environments with Blueprints](https://www.sanity.io/docs/blueprints/manage-environments).

#### Related

[Manage environments with Blueprints](https://www.sanity.io/docs/blueprints/manage-environments)
Deploy one blueprint file to staging, production, and other stacks.

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

[Deploy Blueprints from CI](https://www.sanity.io/docs/blueprints/deploy-blueprints-from-ci)
How to deploy your Blueprint automatically from any CI system using a deploy token and environment variables.



# Project layout and monorepos

Blueprints organize Sanity infrastructure as code: projects, datasets, CORS origins, robot tokens, roles, and Functions. As your project grows, the location of `sanity.blueprint.ts` and the shape of your repository start to matter. This guide explains the three filesystem patterns we support, how dependencies behave in each, and which one to pick for a Turborepo or pnpm workspace.

Prerequisites:

- Familiarity with Blueprints and Functions.
- The latest `sanity` CLI, invoked via `npx sanity@latest` or `pnpm dlx sanity@latest`.

## The rule: lockfile and manifest live together

Your package manager's lockfile and `sanity.blueprint.ts` (the blueprint manifest) should sit in the same folder. The CLI uses the lockfile in the current working directory to detect which package manager you use. If the lockfile isn't there, the CLI defaults to npm, which fails on pnpm features like `catalog:` and `workspace:` dependencies.

In practice:

- `package-lock.json`: manifest at repo root, deploy from root.
- `yarn.lock`: manifest at repo root, deploy from root.
- `pnpm-lock.yaml`: manifest at repo root, deploy from root.

If you can't co-locate them, pass `--fn-installer pnpm` to `blueprints deploy` to force the right installer. Co-locating is the cleaner fix.

## Three filesystem patterns

### Standalone functions project

A small repository whose only purpose is to deploy Functions.

**Example structure**

```text
my-project/
├─ functions/
│  └─ log-event/
│     └─ index.ts
├─ package.json
├─ pnpm-lock.yaml
└─ sanity.blueprint.ts
```

`pnpm install` populates `node_modules` at the root. When you run `pnpm dlx sanity@latest blueprints deploy`, the CLI hydrates each Function's dependencies from that root install and packages them into an asset before uploading.

This setup is fully supported.

### Simple monorepo

Two independent directories, each with their own `package.json` and lockfile. The frontend stands on its own. Everything Sanity-related (Studio, manifest, Functions) lives together under a `sanity/` directory. No workspace tooling required.

**Example structure**

```text
my-project/
├─ frontend/
│  ├─ package.json
│  ├─ pnpm-lock.yaml
│  └─ next.config.ts
└─ sanity/
   ├─ package.json
   ├─ pnpm-lock.yaml
   ├─ sanity.blueprint.ts
   ├─ studio/
   │  └─ sanity.config.ts
   └─ functions/
      └─ log-event/
         └─ index.ts
```

Deploys run from inside `sanity/`. The manifest and lockfile are co-located there, so the CLI detects your package manager correctly with no extra flags. The frontend installs and builds on its own track, untouched by Function deploys.

This setup is fully supported.

### Multi-application project (recommended for monorepos)

This is the layout Turborepo and pnpm workspaces are built for, and it's where Blueprints belong once they manage more than just Functions.

**Example structure**

```text
my-project/
├─ apps/
│  ├─ functions/
│  │  ├─ package.json
│  │  └─ screen-cfp/
│  │     └─ index.ts
│  ├─ studio/
│  └─ web/
├─ packages/
│  └─ shared-utilities/
├─ package.json
├─ pnpm-lock.yaml
├─ pnpm-workspace.yaml
└─ sanity.blueprint.ts
```

The manifest sits at the root next to the lockfile. Each application, including the Functions workspace, owns its own `package.json`. The manifest references each Function via their definers `src: './apps/functions/<name>'`. For example:

**sanity.blueprint.ts**

```
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources:[
    defineDocumentFunction({
      name: 'screen-cfp',
      event: {
        on: ['create']
      },
      src: './apps/functions/screen-cfp'
    })
  ]
})
```

With this layout you can use pnpm's `catalog:` and `workspace:` protocols freely:

**pnpm-workspace.yaml**

```yaml
packages:
  - 'apps/*'
  - 'packages/*'

catalog:
  '@sanity/client': '^7.22.0'
  '@sanity/functions': '^1.2.1'
```

Then in any workspace:

**apps/functions/package.json**

```json
{
  "dependencies": {
    "@sanity/client": "catalog:",
    "@sanity/functions": "catalog:"
  }
}
```

This setup is fully supported.

## Where dependencies live

The CLI looks for dependencies in two places, depending on what it finds.

**Function-level.** If a Function's directory contains a `package.json`, the CLI uses only those dependencies. Nothing else.

**Project-level.** Otherwise, the CLI uses the `package.json` next to `sanity.blueprint.ts`. In a pnpm workspace, that root `package.json` typically declares devDependencies for tooling, and each workspace member, such as `apps/functions/package.json`, owns the runtime dependencies its code imports.

Functions cannot mix both sources. If a Function has its own `package.json`, project-level dependencies are invisible to it. To use a project-level package at the function level, declare it in both places.

### pnpm strictness changes where deps must live

By default, pnpm enforces strict resolution: a workspace can only resolve dependencies declared in its own `package.json`, even if those dependencies exist in the root `node_modules`. Even though the Sanity CLI might be able to find the dependencies up to the root `package.json`, it’s better to follow the default practice and declare them in the workspace member that owns the Function code, typically `apps/functions/package.json`.

## How the CLI bundles your function

For TypeScript Functions in a pnpm workspace, the CLI bundles inline using Vite. Rollup, which Vite uses for production builds, tree-shakes unused exports. Each Function's bundle contains only the parts of its dependencies its source actually imports.

> [!WARNING]
> Non-TypeScript projects bundle full dependencies 
> For npm or yarn projects that doesn’t use TypeScript, the CLI externalizes dependencies and ships them as a `node_modules` folder alongside the source. Per-file bundles are smaller, but the asset includes the full installed packages.

You can override the defaults per resource in the manifest:

**sanity.blueprint.ts**

```typescript
defineDocumentFunction({
  name: 'log-event',
  src: './apps/functions/log-event',
  transpile: false,
  autoResolveDeps: false,
  event: {
    on: ['create'],
    filter: '_type == "event"',
    projection: '{_id}',
    resource: {type: 'dataset', id: 'production'},
  },
})
```

- `transpile: false` is useful when a Function already emits its own build, for example `src: './apps/functions/log-event/dist'`. 
- `autoResolveDeps: false` skips dependency hydration entirely.

## Asset size and native modules

Function assets are capped at 200 MB. The CLI checks this before upload.

Native Node modules, anything that includes a `.node` binary such as `sharp` or `better-sqlite3`, are rejected at build time. The Functions runtime is Node.js v24.x in a sandboxed environment that doesn't support them. If your Function needs image processing or other native work, use a JS-only alternative, or do the work outside the Function and pass the result(s) in.

## Anti-pattern: Manifest nested inside Studio

Don't put `sanity.blueprint.ts` inside the Studio directory. It breaks the lockfile rule today, and it gets in the way as your Blueprint grows to manage more resources over time.

**Avoid this layout**

```text
my-project/
├─ studio/
│  ├─ functions/
│  │  └─ log-event/
│  │     └─ index.ts
│  └─ sanity.blueprint.ts
├─ package.json
└─ pnpm-lock.yaml
```

Today, this breaks the lockfile rule. Editors typically run `pnpm dlx sanity@latest blueprints deploy` from `my-project/studio` because that's where the manifest sits. The lockfile is one level up, so the CLI can't detect it and falls back to npm. If any dependency uses `catalog:` or `workspace:`, the build fails with `EUNSUPPORTEDPROTOCOL`.

All three supported patterns above keep the manifest above the Studio. As your Blueprint accumulates more resources (more Functions, CORS origins, robot tokens, roles, datasets), it's easier to extend a manifest that already sits in the right place. Move `sanity.blueprint.ts` above the Studio directory now to avoid a forced migration later. If you can't restructure right now, pass `--fn-installer pnpm` on every deploy as a stopgap. The migration steps below cover the move.

## Migrate an existing project to root

If your manifest currently lives under `apps/studio/` or `apps/functions/`, here's how to move to the recommended layout without disturbing the deployed stack:

1. Move `sanity.blueprint.ts` to the repository root.
2. Update each `src:` path in the manifest from `'./<name>'` to `'./apps/functions/<name>'`, or wherever the Function code lives.
3. Declare runtime dependencies in the workspace that owns the Function code, not in the root `package.json`.
4. Rebind your local Blueprint config to the existing remote stack:

**npm**

```shell
npx sanity@latest blueprints init \
  --project-id YOUR_PROJECT_ID \
  --stack-id <ST-yourstackid> \
  --blueprint-type ts
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init \
  --project-id YOUR_PROJECT_ID \
  --stack-id <ST-yourstackid> \
  --blueprint-type ts
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init \
  --project-id YOUR_PROJECT_ID \
  --stack-id <ST-yourstackid> \
  --blueprint-type ts
```

**bun**

```shell
bunx sanity@latest blueprints init \
  --project-id YOUR_PROJECT_ID \
  --stack-id <ST-yourstackid> \
  --blueprint-type ts
```

1. Run `pnpm dlx sanity@latest blueprints plan`. The output should show only `~ update` lines, no creates or destroys. Bundles are re-hashed when the manifest moves, but no resources are added or removed.
2. Deploy: `pnpm dlx sanity@latest blueprints deploy`.

If `blueprints plan` shows resource creates or destroys, stop and check that the stack ID and project ID match what was previously deployed.



# Promote a stack

Every blueprint stack has a scope that determines which resource types it can manage. By default, stacks are scoped to a project. To deploy organization-level resources like scheduled functions, you need to promote the stack to organization scope.

## When you need organization scope

[Scheduled functions](https://www.sanity.io/docs/functions/scheduled-function-quickstart) (`sanity.function.cron`) run on a timer rather than responding to document changes in a specific project. Because they operate independently of any single project, they require organization scope.

If you add a scheduled function to a project-scoped stack and run `sanity blueprints deploy`, the deployment will fail with an error indicating that the resource requires organization scope. Promoting your stack resolves this.

## Prerequisites

- An existing project-scoped blueprint stack. If you have not initialized a blueprint yet, see the [Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction) or one of the Blueprint or Function guides.
- A role (such as Administrator) with the `sanity.organization.update` permission on the organization that owns the project. Project-level permissions alone are not sufficient.
- The project must belong to an organization. Standalone projects cannot be promoted.
- No active deployments or operations running on the stack.
- Deploying an organization-scoped blueprint requires an organization admin role, or a robot token with the `sanity.blueprints.deploy` permission.

## Check your current scope

Before promoting, confirm that your stack is currently project-scoped by running the `config` command:

**npm**

```shell
npx sanity@latest blueprints config
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints config
```

**yarn**

```shell
yarn dlx sanity@latest blueprints config
```

**bun**

```shell
bunx sanity@latest blueprints config
```

If the output shows `Scoped to: Project <id>`, the stack is project-scoped and eligible for promotion.

## Promote the stack

Run the promote command from a directory containing your configured blueprint:

**npm**

```shell
npx sanity@latest blueprints promote
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints promote
```

**yarn**

```shell
yarn dlx sanity@latest blueprints promote
```

**bun**

```shell
bunx sanity@latest blueprints promote
```

The CLI will ask you to confirm the promotion. After confirming, the command performs a single atomic operation that:

- Changes the stack's scope from project to organization.
- Sets a value on the stack to preserve the original project ID so your existing project-scoped resources continue to work. You can also explicitly set the project for each resource, but this keeps your existing resources working as expected.
- Updates your local `.sanity/blueprint.config.json` by removing `projectId` and adding `organizationId`.

> [!TIP]
> No redeploy needed
> Promotion does not affect the state of already-deployed resources. Your existing document functions, webhooks, CORS origins, and other project-scoped resources continue working without any changes.

You can optionally pass the `--force` flag to skip the confirmation prompt, or `--stack <name-or-id>` to target a specific stack if you have more than one.

## Verify the promotion

After promotion, confirm the scope change by running the `config` command again:

**npm**

```shell
npx sanity@latest blueprints config
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints config
```

**yarn**

```shell
yarn dlx sanity@latest blueprints config
```

**bun**

```shell
bunx sanity@latest blueprints config
```

The output should now show `Scoped to: Organization <id>`. You can also check `sanity blueprints logs` to see the promotion operation recorded in the stack's history.

## Deploy with scheduled functions

With the stack promoted, you can now define both project-scoped and organization-scoped resources in the same blueprint:

**sanity.blueprint.ts**

```typescript
import {
  defineBlueprint,
  defineDocumentFunction,
  defineScheduledFunction,
} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    // Existing project-scoped resource, still works via defaultProjectId
    defineDocumentFunction({
      name: 'on-publish',
      event: {on: ['create', 'update']},
      // Or, explicitly set the project resource:
      project: `YOUR_PROJECT_ID`
    }),

    // New org-scoped resource, enabled by promotion
    defineScheduledFunction({
      name: 'daily-digest',
      event: {expression: '0 9 * * *'}, // you can also use the day, minute, hour, year syntax
    }),
  ],
})
```

When you run `sanity blueprints deploy`, the scope resolver handles each resource appropriately. Project-scoped resources like document functions resolve through `project`, while organization-scoped resources like scheduled functions use the organization scope directly. See each definer’s [reference documentation](https://reference.sanity.io/_sanity/blueprints/) for further configuration details.

## Things to keep in mind

### Promotion is one-way

There is no command to demote a stack back to project scope. Organization scope is strictly broader than project scope, so an org-scoped stack can do everything a project-scoped stack can. If you promote by mistake, your existing resources continue working exactly as before.

### Configuration changes

The promote command automatically updates your `.sanity/blueprint.config.json` file. If your CI/CD pipeline reads from this file, no pipeline changes are needed. However, if your pipeline sets `SANITY_PROJECT_ID` as an environment variable, switch to `SANITY_ORGANIZATION_ID` after promotion.

### Multiple projects

After promotion, the stack is updated with details from the original project. To target resources in a different project, add an explicit `project` attribute to individual resource definitions. This allows a single org-scoped stack to manage resources across multiple projects.

### Stack limits

Stacks are limited to 3 per scope. After promotion, the stack belongs to the organization and is no longer counted against the project's stack limit. Stack names must be unique within a scope. If the organization already has a stack with the same name, the promotion will fail.

## Troubleshooting

### Permission denied (403)

Promotion requires the `sanity.organization.update` permission. If you receive a 403 error, ask an organization administrator to grant you the required permission.

### Operation in progress (409)

You cannot promote a stack while a deployment or other operation is active. Wait for the current operation to complete, then retry the promote command.

### Duplicate stack name

Stack names must be unique within a scope. If the organization already has a stack with the same name, promotion fails with a `DuplicateStackNameError`. To resolve this, use `--new-stack-name <name>` to rename while promoting. 

### Stack already promoted

The promote command is idempotent. Running it on an already-promoted stack returns success without making any changes. You can safely retry the command if you are unsure whether a previous promotion completed.



# Deploy from CI

There may be instances where you want to deploy your Blueprint automatically from CI, without using our [official GitHub Action](https://www.sanity.io/docs/blueprints/blueprint-action). This page explains the flow step by step so you can run it on any CI system. 

In CI, the deploy process involves four steps:

1. Install the Sanity runtime CLI.
2. Authenticate with a deploy token.
3. Set the project (or organization) and Stack to target.
4. Run blueprints deploy.

The sections below walk through each prerequisites and steps. Read on to understand the flow or to run your own deploy in another CI system.

## How CI differs from local development

On your own machine, two things work automatically that aren't available in CI:

- **Authentication.** You've run sanity login, so the CLI reads your credentials from disk. CI has no logged-in user.
- **Scope and Stack.** When you run sanity blueprints init, the CLI saves your project (or organization) and Stack IDs to .sanity/blueprint.config.json. That file is gitignored by default, so it isn't in a fresh CI checkout.

In CI you supply both explicitly: a token for authentication, and a few environment variables for your scope and Stack.

## Before you get started

This section assumes you have a Sanity project with a Blueprint initialized locally.

The Blueprints CLI commands require an authenticated token to run in CI. Create a deploy token with sanity blueprints mint-deploy-token, described below. It's the only credential your CI pipeline needs.

The deploy token can deploy your Stack, but it can't create a Stack or mint further tokens. Keep `init` and `mint-deploy-token` as local admin steps.

## Create a deploy token

Mint a deploy token with the CLI:

```sh
sanity blueprints mint-deploy-token --print
```

This prints the token to your terminal. Add it to your CI system as a secret, and don't commit it to your repository. Your CI job exposes it to the CLI as the SANITY_AUTH_TOKEN environment variable, which the CLI reads before any credentials on disk.

Minting a token requires admin access to the Stack's scope: project admin for a project-scoped Stack, or organization admin for an organization-scoped Stack. The minted token automatically carries the permissions needed to plan, deploy, and destroy Blueprints in that scope.

The minted token carries the Blueprints deploy role for the stack's scope: `blueprints-deployer` for a project-scoped Stack, or `blueprints-deployer-robot` for an organization-scoped Stack. The token also appears under Robots in Manage, where it can be revoked.

Useful flags: `--label <text>` sets a readable label, `--json` outputs the full result as JSON, and `--print` prints only the raw token for shell capture.

## Set your scope and Stack

Because .sanity/blueprint.config.json isn't in your CI, provide its values as environment variables. The CLI reads the following:

#### Properties

**SANITY_AUTH_TOKEN** (required)

The deploy token you created above.

**SANITY_PROJECT_ID** (string)

Required for project-scoped Stacks. Set this when your config has a projectId.

**SANITY_ORGANIZATION_ID** (string)

Required for org-scoped Stacks. Set this when your config has an organizationId.

**SANITY_BLUEPRINT_STACK_ID** (required)

The Stack to deploy (your ST-… ID). Note the singular BLUEPRINT in the variable name.

A Stack has a single scope, recorded in .sanity/blueprint.config.json. Open that file (or run sanity blueprints info) and set the matching variable in your CI settings: if it contains a projectId, set SANITY_PROJECT_ID; if it contains an organizationId, set SANITY_ORGANIZATION_ID. Set one, never both.

## Run the deploy

With the token and scope variables in your environment, the deploy is a single command. Substitute your package manager commands as needed.

**npm**

```shell
npm ci
npx -y sanity blueprints deploy
```

**pnpm**

```shell
npm ci
pnpm dlx -y sanity blueprints deploy
```

**yarn**

```shell
npm ci
yarn dlx -y sanity blueprints deploy
```

**bun**

```shell
npm ci
bunx -y sanity blueprints deploy
```

`deploy` waits for the deployment to finish and exits with a non-zero status if it fails, so your CI job fails when the deployment fails.

This is everything the GitHub Action does, run by hand.

## Plan on pull requests

`sanity blueprints plan` is read-only. It previews the changes a deploy would make without applying them. A common pattern is to run blueprints plan on pull requests so reviewers can see the intended change, and blueprints deploy when changes merge to your main branch. The plan command uses the same scope and Stack variables as deploy.

## Example: CircleCI

Because the steps are a combination of environment variables and a CLI command, the same flow works on any platform. Here it is end to end in CircleCI (.circleci/config.yml):

```yaml
version: 2.1
jobs:
  deploy-blueprints:
    docker:
      - image: cimg/node:lts
    environment:
      # Non-secret values, safe to commit:
      SANITY_PROJECT_ID: "1234xyz"
      SANITY_BLUEPRINT_STACK_ID: "ST-1234xyz"
      # SANITY_AUTH_TOKEN is set as a project environment variable in
      # CircleCI (Project Settings > Environment Variables), not committed here.
    steps:
      - checkout
      - run: npm ci
      - run: npx -y sanity blueprints deploy
workflows:
  deploy:
    jobs:
      - deploy-blueprints:
          filters:
            branches:
              only: main
```

Set `SANITY_AUTH_TOKEN` as a project environment variable in CircleCI (Project Settings > Environment Variables). CircleCI injects it into the job automatically and the CLI reads it, so the token is never committed to your config file. Unlike GitHub Actions, there's no secret to reference in the config: the variable is simply present in the job's environment.

The pattern is the same everywhere: store the token as a secret, set your scope and Stack variables, and run deploy.

## Troubleshooting

### Start with diagnostics

`sanity blueprints doctor` reports what the CLI resolved in your CI environment: whether it found a valid token, which scope and Stack it resolved, and where each value came from (environment variable or config file). Run it as a step in your pipeline, or after a failed deploy, to see why a deploy failed.

### Deploys failing with a permissions error after promoting a Stack

A deploy token is scoped to a single project or organization. Promoting a Stack from project to organization scope does not re-scope the token. Your existing project-scoped token can no longer deploy the now organization-scoped Stack. To fix it:

1. Mint a new organization-scoped token: `sanity blueprints mint-deploy-token --organization-id your-org-id` (requires organization admin access).
2. Update your CI secret with the new token.
3. Replace `SANITY_PROJECT_ID` with `SANITY_ORGANIZATION_ID`.

The Stack ID doesn't change when you promote, so `SANITY_BLUEPRINT_STACK_ID` stays the same.

### Missing scope: provide --project-id or --organization-id

The CLI couldn't determine your target project or organization. In CI this is expected: .sanity/blueprint.config.json is gitignored and isn't in your checkout. Set SANITY_PROJECT_ID or SANITY_ORGANIZATION_ID (or pass the matching flag), as described in the Set your scope and Stack section above.

### Missing stack: provide --stack…

With no .sanity/ config in the checkout, the CLI doesn't know which Stack to deploy. Set SANITY_BLUEPRINT_STACK_ID (or pass `--stack`).

### Authentication failures

Check that SANITY_AUTH_TOKEN is set in your job environment, and that the token has the right deploy permission for your scope.



# Deploy with GitHub Actions

The official [Blueprints GitHub Actions](https://github.com/sanity-io/blueprints-actions) lets you add Blueprint planning and deployment to your existing GitHub workflows.

## Prerequisites

### Create a Blueprint

Before you get started, you should have a local blueprint configured and committed to the git repository you want to use. If you’re new to blueprints, you can [get started by creating a function](https://www.sanity.io/docs/functions/function-quickstart).

Before using these actions, you need **configuration values** from your project. Run the following command to retrieve your project and stack IDs.

**CLI input**

```sh
npx sanity blueprints config
```

**CLI output**

```sh
Current configuration:
  Sanity Project: <project_id>
  Deployment ID:  <stack_id>
```

### Create a Sanity API token

Next you’ll need a Sanity API token with permission to deploy the blueprint.

1. Go to [sanity.io/manage](https://www.sanity.io/manage).
2. Select your project or organization. Organization-scoped blueprints will require an organization-level token.
3. Go to **API** → **Tokens**.
4. Select **Add API token**.** **
5. Create a token with blueprint deploy permissions.
6. Copy the token (you won't be able to see it again).

If you’re creating tokens programatically, you’ll need the following permissions:

- Project-scoped: `sanity.project.blueprints.deploy`
- Organization-scoped: `sanity.blueprints.deploy`

### Add token to GitHub secrets

Next, add the API token to your GitHub secrets.

1. Go to your GitHub repository.
2. Navigate to **Settings **→ **Secrets and variables** → **Actions**.
3. Select **New repository secret**.
4. Name: `SANITY_TOKEN`.
5. Value: Paste your Sanity API token.
6. Select **Add secret**.

## Create your workflows

There are two actions available: deploy and plan.

The **Deploy action** executes `sanity blueprints deploy` in your GitHub workflow, automatically applying your Blueprint configuration to your Sanity project. It deploys your resources, like functions, and provides a deployment status output for use in your workflow.

Some ways you can use it are:

- On pushes to your main/production branch for continuous deployment.
- As part of a release workflow.
- For scheduled deployments.
- After manual workflow dispatch.

The **Plan action** runs `sanity blueprints plan` and automatically posts the results as a PR comment, giving your team visibility into what changes will be applied. It shows specific changes in the resources, as well as a summary of all changes.

![A Sanity Blueprints deployment plan from a GitHub Actions bot, detailing a new function, removed test resources, and the deploy command.](https://cdn.sanity.io/images/3do82whm/next/1aac0c23b5fb6293d6e9ddf060ca7903bed84a3f-1860x1040.png)

Some ways you can use it are:

- Confirm what a blueprint will do before deploying.
- Automatically post results as a collapsible PR comment.
- Keep your PR discussions clean and organized.

### Create a Deploy workflow

Create `.github/workflows/deploy-blueprints.yml`. Replace the stack and project ID placeholders with your values.

**.github/workflows/deploy-blueprints.yml**

```yaml
name: Deploy Sanity Blueprints

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Install dependencies
        run: npm ci  # or pnpm/yarn

      - name: Deploy blueprints
        uses: sanity-io/blueprints-actions/deploy@deploy-v3
        with:
          sanity-token: ${{ secrets.SANITY_TOKEN }}
          stack-id: 'ST_1234xyz'
          project-id: '1234xyz'
          # organization-id: '1234xyz' # if you're deploying an org-scoped blueprint
```

### Create a Plan workflow

Create `.github/workflows/plan-blueprints.yml`. Replace the stack and project ID placeholders with your values.

**.github/workflows/plan-blueprints.yml**

```yaml
name: Sanity Blueprints Plan

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write  # Required for posting comments

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Install dependencies
        run: npm ci  # or pnpm/yarn

      - name: Plan blueprints changes
        uses: sanity-io/blueprints-actions/plan@plan-v2
        with:
          sanity-token: ${{ secrets.SANITY_TOKEN }}
          stack-id: 'ST_1234xyz'
          project-id: '1234xyz'
          # organization-id: '1234xyz' # if you're planning an org-scoped blueprint
```

### Custom working directory

If your blueprint isn’t at the root of your repository, add `working-directory`.

**deploy-blueprint.yml**

```yaml
name: Sanity Blueprints Plan

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write  # Required for posting comments

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Install dependencies
        run: npm ci  # or pnpm/yarn

      - name: Plan blueprints changes
        uses: sanity-io/blueprints-actions/plan@plan-v2
        with:
          sanity-token: ${{ secrets.SANITY_TOKEN }}
          stack-id: 'ST_1234xyz'
          project-id: '1234xyz'
          working-directory: 'path/to/blueprints'
```

### Use deployment status in workflow

The deploy action logs the deployment progress and provides a `deployment-status` output you can use in subsequent workflow steps. For example:

**action output**

```yaml
- name: Deploy blueprints
  id: deploy
  uses: sanity-io/blueprints-actions/deploy@deploy-v3
  with:
    sanity-token: ${{ secrets.SANITY_TOKEN }}
    stack-id: 'ST_1234xyz'
    project-id: '1234xyz'

- name: Notify on success
  if: steps.deploy.outputs.deployment-status == 'success'
  run: echo "Deployment successful!"
```

## Configuration Reference

#### Properties

**sanity-token** (string, required)

A Sanity API token with deploy permissions.

**stack-id** (string, required)

The blueprint stack ID. Find this by running sanity blueprints config to view the active stack, or sanity blueprints stacks view all current stacks.

**project-id** (string, required)

Sanity project ID. Find this by running sanity blueprints config or in your project settings at sanity.io/manage. Only use when not using an organization-id.

**organization-id** (string, required)

Set the Sanity organization ID for organization-scoped stacks. Only use when not using a project-id.

**working-directory** (string)

Path to the directory containing your blueprint config (sanity.blueprint.ts). Defaults to the repository root.

Visit the [GitHub Actions Repository](https://github.com/sanity-io/blueprints-actions) for additional details.



# Define a webhook

With [webhooks](https://www.sanity.io/docs/content-lake/webhooks) you can send customized HTTP requests when documents in your Content Lake change. If you also need code to run when documents change, [you should try Functions](https://www.sanity.io/docs/functions/functions-introduction).

In this guide, you’ll define a webhook resource with Blueprints and deploy the blueprint to Sanity.

Prerequisites:

- The latest version of `sanity` CLI is recommended to interact with Blueprints. You can always run the latest CLI commands with `npx sanity@latest`.
- An existing project and [a role with permission](https://www.sanity.io/docs/content-lake/roles-concepts) to edit webhooks (requires the `sanity-project-webhooks` permission).
- Webhook support was first introduced in `@sanity/blueprints` v0.11.0. We recommend using the latest version of the library.

## Initialize a new blueprint

To initialize a blueprint in the current directory, run the command below. Replace the project ID with your own. Skip to the next section if you already have a blueprint set up.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```



## Configure the document webhook

Add the `defineDocumentWebhook` helper to your `sanity.blueprint.ts` configuration to define a webhook. 

**sanity.blueprint.ts**

```
import { defineBlueprint, defineDocumentWebhook } from "@sanity/blueprints"

export default defineBlueprint({
  resources: [
    defineDocumentWebhook({
      name: 'my-webhook',
      on: ['create'],
      url: 'https://example.com/webhook',
      filter: '_type == "post"',
      projection: '{_id}',
      dataset: 'production',
      apiVersion: 'v2026-01-01',
    })
  ],
})
```

A full list of available configuration options is available in the [reference documentation](https://reference.sanity.io/_sanity/blueprints/defineDocumentWebhook/).

## Deploy the blueprint

Once you’ve configured your webhook, deploy the blueprint.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

Once the deployment finishes, your webhook will begin sending updates whenever a document changes that matches the configuration.

If you need to make changes, update the blueprint file (`sanity.blueprint.ts`) and run the deploy command again.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.



## Learn more about webhooks

#### Explore webhooks

[GROQ-powered webhooks](https://www.sanity.io/docs/content-lake/webhooks)
Send customized HTTP requests when something in your Content Lake has changed.

[GROQ-Powered Webhooks – Intro to Filters](https://www.sanity.io/docs/developer-guides/filters-in-groq-powered-webhooks)
A thorough intro to using GROQ-filters in a webhook-context

[GROQ-Powered Webhooks – Intro to Projections](https://www.sanity.io/docs/developer-guides/projections-in-groq-powered-webhooks)
A thorough intro to using GROQ-projections in a webhook contest



# Define a CORS origin

CORS is a security mechanism that allows browsers to safely make requests across different origins. Without it, the browser's Same-Origin Policy would block your Sanity Studio from communicating with the Sanity API, since they run on different domains.

In this guide, you’ll define a CORS origin resource with Blueprints and deploy the blueprint to Sanity.

Prerequisites:

- The latest version of `sanity` CLI is recommended to interact with Blueprints. You can always run the latest CLI commands with `npx sanity@latest`.
- An existing project and [a role with permission](https://www.sanity.io/docs/content-lake/roles-concepts) to edit a project’s CORS origins (requires the `sanity-project-cors` permission).
- CORS support was first introduced in `@sanity/blueprints` v0.11.0. We recommend using the latest version of the library.

## Initialize a new blueprint

To initialize a blueprint in the current directory, run the command below. Replace the project ID with your own. Skip to the next section if you already have a blueprint set up.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```



## Configure the CORS origin

Use the `defineCorsOrigin` helper to define an origin. 

**sanity.blueprint.ts**

```
import { defineBlueprint, defineCorsOrigin } from "@sanity/blueprints"

export default defineBlueprint({
  resources: [
    defineCorsOrigin({
      name: 'studio-origin',
      origin: 'https://your-studio.your-domain.com',
      // allowCredentials: true, // optional, only set if needed
    })
  ],
})
```

A full list of available configuration options is available in the [reference documentation](https://reference.sanity.io/_sanity/blueprints/defineCorsOrigin/).

## Deploy the blueprint

Next, deploy the blueprint.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

Once the deployment finishes, your CORs origin resource is active.

If you need to make changes, update the blueprint file (`sanity.blueprint.ts`) and run the deploy command again.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.



## Learn more about CORS

#### Explore CORS

[Access your data (CORS)](https://www.sanity.io/docs/content-lake/cors)
Decide which websites can access your project data.

[CORS and browser security](https://www.sanity.io/docs/content-lake/browser-security-and-cors)
Best practices for configuring CORS origins securely while letting trusted websites interact with your Sanity Dataset.

[CORS CLI command reference](https://www.sanity.io/docs/cli-reference/cors-in-cli)
Interact with CORS-entries for your project



# Define a robot token

[Robot tokens](https://www.sanity.io/docs/content-lake/http-auth) let your code make authenticated requests to Sanity. When created with Blueprints, they’re often used to provide explicit access in [Functions](https://www.sanity.io/docs/functions/robot-tokens-with-functions).

In this guide, you’ll define a robot token resource with Blueprints and deploy the blueprint to Sanity.

Prerequisites:

- The latest version of `sanity` CLI is recommended to interact with Blueprints. You can always run the latest CLI commands with `npx sanity@latest`.
- An existing project and [a role with permission](https://www.sanity.io/docs/content-lake/roles-concepts) to edit robot tokens (requires the `sanity-project-tokens` permission).
- Robot token support was first introduced in `@sanity/blueprints` v0.11.0. We recommend using the latest version of the library.

## Initialize a new blueprint

To initialize a blueprint in the current directory, run the command below. Replace the project ID with your own. Skip to the next section if you already have a blueprint set up.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```



## Configure the robot token

Use the `defineRobotToken` helper to define a robot token resource.

**sanity.blueprint.ts**

```
import { defineBlueprint, defineRobotToken } from "@sanity/blueprints"

export default defineBlueprint({
  resources: [
    defineRobotToken({
      name: 'editor-robot',
      memberships: [
        {
          resourceType: 'project',
          resourceId: 'YOUR_PROJECT_ID',
          roleNames: ['editor']
        }
      ]
    })
  ],
})
```

A full list of available configuration options is available in the [reference documentation](https://reference.sanity.io/_sanity/blueprints/defineRobotToken/).

## Use the token in a function

Reference the token in your function definition to make it available to your custom functions. This makes the function use the custom-scoped robot token instead of the default token with broad read/write permissions.

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineRobotToken({
      name: 'editor-robot',
      memberships: [
        {
          resourceType: 'project',
          resourceId: 'YOUR_PROJECT_ID',
          roleNames: ['editor']
        }
      ]
    }),
    defineDocumentFunction({
      name: 'my-function',
      // Replace `editor-robot` with your token name
      robotToken: '$.resources.editor-robot.token',
      // ... rest of config
    }),
  ]
})
```

## Deploy the blueprint

Next, deploy the blueprint.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

Once the deployment finishes, the robot token is active.

If you need to make changes, update the blueprint file (`sanity.blueprint.ts`) and run the deploy command again.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.



## Learn more about robot tokens

#### Explore robot tokens

[Using robot tokens with Functions](https://www.sanity.io/docs/functions/robot-tokens-with-functions)
Learn how to authenticate Sanity Functions using robot tokens defined with Blueprints.

[Authentication and tokens](https://www.sanity.io/docs/content-lake/http-auth)
How to create tokens and make authenticated requests.

[Roles](https://www.sanity.io/docs/user-guides/roles)
Sanity enforces user access control with roles. Roles help control resource access to datasets and documents.



# Define a custom role

Blueprints allow you to define [custom roles](https://www.sanity.io/docs/user-guides/roles) alongside other resources. Roles are sets of permissions that can be assigned to users and robot tokens. You can even combine roles and robot tokens with Blueprints to provide explicit access in [Functions](https://www.sanity.io/docs/functions/robot-tokens-with-functions).

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

In this guide, you’ll define a custom role resource with Blueprints and deploy the blueprint to Sanity.

Prerequisites:

- The latest version of `sanity` CLI is recommended to interact with Blueprints. You can always run the latest CLI commands with `npx sanity@latest`.
- An existing project and [a role with permission](https://www.sanity.io/docs/content-lake/roles-concepts) to edit roles (requires the `sanity-project-roles` permission).
- Custom role support was first introduced in `@sanity/blueprints` v0.11.0. We recommend using the latest version of the library.

## Initialize a new blueprint

To initialize a blueprint in the current directory, run the command below. Replace the project ID with your own. Skip to the next section if you already have a blueprint set up.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --project-id <project-id> --stack-name production
```



To initialize a blueprint in the current directory, run the command below. Replace the project ID with your own. Skip to the next section if you already have a blueprint set up.

## Configure the custom resource

Use the `defineRole` helper to define a custom role resource.

**sanity.blueprint.ts**

```
import { defineBlueprint, defineRole } from "@sanity/blueprints"

export default defineBlueprint({
  resources: [
    defineRole({
      name: 'custom-robot-role',
      title: 'Custom Robot Role',
      appliesToRobots: true,
      permissions: [{
        name: 'sanity-project-cors',
        action: 'create',
      }],
    })
  ],
})
```

A full list of available configuration options is available in the [reference documentation](https://reference.sanity.io/_sanity/blueprints/defineRole/). You can learn more about the parts that make up a role in the [roles and permissions documentation](https://www.sanity.io/docs/content-lake/roles-concepts).

## Deploy the blueprint

N, deploy the blueprint.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

Once the deployment finishes, the custom role is active.

If you need to make changes, update the blueprint file (`sanity.blueprint.ts`) and run the deploy command again.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.



## Learn more about roles

#### Explore roles

[Roles and permissions](https://www.sanity.io/docs/content-lake/roles-concepts)
Concepts behind Sanity's roles and permissions system: resources, permissions, roles, and users, plus when to reach for custom roles.

[Roles user guide](https://www.sanity.io/docs/user-guides/roles)
Configure and assign roles in your project or organization settings





# Configuration file

[Overview](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

The Blueprints configuration file (`sanity.blueprint.ts`) defines resources, like Functions, for deployment to Sanity's infrastructure.

Interact with Blueprints by using the `npx sanity blueprints` [CLI command](https://www.sanity.io/docs/cli-reference/cli-blueprints).

The top-level of the blueprint configuration file contains the following properties:

#### Properties

**blueprintVersion** (string, required)

Defines the version of the Blueprints specification to use when parsing the configuration. Uses the YYYY-MM-DD format.

**resources** (array, required)

An array of Sanity resources. Right now this is limited to Function resources, but will expand in the future.

Some configuration properties, like `blueprintVersion`, are handled automatically when using the `defineBlueprint` helper.

## Top-level fields

The file default-exports a call to `defineBlueprint`. Most files set `resources` and `values`.

##### Top-level fields

| Key | Type | Notes |
| --- | --- | --- |
| resources | BlueprintResource[] | The resources to manage. Each entry is the output of a definer. |
| values | Record<string, string> | Reusable constants, referenced with $.values.<key>. Values are always strings. |

> [!NOTE]
> Automatic fields
> `defineBlueprint` also sets `blueprintVersion` and `$schema` for you, so you don't write them. They matter only if you hand-author a `sanity.blueprint.json` file instead of using `defineBlueprint`.

## Resources

The following properties are shared across all resources. Additional resource-specific properties follow in the sections below.

#### Properties

**name** (string, required)

A unique function name. Must be an alphanumeric string that can contain dashes or underscores.

**type** (string, required)

A resource type. For Sanity resources, this is made up of the sanity namespace, category, subcategory, and resource types separated by single periods. For example: sanity.function.document or sanity.function.media-library.asset.

> [!WARNING]
> There's no such thing as a rename.
> Changing a resource's name deletes the old resource and creates a new one.

Blueprints works by comparing your declared resources against what's already in the Stack, matching everything by name. It has no concept of an in-place rename. When you change a name, two things happen: the new name doesn't exist yet so Blueprints creates it, and the old name is gone from your blueprint so Blueprints destroys it.

From your perspective it's one rename. From Blueprints' perspective it's two unrelated resources: one created, one destroyed. There's no internal ID linking them, so nothing carries over. For a Function, that means the deployed infrastructure gets torn down and rebuilt, execution logs and history don't migrate, and any IDs or URLs that downstream systems depend on can change. There's no automatic migration and no undo.

> [!WARNING]
> Changing a resource's type under the same name does the same thing.
> Resources are matched only by name. If the name matches but the type differs, Blueprints replaces the resource by destroying the old one and creating a new one.

If you need to change a name or type, treat it as a destroy-and-recreate:

1. Run `blueprints plan` first. It's a safe, read-only preview that shows exactly what will change without touching anything.
2. Confirm the plan shows a destroy of the old resource and a create of the new one. That's how you know things are working as expected.
3. Run `blueprints deploy` to apply. Note that `deploy` doesn't show a preview on its own, so always plan first.
4. Expect a brief gap while the resource is replaced, and handle any state migration yourself.

If keeping the resource matters more than its name, leave the name alone.

### Functions

In addition to the [required common resource properties](https://www.sanity.io/docs/blueprints/blueprint-config) above, functions also contain the following properties.

#### Properties

**src** (string)

The path, relative to the blueprint configuration file, of the individual function directory. Will be inferred from the name if omitted. For example, functions/myFunction.

**type** (string)

Specifies the Function type. Supported Function types are:

sanity.function.document: this Function will react to changes in your dataset documents, like when a document is created, updated or deleted.

sanity.function.media-library.asset: this Function will react to changes in your Media Library, like when an asset is uploaded, updated or deleted. Note that your plan must have access to the Media Library to use this Function type. 

sanity.function.sync-tag-invalidate: this Function will react to changes in your Live Content. It is very similar to the document  and involves calling back into Sanity. More details can be found in the our Sync Tag Invalidate Function guide.

**event** (object)

Configuration options for the triggering event. See the event properties section below for details.

**timeout** (integer)

The max invocation time, in seconds, of the function.

Default: 10

Minimum: 1

Maximum: 900

**memory** (integer)

Sets the max memory allocation, in GBs.

Default: 1

Min: 1

Max: 10

**env** (object)

Set environment variables for the function. The env object accepts custom keys with string values. This is an alternative approach to using the sanity functions env CLI command. Note: Setting environment variables in this manner is only additive. It can create/update variables, but in order to remove an environment variable you must use the sanity functions env remove command.

**transpile** (boolean)

If false, you will need to transpile any TypeScript code yourself and output the results to the individual function's .build directory. Defaults to true.

**autoResolveDeps** (boolean)

If false, disables the automatic dependency resolution. Defaults to true.

A complete list of available properties can be found in the [defineDocumentFunction reference documentation](https://reference.sanity.io/_sanity/blueprints/defineDocumentFunction/).

#### `event` properties

#### Properties

**on** (string)

Defines the types of events that trigger your Function. You can include more than one, but you cannot combine publish with other events. The options are:

create: Activates when a document is created. Defaults to includeDrafts: false and includeAllVersions: false.

delete: Activates when a document is deleted. Defaults to includeDrafts: false and includeAllVersions: false.

update: Activates when a document is updated. Defaults to includeDrafts: false and includeAllVersions: false.

publish (deprecated): Activates when a document is published. Essentially a shorthand for: create + update with includeAllVersions: true. Use explicit create/update events instead.

These actions trigger on individual documents with unique _id values.

Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**filter** (string)

A valid GROQ filter. Learn more about GROQ Filters.



Only include the contents of the filter, not any other surrounding syntax.

✅ Do this: _type == "article"

❌ Not this: [_type == "article"]

Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**projection** (string)

A valid GROQ projection. Example: {title, _id, slug}

Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**includeDrafts** (boolean)

Determines whether events on draft documents (drafts.**) trigger the function. Defaults to false. When false: draft edits are ignored; only published document changes trigger. When true: every draft edit triggers the function. Please note that turning this on can quickly have your Function hit rate limits.



Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**includeAllVersions** (boolean)

Determines whether events on version documents (versions.**) trigger the function. This includes documents in Content Releases and Scheduled Drafts. Defaults to false. When false: version edits are ignored; the function only triggers when versions are published. When true: every version edit triggers the function. Please note that turning this on can quickly have your Function hit rate limits.



Only applies to the following Function types:

sanity.function.document

**resource** (object)

Defines the resource from which changes will trigger your function. If defined, you must specify a type and id. If not set, the resource will default to all datasets for the Blueprint's linked project.

Accepted values depend on what type of Function you are defining:

Optional if your Function type is sanity.function.document or sanity.function.sync-tag-invalidate. If not specified, will react to changes in all datasets in your function’s housing project.

If defined, the resource.type must be dataset and resource.id is specified in the form <projectId>.<datasetName>. 

You can set <datasetName> to * to signify "all datasets in the project with ID <projectId>."

Required if your Function type is sanity.function.media-library.*. The resource.type must be media-library and resource.id should equal your Media Library ID.

#### Example

**sanity.blueprint.ts (TypeScript / JavaScript)**

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

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: "log-event",
      event: {
        on: ["update"],
        filter: "_type == 'post'",
        projection: "{title, _id, _type}",
        resource: {
          type: 'dataset',
          id: 'myProject.myDataset'
        }
      },
      env: {
        example: 'value'
      }
    }),
    // Helper introduced in @sanity/blueprints v0.4.0
    defineMediaLibraryAssetFunction({
      name: "image-title-updated",
      event: {
        on: ["update"],
        filter: "delta::changedAny(title)",
        projection: "{title, _id, versions}",
        resource: {
          type: 'media-library',
          id: 'mlAbcd1234'
        }
      }
    }),
    // Helper introduced in @sanity/blueprints v0.15.0
    defineSyncTagInvalidateFunction({
      name: "invalidate-cache",
      event: {
        resource: {
          type: 'dataset',
          id: 'myProjectId.myProductionDataset'
        }
      }
    })
  ]
})

```

**sanity.blueprint.json (JSON)**

```json
{
  "blueprintVersion": "2024-10-01",
  "resources": [
    {
      "name": "log-event",
      "src": "functions/log-event",
      "type": "sanity.function.document",
      "event": {
        "on": [
          "update"
        ],
        "filter": "_type == 'post'",
        "projection": "{title, _id, _type}",
        "resource": {
          "type": "dataset",
          "id": "myProject.myDataset"
        }
      },
      "env": {
        "example": "value"
      }
    },
    {
      "name": "image-created",
      "src": "functions/image-created",
      "type": "sanity.function.media-library.asset",
      "event": {
        "on": [
          "create"
        ],
        "filter": "assetType == 'sanity.imageAsset'",
        "projection": "{title, _id, versions}",
        "resource": {
          "type": "media-library",
          "id": "mlAbcd1234"
        }
      }
    }
  ]
}
```

### Additional resources

Reference documentation for additional Blueprint resources is available in the `@sanity/blueprints` documentation.

- [CORS reference](https://reference.sanity.io/_sanity/blueprints/defineCorsOrigin/)
- [Webhooks reference](https://reference.sanity.io/_sanity/blueprints/defineDocumentWebhook/)
- [Media Library Asset Function reference](https://reference.sanity.io/_sanity/blueprints/defineMediaLibraryAssetFunction/)
- [Robot token reference](https://reference.sanity.io/_sanity/blueprints/defineRobotToken/)
- [Role reference](https://reference.sanity.io/_sanity/blueprints/defineRole/)

## Common resource fields

Every resource has a unique `name` and an optional `lifecycle`. You set `name`; the definer sets the resource's `type` for you, so you rarely write it directly. The `name` is unique within the Stack and is the resource's identity for matching.

## The lifecycle field

`lifecycle.deletionPolicy` controls what happens to a resource when it's removed from the file or the Stack is destroyed:

##### Deletion policies

| Policy | On a normal deploy | Removed from the file | On destroy |
| --- | --- | --- | --- |
| allow | Updated in place | Destroyed | Destroyed |
| retain | Updated in place | Deploy fails | Kept (detached) |
| replace | Destroyed and recreated | Destroyed | Destroyed |
| protect | Skipped | Deploy fails | Deploy fails |

Stateless resources default to `allow`; stateful resources such as datasets default to `retain`. `lifecycle.ownershipAction` covers attaching, detaching, and cross-stack references, and `lifecycle.dependsOn` orders deployments when there is no parameter reference between resources.

**sanity.blueprint.ts**

```typescript
defineDataset({
  name: 'production',
  project: '$.values.projectId',
  lifecycle: { deletionPolicy: 'retain' },
})
```

## Reference syntax

Resources refer to constants and to each other with a small `$` syntax, passed as a plain string:

##### Reference syntax

| Syntax | Meaning |
| --- | --- |
| $.values.<key> | A value from the values block, resolved when the file runs. |
| $.resources.<name> | Another resource in the file. Creates a dependency edge. |
| $.resources.<name>.id | The generated ID of another resource, usable as a string. |

## TypeScript / JavaScript helpers

You can configure Blueprints with TypeScript and JavaScript. If you select either during `sanity blueprints init`, the CLI prompts you to install the [@sanity/blueprints](https://github.com/sanity-io/blueprints-node) package. You can also add it to an existing project by adding it to your Blueprints-level project directory.

**NPM**

```sh
npm i @sanity/blueprints
```

**PNPM**

```sh
pnpm add @sanity/blueprints
```

The helpers provide defaults and allow you to omit some configuration options. You can always override these defaults by explicitly setting the values as you would with the JSON format.



# Blueprints glossary

Definitions of the core Blueprints terms. For a narrative introduction, see the [Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction).

##### Blueprints glossary

| Term | Definition |
| --- | --- |
| Attach / detach | Bring an existing resource under Blueprints management (attach), or stop managing it without deleting it (detach). |
| Blueprint config file | .sanity/blueprint.config.json. Links a local blueprint file to a remote stack and records the scope. Gitignored by default; not secret. |
| Blueprint file | The file you edit, sanity.blueprint.ts, declaring your desired resources. Also called the manifest. See the Blueprint configuration reference. |
| Blueprints | Sanity's infrastructure-as-code system for managing resources from a file in your repo. |
| Definer | A typed function such as defineCorsOrigin or defineRole that declares a resource and checks your input as you write it. |
| Deletion policy | A per-resource setting (allow, retain, replace, protect) controlling what happens when a resource is removed or a stack is destroyed. |
| Deploy | Applying the blueprint file to a stack so resources match it. The command is blueprints deploy. |
| Destroy | Removing a stack's resources, subject to their deletion policies. The command is blueprints destroy. |
| Organization scope | A stack scoped to an organization. Unlocks org-scoped resource types and spans multiple projects. See the Blueprints scopes reference. |
| Plan | The read-only, deterministic diff between your blueprint file and a stack's current state. The command is blueprints plan. |
| Project scope | A stack scoped to a single project. |
| Promotion | Converting a project-scoped stack to organization scope. One-way and safe. See Promote a stack to organization scope. |
| Reference ($ syntax) | In-file pointers: $.values.<key> for a constant, $.resources.<name> for another resource (which creates a dependency edge). See The resource graph. |
| Resource | A single thing Blueprints manages: a dataset, CORS origin, webhook, role, robot token, or function. Has a unique name. |
| Rollback | Blueprints' best-effort attempt to undo completed changes, in reverse, when a deploy fails partway. See Errors and rollbacks. |
| Scope | The boundary a stack operates within: project or organization. |
| Stack | The deployed counterpart to a blueprint file: the live resources on Sanity's side. One file can deploy to many stacks. |
| Value | A reusable, file-level constant (always a string) defined in the values block and referenced with $.values.<key>. |

#### Related

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

[Stacks and scope](https://www.sanity.io/docs/blueprints/stacks-and-scope)
How one blueprint file maps to many stacks, and what project versus organization scope means.

[The resource graph](https://www.sanity.io/docs/blueprints/resource-graph)
How resources reference each other with the $ syntax, and why a blueprint file is a connected system.

[Errors and rollbacks](https://www.sanity.io/docs/blueprints/errors-and-rollbacks)
How Blueprints catches mistakes early and what happens when a deploy fails.



# Blueprints scopes reference

## The two scopes

##### The two scopes

| Scope | Boundary | Stored as |
| --- | --- | --- |
| Project | One Sanity project. Resources default to that project. | projectId in the config file |
| Organization | An organization. Project-scoped resources must name their project. Unlocks org-scoped resource types. | organizationId in the config file |

The blueprint file doesn't encode scope. Scope lives in `.sanity/blueprint.config.json` and is derived from which ID it holds.

## What needs which scope

Most resource types are project-scoped: they belong to a single project, and on an organization-scoped stack you give each one a `project`. A few require organization scope, such as scheduled (cron) functions. Roles are unscoped.

Deploying an organization-scoped type to a project-scoped stack fails; promote the stack first (see [Promote a stack to organization scope](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope)). For the current scope of each resource type, see the [typed API reference](https://reference.sanity.io/_sanity/blueprints/).

## How the CLI resolves scope

For each of `organizationId`, `projectId`, and `stackId`, the CLI takes the first source that resolves, in this order:

1. Command-line flags (`--organization-id`, `--project-id`, `--stack`)
2. Environment variables (`SANITY_ORGANIZATION_ID`, `SANITY_PROJECT_ID`, `SANITY_BLUEPRINT_STACK_ID`)
3. IDs exported from the blueprint file module
4. The local config file, `.sanity/blueprint.config.json`

Scope is derived as specifically as possible: if a project ID resolves, the scope is project; otherwise, if an organization ID resolves, the scope is organization.

## After promotion

When you [promote](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope) a stack to organization scope, it remembers its original project. Project-scoped resources that don't set a `project` fall back to that project automatically, so your existing resources keep working. Set an explicit `project` on a resource to target a different one.

#### Related

[Stacks and scope](https://www.sanity.io/docs/blueprints/stacks-and-scope)
How one blueprint file maps to many stacks, and what project versus organization scope means.

[Promote a stack to organization scope](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope)
Learn how to promote an existing project-scoped blueprint stack to organization scope to unlock scheduled functions.

[Blueprint configuration reference](https://www.sanity.io/docs/blueprints/blueprint-config)
Reference documentation for the Blueprint configuration files.



# Blueprints CLI commands

The `blueprints` CLI command enables initializing, managing, and deploying Blueprints and resources like Functions.

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

[Functions introduction](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

**npm**

```shell
npx sanity blueprints --help
```

**pnpm**

```shell
pnpm dlx sanity blueprints --help
```

**yarn**

```shell
yarn dlx sanity blueprints --help
```

**bun**

```shell
bunx sanity blueprints --help
```

## Commands

### `add`

**CLI output**

```sh
USAGE
  $ sanity blueprints add TYPE [--install] [-n <value>] [--example <value>] [--fn-helpers] [--fn-installer <value>] [--fn-type <value>] [--javascript] [--json] [--language <value>]

ARGUMENTS
  TYPE  Type of resource to add (only "function" is supported)

FLAGS
  -i, --install               Shortcut for --fn-installer npm
  -n, --name=<value>          Name of the resource to add
      --example=<value>       Example to use for the function resource. Discover examples at https://www.sanity.io/exchange/type=recipes/by=sanity
      --fn-helpers            Add helpers to the new function
      --fn-installer=<value>  Which package manager to use when installing the @sanity/functions helpers
      --fn-type=<value>       Document change event(s) that should trigger the function; you can specify multiple events by specifying this flag multiple times
      --javascript            Use JavaScript instead of TypeScript
      --json                  Format output as json
      --language=<value>      Language of the new function

DESCRIPTION
  This command is deprecated. Use "functions add" instead.
  
  Equivalent usage:
    $ <%= config.bin %> functions add
    $ <%= config.bin %> functions add --name my-function --type document-create

EXAMPLES
    $ sanity blueprints add function

    $ sanity blueprints add function --helpers

    $ sanity blueprints add function --name my-function

    $ sanity blueprints add function --name my-function --fn-type document-create

    $ sanity blueprints add function --name my-function --fn-type document-create --fn-type document-update --lang js
```

### `config`

**CLI output**

```sh
USAGE
  $ sanity blueprints config [--edit] [--json] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -e, --edit                     Modify the configuration interactively, or directly when combined with ID flags.
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to set in the configuration. Requires --edit flag

DESCRIPTION
  Manages the local Blueprint configuration, which links your Blueprint to a Sanity project and Stack.
  
  Without flags, displays the current configuration. Use --edit to interactively modify settings, or combine --edit with ID flags to update values directly (useful for scripting and automation).
  
  If you need to switch your Blueprint to a different Stack, use --edit --stack.

EXAMPLES
    $ sanity blueprints config

    $ sanity blueprints config --edit

    $ sanity blueprints config --edit --project-id <projectId>

    $ sanity blueprints config --edit --project-id <projectId> --stack <name-or-id>
```

### `deploy`

**CLI output**

```sh
USAGE
  $ sanity blueprints deploy [-m <value>] [--json] [--new-stack-name <value>] [--no-wait] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -m, --message=<value>          Message describing the deployment (e.g. reason for change)
      --json                     Format output as json
      --new-stack-name=<value>   Set a new name for the Stack
      --no-wait                  Do not wait for Stack deployment to complete
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Applies your local Blueprint to the remote Stack, creating, updating, or removing resources as needed. This is the primary command for applying infrastructure changes.
  
  Before deploying, run 'blueprints plan' to preview changes. After deployment, use 'blueprints info' to verify Stack status or 'blueprints logs' to monitor activity.
  
  Use --no-wait to queue the deployment and return immediately without waiting for completion.
  
  Use --fn-installer to force which package manager to use when deploying functions.
  
  Set SANITY_ASSET_TIMEOUT (seconds) to override the 180-second timeout for processing resource assets.
  
  Set SANITY_ASSET_CONCURRENCY to override how many resource assets are processed at once (default 4).
  
  Exit codes: 0 deployed, 2 deployment failed, 75 deployment accepted but completion could not be confirmed (rerun 'blueprints info' to check).

EXAMPLES
    $ sanity blueprints deploy

    $ sanity blueprints deploy --message "Enable staging dataset"

    $ sanity blueprints deploy --no-wait

    $ sanity blueprints deploy --fn-installer npm

    $ sanity blueprints deploy --stack <name-or-id>

    $ sanity blueprints deploy --organization-id <orgId> --stack <name-or-id>

    $ sanity blueprints deploy --new-stack-name <new-name>
```

### `destroy`

**CLI output**

```sh
USAGE
  $ sanity blueprints destroy [--force] [--json] [--no-wait] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -f, --force                    Force Stack destruction (skip confirmation)
      --json                     Format output as json
      --no-wait                  Do not wait for Stack destruction to complete
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to destroy (defaults to the locally configured Stack)

DESCRIPTION
  Permanently removes the remote Stack and all its provisioned resources. Your Blueprint manifest and resource files remain intact; "stackId" is unset in your local config.
  
  This is a destructive operation. You will be prompted to confirm unless --force is specified.
  
  Use this to clean up test environments or decommission a Stack you no longer need.
  
  Exit codes: 0 destroyed, 2 destruction failed, 75 destruction accepted but completion could not be confirmed (rerun 'blueprints info' to check).

EXAMPLES
    $ sanity blueprints destroy

    $ sanity blueprints destroy --stack <name-or-id> --project-id <projectId> --force --no-wait
```

### `doctor`

**CLI output**

```sh
USAGE
  $ sanity blueprints doctor [-p <value>] [--fix] [--json]

FLAGS
  -p, --path=<value>  Path to a Blueprint file or directory containing one
      --fix           Interactively fix configuration issues
      --json          Format output as json

DESCRIPTION
  Analyzes your local Blueprint and remote Stack configuration for common issues, such as missing authentication, invalid project references, or misconfigured resources.
  
  Run this command when encountering errors with other Blueprint commands. Use --fix to interactively resolve detected issues.
  
  Supports --json for programmatic consumption of diagnostic results.

EXAMPLES
    $ sanity blueprints doctor

    $ sanity blueprints doctor --fix
```

### `info`

**CLI output**

```sh
USAGE
  $ sanity blueprints info [--verbose] [--json] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -v, --verbose                  Show resource and external IDs
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID

DESCRIPTION
  Displays the current state and metadata of your remote Stack deployment, including deployed resources, status, and configuration.
  
  Use this command to verify a deployment succeeded, check what resources are live, or confirm which Stack your local Blueprint is connected to.
  
  Run 'blueprints stacks' to see all available Stacks in your project or organization.

EXAMPLES
    $ sanity blueprints info

    $ sanity blueprints info --stack <name-or-id>

    $ sanity blueprints info --project-id <id> --stack <name-or-id>

    $ sanity blueprints info --organization-id <orgId> --stack <name-or-id>
```

### `init`

**CLI output**

```sh
USAGE
  $ sanity blueprints init [DIR] [--blueprint-type <value>] [--dir <value>] [--example <value>] [--json] [--organization-id <value>] [--project-id <value>] [--stack-id <value>] [--stack-name <value>]

ARGUMENTS
  [DIR]  Directory to create the local Blueprint in (defaults to the current directory)

FLAGS
      --blueprint-type=<value>   Blueprint manifest type to use for the local Blueprint
      --dir=<value>              Directory to create the local Blueprint in
      --example=<value>          Example to use for the local Blueprint
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack-id=<value>         Existing Stack ID used to scope local Blueprint
      --stack-name=<value>       Name to use for a new Stack provisioned during initialization

DESCRIPTION
  A Blueprint is your local infrastructure-as-code configuration that defines Sanity resources (datasets, functions, etc.). A Stack is the remote deployment target where your Blueprint is applied.
  
  This is typically the first command you run in a new project. It creates a local Blueprint manifest file (sanity.blueprint.ts, .js, or .json) and provisions a new remote Stack.
  Additionally, a Blueprint configuration file is created in .sanity/ containing the scope and Stack IDs. A .gitignore covering node_modules, .env, and Function build output is created or updated; the .sanity/ config itself is not ignored.
  
  After initialization, use 'blueprints plan' to preview changes, then 'blueprints deploy' to apply them.
  
  Running without a directory prompts to confirm the current directory. Run 'blueprints init .' to initialize in the current directory without a prompt.

EXAMPLES
    $ sanity blueprints init

    $ sanity blueprints init .

    $ sanity blueprints init [directory]

    $ sanity blueprints init --blueprint-type <json|js|ts>

    $ sanity blueprints init --organization-id <organizationId>

    $ sanity blueprints init --project-id <projectId>

    $ sanity blueprints init --stack-name <newStackName>

    $ sanity blueprints init --stack-id <existingStackId>

    $ sanity blueprints init new-stack --type <json|js|ts> --org <organizationId> --name <newStackName>

    $ sanity blueprints init old-stack --type <json|js|ts> --project-id <projectId> --stack-id <existingStackId>
```

### `logs`

**CLI output**

```sh
USAGE
  $ sanity blueprints logs [-l <value>] [--watch] [--before <value>] [--json] [--organization-id <value>] [--project-id <value>] [--since <value>] [--stack <value>]

FLAGS
  -l, --limit=<value>            Maximum number of log entries to retrieve (1-500)
  -w, --watch                    Watch for new Stack logs
      --before=<value>           Only show logs before this ISO 8601 timestamp
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --since=<value>            Only show logs after this ISO 8601 timestamp
      --stack=<value>            Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Retrieves Stack deployment logs, useful for debugging and monitoring deployment activity.
  
  Use --watch (-w) to tail logs in real-time.
  
  Use --limit, --since, or --before to narrow the result set when not watching.
  
  If you're not seeing expected logs, verify your Stack is deployed with 'blueprints info'.

EXAMPLES
    $ sanity blueprints logs

    $ sanity blueprints logs --watch

    $ sanity blueprints logs --stack <name-or-id>

    $ sanity blueprints logs --limit 500

    $ sanity blueprints logs --since 2026-05-01T00:00:00Z

    $ sanity blueprints logs --before 2026-05-01T00:00:00Z
```

### `mint-deploy-token`

**CLI output**

```sh
USAGE
  $ sanity blueprints mint-deploy-token [--print] [--json] [--label <value>] [--organization-id <value>] [--project-id <value>]

FLAGS
  -P, --print                    Print only the raw token to stdout (suitable for shell substitution)
      --json                     Format output as json
      --label=<value>            Human-readable label for the robot. Defaults to a generated value.
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack

DESCRIPTION
  Mints a long-lived robot token with the role required to plan, deploy, and destroy Blueprints in this project or organization.
  
  By default the command runs interactively and asks how you want to receive the token (clipboard, print, or exit). Use --print to emit only the raw token for shell pipelines, or --json for full API output.
  
  The minted token is also visible in your Sanity Manage UI under Robots, where it can be revoked.

EXAMPLES
    $ sanity blueprints mint-deploy-token

    $ sanity blueprints mint-deploy-token --label "ci-deploy"

    $ sanity blueprints mint-deploy-token --print

    $ export SANITY_AUTH_TOKEN=$(sanity blueprints mint-deploy-token --print)

    $ sanity blueprints mint-deploy-token --json

    $ sanity blueprints mint-deploy-token --project-id <projectId>

    $ sanity blueprints mint-deploy-token --organization-id <orgId>
```

### `plan`

**CLI output**

```sh
USAGE
  $ sanity blueprints plan [--json] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Use this command to preview what changes will be applied to your remote Stack before deploying. This is a safe, read-only operation—no resources are created, modified, or deleted.
  
  Run 'blueprints plan' after making local changes to your Blueprint manifest to verify the expected diff. When ready, run 'blueprints deploy' to apply changes.

EXAMPLES
    $ sanity blueprints plan

    $ sanity blueprints plan --stack <name-or-id>

    $ sanity blueprints plan --organization-id <orgId> --stack <name-or-id>
```

### `promote`

**CLI output**

```sh
USAGE
  $ sanity blueprints promote [--force] [--json] [--new-stack-name <value>] [--project-id <value>] [--stack <value>]

FLAGS
      --force                   Skip confirmation prompt
      --json                    Format output as json
      --new-stack-name=<value>  Set a new name for the Stack while promoting
      --project-id=<value>      Sanity project ID used to scope Blueprint and Stack
      --stack=<value>           Stack name or ID to promote

DESCRIPTION
  Promotes a deployed Stack to organization scope, enabling management of org-level resources. Promotion cannot be reversed.
  
  Your local Blueprint configuration will be updated to reflect the new scope.

EXAMPLES
    $ sanity blueprints promote

    $ sanity blueprints promote --stack <name-or-id>

    $ sanity blueprints promote --project-id <projectId> --stack <name-or-id>

    $ sanity blueprints promote --new-stack-name <new-name>
```

### `stacks`

**CLI output**

```sh
USAGE
  $ sanity blueprints stacks [--all] [--include-projects] [--json] [--organization-id <value>] [--project-id <value>]

FLAGS
      --all                      List Stacks from every organization and project you have access to
      --include-projects         Include Stacks from all projects within the organization. Requires --organization-id.
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack

DESCRIPTION
  Shows all Stacks associated with a project or organization. By default, lists Stacks scoped to the local Blueprint.
  
  Use this to discover existing Stacks you can scope a local Blueprint to (using 'blueprints config --edit'), or to audit what's deployed across your project.
  
  Without a scope, prompts for an organization or project. Use --all to list Stacks across every organization and project you can access, or --include-projects with --organization-id for one organization and its projects.

EXAMPLES
    $ sanity blueprints stacks

    $ sanity blueprints stacks --all

    $ sanity blueprints stacks --project-id <projectId>

    $ sanity blueprints stacks --organization-id <organizationId>

    $ sanity blueprints stacks --organization-id <organizationId> --include-projects
```



# Deploy custom functions to automate content operations

#### Create your first function

[Create a Document Function](https://www.sanity.io/docs/functions/function-quickstart)
Start building with Functions by deploying a new function to Sanity's infrastructure.

[Create a Media Library Asset Function](https://www.sanity.io/docs/functions/asset-function-quickstart)
Start building a function that reacts to changes to a Media Library asset.

[Official recipes](https://www.sanity.io/recipes)
Get started with pre-built functions for popular use cases

#### Core concepts and guides

[Introduction](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

[Testing functions locally](https://www.sanity.io/docs/functions/functions-local-testing)
Simulate Functions locally with real data and an interactive playground.

[Configure @sanity/client in Functions](https://www.sanity.io/docs/functions/functions-js-client)
Learn to use the JavaScript client in a Sanity Function.

[Add environment variables to functions](https://www.sanity.io/docs/functions/function-env-vars)
Learn to add environment variables to your functions.

[Functions cheat sheet](https://www.sanity.io/docs/functions/functions-cheatsheet)
Common patterns and techniques for creating Functions.



# Introduction

Functions enable you to execute custom logic whenever changes occur in your content—all without requiring your own infrastructure. 

With Functions, you can:

- Enrich, validate, and constrain your content in new ways.
- Create complex workflows.
- Connect changes in your content to external applications and services. Refresh a CDN cache, trigger social posts, update inventory, and more.

#### Get started

[Create a Document Function](https://www.sanity.io/docs/functions/function-quickstart)
Start building with Functions by deploying a new function to Sanity's infrastructure.

[Create a Scheduled Function](https://www.sanity.io/docs/functions/scheduled-function-quickstart)
Create your first scheduled function, a Sanity function that runs on a set schedule, with Blueprints.

[Create a Sync Tag Invalidate Function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart)
Build and deploy a new Sync Tag Invalidate function to Sanity's infrastructure.

[Create a Media Library Asset Function](https://www.sanity.io/docs/functions/asset-function-quickstart)
Start building a function that reacts to changes to a Media Library asset.

[Official Function recipes](https://www.sanity.io/exchange/type=schemas/by=sanity)
Function recipes from the Sanity team

## Requirements

- Functions run on **Node.js v24.x**. We encourage you to use the same version in local testing to avoid unsupported features or syntax changes. You can adjust the runtime version with the `runtime` [configuration setting](https://reference.sanity.io/_sanity/blueprints/defineDocumentFunction/).
- The lastest version of the `sanity` CLI is recommended for interacting with Blueprints and Functions. You can always run the latest CLI commands with `npx sanity@latest`.

## Core concepts

### Functions

Functions are small, single-purpose pieces of code that run on Sanity's cloud infrastructure. They act on changes in your content and allow you to extend the capabilities of your existing content management workflows.

When changes in your content trigger a function, they pass along details about the document. [Using GROQ, you can further refine](https://www.sanity.io/docs/functions/function-quickstart):

- What *kinds* of content changes trigger a function using GROQ filters.
- What *sections* of content are passed to functions using GROQ projections.

Functions can access the full range of Sanity's APIs so you can interact with all of your content, not just the document that triggered the change.

#### Function types

There are different types of functions you can use for different parts of Sanity, and different invoking scenarios. The available function types are:

- [Document functions](https://www.sanity.io/docs/functions/function-quickstart) (`sanity.function.document`): React to any document changes in a project dataset.
- [Media Library Asset functions](https://www.sanity.io/docs/functions/asset-function-quickstart) (`sanity.function.media-library.asset`): React to asset changes in Media Library. This is limited to the `sanity.asset` document type.
- [Sync tag invalidate functions](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) (`sanity.function.sync-tag-invalidate`): React to changes in your Live Content by way of sync tags.
- [Scheduled functions](https://www.sanity.io/docs/functions/scheduled-function-quickstart) (`sanity.function.cron`): Run your code on Sanity’s infrastructure at a set interval.
- [PubSub functions](https://www.sanity.io/docs/functions/pubsub-function-quickstart) (`sanity.function.pubsub`): A function you can invoke from other functions.

More function types will come in the future.

#### Organizing Functions

All function code resides in a dedicated directory for each individual function.

**Example directory structure**

```text
marketing_site/
├─ studio/
├─ next-app/
├─ functions/
│  ├─ my-function/ <-- directory matches the function name
│  │  ├─ index.ts
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/

```

You can treat functions as individual projects, with packages included in their individual `package.json` files, or as part of a larger system with packages installed at alongside the `sanity.blueprints.ts` configuration. You can even mix the two approaches, should your project require it. More details in the [Function dependencies](https://www.sanity.io/docs/functions/function-dependencies) docs.

### Blueprints

A function on its own doesn't know much about the larger Sanity ecosystem. That's where Blueprints come in. A blueprint is a template that describes Sanity resources. For Functions, blueprints describe when and where your function should trigger. 

#### Learn more about Blueprints

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

### Event-driven workflow

Many Functions work on an event system. They react to changes in your data. Did an editor publish a new document? Run a function. Was a versioned document just updated? Run a function. Did someone just upload a new image to the Media Library that needs to kick off an approval workflow? Yep, run a function.

Functions support the following events:

- `create`: a new document is created.
- `update`: an existing document is modified.
- `delete`: an existing document is deleted.

You can learn more about document lifecycles in the [documents documentation](https://www.sanity.io/docs/content-lake/documents).

### Testing and logging

Your development process might need some adjustments to work with functions. As they run remotely on Sanity's infrastructure, you'll rely on local test commands and checking the logs from the CLI to debug your function logic.

Learn more about [testing your functions locally](https://www.sanity.io/docs/functions/functions-local-testing).

### Dependencies

It can be tempting to treat functions just like any other TS/JS project, but you should use restraint when including additional dependencies. See our guide on [structuring function dependencies in your projects](https://www.sanity.io/docs/functions/function-dependencies).

Additionally, prefer platform-agnostic packages in the JS ecosystem over libraries that wrap native code. This will help ensure your functions run as expected once deployed—regardless of your local environment. 

### Deployment

You deploy functions as part of deploying a blueprint. You can learn more about deployment in the function quick starts, or use the [Blueprints GitHub Action](https://www.sanity.io/docs/blueprints/blueprint-action) to deploy them.

## Usage and cost considerations

### General costs

Functions use three variables when calculating cost.

- Invocations: The total number of times your function runs.
- Memory: The amount of memory a function uses to run. This defaults to 1GB, but you can adjust it up to 10GB in the blueprint configuration for each function.
- Duration: The execution time of the function. 

Memory and duration combine to to give a GB-second calculation. For example, a function with 1GB of memory that runs for 2 seconds is 2GB-seconds. Multiply that by the number of total invocations, and you have your total GB-seconds. 

As another example, if your functions average 1GB in memory-size and 40ms in duration, you could run 500k invocations to reach 20K GB-seconds.

Every function will be different, and your total usage accounts for all of your organization's functions. [Learn more on the pricing page](https://www.sanity.io/pricing).

### View your Functions usage

Functions usage is metered for the whole organization, not per project. To see it, go to [sanity.io/manage](https://www.sanity.io/manage), select the organization, and open the **Usage** tab. The figures are in the **Compute** section, which shows invocations and GB-seconds.

### API requests from functions

Invocations and compute time are metered separately from your project's API usage, but they do not replace it. Any request a function makes to the Content Lake counts toward the project's **API requests** or **API CDN requests** quota, the same as a request from any other client. Running inside Sanity's infrastructure does not exempt a function from those quotas.

To keep that usage down, read the document data from the incoming event payload instead of re-fetching the document that triggered the function, and use a GROQ projection to shape the payload so a second request is unnecessary.

### Scheduled Function frequency limits

Depending on your plan, you may experience limits to the number of actively scheduled functions you have, and how often they can run.

Each plan has a minimum threshold for how often a function can run

- Free: daily
- Growth: hourly
- Enterprise: minutely

For more details, [check your plan limits on the pricing page](https://www.sanity.io/pricing).

## Limitations

### Max function size

**Limit**: 200MB

Although your individual function's TypeScript or JavaScript code may appear small, it can rapidly expand in size when packages are included.

We strongly suggest keeping your functions small. The larger the function, the slower it is. You can limit the size, and therefore increase the execution speed by:

- Limit dependency usage to only what's necessary.
- Choose performant, slim libraries.
- If you must use large libraries, consider bundling or tree-shaking in advance.

If your functions require too many dependencies, it may help to narrow their purpose and split the logic into multiple functions.

### Max function execution time

Functions default to a max execution time of 10 seconds. In the [Blueprint configuration](https://www.sanity.io/docs/blueprints/blueprint-config), this can be configured from 1 to 900 seconds.

### Rate limits

To prevent accidental recursion and unexpected behaviors, we rate limit function executions.

**Per document**: If a function is invoked more than 200 times within 30 seconds in a single document, we stop further executions until the rate drops below the limit.

**Per project**: If functions from the same project are invoked more than 4000 times within 30 seconds, we stop further executions until the rate drops below the limit.

### Sync tag invalidate function limitation

To prevent race conditions and unexpected behavior, a dataset can only have a single sync tag invalidate function deployed.

If a dataset already has a sync tag invalidate function deployed, any attempt to deploy a blueprint that contains a sync tag invalidate function affecting the same dataset will return a `a sync tag invalidation subscription already exists` error.  

### Function scope

When writing projections for a function, you’re limited to the invoking document’s scope. The exception being using `→` to follow references.

For example, projections containing a filter like shown below will fail silently:

**index.ts**

```groq
{
  _id,
  title,
  specification {code},
  "referencedBy": *[references(^._id)] {
    _id,
    title,
    specification {code}
  }
}
```

Instead, you’ll need to handle any nested filtering by making a new request inside the function. See our guide on [using the client library](https://www.sanity.io/docs/functions/functions-js-client) for more details.



# Create a Document Function

Functions allow you to run small, single-purpose code whenever your content in Sanity changes. This guide explains how to set up your project, initialize your first blueprint, add a function, and deploy it to Sanity's infrastructure.

Prerequisites:

- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Node.js v24.x. We highly suggest working on this version as it is the same version that your functions will run when deployed to Sanity.
- An existing project and [a role with Deploy Studio permissions](https://www.sanity.io/docs/user-guides/roles) (the `deployStudio` grant). 

> [!WARNING]
> Avoid recursive loops
> At this time, Sanity Functions limit recursive loops when using the `@sanity/client` v7.12.0 or later. Use caution when writing functions that may trigger themselves by editing other documents that trigger the function.
> Initiating multiple recursive functions may trigger [rate-limiting](https://www.sanity.io/docs/functions/functions-introduction) and may impact your usage limits sooner than expected. If you think you've deployed a recursive function or one that triggers too often, immediately override the deployment with new code, or `destroy` the blueprint.



## Set up your project

To create a function, you need to initialize a blueprint. Blueprints are templates that describe Sanity resources. In this case, a blueprint describes how your function will respond to updates in your Sanity project. We recommend keeping functions and blueprints a level above your Studio directory. 

For example, if you have a Marketing Website that uses Sanity, you may have a structure like this:

```text
marketing-site/
├─ studio/
├─ next-app/
```

If you initialize the blueprint in the `marketing-site` directory, functions and future resources will live alongside the `studio` and `next-app` directory.

## Create a blueprint

Initialize your first blueprint with the `init` command. Replace <project-id> with your project ID, found in manage or your sanity.config.ts file.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

This configures a new blueprint for your project, adds a `sanity.blueprint.ts` [config file](https://www.sanity.io/docs/blueprints/blueprint-config) to the current directory (`.`), and creates a new [stack](https://www.sanity.io/docs/blueprints/blueprints-introduction) named production.

Follow the prompt and run your package manager’s install command to add the dependencies.

**npm**

```shell
npm install
```

**pnpm**

```shell
pnpm install
```

**yarn**

```shell
yarn install
```

**bun**

```shell
bun install
```



## Create a function

Use the `sanity functions add` command to add a new function. You can also run it without any flags for interactive mode.

**npm**

```shell
npx sanity@latest functions add --name log-event --type document-create --type document-update --installer npm
```

**pnpm**

```shell
pnpm dlx sanity@latest functions add --name log-event --type document-create --type document-update --installer npm
```

**yarn**

```shell
yarn dlx sanity@latest functions add --name log-event --type document-create --type document-update --installer npm
```

**bun**

```shell
bunx sanity@latest functions add --name log-event --type document-create --type document-update --installer npm
```

> [!TIP]
> If you’re using a package manager other than npm, set the `--installer` flag to your package manager, like `pnpm` or `yarn`. Run `sanity functions add --help` for more details.

After running the command, follow the prompt and add the function declaration to your `sanity.blueprint.ts` configuration. Your file should look like this:

**sanity.blueprint.ts**

```
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({name: 'log-event', event: {on: ['create', 'update']}}),
  ],
})

```

The `on` property takes an array of trigger events:

- `create`: Fires when a new document is created for the first time.
- `update`: Fires when changes are made to an existing document.
- `delete`: Fires when a document is deleted.

For existing, published documents, `update` will only trigger when a draft or version is published and *updates* the document. In many cases where you'd use `update` on published documents, it may be better to use `['create', 'update']`. [Learn more about document lifecycles](https://www.sanity.io/docs/content-lake/documents).

This is the minimal configuration for defining a function in a blueprint file. You can see all available options in the [Function section of the Blueprints configuration reference documentation](https://www.sanity.io/docs/blueprints/blueprint-config).

If you've followed the directory structure mentioned earlier, you'll see it grow to something like this:

```text
marketing-site/
├─ studio/
├─ next-app/
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/
├─ functions/
│  ├─ log-event/
│  │  ├─ index.ts
```

After updating the `sanity.blueprint.ts` file, open `functions/log-event/index.ts` in your editor. 

> [!TIP]
> The documentEventHandler function
> TypeScript functions can take advantage of the `documentEventHandler` helper function to provide type support. Examples in this article include both TypeScript and JavaScript function syntax.

Every function exports a `handler` from the index file.

**functions/log-event/index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
})
```

**functions/log-event/index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
}
```

The handler receives a `context` and an `event`. The context contains information to help you interact with your Sanity datastore, such as `clientOptions` to configure a `@sanity/client`. 

The `event` contains information about the action that triggered the function. Most functions will use `event.data`, which contains the contents of the Sanity document. You can learn more in the [Function handler reference](https://www.sanity.io/docs/functions/function-wrapper).

### Limit the scope with GROQ

This function will run every time any document publishes and return the entire document to `event.data`. This includes system documents. Let’s narrow the scope.

Open the `sanity.blueprint.ts` file and update it to include an `event` object with the `on` and `filter` properties:

**sanity.blueprint.ts**

```typescript
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'log-event',
      event: {
        on: ['create', 'update'],
        filter: '_type == "post"'
      }
    }),
  ],
})
```



`filter` accepts a [GROQ filter](https://www.sanity.io/docs/specifications/groq-syntax) that limits which documents will trigger the function. Only include the filter contents, *the portion inside the square brackets*, of your GROQ query. For example, rather than `*[_type == 'post']`, only include `_type == 'post'`.

Projections let you shape the contents passed to the event. Set the `projection` property on `event`. If you want your function to receive an object instead of individual attributes, ensure your `projection` is wrapped in curly braces (`{}`) as shown in the example.

**sanity.blueprint.ts**

```typescript
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'log-event',
      event: {
        on: ['create', 'update'],
        filter: '_type == "post"',
        projection: "{_id, content}"
      }
    }),
  ],
})
```

Projections don't limit what fields trigger the function, only which data is passed into the function.

GROQ is a powerful query language, and with features such as [delta functions](https://www.sanity.io/docs/specifications/groq-functions) —to help you determine *what* in a document changed as well as *how* it changed—you can narrow the scope even farther. Check out the [GROQ Query Cheat Sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet) for more ideas, and the [Functions cheat sheet](https://www.sanity.io/docs/functions/functions-cheatsheet) for additional function-specific techniques.

### Limit to a specific dataset

Functions will run against all of a project’s datasets by default. We recommend scoping a function to your desired dataset. Use the `projectId.datasetName` format as the resource ID in the `event`.

**sanity.blueprint.ts**

```typescript
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'log-event',
      event: {
        on: ['create', 'update'],
        filter: '_type == "post"',
        projection: "{_id, content}",
        resource: {
          type: 'dataset',
          id: 'myProjectId.production'
        }
      }
    }),
  ],
})
```

## Test the function locally

You can test functions locally with the functions development playground. Local testing is a great way to experiment without affecting your usage quota.

To launch the development playground, run the following:

**npm**

```shell
npx sanity functions dev
```

**pnpm**

```shell
pnpm dlx sanity functions dev
```

**yarn**

```shell
yarn dlx sanity functions dev
```

**bun**

```shell
bunx sanity functions dev
```

If you run this on the starter function from earlier, you'll see the default output message in the console pane.

![A screenshot of the functions playground interface](https://cdn.sanity.io/images/3do82whm/next/e5d0e1a477004bd36d24b9a52863accddb565309-2904x1456.png)

To test with real data from your project, you can pass a document `_id` and select a project/dataset to fetch a document and pass it to the function's `event`. Don't forget to press the download button next to the document ID to populate the input. You can also manually enter a document-like shape. In either case, make sure it matches your projection criteria.

Update your function to log the `event` and you'll see the supplied document details the next time you run the function in the playground.

**functions/log-event/index.ts (TypeScript)**

```typescript
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
  console.log('Event:', event)
})
```

**functions/log-event/index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
  console.log('Event:', event)
}
```

> [!TIP]
> Development playground
> In addition to the `sanity functions dev` command, there's also a more traditional CLI testing interface. 
> Run the `sanity functions test functionName` command to run the function locally. You can learn more in the [local testing guide](https://www.sanity.io/docs/functions/functions-local-testing) and the [functions CLI reference](https://www.sanity.io/docs/cli-reference/functions).

## Deploy a function

Once you're satisfied that the function works as expected, deploy it by deploying the blueprint stack.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

You can begin using your function when the deployment finishes. If you set a filter earlier, edit a document that matches it and publish the changes to trigger the function. 

If you need to change the function, update your code and re-run the deploy command to push the new changes live.

## Check the logs

When you tested the function locally, you saw the logs directly in your console. Once deployed, the function and its logs are in the cloud.

View the logs with the `functions logs` command. Replace `log-event` with your function name.

**npm**

```shell
npx sanity functions logs log-event
```

**pnpm**

```shell
pnpm dlx sanity functions logs log-event
```

**yarn**

```shell
yarn dlx sanity functions logs log-event
```

**bun**

```shell
bunx sanity functions logs log-event
```

This command outputs the function's logs. Try updating your document, publishing the change, and running the command again to see new logs.

> [!NOTE]
> System documents
> If you didn't limit the scope of the function by setting a GROQ filter earlier, every change to a published document will run the function. This can greatly increase your usage, so it's best to create specific filters for your documents.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.





# Create a Media Library Asset Function

Media Library Asset Functions allow you to run small, single-purpose code whenever a Media Library `sanity.asset` document changes. These are the [container documents that hold basic metadata, versions, and aspect data](https://www.sanity.io/docs/content-lake/document-reference).

You can try things like:

- Compare changes in an asset's aspect data.
- Kick off a review flow when new versions are added to an asset.
- Update references when assets are deleted.

This guide explains how to set up your project, initialize your first blueprint, add a function, and deploy it to Sanity's infrastructure.

Prerequisites:

- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Node.js v24.x. We highly suggest working on this version as it is the same version that your functions will run when deployed to Sanity.
- An existing project and [a role with Deploy Studio permissions](https://www.sanity.io/docs/user-guides/roles) (the `deployStudio` grant). 

> [!WARNING]
> Avoid recursive loops
> At this time, Sanity Functions limit recursive loops when using the `@sanity/client` v7.12.0 or later. Use caution when writing functions that may trigger themselves by editing other documents that trigger the function.
> Initiating multiple recursive functions may trigger [rate-limiting](https://www.sanity.io/docs/functions/functions-introduction) and may impact your usage limits sooner than expected. If you think you've deployed a recursive function or one that triggers too often, immediately override the deployment with new code, or `destroy` the blueprint.



## Set up your project

To create a function, you need to initialize a blueprint. Blueprints are templates that describe Sanity resources. In this case, a blueprint describes how your function will respond to updates in your Sanity project. We recommend keeping functions and blueprints a level above your Studio directory. 

For example, if you have a Marketing Website that uses Sanity, you may have a structure like this:

```text
marketing-site/
├─ studio/
├─ next-app/
```

If you initialize the blueprint in the `marketing-site` directory, functions and future resources will live alongside the `studio` and `next-app` directory.

## Create a blueprint

Initialize your first blueprint with the `init` command. Replace <project-id> with your project ID, found in manage or your sanity.config.ts file.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

This configures a new blueprint for your project, adds a `sanity.blueprint.ts` [config file](https://www.sanity.io/docs/blueprints/blueprint-config) to the current directory (`.`), and creates a new [stack](https://www.sanity.io/docs/blueprints/blueprints-introduction) named production.

Follow the prompt and run your package manager’s install command to add the dependencies.

**npm**

```shell
npm install
```

**pnpm**

```shell
pnpm install
```

**yarn**

```shell
yarn install
```

**bun**

```shell
bun install
```



## Create a function

Use the `sanity functions add` command to add a new function. You can also run it without any flags for interactive mode.

**npm**

```shell
npx sanity@latest functions add --name asset-update --type media-library-asset-create --type media-library-asset-update --installer npm
```

**pnpm**

```shell
pnpm dlx sanity@latest functions add --name asset-update --type media-library-asset-create --type media-library-asset-update --installer npm
```

**yarn**

```shell
yarn dlx sanity@latest functions add --name asset-update --type media-library-asset-create --type media-library-asset-update --installer npm
```

**bun**

```shell
bunx sanity@latest functions add --name asset-update --type media-library-asset-create --type media-library-asset-update --installer npm
```

> [!TIP]
> If you’re using a package manager other than npm, set the `--installer` flag to your package manager, like `pnpm` or `yarn`. Run `sanity functions add --help` for more details.

After running the command, follow the prompt and add the function declaration to your `sanity.blueprint.ts` configuration. Your file should look like this:

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineMediaLibraryAssetFunction({
      name: 'asset-update',
      event: {
        on: ['create', 'update'],
        resource: {type: 'media-library', id: 'my-media-library-id'}
      }
    })
  ],
})
```

Replace `my-media-library-id` with your [Media Library ID](https://www.sanity.io/docs/media-library/configure-library).

The `on` property is already set and takes an array of trigger events:

- `create`: Fires when a new document is created for the first time.
- `update`: Fires when changes are made to an existing document.
- `delete`: Fires when an asset document is deleted.

For existing, published documents, `update` will only trigger when a draft is published and *updates* the document. In many cases where you'd use `update` on published documents, it may be better to use `['create', 'update']` to ensure new documents also trigger the function. [Learn more about document lifecycles](https://www.sanity.io/docs/content-lake/documents).

This is the minimal configuration for defining a Media Library Asset Function in a blueprint file. You can see all available options in the [Function section of the Blueprints configuration reference documentation](https://www.sanity.io/docs/blueprints/blueprint-config).

If you've followed the directory structure mentioned earlier, you'll see it grow to something like this:

```text
marketing-site/
├─ studio/
├─ next-app/
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/
├─ functions/
│  ├─ asset-update/
│  │  ├─ index.ts
```

After updating the `sanity.blueprint.ts` file, open `functions/asset-update/index.ts` in your editor. 

> [!TIP]
> The documentEventHandler function
> TypeScript functions can take advantage of the `documentEventHandler` helper function to provide type support. Examples in this article include both TypeScript and JavaScript function syntax.

Every function exports a `handler` from the index file.

**functions/asset-update/index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
})
```

**functions/asset-update/index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
}
```

The handler receives a `context` and an `event`. The context contains information to help you interact with your Sanity datastore, such as `clientOptions` to configure a `@sanity/client`. 

The `event` contains information about the action that triggered the function. Most functions will use `event.data`, which contains the contents of the Sanity document. You can learn more in the [Function handler reference](https://www.sanity.io/docs/functions/function-wrapper).

### Limit the scope with GROQ

This function will run every time any Media Library asset documents (documents with a `_type` of `sanity.asset`) change and it will return the entire document to `event.data`. Let’s narrow the scope by writing a GROQ filter.

#### Filters

Open the `sanity.blueprint.ts` file and add a `filter` to the `event` object:

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineMediaLibraryAssetFunction({
      name: 'asset-update',
      event: {
        on: ['create', 'update'],
        resource: {type: 'media-library', id: 'my-media-library-id'},
        filter: 'cdnAccessPolicy == "private"',
      }
    })
  ],
})
```

This filter causes the function to only run when private assets are created or updated.

`filter` accepts a [GROQ filter](https://www.sanity.io/docs/specifications/groq-syntax) that limits which documents will trigger the function. Only include the filter contents, *the portion inside the square brackets*, of your GROQ query. For example, rather than `*[cdnAccessPolicy == 'private']`, only include `cdnAccessPolicy == 'post'`. 

#### Projections

Projections let you shape the contents passed to the event. Set the `projection` property on `event`. If you want your function to receive an object instead of individual attributes, ensure your `projection` is wrapped in curly braces (`{}`) as shown in the example.

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineMediaLibraryAssetFunction({
      name: 'asset-update',
      event: {
        on: ['create', 'update'],
        resource: {type: 'media-library', id: 'my-media-library-id'},
        filter: 'cdnAccessPolicy == "private"',
        projection: '{_id, aspects, currentVersion, title, url}'
      }
    })
  ],
})
```

Projections don't limit what fields trigger the function, only which data is passed into the function.

GROQ is a powerful query language, and with features such as [delta functions](https://www.sanity.io/docs/specifications/groq-functions) —to help you determine *what* in a document changed as well as *how* it changed—you can narrow the scope even farther. Check out the [GROQ Query Cheat Sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet) for more ideas, and the [Functions cheat sheet](https://www.sanity.io/docs/functions/functions-cheatsheet) for additional function-specific techniques.

## Test the function locally

You can test functions locally with the functions development playground. Local testing is a great way to experiment without affecting your usage quota.

To launch the development playground, run the following:

**npm**

```shell
npx sanity functions dev
```

**pnpm**

```shell
pnpm dlx sanity functions dev
```

**yarn**

```shell
yarn dlx sanity functions dev
```

**bun**

```shell
bunx sanity functions dev
```

If you run this on the starter function from earlier, you'll see the default output message in the console pane.

![A screenshot of the functions playground interface](https://cdn.sanity.io/images/3do82whm/next/e5d0e1a477004bd36d24b9a52863accddb565309-2904x1456.png)

To test with real data from your Media Library, you can pass a document `_id` and select your library ID to fetch an asset document and pass it to the function's `event`. Don't forget to press the download button next to the document ID to populate the input. You can also manually enter a document-like shape. In either case, make sure it matches your projection criteria.

Update your function to log the `event` and you'll see the supplied document details the next time you run the function in the playground.

**functions/asset-update/index.ts (TypeScript)**

```typescript
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
  console.log('Event:', event)
})
```

**functions/asset-update/index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
  console.log('Event:', event)
}
```

> [!TIP]
> Development playground
> In addition to the `sanity functions dev` command, there's also a more traditional CLI testing interface. 
> Run the `sanity functions test functionName` command to run the function locally. You can learn more in the [local testing guide](https://www.sanity.io/docs/functions/functions-local-testing) and the [functions CLI reference](https://www.sanity.io/docs/cli-reference/functions).

## Deploy a function

Once you're satisfied that the function works as expected, deploy it by deploying the blueprint stack.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

You can begin using your function when the deployment finishes. If you set a filter earlier, edit a document that matches it and publish the changes to trigger the function. 

If you need to change the function, update your code and re-run the deploy command to push the new changes live.

## Check the logs

When you tested the function locally, you saw the logs directly in your console. Once deployed, the function and its logs are in the cloud.

View the logs with the `functions logs` command. Replace `asset-update` with your function name.

**npm**

```shell
npx sanity functions logs asset-update
```

**pnpm**

```shell
pnpm dlx sanity functions logs asset-update
```

**yarn**

```shell
yarn dlx sanity functions logs asset-update
```

**bun**

```shell
bunx sanity functions logs asset-update
```

This command outputs the function's logs. Try updating your document, publishing the change, and running the command again to see new logs.

> [!NOTE]
> System documents
> If you didn't limit the scope of the function by setting a GROQ filter earlier, every change to a published document will run the function. This can greatly increase your usage, so it's best to create specific filters for your documents.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.





# Create a Sync Tag Invalidate Function

Sync Tag Invalidate Functions allow you to run small, single-purpose code whenever your updated content is ready for querying. This guide explains how to set up your project, initialize your first blueprint, add a function, and deploy it to Sanity's infrastructure.

> [!WARNING]
> Avoid deploying multiple sync-tag-invalidate functions per dataset
> Having multiple `sync-tag-invalidate` functions set up for a single dataset may lead to race conditions, unexpected results, or increased usage.
> We recommend scoping your `sync-tag-invalidate` function to a specific dataset as shown below, or see the Function `event` [Blueprint configuration reference documentation](https://www.sanity.io/docs/blueprints/blueprint-config) for more configuration options.

Prerequisites:

- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Node.js v24.x. We highly suggest working on this version as it is the same version that your functions will run when deployed to Sanity.
- An existing project and [a role with Deploy Studio permissions](https://www.sanity.io/docs/user-guides/roles) (the `deployStudio` grant). 

> [!WARNING]
> Avoid recursive loops
> At this time, Sanity Functions limit recursive loops when using the `@sanity/client` v7.12.0 or later. Use caution when writing functions that may trigger themselves by editing other documents that trigger the function.
> Initiating multiple recursive functions may trigger [rate-limiting](https://www.sanity.io/docs/functions/functions-introduction) and may impact your usage limits sooner than expected. If you think you've deployed a recursive function or one that triggers too often, immediately override the deployment with new code, or `destroy` the blueprint.

If you’ve previously created a project, you can skip ahead to [Add a Sync Tag Invalidate function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart).

## Set up your project

To create a function, you need to initialize a blueprint. Blueprints are templates that describe Sanity resources. In this case, a blueprint describes how your function will respond to updates in your Sanity project. We recommend keeping functions and blueprints a level above your Studio directory. 

For example, if you have a Marketing Website that uses Sanity, you may have a structure like this:

```text
marketing-site/
├─ studio/
├─ next-app/
```

If you initialize the blueprint in the `marketing-site` directory, functions and future resources will live alongside the `studio` and `next-app` directory.

## Create a blueprint

Initialize your first blueprint with the `init` command. Replace <project-id> with your project ID, found in manage or your sanity.config.ts file.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

This configures a new blueprint for your project, adds a `sanity.blueprint.ts` [config file](https://www.sanity.io/docs/blueprints/blueprint-config) to the current directory (`.`), and creates a new [stack](https://www.sanity.io/docs/blueprints/blueprints-introduction) named production.

Follow the prompt and run your package manager’s install command to add the dependencies.

**npm**

```shell
npm install
```

**pnpm**

```shell
pnpm install
```

**yarn**

```shell
yarn install
```

**bun**

```shell
bun install
```



## Add a Sync Tag Invalidate function

Use the `sanity functions add` command to add a new function. You can also run it without any flags for interactive mode.

**npm**

```shell
npx sanity@latest functions add --name invalidate-tags --type sync-tag-invalidate --installer npm
```

**pnpm**

```shell
pnpm dlx sanity@latest functions add --name invalidate-tags --type sync-tag-invalidate --installer npm
```

**yarn**

```shell
yarn dlx sanity@latest functions add --name invalidate-tags --type sync-tag-invalidate --installer npm
```

**bun**

```shell
bunx sanity@latest functions add --name invalidate-tags --type sync-tag-invalidate --installer npm
```

> [!TIP]
> If you’re using a package manager other than npm, set the `--installer` flag to your package manager, like `pnpm` or `yarn`. Run `sanity functions add --help` for more details.

After running the command, follow the prompt and add the function declaration to your `sanity.blueprint.ts` configuration. Your file should look like this:

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineSyncTagInvalidateFunction({name: 'invalidate-tags'}),
  ],
})

```

By default, your new function will receive sync tag invalidation events for *all* datasets in your project. We recommend you add an `event` to your function definition with a `resource` scoping your function to a particular dataset.

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineSyncTagInvalidateFunction({
      name: "invalidate-tags",
      event: {
        resource: {
          type: 'dataset',
          id: 'myProjectId.myProductionDataset'
        }
      }
    })
  ],
})

```

You can see all available options in the [Function section of the Blueprints configuration reference documentation](https://www.sanity.io/docs/blueprints/blueprint-config).

If you've followed the directory structure mentioned earlier, you'll see it grow to something like this:

```text
marketing-site/
├─ studio/
├─ next-app/
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/
├─ functions/
│  ├─ invalidate-tags/
│  │  ├─ index.ts
```

After updating the `sanity.blueprint.ts` file, open `functions/invalidate-tags/index.ts` in your editor. 

> [!TIP]
> The syncTagInvalidateEventHandler function
> TypeScript functions can take advantage of the `syncTagInvalidateEventHandler` helper function to provide type support. Examples in this article include both TypeScript and JavaScript function syntax.

Every function exports a `handler` from the index file.

**functions/invalidate-tags/index.ts (TypeScript)**

```
import { syncTagInvalidateEventHandler } from '@sanity/functions'

export const handler = syncTagInvalidateEventHandler(async ({ context, event, done }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`Your sync tag invalidate Sanity Function was called at ${time}`)
  // TODO: add code to do something with the invalidated sync tags provided to you in `event.data.syncTags`
  try {
    // notify Sanity that you have completed invalidation
    const response = await done(event.data.syncTags)
    console.log('Invalidation complete, Sanity responded with an HTTP', response.status)
  } catch (e) {
    console.error('Error invoking Sanity invalidation done endpoint!', e)
  }
})
```

**functions/invalidate-tags/index.js (JavaScript)**

```javascript
import { syncTagInvalidateEventHandler } from '@sanity/functions'

export const handler = syncTagInvalidateEventHandler(async ({ context, event, done }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`Your sync tag invalidate Sanity Function was called at ${time}`)
  // TODO: add code to do something with the invalidated sync tags provided to you in `event.data.syncTags`
  try {
    // notify Sanity that you have completed invalidation
    const response = await done(event.data.syncTags)
    console.log('Invalidation complete, Sanity responded with an HTTP', response.status)
  } catch (e) {
    console.error('Error invoking Sanity invalidation done endpoint!', e)
  }
})
```

The handler receives a `context`, an `event` and a `done` callback.

The `event` contains the sync tags that were invalidated, available at `event.data.syncTags`. You can learn more in the [Function handler reference](https://www.sanity.io/docs/functions/function-wrapper).

The `done` callback is an asynchronous method issuing an HTTP request back to Sanity, notifying us that you have completed your invalidation routine. It is a thin wrapper around native node.js `fetch`, returning a fetch `Response`.

> [!WARNING]
> Your function must notify Sanity that your invalidation routine is complete by invoking the `done` callback. Calling `done` is what releases the invalidation to clients subscribed to the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) with `waitFor=function`. With that parameter set, Sanity holds these sync tag events back until your function has processed them, so if `done` never completes, those clients won't receive the change, leading to unexpected Live Content behavior. It is your responsibility to confirm the callback completed successfully, so we recommend wrapping the `done` invocation in a `try/catch` and logging any failures.

## Test the function locally

You can test functions locally with the functions development playground. Local testing is a great way to experiment without affecting your usage quota.

To launch the development playground, run the following:

**npm**

```shell
npx sanity functions dev
```

**pnpm**

```shell
pnpm dlx sanity functions dev
```

**yarn**

```shell
yarn dlx sanity functions dev
```

**bun**

```shell
bunx sanity functions dev
```

If you run this on the starter function from earlier, you'll see the default output message in the console pane.

![A dark-themed UI for a functions application, showing a JSON payload to invalidate sync tags and a console log of a successful HTTP 204 response.](https://cdn.sanity.io/images/3do82whm/next/11155be59ad797b2aff1d784c7e86e43627164c3-2566x1682.png)

Select your new Sync Tag Invalidate function from the list on the left. Note that the Sync Tag Payload panel is populated with a dummy sync tag invalidate event shape that your function can use as a test payload.

Click the Run button at the bottom, and you should see the starter Sync Tag Invalidate function template code output its logs to the Console panel:

**console**

```text
4/10/2026 9:14:52 AM INFO Your sync tag invalidate Sanity Function was called at 9:14:52 AM
4/10/2026 9:14:52 AM INFO Invalidation complete, Sanity responded with an HTTP 204
```

> [!TIP]
> Development playground
> In addition to the `sanity functions dev` command, there's also a more traditional CLI testing interface. 
> Run the `sanity functions test functionName` command to run the function locally. You can learn more in the [local testing guide](https://www.sanity.io/docs/functions/functions-local-testing) and the [functions CLI reference](https://www.sanity.io/docs/cli-reference/functions).

## Deploy a function

Once you're satisfied that the function works as expected, deploy it by deploying the blueprint stack.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

You can begin using your function when the deployment finishes. Edit a document in a dataset and publish the changes to trigger the function. 

If you need to change the function, update your code and re-run the deploy command to push the new changes live.

## Check the logs

When you tested the function locally, you saw the logs directly in your console. Once deployed, the function and its logs are in the cloud.

View the logs with the `functions logs` command. Replace `invalidate-tags` with your function name.

**npm**

```shell
npx sanity functions logs invalidate-tags
```

**pnpm**

```shell
pnpm dlx sanity functions logs invalidate-tags
```

**yarn**

```shell
yarn dlx sanity functions logs invalidate-tags
```

**bun**

```shell
bunx sanity functions logs invalidate-tags
```

This command outputs the function's logs. Try updating your document, publishing the change, and running the command again to see new logs.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.





# Create a Scheduled Function

Functions allow you to run small, single-purpose code whenever your content in Sanity changes. This guide explains how to set up your project, initialize your first blueprint, add a function, and deploy it to Sanity's infrastructure.

Prerequisites:

- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Node.js v24.x. We highly suggest working on this version as it is the same version that your functions will run when deployed to Sanity.
- Different plans offer different function cadence limits. Check the [Functions pricing section](https://www.sanity.io/docs/functions/functions-introduction) for more details.
- **If you’re using an existing blueprint**, it needs to use an [organization-scoped](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope) stack.
- Deploying an organization-scoped stack requires the organization admin role, the new blueprint deployer role, or a [robot token](https://www.sanity.io/docs/content-lake/http-auth) with the `sanity.blueprints.deploy` permission.

If you’ve previously set up your project and added functions, skip ahead to the [Add a scheduled function](https://www.sanity.io/docs/functions/scheduled-function-quickstart) section.

## Set up your project

To create a function, you need to initialize a blueprint. Blueprints are templates that describe Sanity resources. In this case, a blueprint describes when your function will run. We recommend keeping functions and blueprints a level above your Studio directory.

For example, if you have a Marketing Website that uses Sanity, you may have a structure like this:

```text
marketing-site/
├─ studio/
├─ next-app/
```

If you initialize the blueprint in the `marketing-site` directory, functions and future resources will live alongside the `studio` and `next-app` directory.

## Create a blueprint

Initialize your first blueprint with the `init` command. Replace <organization-id> with your organization ID, found in [manage](https://www.sanity.io/manage).

**npm**

```shell
npx sanity@latest blueprints init . --type ts --stack-name production --organization-id <organization-id>
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --stack-name production --organization-id <organization-id>
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --stack-name production --organization-id <organization-id>
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --stack-name production --organization-id <organization-id>
```

This configures an [organization-scoped blueprint stack](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope), adds a `sanity.blueprint.ts` [config file](https://www.sanity.io/docs/blueprints/blueprint-config) to the current directory (`.`), and creates a new [stack](https://www.sanity.io/docs/blueprints/blueprints-introduction) named production.

Follow the prompt and run your package manager’s install command to add the dependencies.

**npm**

```shell
npm install
```

**pnpm**

```shell
pnpm install
```

**yarn**

```shell
yarn install
```

**bun**

```shell
bun install
```

## Add a scheduled function

Use the `sanity functions add` command to add a new function. You can also run it without any flags for interactive mode.

**npm**

```shell
npx sanity@latest functions add --name expire-cache --type scheduled-function --language ts --installer npm
```

**pnpm**

```shell
pnpm dlx sanity@latest functions add --name expire-cache --type scheduled-function --language ts --installer npm
```

**yarn**

```shell
yarn dlx sanity@latest functions add --name expire-cache --type scheduled-function --language ts --installer npm
```

**bun**

```shell
bunx sanity@latest functions add --name expire-cache --type scheduled-function --language ts --installer npm
```

> [!TIP]
> If you’re using a package manager other than npm, set the `--installer` flag to your package manager, like `pnpm` or `yarn`. Run `sanity functions add --help` for more details.

After running the command, follow the prompt and add the function declaration to your `sanity.blueprint.ts` configuration. Your file should look like this:

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineScheduledFunction({name: 'expire-cache', event: {expression: '0 0 * * *'}}),
  ],
})

```

This is the minimal configuration for defining a function in a blueprint file. You can see all available options in the [Function section of the Blueprints configuration reference documentation](https://www.sanity.io/docs/blueprints/blueprint-config).

If you've followed the directory structure mentioned earlier, you'll see it grow to something like this:

```text
marketing-site/
├─ studio/
├─ next-app/
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/
├─ functions/
│  ├─ expire-cache/
│  │  ├─ index.ts
```

After updating the `sanity.blueprint.ts` file, open `functions/expire-cache/index.ts` in your editor. 

> [!TIP]
> The scheduledEventHandler function
> TypeScript functions can take advantage of the `scheduledEventHandler` helper function to provide type support. Examples in this article include both TypeScript and JavaScript function syntax.

Every function exports a `handler` from the index file.

**functions/expire-cache/index.ts (TypeScript)**

```
import { scheduledEventHandler } from '@sanity/functions'

export const handler = scheduledEventHandler(async ({ context }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
})
```

**functions/expire-cache/index.js (JavaScript)**

```javascript
export async function handler({context}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
}
```

If you plan to interact with a Sanity project’s dataset from your scheduled function, you can [install the Sanity client](https://www.sanity.io/docs/functions/functions-js-client) and configure it. As scheduled functions are organization-scoped, they don’t have a project and dataset in their `context`. You need to explicitly [define a robot token](https://www.sanity.io/docs/functions/robot-tokens-with-functions) and set the projectId and dataset when configuring the client.

Create the robot token in your blueprint and reference it from the scheduled function definer:

**sanity.blueprint.ts**

```
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 * * *'},
      robotToken: '$.resources.my-robot.token',
    }),
  ],
})

```

Configure the client:

**index.ts**

```
import { scheduledEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = scheduledEventHandler(async ({ context }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)

  const client = createClient({
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'YOUR_DATASET',
    apiVersion: '2026-04-29',
    token: context.clientOptions?.token,
  })
})
```



## Set a schedule

Scheduled function events can use the universal, but often difficult to read, [UNIX Cron Expression](https://www.ibm.com/docs/en/db2-as-a-service?topic=task-unix-cron-format) format. To make it easier to understand when your function will run we also support an explicit event format.

**Explicit format**

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

export default defineBlueprint({
  resources: [
    defineScheduledFunction({
      name: 'expire-cache',
      event: {
          minute: '0',
          hour: '0',
          dayOfMonth: '*',
          month: '*',
          dayOfWeek: '*',
      }
    }),
  ],
})
```

**CRON format**

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

export default defineBlueprint({
  resources: [
    defineScheduledFunction({
      name: 'expire-cache',
      event: {
        expression: '0 0 * * *',
      }
    }),
  ],
})
```

While this format is more verbose, it is easier to read that this function will run at midnight UTC every day of the year.

### Set a timezone (optional)

In order to give you better control over exactly when you function executes you can provide a timezone property to your schedule function.

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineScheduledFunction({
      name: 'expire-cache',
      event: {
          minute: '0',
          hour: '0',
          dayOfMonth: '*',
          month: '*',
          dayOfWeek: '*',
      },
      timezone: 'America/New_York'
    }),
  ],
})
```

The `timezone` property supports an [IANA time zone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), such as `America/New_York` or `Europe/Berlin`. If a timezone isn’t set, the schedule defaults to UTC.

## Test the function locally

You can test functions locally with the functions development playground. Local testing is a great way to experiment without affecting your usage quota.

To launch the development playground, run the following:

**npm**

```shell
npx sanity functions dev
```

**pnpm**

```shell
pnpm dlx sanity functions dev
```

**yarn**

```shell
yarn dlx sanity functions dev
```

**bun**

```shell
bunx sanity functions dev
```

If you run this on the starter function from earlier, you'll see the default output message in the console pane.

![A screenshot of the functions playground interface](https://cdn.sanity.io/images/3do82whm/next/e5d0e1a477004bd36d24b9a52863accddb565309-2904x1456.png)

> [!TIP]
> Development playground
> In addition to the `sanity functions dev` command, there's also a more traditional CLI testing interface. 
> Run the `sanity functions test functionName` command to run the function locally. You can learn more in the [local testing guide](https://www.sanity.io/docs/functions/functions-local-testing) and the [functions CLI reference](https://www.sanity.io/docs/cli-reference/functions).

## Deploy the blueprint

Once you're satisfied that the function works as expected, deploy it by deploying the blueprint stack.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

You can begin using your function when the deployment finishes. In the case of scheduled functions, you’ll need to wait for the event interval for it to run. 

If you need to change the function, update your code and re-run the deploy command to push the new changes live.

## Check the logs

When you tested the function locally, you saw the logs directly in your console. Once deployed, the function and its logs are in the cloud.

View the logs with the `functions logs` command. Replace `expire-cache` with your function name.

**npm**

```shell
npx sanity functions logs expire-cache
```

**pnpm**

```shell
pnpm dlx sanity functions logs expire-cache
```

**yarn**

```shell
yarn dlx sanity functions logs expire-cache
```

**bun**

```shell
bunx sanity functions logs expire-cache
```

This command outputs the function's logs. Run the command again after the scheduled interval passes to see new logs.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.







# Create a PubSub function

Functions allow you to run small, single-purpose code whenever your content in Sanity changes. This guide explains how to set up your project, initialize your first blueprint, add a function, and deploy it to Sanity's infrastructure.

Prerequisites:

- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Node.js v24.x. We highly suggest working on this version as it is the same version that your functions will run when deployed to Sanity.
- An existing project and [a role with Deploy Studio permissions](https://www.sanity.io/docs/user-guides/roles) (the `deployStudio` grant). 

> [!WARNING]
> Avoid recursive loops
> At this time, Sanity Functions limit recursive loops when using the `@sanity/client` v7.12.0 or later. Use caution when writing functions that may trigger themselves by editing other documents that trigger the function.
> Initiating multiple recursive functions may trigger [rate-limiting](https://www.sanity.io/docs/functions/functions-introduction) and may impact your usage limits sooner than expected. If you think you've deployed a recursive function or one that triggers too often, immediately override the deployment with new code, or `destroy` the blueprint.



## Set up your project

To create a function, you need to initialize a blueprint. Blueprints are templates that describe Sanity resources. In this case, a blueprint describes how your function will respond to updates in your Sanity project. We recommend keeping functions and blueprints a level above your Studio directory. 

For example, if you have a Marketing Website that uses Sanity, you may have a structure like this:

```text
marketing-site/
├─ studio/
├─ next-app/
```

If you initialize the blueprint in the `marketing-site` directory, functions and future resources will live alongside the `studio` and `next-app` directory.

## Create a blueprint

Initialize your first blueprint with the `init` command. Replace <project-id> with your project ID, found in manage or your sanity.config.ts file.

**npm**

```shell
npx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

**bun**

```shell
bunx sanity@latest blueprints init . --type ts --stack-name production --project-id <project-id>
```

This configures a new blueprint for your project, adds a `sanity.blueprint.ts` [config file](https://www.sanity.io/docs/blueprints/blueprint-config) to the current directory (`.`), and creates a new [stack](https://www.sanity.io/docs/blueprints/blueprints-introduction) named production.

Follow the prompt and run your package manager’s install command to add the dependencies.

**npm**

```shell
npm install
```

**pnpm**

```shell
pnpm install
```

**yarn**

```shell
yarn install
```

**bun**

```shell
bun install
```



## Create a function

Use the `sanity functions add` command to add a new function. You can also run it without any flags for interactive mode.

**npm**

```shell
npx sanity@latest functions add --name slack-post --type pub-sub --installer npm
```

**pnpm**

```shell
pnpm dlx sanity@latest functions add --name slack-post --type pub-sub --installer npm
```

**yarn**

```shell
yarn dlx sanity@latest functions add --name slack-post --type pub-sub --installer npm
```

**bun**

```shell
bunx sanity@latest functions add --name slack-post --type pub-sub --installer npm
```

> [!TIP]
> If you’re using a package manager other than npm, set the `--installer` flag to your package manager, like `pnpm` or `yarn`. Run `sanity functions add --help` for more details.

After running the command, follow the prompt and add the function declaration to your `sanity.blueprint.ts` configuration. Your file should look like this:

**sanity.blueprint.ts**

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

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

```

This is the minimal configuration for defining a function in a blueprint file. You can see all available options in the [Function section of the Blueprints configuration reference documentation](https://www.sanity.io/docs/blueprints/blueprint-config).

If you've followed the directory structure mentioned earlier, you'll see it grow to something like this:

```text
marketing-site/
├─ studio/
├─ next-app/
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/
├─ functions/
│  ├─ slack-post/
│  │  ├─ index.ts
```

After updating the `sanity.blueprint.ts` file, open `functions/slack-post/index.ts` in your editor. 

> [!TIP]
> The pubSubEventHandler function
> TypeScript functions can take advantage of the `pubSubEventHandler` helper function to provide type support. Examples in this article include both TypeScript and JavaScript function syntax.

Every function exports a `handler` from the index file.

**functions/slack-post/index.ts (TypeScript)**

```typescript
import { pubSubEventHandler } from '@sanity/functions'

export const handler = pubSubEventHandler(async ({ context, event }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
})
```

**functions/slack-post/index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
}
```

The handler receives a `context` and an `event`. The `context` contains information to help you interact with your Sanity datastore, such as `clientOptions` to configure a `@sanity/client`. 

The `event` contains information about the action that triggered the function. Most functions will use `event.data`. You can learn more in the [Function handler reference](https://www.sanity.io/docs/functions/function-wrapper).

## Test the function locally

You can test functions locally with the functions development playground. Local testing is a great way to experiment without affecting your usage quota.

To launch the development playground, run the following:

**npm**

```shell
npx sanity functions dev
```

**pnpm**

```shell
pnpm dlx sanity functions dev
```

**yarn**

```shell
yarn dlx sanity functions dev
```

**bun**

```shell
bunx sanity functions dev
```

If you run this on the starter function from earlier, you'll see the default output message in the console pane.

![A dark-themed developer console showing the Pubsub function selected and an editable JSON payload.](https://cdn.sanity.io/images/3do82whm/next/ca1d4096802a3aed95eb92be61a427de0af9f161-1541x871.png)

Update your function to log the `event` and you'll see the supplied document details the next time you run the function in the playground.

**functions/slack-post/index.ts (TypeScript)**

```typescript
import { pubSubEventHandler } from '@sanity/functions'

export const handler = pubSubEventHandler(async ({ context, event }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
  console.log('Event:', event)
})
```

**functions/slack-post/index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)
  console.log('Event:', event)
}
```

> [!TIP]
> Development playground
> In addition to the `sanity functions dev` command, there's also a more traditional CLI testing interface. 
> Run the `sanity functions test functionName` command to run the function locally. You can learn more in the [local testing guide](https://www.sanity.io/docs/functions/functions-local-testing) and the [functions CLI reference](https://www.sanity.io/docs/cli-reference/functions).

## Deploy a function

Once you're satisfied that the function works as expected, deploy it by deploying the blueprint stack.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

You can begin using your function when the deployment finishes. Learn more about [invoking PubSub Functions](https://www.sanity.io/docs/functions/function-to-function-invocation). 

If you need to change the function, update your code and re-run the deploy command to push the new changes live.

## Check the logs

When you tested the function locally, you saw the logs directly in your console. Once deployed, the function and its logs are in the cloud.

View the logs with the `functions logs` command. Replace `log-event` with your function name.

**npm**

```shell
npx sanity functions logs slack-post
```

**pnpm**

```shell
pnpm dlx sanity functions logs slack-post
```

**yarn**

```shell
yarn dlx sanity functions logs slack-post
```

**bun**

```shell
bunx sanity functions logs slack-post
```

This command outputs the function's logs. Try updating your document, publishing the change, and running the command again to see new logs.

## Destroy a deployed blueprint

Sometimes you want to remove a deployed resource so it won't run anymore or affect any future usage quotas. 

To remove a resources created with a blueprint, you need to either:

1. Remove the definition from the blueprint, and run the `deploy` command again.
2. Destroy the blueprint with the `destroy` command.

The `blueprints destroy` command removes, or undeploys*,* the blueprint and all of its resources from Sanity's infrastructure. It does not remove your local files. 

**npm**

```shell
npx sanity blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity blueprints destroy
```

**yarn**

```shell
yarn dlx sanity blueprints destroy
```

**bun**

```shell
bunx sanity blueprints destroy
```

To remove the resource from the blueprint locally, you can remove it from the `resources` array in the `sanity.blueprint.ts` file, then delete any associated files.

### Redeploying a destroyed blueprint

When you run `blueprints destroy`, it's as if you never used `blueprints init` during setup. The only difference is you still have all the files in your directory. To use this blueprint again and redeploy it, you'll need to let Sanity know about it. You can do this by running init again:

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

This launches an editing interface that lets you reconfigure the blueprint, if needed, and it reconnects the blueprint to Sanity. Now you can add more functions or redeploy. Keep in mind that any environment variables added before destroying the blueprint will not carry over.



## Next steps

Now that you’ve defined your first PubSub function, [learn how to invoke it from other functions](https://www.sanity.io/docs/functions/function-to-function-invocation).



# Manage dependencies

Once you go beyond the basics of creating your first Function, you'll likely need to add dependencies ([like the @sanity/client](https://www.sanity.io/docs/functions/functions-js-client)). Functions use a dependency management system that tries to match how you'd like to work, while still remaining efficient. 

This article explains the different approaches to using dependency packages in your functions. For guidance on file structures for the Blueprints configuration that Functions rely on in different project layouts and monorepo contexts, see [Project layout and monorepos](https://www.sanity.io/docs/blueprints/project-layout-and-monorepos).

Prerequisites:

- Complete the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart), or be comfortable writing and deploying a Sanity Function.
- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.

## Dependency placement

You have a few options for where to place a dependency, or package, as part of your function code.

### Project-level

Project-level dependencies are positioned alongside the `sanity.blueprint.ts` file and often defined at the root of the project. If you've initialized the blueprint with a TypeScript/JavaScript configuration, you already have a `package.json` at this level.

**Example structure**

```text
marketing_site/
├─ studio/
├─ next-app/
├─ functions/
│  ├─ my-function/
│  │  ├─ index.ts
├─ sanity.blueprint.ts
├─ package.json <-- Install dependencies here, alongside the blueprint configuration file
├─ node_modules/

```

This allows you to manage your function dependencies alongside any project-wide dependencies, like developer dependencies, and share them across functions.

### Function-level

Sometimes it makes sense to keep a function and all of its dependencies contained in a single directory, separate from the concerns of the greater project. 

To do this, navigate to the individual function's directory and initialize a new `package.json` with `npm init` or similar, and then add dependencies directly to the function.

**Example structure**

```text
marketing_site/
├─ studio/
├─ next-app/
├─ functions/
│  ├─ my-function/
│  │  ├─ index.ts
│  │  ├─ package.json <-- Install dependencies here, alongside the function code
│  │  ├─ node_modules/
├─ sanity.blueprint.ts
```

### Mixed dependency environment

You may have instances where some functions use the function-level system, and others use the project-level system. 

In this scenario, those using the function-level structure will use their own, co-located dependencies. Other functions will use the project-level dependency system.

Functions **will not** use or mix both sources. If you'd like a function using the function-level system to use a dependency from the project-level, you must also install it as a dependency at the function level.

**Example structure**

```text
marketing_site/
├─ studio/
├─ next-app/
├─ functions/
│  ├─ my-function/
│  │  ├─ index.ts
│  │  ├─ package.json <-- This is used for my-function
│  │  ├─ node_modules/
│  ├─ log-event/
│  │  ├─ index.ts
├─ sanity.blueprint.ts
├─ package.json <-- This is used for log-event
├─ node_modules/
```

## Moving between systems

If you have an existing function that's set up with the function-level system, you can migrate it to project-level in the following steps:

1. Add the function dependencies to the project-level package manifest. Either by using `npm install <package name(s)>` or by adding the packages to the file manually and running `npm install`.
2. Remove the `package.json` file and `node_modules` directory from the function folder.

The process is similar going in the other direction. To move from the project-level system to function-level for an individual function:

1. Navigate to the function's directory and initialize a `package.json` with `npm init` or similar.
2. Install any packages at this level, alongside your function code with `npm i <package name>`.
3. If the dependency is no longer required by other functions, remove the packages from the project-level `package.json` manually or by running `npm uninstall <package name>` in the same directory as your `sanity.blueprint.ts` file.



# Local testing

Developing functions require a different mindset than developing your studio or front-end. They react to changes in your data. To avoid making a change, deploying, changing a document, and checking the logs of a function, you can take advantage of the included local testing tools to simulate how your functions will interact with your data.

Prerequisites:

- Complete the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart), or be comfortable writing and deploying a Sanity Function.
- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Run the commands in this guide from the directory containing your blueprints configuration. If you're not already logged in, the CLI will prompt you to do so.

> [!NOTE]
> Keep in mind that testing locally will still verify that any supplied data matches your projections, filters, and any other criteria you've set in your function.

There are two approaches to running Sanity Functions locally. The `dev` command launches the development playground—a visual interface where you can run functions, edit the test data, and view the output. The `test` command offers a more traditional CLI experience where you can pass settings and test data directly as you invoke functions.

## Development playground

The Functions CLI includes an interactive playground where you can test your functions and see results prior to deploying them to Sanity's infrastructure.

Run the following command from your blueprint directory:

**NPM**

```sh
npx sanity@latest functions dev
```

**PNPM**

```sh
pnpx sanity@latest functions dev
```

This starts a local server running at `http://localhost:8080`. You can also specify a port with the `--port` flag. 

### Parts of the playground interface

![a screenshot of the functions developer playground](https://cdn.sanity.io/images/3do82whm/next/aa3a190578f91da76db32d2faca062fe705d7939-2784x1758.png)

1. Select which project, dataset, event type, and API version you want to use to test the function.
2. Select the function you wish to test.
3. Toggle **"With Token"** to supply a `token` to the function handler's `context.clientOptions` object. A token is omitted from local testing unless this is toggled.
4. Optionally, pass a valid document ID and select the **download icon** to fetch the document and populate the sample document field with real data. 
5. You can edit the document field manually, or use the download document option (4). The input expects a document-like shape. The "After document" field is only available when an *update* or *publish* event is selected.
6. Output from the function is displayed in the console. You can toggle the **"Preserve log"** option to keep logs between runs.
7. Run the function locally with the **"Run"** button.

You can stop the playground at any time by returning to your terminal and pressing `Ctrl+C`.

## The CLI `test` command

If you prefer a CLI-based testing interface, you can run the following command:

**PNPM**

```sh
pnpx sanity@latest functions test functionName
```

**NPM**

```sh
npx sanity@latest functions test functionName
```

Output from your functions will log to the console.

### Common CLI flags

The CLI includes flags to help you supply data and configuration settings to your functions. The following are some common flags in use. To see a full list, see the [functions CLI reference documentation](https://www.sanity.io/docs/cli-reference/functions) or run `npx sanity functions test --help`.

#### Supply data from a document in your dataset

Use the `--dataset`, `--project-id`, and `--document-id` flags to fetch real documents to use as source data for your function.

```sh
pnpx sanity@latest functions test log-event --document-id 52df8926-1afe-413b-bd23-e9efbc32cea3 --project-id 123456 --dataset production
```

#### Simulate event triggers

Sometimes you have logic in your function that reacts to different events, like `delta::operation`. Use the `--event` flag to simulate different event types. The default is `create`.

```sh
pnpx sanity@latest functions test log-event --event delete
```

#### Supply data from a file or string

**JSON file**

```sh
pnpx sanity functions test log-event --file sample-document.json
```

**Data string**

```sh
pnpx sanity functions test log-event --data '{ "_type": "post", "_id": "123456", "content": "test content" }'
```

#### Before/After data for delta GROQ

When paired with `--event update`, you can use `-before` and `-after` on the `--document-id`, `--data`, and `--file` flags to supply changes between two documents for delta GROQ functions.

```sh
pnpx sanity@latest functions test log-event --event update --document-id-before 52df8926-1afe-413b-bd23-e9efbc32cea3 --document-id-after 52354-1ad-654d-4565 --dataset production
```

#### Include your personal token for API calls

Local invocations don't include a `token` by default. By setting the flag, the function handler will have access to it at `context.clientOptions.token`.

```sh
pnpx sanity@latest functions test log-event --with-user-token
```

## Tips and best practices

### Use real data that matches your event

Your blueprint defines a function's event, including the filter and projection. The testing tools respect these settings and won't invoke a function if the incoming document shape wouldn't trigger it in a production setting.

### Detect local invocation

Even when you test your functions locally, your code still executes. If you're interacting with a Sanity dataset or external API from within a function, be careful not to trigger writes accidentally.

You can avoid accidental writes by checking if a function is invoked locally. Both the `test` and `dev` command set the `context.local` value to `true`. It remains undefined in deployed functions.

```
if (context.local) {
  console.log('This code only runs when testing locally.')
}

if (!context.local) {
  console.log('This code only runs in the deployed function.')
}
```

You can also pair this with the `dryRun` functionality in many Sanity libraries and APIs. For example:

**Patch**

```
// will set dryRun to true in test / dev
client
  .patch('bike-123')
  .set({inStock: false})
  .inc({numSold: 1})
  .commit({ dryRun: context.local }) 
```

**Actions**

```
// will set dryRun to true in test
client.action({
    actionType: 'sanity.action.document.create',
    publishedId: 'bike-123',
    attributes: {name: 'Sanity Tandem Extraordinaire', _type: 'bike', seats: 1},
    ifExists: 'fail',
  },
  {dryRun: context.local}
)
```

**Agent Actions**

```
client.agent.action.generate({
  schemaId: 'your-schema-id',
  documentId: 'your-document-id',
  instruction: 'Write a summary for the following topic: $topic',
  instructionParams: {
    topic: 'Grapefruit',
  },
  target: {path: ['body']},
  noWrite: context.local
})
```



# Function to function invocation

Sanity Functions can now invoke other Sanity Functions directly from within your code. Combined with Runtime resource discovery, this lets you chain, fan out, and compose function logic without routing everything back through document change events.

Previously, if one function's work needed to trigger another function, the only way was through document change events: a function would modify a document, that mutation would raise a new change event, and Sanity would invoke the next function in response. This worked, but it meant every step in a chain had to be modeled as a document mutation, even when no document change was actually the point.

Sanity Functions now support invoking a function directly from your code, and exposes the other resources in your Blueprint (functions, CORS origins, datasets, etc.) so you can reference them by name at runtime.

Prerequisites:

- Complete the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart), or be comfortable creating and deploying a function.
- Use the latest version of the `sanity` CLI (`sanity@latest`) to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.

## Chaining function invocations

**Without invoke:** chaining through document events

1. Modifying a document raises a document change event.
2. Sanity invokes your code.
3. Your code modifies the document.
4. Another document change event is raised.
5. Sanity invokes the next function.
6. Repeat for each step in the chain.

Every link in the chain depended on a document mutation to trigger the next one, even for steps that had nothing to do with the document itself, such as posting a Slack message or calling an external API.

**With invoke:** chaining through invoke

1. Modifying a document raises a document change event.
2. Sanity invokes your code.
3. Your code invokes a [Sanity PubSub Function](https://www.sanity.io/docs/functions/pubsub-function-quickstart) directly.

The intermediate document mutation is no longer required. A function can call the next step in the pipeline as a normal function call.

## Why use invoke?

**Function composition.** Break logic into small, focused functions and call them from one another, instead of duplicating logic across functions.

**Fan-out processing.** A single function can invoke many others in parallel, distributing heavy workloads across multiple invocations rather than processing everything serially in one function.

**Privilege separation.** A broadly-permissioned function can hand off sensitive operations to a narrowly-scoped function, rather than holding every permission itself.

**Resource isolation.** Different workloads can run with their own memory and timeout configurations, invoked dynamically from a coordinating function instead of being forced to share one configuration.

## How to invoke a function

### Step 1: Create a PubSub function

Create a pubsub function, a Sanity Function you can trigger from other functions.

**functions/slack-post/index.ts**

```typescript
import { pubSubEventHandler } from '@sanity/functions'

export const handler = pubSubEventHandler(
  async ({ context, event }) => {
    const time = new Date().toLocaleTimeString()
    console.log(`Your pubsub Sanity Function was called at ${time}`)
  }
)
```

### Step 2: Add it to your Blueprint

Add this new function to your Blueprint.

**sanity.blueprint.ts**

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

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

```

### Step 3: Import `invoke`

Import `invoke` from the standard functions library in any function that you want to `invoke` other functions.

**functions/calling-slack-post/index.ts**

```typescript
import { invoke } from '@sanity/functions'
```

### Step 4: Call `invoke`

Call `invoke` with the name of the target function, passing along your `context` and the event payload you want it to receive.

**functions/calliing-slack-post/index.ts**

```typescript
await invoke('slack-post', {
  context,
  event: { data: message }
})
```

The target function (`slack-post` in this example) runs as its own function invocation, receiving whatever `context` and `event` you pass it - it doesn't need to know it was invoked by another function rather than by a document event.

## Understanding invoke's execution modes

`invoke` takes an optional third parameter, `{ sync: boolean }`. If you omit it, `sync` defaults to `false`. This is the existing async behavior, unchanged. If you pass `{ sync: true }`, `invoke` waits for the target function to finish and gives you back its response.

### Async (default): fire-and-forget

`invoke` resolves as soon as the target function's invocation request is *accepted.* It does not wait for that function to finish running. If the request itself is rejected (bad name, bad payload, etc.), `invoke` throws. A resolved async `invoke` call tells you "the function was triggered," not "the function is done."

#### ❌ Wrong: assuming async invoke waits for a result

**index.ts**

```typescript
import { invoke } from '@sanity/functions'

export default async function handler(context, event) {
  // WRONG - async invoke does not return the invoked function's 
  // output.
  // This will be undefined even though 'slack-post' ran successfully.
  const result = await invoke('slack-post', {
    context,
    event: { data: event.data }
  })

  if (result.ok) {
    // Never behaves as expected - `result` isn't the invoked
    // function's return value in async mode.
    console.log('Message posted:', result.ok)
  }
}
```

Or…

**index.ts**

```typescript
// WRONG - chaining logic that depends on the invoked function
// having already completed its work.
await invoke('resize-image', { context, event })

// Runs immediately after invoke() resolves, not after
// resize-image actually finishes resizing anything.
const resized = await client.fetch
   (`*[_id == $id][0].resizedUrl`, { id: event.data._id })
```

#### ✅ Correct: treat async invoke as fire-and-forget

**index.ts**

```typescript
import { invoke } from '@sanity/functions'

export default async function handler(context, event) {
  try {
    // sync defaults to false - invoke resolves once the call
    // is accepted, that's all we can rely on here.
    await invoke('slack-post', {
      context,
      event: { data: event.data }
    })
  } catch (err) {
    // Only errors *accepting* the invocation land here
    // (bad function name, malformed payload, etc.)
    console.error('Failed to trigger slack-post:', err)
  }
}
```

Or…

**index.ts**

```typescript
// ✅ Correct: fan-out, where each invoked function is
// independent and doesn't need to report back synchronously.
await Promise.all(
  batches.map((batch) =>
    invoke('process-batch', { context, event: { data: batch } })
  )
)
// All we know here: every batch was accepted for processing,
// not that processing is complete.
```

### Sync: waiting for a response

Passing `{ sync: true }` as the third argument tells `invoke` to wait for the target function to finish executing, and to return its response.

**index.ts**

```typescript
const response = await invoke(
  'slack-post',
  { context, event: { data: event.data } },
  { sync: true }
)
```

Use this only when your function's next step genuinely depends on the invoked function's output or on it having definitely finished. For example, a validation function that needs a yes/no answer before deciding whether to proceed. Sync invocation should be the exception, not the default. It ties up your caller's execution (and its timeout/memory budget) for as long as the callee takes to run, and it forces steps to execute one at a time instead of in parallel. This is the opposite of what fan-out and privilege separation are meant to achieve. If you find yourself reaching for `{ sync: true }` in most of your `invoke` calls, that's usually a sign the logic belongs in one function rather than two.

#### ❌ Wrong: sync invocation as the default habit

**index.ts**

```typescript
// WRONG - no actual dependency on the result; this should
// just be a normal async invoke.
const response = await invoke(
  'slack-post',
  { context, event: { data: event.data } },
  { sync: true }
)

// response is never used - we paid for a synchronous wait
// for nothing.
```

Or…

**index.ts**

```typescript
// WRONG - using sync to fan out, defeating the purpose of
// parallel, independent invocations.
for (const batch of batches) {
  await invoke('process-batch', { 
      context,
      event: { data: batch } 
    },
    { sync: true }
  )
}
// This serializes what should be parallel work, and blocks
// this function until every batch finishes one at a time.
```

#### ✅ Correct: sync invocation for a genuine dependency

**index.ts**

```typescript
export default async function handler(context, event) {
  // We can't proceed without knowing whether this content
  // passes validation - that can only be answered synchronously.
  const response = await invoke(
    'validate-content',
    { context, event: { data: event.data } },
    { sync: true }
  )

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

  // Only now, with a real answer in hand, do we continue.
  await invoke('publish-content', { 
    context, 
    event: { data: event.data } }
  )
}

```

## Deciding which mode to use

##### Choosing between Async and Synchronous invocation

|  | **Async (default / sync: false)** | **Sync (sync: true)** |
| --- | --- | --- |
| Waits for completion? | No - only for acceptance | Yes |
| Returns callee's response? | No | Yes |
| Best for | Fan-out, chaining steps, privilege separation | Steps that genuinely can't proceed without the callee's result |
| Use liberally? | Yes, this is the default pattern | No - reserve for cases async can't solve |

## Example: fan out

In this example our function receives a document mutation event when a blog post goes from draft to published. The act of publishing means we want to blast out this new blog post to all of our socials.

At a high level, the pipeline looks like:

1. Our document change function is invoked when the post becomes published.
2. The function invokes many functions in parallel to post to social media sites.

**index.ts**

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

export const handler = documentEventHandler(async ({ context, event }) => {
    const time = new Date().toLocaleTimeString()
    console.log(`👋 A new blog post has been published at ${time}`)

    await Promise.all([
        invoke('post-to-bluesky', { context, event }),
        invoke('post-to-linkedin', { context, event }),
        invoke('post-to-mastodon', { context, event }),
        invoke('post-to-threads', { context, event }),
        invoke('post-to-x', { context, event }),
    ])
})

```

This mirrors the fan-out pattern above: the document change logic and the social-posting logic stay in separate, independently-configured functions, connected by `invoke` instead of a document mutation.



# Configure @sanity/client

Functions have the ability to connect back to Sanity and manipulate not only the incoming document, but any document in your dataset. By including details about your project in the context, Functions enable you to configure a `@sanity/client` instance and interact with any Sanity API.

In this guide, you'll learn to install and configure `@sanity/client` in a Sanity Function. Then you'll use it to make interact with your project dataset.

Prerequisites:

- Complete the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart), or be comfortable writing and deploying a Sanity Function. This guide assumes you have a blueprint defined with a function you want to add the client to.
- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.

## Add `@sanity/client` to your function



To install the Sanity client, add it to your project at the blueprints root (where your `sanity.blueprint.ts` file is). 

**NPM**

```sh
npm install @sanity/client
```

**PNPM**

```sh
pnpm add @sanity/client
```

> [!TIP]
> Function dependencies
> Functions can be self-contained, managing their own dependencies, or share dependencies with other functions in the Blueprint. This guide adds dependencies at the Blueprint level, where all functions can import them. 
> If you prefer, you can keep each function self-contained by adding dependencies directly to individual function folders. To do so, navigate to the function folder before installing the package. In this case, the function will only use it's local dependencies.
> Learn more in the guide on [managing function dependencies](https://www.sanity.io/docs/functions/function-dependencies).

Once the install completes, open the function's `index.ts` file and update the starter code. 

- Import `createClient` .
- Use `context.clientOptions` to configure the client.
- Make and log a request to the API to test the client.

**index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = documentEventHandler(async ({ context, event }) => {
  const client = createClient({
    ...context.clientOptions,
    apiVersion: '2025-05-08',
  })
  const posts = await client.fetch('*[_type == "post"]')
  console.log(posts)
})
```

**index.js (JavaScript**

```javascript
import { createClient } from '@sanity/client'
export async function handler({context, event}) {
  const client = createClient({
    ...context.clientOptions,
    apiVersion: '2025-05-08',
  })
  const posts = await client.fetch('*[_type == "post"]')
  console.log(posts)
}
```

The context includes a `clientOptions` object with details about your project, dataset, and a robot token. Use `clientOptions` along with any preferred settings to create a new client. Aside from the values included with `clientOptions`, you must also set your preferred `apiVersion`. You should also set the `useCdn` option, along with any other client configuration settings you need for your specific request.

When developing locally with the `test` command, `clientOptions` only contains the `projectId` and `apiHost` values. You can pass additional values to `clientOptions` by using flags when running the test. The `--dataset` command lets you define the dataset, and `--with-user-token` will pass your user token. For example:

```sh
pnpx sanity functions test event-log --dataset production --with-user-token
```

> [!TIP]
> Obfuscated tokens
> Logs will obfuscate `clientOptions.token` by replacing parts of the token with asterisks. This is limited to logging, and you can safely use the token to make API calls.

You can read more about clientOptions and all available properties in the [handler reference](https://www.sanity.io/docs/functions/function-wrapper).

Additional client-specific configuration options, and usage guides for the JavaScript client are available in the [@sanity/client getting started guide](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started).

## Preventing writes during testing

The `sanity functions test` and `dev` commands simulate a function invocation, but they still execute your code. This can lead to mutations. To prevent this, you can take advantage of `context.local`.

You can wrap mutations in a conditional check to limit their execution to production only.

```
// Check if function context is NOT test/dev
if (!context.local) {
  await client.createOrReplace(doc)
}
```

You can also use `context.local` with the `dryRun` or `noWrite` options for various mutations and actions.

**Patch**

```
// will set dryRun to true in test / dev
client
  .patch('bike-123')
  .set({inStock: false})
  .inc({numSold: 1})
  .commit({ dryRun: context.local }) 
```

**Actions**

```
// will set dryRun to true in test
client.action({
    actionType: 'sanity.action.document.create',
    publishedId: 'bike-123',
    attributes: {name: 'Sanity Tandem Extraordinaire', _type: 'bike', seats: 1},
    ifExists: 'fail',
  },
  {dryRun: context.local}
)
```

**Agent Actions**

```
client.agent.action.generate({
  schemaId: 'your-schema-id',
  documentId: 'your-document-id',
  instruction: 'Write a summary for the following topic: $topic',
  instructionParams: {
    topic: 'Grapefruit',
  },
  target: {path: ['body']},
  noWrite: context.local
})
```

## Use the client with Media Library

Accessing Media Library (ML) with the client from functions require some additional information. 

> [!NOTE]
> ML support in functions requires `@sanity/blueprints` v0.4.0 or later and `@sanity/functions` v1.1.0 or later. ML support in `@sanity/client` requires v7.14.1 or later.

### Media Library Functions

If your function is a Media Library function, one triggered by events in the Media Library, you can get the Media Library identifier from the `context`.

The `context.eventResourceId` will be your Media Library's identifier. Keep in mind that in non-media-library functions, this identifier will be that of the triggering resource, like a dataset.

**index.ts**

```
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = documentEventHandler(async ({ context, event }) => {
  // This will be the Media Library ID of the triggering library
  const { eventResourceId } = context

  // Configure the client to make requests to Media Library
  const client = createClient({
    ...context.clientOptions,
    apiVersion: '2025-05-08',
    resource: {
      type: 'media-library',
      id: eventResourceId
    }
  })
  
  const QUERY = `*[_type == 'sanity.imageAsset']`
  
  const response = await client.fetch(QUERY)
})
```

### Document Functions

If your function is a document function, one not triggered by a Media Library event, you'll need to pass the ML identifier in another way. Either by hard-coding it in the function, or by setting it as an [environment variable](https://www.sanity.io/docs/functions/function-env-vars).

## Use the client in a scheduled function

If you plan to interact with a Sanity project’s dataset from your scheduled function, you can [install the Sanity client](https://www.sanity.io/docs/functions/functions-js-client) and configure it. As scheduled functions are organization-scoped, they don’t have a project and dataset in their `context`. You need to explicitly [define a robot token](https://www.sanity.io/docs/functions/robot-tokens-with-functions) and set the projectId and dataset when configuring the client.

Create the robot token in your blueprint and reference it from the scheduled function definer:

**sanity.blueprint.ts**

```
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 * * *'},
      robotToken: '$.resources.my-robot.token',
    }),
  ],
})

```

Configure the client:

**index.ts**

```
import { scheduledEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = scheduledEventHandler(async ({ context }) => {
  const time = new Date().toLocaleTimeString()
  console.log(`👋 Your Sanity Function was called at ${time}`)

  const client = createClient({
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'YOUR_DATASET',
    apiVersion: '2026-04-29',
    token: context.clientOptions?.token,
  })
})
```



## Tips for using the client

- The `sanity functions test` command won't include a token or dataset by default. To include a token in `context.clientOptions`, run the command with the `--with-user-token` flag. To set a dataset, use the `--dataset my-dataset-name` flag, replacing the value with your project's dataset.
- Be cautious mutating the incoming document—the one that triggered the function—in a way that will trigger it again. The client limits recursive chains to 16 invocations.
- Don't re-fetch the `event` document unless you need. Use the incoming payload to save a request.
- The Sanity client isn't required to make requests to Sanity. If you're focused on the fastest, lightest function possible, you can build API calls manually with Node's `fetch` and the token, projectId, and dataset from `context.clientOptions`.



# Add environment variables

Environment variables let you keep secrets, like tokens or API keys, hidden and out of version control. Sanity Functions lets you manage environment variables from the CLI so they're available to your deployed functions.

In this guide, you'll learn to add environment variables and access them from within your function code.

Prerequisites:

- Complete the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart), or be comfortable creating and deploying a function.
- Use the latest version of the `sanity` CLI (`sanity@latest`) to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.

## Create a function

If you don't already have a blueprint and function set up, create them now.

Initialize a blueprint:

**npm**

```shell
npx sanity@latest blueprints init
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints init
```

**yarn**

```shell
yarn dlx sanity@latest blueprints init
```

**bun**

```shell
bunx sanity@latest blueprints init
```

Add a function:

**npm**

```shell
npx sanity@latest functions add
```

**pnpm**

```shell
pnpm dlx sanity@latest functions add
```

**yarn**

```shell
yarn dlx sanity@latest functions add
```

**bun**

```shell
bunx sanity@latest functions add
```

In this example, set the function to trigger on **Document Create** and **Document Update**, use **TypeScript**, and set the name to `envExample`.

**Output**

```text
✔ Enter function name: envExample
✔ Choose events to trigger your function: Document Create, Document Update
✔ Choose function language: TypeScript
✔ Add @sanity/functions helpers to the new Function? yes
✔ How to install the @sanity/functions helpers: npm
```

This creates a function in the `functions/envExample` directory.

## Develop locally

Variables added with `functions env add` aren't available locally, but you can simulate them by prefixing your CLI command with the variable and value.

Start by updating the function to display the variable. This example uses a variable called `SANITY_SECRET_SAUCE`.

**index.ts (TypeScript)**

```typescript
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  console.log(`The secret: ${process.env.SANITY_SECRET_SAUCE}`)
})
```

**index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  console.log(`The secret: ${process.env.SANITY_SECRET_SAUCE}`)
}
```

All environment variables are accessible on `process.env`.

To test the function's access to a variable, prefix the CLI command with it.

**CLI**

```sh
SANITY_SECRET_SAUCE="content operating system" npx sanity@latest functions test envExample
```

If everything worked, you'll see this output:

**Output**

```text
Logs:
The secret: content operating system
```

Now that it works locally, make the same variable available to your deployed function.

## Add an environment variable

Before you can add environment variables, you need to deploy the blueprint.

**npm**

```shell
npx sanity@latest blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints deploy
```

**yarn**

```shell
yarn dlx sanity@latest blueprints deploy
```

**bun**

```shell
bunx sanity@latest blueprints deploy
```

With the blueprint deployed, you can add environment variables to the function.

Add them with the `sanity functions env add FUNCTION_NAME VARIABLE_NAME VARIABLE_VALUE` command.

**npm**

```shell
npx sanity@latest functions env add envExample SANITY_SECRET_SAUCE "content operating system"
```

**pnpm**

```shell
pnpm dlx sanity@latest functions env add envExample SANITY_SECRET_SAUCE "content operating system"
```

**yarn**

```shell
yarn dlx sanity@latest functions env add envExample SANITY_SECRET_SAUCE "content operating system"
```

**bun**

```shell
bunx sanity@latest functions env add envExample SANITY_SECRET_SAUCE "content operating system"
```

Create or update a document to trigger the function, then check the function's logs. The output matches the one from the local test.

**npm**

```shell
npx sanity@latest functions logs envExample
```

**pnpm**

```shell
pnpm dlx sanity@latest functions logs envExample
```

**yarn**

```shell
yarn dlx sanity@latest functions logs envExample
```

**bun**

```shell
bunx sanity@latest functions logs envExample
```

You've now deployed and accessed an environment variable from a function.

When you're done with this function and blueprint, `destroy` the blueprint to prevent unexpected billing:

**npm**

```shell
npx sanity@latest blueprints destroy
```

**pnpm**

```shell
pnpm dlx sanity@latest blueprints destroy
```

**yarn**

```shell
yarn dlx sanity@latest blueprints destroy
```

**bun**

```shell
bunx sanity@latest blueprints destroy
```

## List and remove environment variables

Environment variables are linked to individual functions. In addition to `add`, you can use the following commands to interact with them:

- `sanity functions env list FUNCTION_NAME`: List the environment variable keys set on the given function. Values are never displayed.
- `sanity functions env remove FUNCTION_NAME VARIABLE_NAME`: Remove the variable from the deployed function.

For additional usage information, add `--help` after each CLI command. You can read more about the CLI in the [Functions CLI reference](https://www.sanity.io/docs/cli-reference/functions).

## Troubleshooting

### Adding a variable before deploying

`sanity functions env add` writes to the Sanity’s Functions service, not to your local blueprint. The function has to exist in a deployed stack first, so running it before `sanity blueprints deploy` fails. The command doesn't warn and continue; it stops. `env list` and `env remove` behave the same way.

Which message you get depends on how far the CLI gets:

- `Missing Stack: provide --stack, or set a Stack in your Blueprint config (`sanity blueprints config --edit`).`: No stack is configured yet.
- `Missing Stack deployment`: A stack ID is set, but no deployment exists to load.
- `Unable to find deployed function: "FUNCTION_NAME"`: The stack is deployed, and the function is declared locally but isn't in the deployment yet.
- `Unable to find function: "FUNCTION_NAME"`: The name doesn't match any function in the stack.

None of these messages tells you to deploy. They point you at `sanity blueprints doctor`, which reports the configuration problem but not the ordering mistake behind it. Run `sanity blueprints deploy` first, then add the variable. Adding a variable doesn't redeploy the stack, and the new value takes effect on the next function invocation.

### Running the command outside a blueprint directory

Run the command from the blueprint directory or a subdirectory. The manifest is required even though the command only touches remote state. Without it you get `Could not find a Blueprint manifest (sanity.blueprint.ts, .js, or .json).` If the API rejects the change after the function resolves, the CLI reports `Failed to update VARIABLE_NAME` with the reason from the Functions API.



# Assign custom robot tokens

Robot tokens enable your functions to authenticate API calls to Sanity without managing credentials manually. You can choose to create a token yourself as shown in this guide, or one will be created when you deploy your blueprint.

Prerequisites:

- Functions run on Node.js v24.x.
- The latest version of the Sanity CLI is recommended. Run commands with `npx sanity@latest`.
- A Sanity project ID where you have permission to create robots and deploy functions.

## Robot tokens

Robots are service accounts that provide authentication tokens for automated access. When you define a token in a blueprint:

1. The token is created during deployment with the specified roles.
2. A token is generated and managed by Sanity.
3. Functions reference the token like so: `$.resources.<robot-name>.token`.
4. The token is injected into your function to be used at function runtime.

These are similar to [robot tokens defined in your project or organization settings](https://www.sanity.io/docs/content-lake/http-auth), but they are [managed by the blueprint](https://www.sanity.io/docs/blueprints/blueprints-robot-tokens) instead.

## Define a robot token

Use the `defineRobotToken` helper to define a token in your blueprint configuration.

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineRobotToken({
      name: 'my-robot',
      label: 'My Robot',
      memberships: [
        {
          resourceType: 'project',
          resourceId: 'abc123',
          roleNames: ['editor'],
        },
      ],
    })
  ]
})
```

You can find a list of available roles for your project with the [Access API](https://www.sanity.io/docs/http-reference/access-api#getroles), or by viewing the roles in manage.

A complete list of configuration options is available in [the reference documentation](https://reference.sanity.io/_sanity/blueprints/defineRobotToken/).

### Using the token in Functions

Reference the robot token in your Function definition:

**sanity.blueprint.ts**

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

export default defineBlueprint({
  resources: [
    defineRobotToken({
      name: 'my-robot',
      label: 'My Robot',
      memberships: [
        {
          resourceType: 'project',
          resourceId: 'abc123',
          roleNames: ['editor'],
        },
      ],
    }),
    defineDocumentFunction({
      name: 'my-function',
      robotToken: '$.resources.my-robot.token',
      // ... rest of config
    }),
  ]
})
```

When configured like this, your function receives the token as part of the `context.clientOptions` and can be used to [configure a Sanity client](https://www.sanity.io/docs/functions/functions-js-client).

## Define custom roles

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You can [define custom roles in the blueprint](https://www.sanity.io/docs/blueprints/blueprints-role), then use them to define a robot token. This example defines a role, `function-user`, then defines a robot with as a member of that role, and finally assigns that robot token to the function.

**sanity.blueprint.ts**

```
import {defineBlueprint, defineRobotToken, defineRole, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineRole({
      name: 'function-user',
      title: 'Function User',
      permissions: [
        {
          name: 'read-documents',
          action: 'read',
          filter: '_type == "post"',
        },
        {
          name: 'write-documents',
          action: 'update',
          filter: '_type == "post"',
        },
      ],
    }),
    
    defineRobotToken({
      name: 'my-robot',
      memberships: [
        {
          resourceType: 'project',
          resourceId: 'abc123',
          roleNames: ['function-user'],
        },
      ],
    }),
    defineDocumentFunction({
      name: 'my-function',
      robotToken: '$.resources.my-robot.token',
      // ... rest of config
    }),
  ]
})
```

## Best practices

### Apply least privilege

Create custom roles with only the permissions your function needs, as shown in the role example above. 

### Keep credentials out of source control

The actual token value is managed by Blueprints and never stored in your repository.

### Match Node.js versions

Use Node.js v24.x locally to match the Functions runtime and avoid unexpected behavior.

## Troubleshooting

### Validation error: "robotToken must be a string"

Ensure the value is exactly `$.resources.<robot-name>.token` (a string, not an object or function call).

### Permission denied at runtime

The robot's roles don't permit the operation. Review `memberships.roleNames` and ensure the assigned roles grant the necessary permissions.



# Common patterns

Functions create the ability for countless content-driven opportunities. This guide collects common patterns and approaches to working with Functions.

Prerequisites:

- Complete the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart), or be comfortable writing and deploying a Sanity Function.
- The latest version of `sanity` CLI (`sanity@latest`) is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.

The examples below assume you've created a new function, and configured it to trigger based on your own schema requirements.

## Explore the exchange

Looking for more ideas and ready-made functions? Check out the a curated list in the exchange.

#### Explore more functions

[Auto-tag blog posts](https://www.sanity.io/recipes/auto-tag-function-ba7ce6e2)
AI-powered automatic tagging for Sanity blog posts that analyzes content to generate 3 relevant tags, maintaining consistency by reusing existing tags from your content library.

[Algolia Sync](https://www.sanity.io/recipes/algolia-sync-function-cae9ec0f)
Automatically update your Algolia index

[Post to Bluesky](https://www.sanity.io/recipes/post-to-bluesky-fd18322f)
Notify your audience when you publish a new document.

[Explore more recipes](https://www.sanity.io/recipes)
See more official and community functions on the Exchange.

## Ping an endpoint on publish

A common approach to invalidating CDNs and triggering new builds is to ping, or make a GET request, to an endpoint. Some require you to provide specifics, such as the endpoint or slug for targeted refreshes. Others only require a single URL.

Create a function and configure it to trigger when your target document publishes. For the example, make sure to [define an environment variable](https://www.sanity.io/docs/functions/function-env-vars) named `DEPLOY_HOOK_URL`.

**index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'

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")
  }
  try {
    await fetch(URL)
  } catch (error) {
    console.error(error)
  }
})
```

**index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  const URL = process.env.DEPLOY_HOOK_URL
  if (!URL) {
    throw new Error("DEPLOY_HOOK_URL is not set")
  }
  try {
    await fetch(URL)
  } catch (error) {
    console.error(error)
  }
}
```

To find the deploy or trigger URL for your provider, check their documentation. We've included a few common links below:

- [Vercel: Create and trigger deploy hooks](https://vercel.com/docs/deploy-hooks)
- [Azure: purge content](https://learn.microsoft.com/en-us/rest/api/cdn/endpoints/purge-content?view=rest-cdn-2025-04-15&tabs=HTTP)
- [Cloudflare: purge cache](https://developers.cloudflare.com/api/resources/cache/methods/purge/)

## Automatically translate documents

You can combine Agent Actions Translate with Functions to translate documents automatically.

We recommend completing [the quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart) if you haven't used Translate before.

First, create a function and configure it to only trigger when a document's language is in your "from" language. Here's an example of the function resource in `sanity.blueprint.ts`.

**sanity.blueprint.ts**

```typescript
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: "translate",
      event: {
        on: ["publish"],
        filter: "_type == 'post' && language == 'en-US'",
        projection: "{_id}"
      }
    }),
  ],
})
```

> [!TIP]
> Use caution when creating documents
> The GROQ filter in this example is important. It makes sure that the function only runs when the language is set to English. When we generate a new translation in the next code block, Translate sets that field to Greek. This stops the new document from triggering the same function and creating a recursive loop.
> You could also create draft or version documents to prevent the "on publish" function from triggering.

For this approach, we have documents with a `language` set. We only want the English language files.

1. [Import and configure the @sanity/client](https://www.sanity.io/docs/functions/functions-js-client).
2. Capture the document `data` from the `event`.
3. Construct a `translate` request.

**index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = documentEventHandler(async ({ context, event }) => {
  const { data } = event
  const client = createClient({
    ...context.clientOptions,
    apiVersion: 'vX',
  })
  const targetLanguage = {
    id: "el-GR",
    title: "Greek"
  }
  // Create a consistent ID based on the source and target language.
  // This allows the function to override the document in the future
  const targetId = `${data._id}-${targetLanguage.id}`

  try {

    await client.agent.action.translate({
      // Replace with your schema ID
      schemaId: "your-schema-id",
      
      // Tell the client to run the action asynchronously.
      // We don't need to wait for it to complete.
      async: true,
      
      // Tell the client the ID of the document to use as the source.
      documentId: data._id,

      // Set the language field to the target language.
      languageFieldPath: "language",
      
      // Set the operation mode
      // createOrReplace will override the ID in future invocations.
      targetDocument: { 
        operation: "createOrReplace",
        _id: targetId
      },
      
      // Set the 'from' and 'to' language
      fromLanguage: {id: "en-US", title: "English"},
      toLanguage: {id: targetLanguage.id, title: targetLanguage.title},
    });
  } catch (error) {
    console.error(error)
  }
})
```

**index.js (JavaScript)**

```javascript
import { createClient } from '@sanity/client'

export async function handler({context, event}) {
  const { data } = event
  const client = createClient({
    ...context.clientOptions,
    apiVersion: 'vX',
  })
  const targetLanguage = {
    id: "el-GR",
    title: "Greek"
  }
  // Create a consistent ID based on the source and target language.
  // This allows the function to override the document in the future
  const targetId = `${data._id}-${targetLanguage.id}`

  try {

    await client.agent.action.translate({
      // Replace with your schema ID
      schemaId: "your-schema-id",
      
      // Tell the client to run the action asynchronously.
      // We don't need to wait for it to complete.
      async: true,
      
      // Tell the client the ID of the document to use as the source.
      documentId: data._id,

      // Set the language field to the target language.
      languageFieldPath: "language",
      
      // Set the operation mode
      // createOrReplace will override the ID in future invocations.
      targetDocument: { 
        operation: "createOrReplace",
        _id: targetId
      },
      
      // Set the 'from' and 'to' language
      fromLanguage: {id: "en-US", title: "English"},
      toLanguage: {id: targetLanguage.id, title: targetLanguage.title},
    });
  } catch (error) {
    console.error(error)
  }
}
```

Now, when you publish an English-language document, it will create a Greek version. [Learn more about Agent Actions here](https://www.sanity.io/docs/agent-actions/introduction).

## Set an undefined value with `setIfMissing`

You may have values in documents that are sometimes set by people, but otherwise could be derived programatically. This example uses GROQ's `!defined` function and a `setIfMissing` patch to add a the current date and time as the published date to a document, but only when it hasn't been set. 

For this example, you'll need to:

1. Set up a new function or edit an existing one.
2. Import and configure the `@sanity/client` if you haven't already.

First, modify the following filter and add it to your function's `event` in the `sanity.blueprint.ts` configuration.

```text
"filter": "_type == 'post' && !defined(firstPublished)"
```

Adjust the `_type` and `firstPublished` values to match properties from your schema.  `!defined` checks that the property is not set, which prevents the function from running if the document receives future updates.

Next, create a `setIfMissing` patch to set the same field from the filter. `setIfMissing` is  redundant here, as it *should* be empty if `!defined` worked as intended. It's still a useful to approach when you only want to update empty fields.

**index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'

export const handler = documentEventHandler(async ({ context, event }) => {
  const { data } = event
  const client = createClient({
    ...context.clientOptions,
    apiVersion: "2026-02-27"
  })
  
  try {
    await client.patch(data._id, {
      setIfMissing: {
        firstPublished: new Date().toISOString()
      }
    }).commit()
  } catch (error) {
    console.error(error)
  }
})
```

**index.js (JavaScript)**

```javascript
import { createClient } from '@sanity/client'

export async function handler({context, event}) {
  const { data } = event
  const client = createClient({
    ...context.clientOptions,
    apiVersion: "2026-02-27"
  })
  
  try {
    await client.patch(data._id, {
      setIfMissing: {
        firstPublished: new Date().toISOString()
      }
    }).commit()
  } catch (error) {
    console.error(error)
  }
}
```

## Scope Functions to a specific dataset

By default, your functions run against all datasets for the project they've been configured with. You can define a [resource](https://www.sanity.io/docs/blueprints/blueprint-config) in your functions config to cause only changes in a specific dataset to trigger functions.

**sanity.blueprint.ts**

```
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: "log-event",
      event: {
        on: ["update"],
        filter: "_type == 'post'",
        resource: {
          type: 'dataset',
          id: 'myProjectId.production'
        },
      },
    })
  ]
})
```

Alternatively, you can also narrow dataset scope with filters and the `sanity::dataset()` function.

**sanity.blueprint.ts**

```
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: "log-event",
      event: {
        on: ["update"],
        filter: "_type == 'post' && sanity::dataset() == 'production'",
      },
    })
  ]
})
```

## Use Functions with Media Library assets

To configure a function to react to changes in assets in Media Library, use the `defineMediaLibraryAssetFunction` helper and configure the [resource object](https://www.sanity.io/docs/blueprints/blueprint-config).

You can try things like: 

- Comparing changes in an asset's aspect data.
- Kicking off a review flow when new versions are added to an asset.
- Update references when assets are deleted.

> [!WARNING]
> If you're using the TS/JS configuration format, you'll need to update `@sanity/blueprints` to v0.4.0 or later to access `defineMediaLibaryAssetFunction`.

Asset functions only support the `sanity.asset` document `_type`. You can apply additional filters, but it will only run on documents of this type.

For example, this function will run whenever someone deletes an asset that's referenced by another document in your organization.

**sanity.blueprint.ts**

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

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

If you need to query or mutate ML documents from your function, make sure to [configure the @sanity/client](https://www.sanity.io/docs/functions/functions-js-client) for use with Media Library.

Learn more about working with Media Library documents in the [Media Library documentation](https://www.sanity.io/docs/media-library/introduction).

## Enable recursion control in unofficial clients

Sanity client (`@sanity/client` v7.12.0 or later) includes recursion protection by reading and setting a lineage header. If you're mutating documents from a function and not using the client, you can implement this functionality yourself.

1. Read the `process.env.X_SANITY_LINEAGE` environment variable.
2. Pass the value to the `X-Sanity-Lineage` header of any requests that mutate a document.

This allows Sanity's infrastructure to limit function invocation chains just as it does for the official client.



# Handler reference

[Overview](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

[Quick start](https://www.sanity.io/docs/functions/function-quickstart)
Start building with Functions by deploying a new function to Sanity's infrastructure.

Every Function must export a `handler`. Handlers contain the logic that the Function infrastructure runs when your document changes trigger the function.

Create a function handler with the `sanity blueprints add function` command. Every handler receives an object containing `context` and `event` parameters. The function does not require a return value.

## `context` properties

#### Properties

**clientOptions** (object)

Provides properties for configuring the Sanity client (@sanity/client). Most commonly used to pass details about the invoking project dataset to a client configuration. See the configuring @sanity/client in Functions guide for details.

**local** (boolean)

The context.local value is set to true for functions invoked with sanity functions test and sanity functions dev. This can be helpful when you want code to only execute in local environments.

It is undefined for functions in production.

**eventResourceType** (string)

The resource type that triggered the function. For Document functions, this would be dataset. For Media Library functions, this would be media-library.

**eventResourceId** (string)

The resource ID that triggered the function. For Document functions, this would be the ID of a dataset in the form <project-id>.<dataset-name>. For Media Library functions, this would be the Media Library id.

### `clientOptions` properties

#### Properties

**projectId** (string)

The ID of the project that triggered this function.

**dataset** (string)

The dataset name of the project that triggered this function. 

The sanity functions test command won't include a dataset by default. Run with the --dataset flag to pass a dataset to clientOptions. For example: sanity functions test log-event --dataset production

**apiHost** (string)

Defaults to https://api.sanity.io.

**token** (string)

A token with access to your Sanity project. It is recommended to define a Robot Token Blueprint resource yourself with permissions with explicit permissions and assign the token to your Function resource. For sanity.function.document Functions, this token is automatically generated with the editor role and added to your project when deploying the blueprint. For other function types, you must explicitly define a Robot Token resource. See Using robot tokens with Functions for more details.

The sanity functions test command won't include a token by default. Run with the --with-user-token flag to pass a the logged-in user's token.

Note: the token is obfuscated in logs for security. You can directly use it to configure the Sanity client or to make API calls.

### Example context

```javascript
{
  clientOptions: {
    apiHost: 'https://api.sanity.io',
    projectId: 'abc123',
    dataset: 'production',
    token: '***************'
  }
}
```

## `event` properties

Contains the shape of the event, which depends on the event:

- In the case of `document` and `media-library` Function events, like `publish`, the event shape is the document. This will vary based on your schema.
- In the case of `sync-tag-invalidate` Function events, the sync tags will be present under `event.data.syncTags`.

### Example `document` event

```javascript
{
  data: { 
    _id: '1234',
    _type: 'article',
    title: 'Functions quick start',
    _createdAt: '2025-04-24T16:26:58.901Z',
    _publishedAt: '2025-04-24T16:26:58.901Z',
  }
}
```

### Example `sync-tag-invalidate` event

```javascript
{
  data: { 
    syncTags: ['s1:1023', 's3:3021']
  }
}
```

## Example handler

**index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  console.log("Context: ", context)
  console.log("Event: ", event)
})
```

**index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  console.log("Context: ", context)
  console.log("Event: ", event)
}
```

## Type support

When you create a new TypeScript function with `sanity blueprint add`, you'll be prompted to add types. 

If you did not add types as part of the init process, they are available in the [@sanity/functions](https://www.npmjs.com/package/@sanity/functions) package:

**npm**

```shell
npm install -D @sanity/functions
```

**pnpm**

```shell
pnpm add -D @sanity/functions
```

**yarn**

```shell
yarn add --dev @sanity/functions
```

**bun**

```shell
bun add --dev @sanity/functions
```

You can then import and use the `documentEventHandler` helper to provide type support. See the example TS handler above for implementation details.

### Basic usage

Import `documentEventHandler`.

**index.ts**

```
import {documentEventHandler} from '@sanity/functions'

export const handler = documentEventHandler(async ({context, event}) => {
  // Your function implementation
  console.log('Document updated:', event.data)
})
```

### Pass type for event data

If you need to type `event.data`, and you know the shape of your incoming data, you can provide it to `documentEventHandler`.

**index.ts**

```
import {documentEventHandler} from '@sanity/functions'

interface NotificationData {
  documentId: string
  text: string
}

export const handler = documentEventHandler<NotificationData>(async ({event}) => {
  console.log(event.data.text) // Typed as `string`
  console.log(event.data.notSet) // Will yield type error
})
```

### Type only (TypeScript)

Import the `DocumentEventHandler` type.

**index.ts**

```
import {type DocumentEventHandler} from '@sanity/functions'

export const handler: DocumentEventHandler = async ({context, event}) => {
  // …
}

// …you can also define the data type:
export const handler: DocumentEventHandler<{text: string}> = async ({event}) => {
  console.log(event.data.text)
}
```

### Type only (JavaScript)

Use the `@type` comment syntax.

**index.js**

```javascript
/** @type {import('@sanity/functions').DocumentEventHandler} */
export const handler = async ({context, event}) => {
  console.log(event.data.text)
}

// …you can also define the data type:
/** @type {import('@sanity/functions').DocumentEventHandler<{text: string}>} */
export const handler = async ({event}) => {
  console.log(event.data.text)
}
```





# Function CLI commands

The `functions` CLI command enables managing and testing functions. It's used alongside the `blueprints` command to create and deploy functions.

[Functions introduction](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

**npm**

```shell
npx sanity functions --help
```

**pnpm**

```shell
pnpm dlx sanity functions --help
```

**yarn**

```shell
yarn dlx sanity functions --help
```

**bun**

```shell
bunx sanity functions --help
```

## Commands

### `add`

**CLI output**

```sh
USAGE
  $ sanity functions add [--install] [-n <value>] [--example <value>] [--helpers] [--installer <value>] [--javascript] [--json] [--language <value>] [--type <value>]

FLAGS
  -i, --install            Shortcut for --installer npm
  -n, --name=<value>       Name of the Function to add
      --example=<value>    Example to use for the Function
      --helpers            Add helpers to the new Function
      --installer=<value>  How to install the @sanity/functions helpers
      --javascript         Use JavaScript instead of TypeScript
      --json               Format output as json
      --language=<value>   Language of the new Function
      --type=<value>       Document change event(s) that should trigger the function; you can specify multiple events by specifying this flag multiple times

DESCRIPTION
  Scaffolds a new Function in the functions/ folder and templates a resource for your Blueprint manifest.
  
  Functions are serverless handlers triggered by document, live content or media-library events (create, update, delete, publish).
  
  After adding, use 'functions dev' to test locally, then 'blueprints deploy' to publish.

EXAMPLES
    $ sanity functions add

    $ sanity functions add --helpers

    $ sanity functions add --name my-function

    $ sanity functions add --name my-function --type document-create

    $ sanity functions add --name my-function --type document-create --type document-update --lang js
```

### `dev`

**CLI output**

```sh
USAGE
  $ sanity functions dev [-h <value>] [-p <value>] [-t <value>] [--json]

FLAGS
  -h, --host=<value>     The local network interface at which to listen. [default: "localhost"]
  -p, --port=<value>     TCP port to start emulator on. [default: 8080]
  -t, --timeout=<value>  Maximum execution time for all functions, in seconds. Takes precedence over function-specific `timeout`
      --json             Format output as json

DESCRIPTION
  Runs a local, web-based development server to test your functions before deploying.
  
  Open the emulator in your browser to interactively test your functions with the payload editor.
  
  Optionally, set the host and port with the --host and --port flags. Port 8974 is reserved for the emulator's live-reload WebSocket server. Function timeout can be configured with the --timeout flag.
  
  To invoke a function with the CLI, use 'functions test'.

EXAMPLES
    $ sanity functions dev --host 127.0.0.1 --port 3333

    $ sanity functions dev --timeout 60
```

### `env`

#### `add`

**CLI output**

```sh
USAGE
  $ sanity functions env add NAME KEY VALUE [--json] [--stack <value>]

ARGUMENTS
  NAME   The name of the Sanity Function
  KEY    The name of the environment variable
  VALUE  The value of the environment variable

FLAGS
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Sets an environment variable in a deployed Sanity Function. If the variable already exists, its value is updated.
  
  Environment variables are useful for API keys, configuration values, and other secrets that shouldn't be hardcoded. Changes take effect on the next function invocation.

EXAMPLES
    $ sanity functions env add MyFunction API_URL https://api.example.com/

    $ sanity functions env add --stack <name-or-id> MyFunction API_URL https://api.example.com/
```

#### `list`

**CLI output**

```sh
USAGE
  $ sanity functions env list NAME [--json] [--stack <value>]

ARGUMENTS
  NAME  The name of the Sanity Function

FLAGS
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Displays all environment variables (keys only) configured in a deployed Sanity Function.
  
  Use 'functions env add' to set variables or 'functions env remove' to delete them.

EXAMPLES
    $ sanity functions env list MyFunction

    $ sanity functions env list --stack <name-or-id> MyFunction
```

#### `remove`

**CLI output**

```sh
USAGE
  $ sanity functions env remove NAME KEY [--json] [--stack <value>]

ARGUMENTS
  NAME  The name of the Sanity Function
  KEY   The name of the environment variable

FLAGS
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Deletes an environment variable from a deployed Sanity Function. The change takes effect on the next function invocation.
  
  Use 'functions env list' to see current variables before removing.

EXAMPLES
    $ sanity functions env remove MyFunction API_URL

    $ sanity functions env remove --stack <name-or-id> MyFunction API_URL
```

### `logs`

**CLI output**

```sh
USAGE
  $ sanity functions logs [NAME] [--delete] [--force] [-l <value>] [--utc] [--watch] [--json] [--stack <value>]

ARGUMENTS
  [NAME]  The name of the Sanity Function

FLAGS
  -d, --delete         Delete all logs for the function
  -f, --force          Skip confirmation for deleting logs
  -l, --limit=<value>  Total number of log entries to retrieve
  -u, --utc            Show dates in UTC time zone
  -w, --watch          Watch for new logs (streaming mode)
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Fetches execution logs from a deployed function, useful for debugging production issues or monitoring activity.
  
  Use --watch (-w) to stream logs in real-time. Use --delete to clear all logs for a function (requires confirmation unless --force is specified).

EXAMPLES
    $ sanity functions logs <name>

    $ sanity functions logs <name> --json

    $ sanity functions logs <name> --limit 100

    $ sanity functions logs <name> --delete
```

### `test`

**CLI output**

```sh
USAGE
  $ sanity functions test [NAME] [-a <value>] [-d <value>] [-e <value>] [-f <value>] [-t <value>] [--data-after <value>] [--data-before <value>] [--dataset <value>] [--document-id <value>] [--document-id-after <value>] [--document-id-before <value>] [--file-after <value>] [--file-before <value>] [--json] [--media-library-id <value>] [--no-wait] [--organization-id <value>] [--project-id <value>] [--with-user-token]

ARGUMENTS
  [NAME]  The name of the Sanity Function

FLAGS
  -a, --api=<value>                 Sanity API Version to use
  -d, --data=<value>                Data to send to the function
  -e, --event=<value>               Type of event (create, update, delete)
  -f, --file=<value>                Read data from file and send to the function
  -t, --timeout=<value>             Execution timeout value in seconds
      --data-after=<value>          Current document
      --data-before=<value>         Original document
      --dataset=<value>             The Sanity dataset to use
      --document-id=<value>         Document to fetch and send to function
      --document-id-after=<value>   Current document
      --document-id-before=<value>  Original document
      --file-after=<value>          Current document
      --file-before=<value>         Original document
      --json                        Format output as json
      --media-library-id=<value>    Sanity Media Library ID to use
      --no-wait                     Skip durable wait delays instead of sleeping
      --organization-id=<value>     Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>          Sanity project ID used to scope Blueprint and Stack
      --with-user-token             Prime access token from CLI config

DESCRIPTION
  Executes a function locally with the provided payload, simulating how it would run when deployed. Use this to test your function logic before deploying.
  
  Provide test data via --data (inline JSON), --file (JSON file), or --document-id (fetch from Sanity). For update events, use the before/after flag pairs to simulate document changes.

EXAMPLES
    $ sanity functions test <name> --data '{ "id": 1 }'

    $ sanity functions test <name> --file 'payload.json'

    $ sanity functions test <name> --data '{ "id": 1 }' --timeout 60

    $ sanity functions test <name> --event update --data-before '{ "title": "before" }' --data-after '{ "title": "after" }'
```



# Canvas

#### Get started with Canvas

[Introduction to Canvas](https://www.sanity.io/docs/canvas/introduction-to-canvas)
A familiar writing tool that understands your content structure, so content can flow directly into the right Studio fields with a single click.

[Writing in Canvas](https://www.sanity.io/docs/canvas/writing)
Write and collaborate in Canvas with AI assistance, contextual notes, and real-time collaboration.

[Working with templates](https://www.sanity.io/docs/canvas/templates)
Create reusable Canvas templates with pre-configured content types and field labels for repeatable content workflows.

#### Configure Canvas

[Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content)
Bridge the gap between Canvas and Studio. Structure your content with field labels and send it to your studio without laborious copy-pasting.

[Configure Canvas for your content](https://www.sanity.io/docs/canvas/configure-canvas)
Make your Studio schema available in Canvas and configure how it surfaces to writers.

#### Reference

[Troubleshooting Canvas](https://www.sanity.io/docs/canvas/troubleshooting)
Common issues writers run into when using Canvas, with suggested fixes.

[Keyboard shortcuts](https://www.sanity.io/docs/canvas/keyboard-shortcuts)
Reference for Canvas keyboard shortcuts covering text formatting, document navigation, text selection, document management, collaboration, and Canvas-specific features.



# Introduction

[Sanity Studio](https://www.sanity.io/docs/sanity-studio) is where your team manages and publishes content. Canvas is where you write it.

![Content editing interface displaying an article titled 'The end of the editorial copy-paste' and an open dropdown for assigning content field labels.](https://cdn.sanity.io/images/3do82whm/next/fbb241bf5af891a7f5df1c3680e7c1e249ca4fa6-1464x783.png)

## What is Canvas?

Canvas is a collaborative writing environment built into Sanity. It gives your team a familiar, distraction-free space to write and collaborate, with AI writing assistance and contextual notes to support the creative process. When your content is ready, field labels connect it to your Studio schema so you can send it directly into the right fields with a single click.

Most content teams write in generic document editors like Google Docs, Word, or Notion, then manually copy and paste the content, field by field, into their CMS. Canvas eliminates that laborious hand-off.

## Who Canvas is for

Canvas is built for content teams: writers, editors, and content strategists who produce structured content that ends up in Sanity Studio. If your team currently writes in document templates and then manually transfers content into Studio, Canvas makes that transfer much easier. If you write in Canvas, content seamlessly flows from where it's written to where it's published.

Developers and Studio maintainers configure which content types are available in Canvas and how schema fields surface to writers. See [Configure Canvas for your content](https://www.sanity.io/docs/canvas/configure-canvas) for setup instructions.

## How Canvas works

A content type is the schema for a kind of content, like Article or Blog post. Field labels are inline markers that connect parts of your writing to specific fields in that schema. Together, they let Canvas know where each piece of your content belongs in Studio.

Canvas work happens in three phases: write, structure, and send to Studio.

### Write

Start in a clean, distraction-free editor. Use slash commands, Markdown shortcuts, or the formatting toolbar. Collaborate in real time with your team. Add contextual notes to guide AI assistance, and use the built-in ghostwriter to help draft, rewrite, or expand your content.

→ Learn more in [Writing in Canvas](https://www.sanity.io/docs/canvas/writing)

### Structure

Set a content type to connect your document to a Studio schema. Then use field labels to assign each part of your content to a Studio field. Apply labels manually or let AI label the entire document automatically. Choose what to include in Studio and what to keep as annotations or instructions.

→ Learn more in [Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content)

### Send to Studio

When your content is structured and reviewed, create or update a Studio document. Content flows into the corresponding Studio fields exactly as structured. Your team's existing publishing workflows in Studio remain unchanged.

→ Learn more in [Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content)

## Explore Canvas

- [Writing in Canvas](https://www.sanity.io/docs/canvas/writing) — Write and collaborate in a familiar document editor with AI assistance, contextual notes, and real-time collaboration.
- [Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content) — Connect your content to a Studio schema with field labels. Apply structure manually or automatically, then send to Studio in one click.
- [Working with templates](https://www.sanity.io/docs/canvas/templates) — Create reusable templates with pre-configured content types and field labels for repeatable content workflows.
- [Configure Canvas for your content](https://www.sanity.io/docs/canvas/configure-canvas) — Control how your schema fields surface in Canvas.



# Writing in Canvas

Canvas is a collaborative writing environment with AI assistance, contextual notes, and real-time collaboration. 

This guide covers everything you need to know about writing and collaborating in Canvas.

## The document editor

Canvas offers a clean, distraction-free writing environment that should feel instantly familiar to anyone who has used a modern word processor or text editor. The interface is designed to put your content front and center, allowing you to focus on getting your thoughts down without any clutter or unnecessary features getting in the way.

![A note-taking app displays a document titled "Top destinations for potato lovers" with an option for AI-assisted note organization.](https://cdn.sanity.io/images/3do82whm/next/43d41a8deae8f183c8da1c263ecd4fa6408b43d8-1668x739.png)

### Documents

Documents are the core unit of Canvas. You can browse all your existing documents in **All documents**, where you'll also find templates created by you or others in your organization.

![The Canvas document management application interface, with "Created by me" selected in the sidebar, displaying a list of documents.](https://cdn.sanity.io/images/3do82whm/next/ab9f36e4914d0e8a62f147b1874ab636d8c74438-1668x739.png)

To create a new document, use the sidebar or the document browser. To delete a document, click the ellipsis menu in the top right corner of the editor and select **Delete**. This action is permanent.

## Formatting

Canvas supports the formatting habits you already have. 

![Text "Are you a true potato lover looking for your next adventure?" with "looking" highlighted, above an editing toolbar.](https://cdn.sanity.io/images/3do82whm/next/a79b0dcde124bfae5790e4f658b4a42323945c0c-495x114.png)

- Inline ****markdown**** formatting
- `/ ` slash commands 
- Select some text and click the **B** or *i* icon 

### Slash commands

You can use familiar slash ` / `commands to quickly apply headings, lists, quotes, and more without taking your hands off the keyboard. 

![A command palette menu showing 'Instruction' highlighted under the AI section.](https://cdn.sanity.io/images/3do82whm/next/08f3864336efce1792362c5cd78cdb58eef25e65-1026x537.png)

### Keyboard shortcuts

Canvas supports many common keyboard shortcuts for formatting text. And has a couple additional shortcuts added to the roster for common operations. See [Keyboard shortcuts](https://www.sanity.io/docs/canvas/keyboard-shortcuts) for the full list.

### Formatting toolbar

For those who prefer a more visual approach, basic formatting options like bold, italic, and underline are also available via buttons in a popover whenever text is selected.

![Text "Are you a true potato lover looking for your next adventure?" with the word "looking" highlighted, above a text editing toolbar.](https://cdn.sanity.io/images/3do82whm/next/a79b0dcde124bfae5790e4f658b4a42323945c0c-495x114.png)

## Working with images

You can add images to your Canvas document by pasting or dragging and dropping them into the editor, or by using the slash ` / ` command menu. Images are stored with your document and can be included when you send content to Studio.

![Exterior of the Canadian Potato Museum with a giant potato statue and two people posing.](https://cdn.sanity.io/images/3do82whm/next/319fb543251733a28510f5e0ef467a4f3d1bea9d-1312x857.png)

## Content references

Once a content type is set, you can reference existing Studio content directly from Canvas using the `@` shortcut. This lets you search for and insert references to existing Studio documents: authors, topics, related articles, or any other content type you have access to in the connected studio. When searching for references inside a field label, reference search is filtered to include only matching content types.

Writers can build document relationships as part of the writing process rather than as a separate data-entry step in Studio.

The `@` reference shortcut requires at least read access to the connected Studio. If you have Canvas-only access, you can work with field labels but won't be able to insert content references. Request access through the Canvas UI or ask your administrator for Studio read access if your workflow requires references.

## AI writing assistance

Canvas offers multiple modes of AI support. Most readily apparent is the subtle circle icon that follows you around the document, affectionally known as "the Blip".

![A "Resources" list of bullet points, with a "Ghostwrite" menu open displaying options such as "Show me options" and "Rewrite paragraph."](https://cdn.sanity.io/images/3do82whm/next/f74d577ad55cc8d410755677c92c9349bbc8d1fe-1920x1197.png)

### Ghostwrite

**Ghostwrite** is your go-to for generating new content or expanding on existing ideas. When you select this option, the AI assistant analyzes your current position in the document, along with any relevant notes and surrounding context, to suggest a continuation of your writing. Depending on where your cursor is placed, the AI may suggest completing the current sentence, starting a new paragraph, or even beginning a new section with a relevant heading.

### Show options

**Show options** presents you with a range of alternative suggestions for how to continue your writing. When you click this option, the AI generates multiple possible paths forward based on your current context and notes. These options might include different ways to complete the current thought, introduce a new idea, or transition to a related topic.

![A webpage showing a "Conclusion" about potato museums, with text about interactive exhibits and a sidebar menu with "Hands-On Potato Experiences" highlighted.](https://cdn.sanity.io/images/3do82whm/next/a9c43018335d28b381b91fe61784d1313e1f03df-3840x1739.png)

### Rewrite

**Rewrite paragraph** generates an alternative version of the current paragraph, with the option to provide a brief on what to change.

![A UI showing an original text about potatoes and a more enthusiastic rewritten version, with options to accept or restore.](https://cdn.sanity.io/images/3do82whm/next/d730d574ed398199e95bf830af49de24cdc07ab4-1920x1957.png)

### AI instructions

Create and run AI prompts directly from text in your document, or use persistent instruction blocks that can be included in templates.

![AI-generated draft blog post about potato tourism.](https://cdn.sanity.io/images/3do82whm/next/525d7d09170a65deb37d6e9754bf0329d464b537-1332x693.png)

## Notes

Notes provide context, facts, style guidelines, and inspiration to inform your writing, and they help the built-in ghostwriter make relevant and informed suggestions. By attaching relevant notes to your document or template, you give the AI the background knowledge and topical awareness it needs to be of actual help.

The more relevant and specific your notes, the better the AI can tailor its output.

### Note types

Canvas supports four types of notes, each serving a different purpose:

- **Context notes** provide high-level background information and framing for the document, such as project briefs, target audience details, or internal enablement material.
- **Fact notes** contain specific data points, quotes, or pieces of information that should be treated as factual and incorporated into the content where relevant.
- **Style notes** outline the desired voice, tone, and stylistic guidelines for the document, so the ghostwriter adopts the appropriate tone and style for the piece.
- **Inspiration notes** collect examples, analogies, or creative prompts to inspire the writing and infuse it with engaging elements.

![A notes application interface displaying four notes: 'Best destinations for carrot lovers blog post', 'Mission statement', 'List of potato museums', and 'Voice and tone'.](https://cdn.sanity.io/images/3do82whm/next/74c7e94c183c57c27da253fab29ed22e70e25ef8-486x248.png)

### Creating and managing notes

To create a note, click the **+** button at the top of the notes panel. Add text, images, or PDF files. Canvas will classify the note type and suggest a title automatically, though you can override both. When you paste a URL into Notes, you have the option to include the linked content as context.

![Context menu for "https://sanity.io" with options "Paste as", "Context", and "Link".](https://cdn.sanity.io/images/3do82whm/next/a680bcae2a7514d9fb8320c9290f683633bd5c49-274x290.png)

Move and rearrange notes by dragging them in the notes panel. Right-click any note to duplicate or delete it. Rename a note by clicking its title.

### How notes inform the AI assistant

When you provide notes, the AI uses this information to guide its content generation. Context notes help the AI understand the big picture and overall purpose of the document. Fact notes ensure accuracy by providing specific data points to incorporate. Style notes allow the AI to adopt the appropriate voice and tone for the piece. And inspiration notes give the AI creative fodder to draw from, helping to make the writing more engaging and colorful.

### AI assistance inside notes

You can also use the AI assistant inside individual notes. Select text and press **Cmd+Return** to run it as an instruction, or click the AI contextual menu icon (the subtle circle that follows your cursor) to access **Ghostwrite**, **Show options**, and **Rewrite**. This is useful for refining notes without leaving the notes panel.

## Writing with structure in mind

If a content type has been set on your document, document content can be annotated with field labels. These labels show which parts of your content map to which Studio fields, so you can see the structure of your content without leaving the editor.

You can still write freely: field labels are visible context, not obstacles. They give you full control over how your content transfers to Studio fields, and you'll be able to send your content to Studio with a single click when you're ready.

If you prefer starting with a clear structure, add field labels to the document or template before adding any content. Alternatively, you can apply field labels manually or automatically to any free-form content in the document once the content type is set.

For the full walkthrough on setting content types, applying field labels, and sending content to Studio, see [Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content).

## Collaboration

Canvas supports real-time collaborative editing. Presence indicators show who else is in the document and where they're working. Leave comments on specific content, and tag colleagues for review. History logs provide an audit trail of who made changes and when.

### Real-time editing

Multiple people can work in the same Canvas document at the same time. Changes appear in real time for all collaborators.

### Presence indicators

Presence indicators show who else is in the document and where they're currently working.

### Comments

Leave comments on specific content to provide feedback or ask questions. Tag colleagues with `@` mentions in comments to bring them into the conversation. Note that `@` mentions in comments are separate from `@` content references in the document body.

### History

History logs provide an audit trail of who changed what and when, so your team can track the evolution of a document.



# Keyboard shortcuts

A reference for Canvas keyboard shortcuts, covering text formatting, document navigation, text selection, document management, collaboration, and Canvas-specific features.

## Collaboration

| Action | Shortcut |
| --- | --- |
| Add comment | Cmd+Option+M |

## Canvas-specific features

| Action | Shortcut |
| --- | --- |
| Add field labels to selection | Cmd+Option+L |
| Rewrite content (selection) | Cmd+Shift+Enter |
| Run as instruction (selection) | Cmd+Enter |

## Document navigation

| Action | Shortcut |
| --- | --- |
| Beginning of document | Cmd+Up |
| End of document | Cmd+Down |
| Beginning of line | Cmd+Left |
| End of line | Cmd+Right |
| Word left | Option+Left |
| Word right | Option+Right |

## Text selection

| Action | Shortcut |
| --- | --- |
| Select word left | Option+Shift+Left |
| Select word right | Option+Shift+Right |
| Select to line start | Cmd+Shift+Left |
| Select to line end | Cmd+Shift+Right |
| Select to document start | Cmd+Shift+Up |
| Select to document end | Cmd+Shift+Down |
| Select word (mouse) | Double-click |
| Select paragraph (mouse) | Triple-click |



## Text formatting

| Action | Shortcut |
| --- | --- |
| Bold | Cmd+B |
| Italic | Cmd+I |
| Underline | Cmd+U |
| Strikethrough | Cmd+Shift+X |
| Inline code | Cmd+E |
| Insert or edit link | Cmd+K |

## Paragraph formatting

| Action | Shortcut |
| --- | --- |
| Heading 1 | Cmd+Option+1 |
| Heading 2 | Cmd+Option+2 |
| Heading 3 | Cmd+Option+3 |
| Heading 4 | Cmd+Option+4 |
| Heading 5 | Cmd+Option+5 |
| Heading 6 | Cmd+Option+6 |
| Normal text | Cmd+Option+0 |

## Document management

| Action | Shortcut |
| --- | --- |
| Print | Cmd+P |
| Zoom in | Cmd++ |
| Zoom out | Cmd+- |
| Zoom 100% | Cmd+0 |





# Working with templates

Templates turn Canvas into a repeatable workflow tool. Save any Canvas document as a template to give your team a consistent starting point for content they produce regularly, with content types, field labels, and editorial instructions already in place.

Templates involve three roles: the person who sets up the template (usually a team lead or developer), the writer who uses it to start new documents, and the reviewer or Studio editor who sends the finished content to Studio. The setup happens once; everything else is repeatable.

## What are Canvas templates?

Any Canvas document can be saved as a template. Templates are reusable starting points for new documents. When someone creates a new document from a template, the template's content, structure, and configuration carry over into the new document.

Templates are especially useful when paired with field labels. A template with a content type and field labels pre-configured gives writers a schema-aware starting point: they can see what fields are expected and start filling in content immediately, without needing to set up the content type or apply labels themselves.

![A Sanity content studio interface showing a read-only "Article template" with input fields and an open dropdown menu with document options.](https://cdn.sanity.io/images/3do82whm/next/d3966c7d2c85b41b6712840d846b9dd034a30301-2824x1564.png)

## Creating a template

### Saving a document as a template

To create a template, open any Canvas document and save it as a template from the document menu. The document's content, content type, field labels, and content inclusion states are all preserved in the template.

### Setting a content type on a template

If your template has a content type set, that content type carries over to every document created from it. Writers start with structure already in place, even if they don't have access to the target Studio.

### Pre-configuring field labels

Templates can include available field labels for a content type. If your writers only need to fill in certain fields (for example, title, body, and excerpt, but not SEO metadata or internal tags), you can set up the template with only the relevant labels visible. This scopes the template to the writer's task and reduces noise from fields they don't need to touch.

### Adding instructions

Because excluded content doesn't flow to Studio, it's a natural place for editorial instructions inside a template. You can add guidance for specific fields (for example, "Write a 2-3 sentence summary for social sharing" under an excerpt field label) and mark it as excluded. Writers see the instructions as they work, but the instructions never end up in Studio.

![A digital marketing campaign template in Canvas showing sections for outline, blog post, and social media ideas with AI instruction prompts embedded into the template](https://cdn.sanity.io/images/3do82whm/next/2361c47bf07154c78a5dccf27a371fcc7e63b4a7-3022x1604.png)



## Starting from a template

### What carries over

When you create a new document from a template, the following carry over:

- Notes
- Content type (if set)
- Field labels visible in the document
- Content marked as excluded (including editorial instructions)
- Any pre-filled content in the template

### Adding or removing field labels after creation

Documents created from templates are fully editable. Writers can add field labels that weren't included in the template, remove labels they don't need, or change the content type entirely. The template provides a starting point, not a constraint.

### Creating and updating Studio documents from templates

Documents created from templates work exactly like any other Canvas document when it comes to sending content to Studio. Writers with Studio access can create new Studio documents or update existing ones. The same field labeling, inclusion, and conflict-handling behaviors apply.

## Templates for teams without Studio access

Templates enable schema-aware editing for writers who don't have direct Studio access. Because the content type and field labels are saved to the Canvas document, a team lead or developer can set up a template with the right structure, and writers can use it without needing to connect to Studio themselves.

This addresses teams where writers focus on content creation and don't need to interact with the CMS directly. Writers see the structure, follow the guidance, and fill in the content. Someone with Studio access reviews and sends it to Studio when it's ready.

## Managing templates

Templates are accessible from the document sidebar alongside your existing documents. Templates created by anyone in your organization are available to all members. You can browse, filter, and select templates when creating a new document.

![Sanity Canvas interface displaying the "Templates" section with predefined templates for Welcome, Marketing campaign, and Documentation article.](https://cdn.sanity.io/images/3do82whm/next/e8f11256ab39f2de0c828542ac669ed215b293e9-1505x363.png)



# Troubleshooting Canvas

Common issues writers run into when using Canvas, with suggested fixes.

## I don't see my Studios or content type in the content type panel

Make sure your Studio has been [configured properly](https://www.sanity.io/docs/canvas/configure-canvas) to allow Canvas to connect. This involves configuring and deploying the relevant Studio.

## My content type is missing fields

Check that the relevant fields aren't configured to be excluded from Canvas in your Studio schema. This requires a Studio configuration update and redeployment. See [Configure Canvas for your content](https://www.sanity.io/docs/canvas/configure-canvas).

## AI labeling is producing inaccurate results

A few things to try:

- **Simplify your document.** Remove placeholder copy, draft notes, or inline instructions before running AI labeling.
- **Use clear content breaks.** Separate content for different fields into distinct paragraphs or sections.
- **Add field name hints.** Include a plain-text heading like "Title" or "Excerpt" above the relevant content.
- If results are still off, switch to manual labeling with the `=l` command for the problematic fields.

## Content reordered unexpectedly after labeling

Field labels follow your schema's field order. When labels are applied, content reorders to match the structure defined in your content type. This is expected behavior: it ensures your document mirrors the Studio field order. The exceptions are modular content blocks and array items, where you control the order.

## I can't reference Studio content with `@`

The `@` reference shortcut requires at least read access to the connected Studio, and referenced documents need to be in the same dataset as that Studio. If you have Canvas-only access, you can work with field labels but won't be able to insert content references. Ask your administrator for Studio read access if your workflow requires references.

When you add a reference inside a field label, results are filtered to only include the document types specified in the content schema.



# Configure Canvas for your content

This guide is for developers and [Sanity Studio](https://www.sanity.io/docs/sanity-studio) maintainers. It covers how to deploy your schema so Canvas can access it, and how to tailor which content types and fields are available.

For a conceptual overview of how Canvas works and where it fits into your content workflow, see [Canvas](https://www.sanity.io/docs/canvas/introduction-to-canvas). For the writer-facing guide to structuring content and sending it to Studio, see [Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content).

## Prerequisites

### Required packages and versions

Make sure your project is updated to `v3.88.1` or later of Sanity Studio. `@latest` is always recommended.

### Permissions

- Writers need read access to the destination studio and permissions for relevant content types to set a content type on a Canvas document.
- Once set, anyone with access to the Canvas document can work with field labels, even without studio access.
- The `@` content reference shortcut requires read access to the connected studio.
- The Send to Studio feature requires edit permissions for the relevant studio.

## Enabling Canvas for your project

Canvas can work with any deployed Studio schema. Once your schema is deployed to your dataset, all content types in that schema are automatically available to Canvas users who have access to the studio.

For Sanity-hosted studios:

1. Make sure your project is updated to `v3.88.1` or later of Sanity Studio.
2. Deploy your Studio by running `npx sanity deploy`.

If your Studio is embedded or self-hosted, see [Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure) for onboarding instructions. For more details on how schema deployment works, see [Schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment).

No additional configuration is needed to make your schema available in Canvas. The `options.canvasApp` configuration described below is optional and only needed if you want to exclude specific types or fields, or provide additional context to the labeling AI.

## Configuring schemas for Canvas

To tailor how Canvas handles your Studio schema, use the `options.canvasApp` configuration available on all schema types. This lets you:

- Exclude specific types or fields from appearing in Canvas using `options.canvasApp.exclude`.
- Provide additional context about the intended purpose of a type or field using `options.canvasApp.purpose`.

> [!TIP]
> Protip
> Excluding fields that aren't useful to edit in Canvas is beneficial in more than one way! You'll deliver a cleaner, more intuitive experience to your content team, and you'll avoid problems that can occur in Canvas when faced with overly complex schemas. The number of fields handled by Canvas is hard-capped at 1000. If your document schema runs up against this limit, consider excluding certain fields.

Be particularly diligent with your exclusions for schemas that are very large, have a high number of types, are recursive (self-referencing), or have big arrays of several different types.

### Controlling field visibility

To prevent a document type from being selectable in Canvas, set the `exclude` option to `true`:

**policySchema.ts**

```typescript
import {defineType} from 'sanity'

export default defineType({
  name: 'policy',
  type: 'document',
  description: 'Internal policy documents',
  options: {
    canvasApp: {
      exclude: true
    },
  },
  fields: [
    // ...
  ]
})
```

Similarly, you can exclude specific fields within a document type by setting `options.canvasApp.exclude` to `true` on the field:

**articleSchema.ts**

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'article',
  type: 'document',
  fields: [
    defineField({
      name: 'internalNotes',
      type: 'text',
      options: {
        canvasApp: {exclude: true}
      }
    }),
    // ...
  ]
})
```

In this example, the `article` type is still available in Canvas, but the `internalNotes` field is not shown or available for field labeling.

### Validation

Validation rules like character limits are shown in Canvas when action is needed to add or correct invalid content. Custom validation rules cannot be shown in Canvas.

### Field descriptions and validation

Field descriptions and validation rules from your schema are surfaced in Canvas. The guidance your team would typically maintain separately (content requirements, formatting expectations, character limits) is pulled from your schema and can be displayed where writers need it.

![Description of Canvas as a collaborative writing environment with AI assistance, contextual notes, and real-time collaboration.](https://cdn.sanity.io/images/3do82whm/next/28e2f24993176c55d7afdf7e00be406a16f7dc69-1534x254.png)

To make the most of this, write clear, writer-friendly descriptions on your schema fields. Think of them as inline instructions for the person writing the content, not just documentation for developers.

### Adding context with purpose

The `options.canvasApp.purpose` option lets you provide additional context about the intended purpose or usage of a specific type or field. This can help automated labeling make more accurate decisions when mapping content to fields.

For example, if you have a `tags` field in your schema that's intended specifically for SEO keywords rather than general content categorization, you can clarify this using the purpose option:

**articleSchema.ts**

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'article',
  type: 'document',
  fields: [
    defineField({
      name: 'tags',
      type: 'array',
      of: [{type: 'string'}],
      options: {
        canvasApp: {
          purpose: 'SEO keywords to improve search visibility, not general categorization tags.',
        },
      },
    }),
    // ...
  ],
})
```

Consider using the `purpose` option when added clarity would be helpful. Often, automatic labeling will get it right, so give it a try first and add `purpose` details only if needed to refine the results.

## Schema design tips for Canvas

When designing schemas that will be used with Canvas, keep the following in mind:

- Write clear, writer-friendly field descriptions. These surface directly to writers as inline guidance.
- Exclude fields that writers don't need to interact with (internal metadata, computed fields, system fields).
- Use the `purpose` option sparingly and only where automated labeling needs a nudge.
- Keep schema complexity manageable. Very large schemas with deeply nested structures can be harder for both writers and automated labeling to work with.

## Troubleshooting

### Can't find your project in Canvas?

Make sure your Studio has been deployed with a recent version of Sanity Studio (`v3.88.1` or later). Canvas reads your schema from the deployed Studio. If your Studio is embedded or self-hosted, make sure it has been onboarded to Dashboard.

### Content type is missing fields

Check that the missing fields aren't configured with `options.canvasApp.exclude: true`. Also confirm that the Studio has been redeployed since the fields were added to your schema.

### Automated labeling is mapping content incorrectly

Try adding a `purpose` option to the fields where labeling is off. Clear, specific purpose descriptions help the AI make better mapping decisions. If a field is consistently mislabeled, check that its field name and description are unambiguous.

If you're a writer seeing Canvas behave unexpectedly, see [Troubleshooting Canvas](https://www.sanity.io/docs/canvas/troubleshooting).



# Build with AI

#### Supercharge your workflow

[Get started with Sanity and AI](https://www.sanity.io/docs/ai/get-started)
Set up the Sanity Agent Toolkit and MCP server to help AI assistants generate high-quality Sanity code that follows established best practices.

[Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server)
Enable AI agents to interact with your Sanity workspace through the Model Context Protocol (MCP).

[Agent Toolkit](https://github.com/sanity-io/agent-toolkit)
Install rules, skills, and more to supercharge your development environment.

#### Add AI to your Sanity Apps

[Sanity Context](https://www.sanity.io/docs/ai/sanity-context)
Sanity Context exposes the content kept in Sanity to your agents through a hosted, read-only MCP server, from your live dataset or from Knowledge Bases built ahead of time.

[Explore Agent Actions](https://www.sanity.io/docs/agent-actions)
Add AI generation, transformation, and translation abilities to your apps.

[Add AI Assist to Studio](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)
Give your editors user-friendly AI tools directly in Studio.



# Get started

> [!NOTE]
> Just want to get started?
> If you want an agent to set up a Sanity project for you, start with [Quickstart: AI coding agents](https://www.sanity.io/docs/getting-started/ai-coding-agents) or [Quickstart: AI app builders](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart). This page goes deeper on the AI tooling itself.

AI tools can dramatically accelerate Sanity development, but without proper guidance, they often produce generic code that fails to leverage Sanity's full capabilities.

This guide will help you:

- Set up AI tools to generate high-quality Sanity code.
- Avoid common pitfalls of AI-generated configurations.
- Implement best practices from Sanity into your AI workflow.

## Configure the MCP server

The fastest way to connect your AI tools to Sanity is with the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server). Run the following command to automatically detect and configure the MCP server for Cursor, Claude Code, and VS Code:

**npm**

```shell
npx sanity@latest mcp configure
```

**pnpm**

```shell
pnpm dlx sanity@latest mcp configure
```

**yarn**

```shell
yarn dlx sanity@latest mcp configure
```

**bun**

```shell
bunx sanity@latest mcp configure
```

This detects and configures the MCP server automatically. See the [MCP server documentation](https://www.sanity.io/docs/ai/mcp-server) for manual configuration options and troubleshooting. If you’re starting a new project with `sanity init`, the CLI will help you set up the MCP server as part of the setup steps. 

## Add Sanity skills and plugins

The [Sanity Agent Toolkit](https://github.com/sanity-io/agent-toolkit) is a collection of resources to help AI agents build better with Sanity.

It includes:

- **Agent skills** covering Sanity best pracitces, AEO/SEO, content modelling and personalisation.
- **Claude Code plugin** with slash commands and interactive skills for common workflows.
- **Cursor plugin** with automatic MCP setup, slash commands and agent skills all included

You can also install skills directly by running the following command from your project directory:

**npm**

```shell
npx skills add sanity-io/agent-toolkit
```

**pnpm**

```shell
pnpm dlx skills add sanity-io/agent-toolkit
```

**yarn**

```shell
yarn dlx skills add sanity-io/agent-toolkit
```

**bun**

```shell
bunx skills add sanity-io/agent-toolkit
```

## Leveraging documentation content

The Sanity documentation has several ways you can use AI to get the job done:

- **For quick, specific reference**: Use the **Copy article** button on all articles that puts the markdown version of the content on your clipboard. You can also add `.md` at the end of any article URL to get the markdown version.
- **For comprehensive context**: You can point LLMs to `/docs/llms.txt` and `/docs/llms-full.txt` to access all the links and the full corpus as markdown formatted content.
- **For interactive queries**: Use the [MCP server](https://www.sanity.io/docs/ai/mcp-server)'s `search_docs` and `read_docs` tools.
- **For CLI-based work**: You can even tell LLMs to use `sanity docs search` and `sanity docs read` to find docs articles.

In tools like Cursor that support local docs, you can add the Sanity Docs and Learn materials directly by typing `@Docs` in their agent chats.

## Leveraging Sanity Learn content

All course and lesson material on [Sanity Learn](https://www.sanity.io/learn) is also available in the LLM-friendly `llms.txt` standard. You can read [how we made this](https://www.sanity.io/blog/improving-the-agent-experience-for-sanity-learn) on our blog.

There are two different sizes you can import into your IDE:

- `/llms.txt` is an abbreviated index of all the content with links.
- `/llms-full.txt` is the complete content (sometimes optimized to fit within the context window limits).



# MCP setup and introduction

The Sanity Model Context Protocol (MCP) server enables AI assistants like Claude Code and Cursor to interact directly with your Sanity projects.

With the MCP server, agents can go beyond code generation and perform advanced content management operations in your Sanity projects. Agents can execute GROQ queries, manage releases, and patch documents with full awareness of your schema, eliminating the need to manually supply context.



## Installation

The Sanity MCP server is hosted on Sanity's own infrastructure on `https://mcp.sanity.io`. It follows Anthropic's official MCP specification and works with any MCP-compatible client. It supports authentication through both OAuth (default) and token-based authentication.

**Prerequisites:**

- An MCP-compatible client, such as [Claude Code](https://docs.anthropic.com/en/docs/claude-code/mcp), [Cursor](https://docs.cursor.com/context/mcp#installing-mcp-servers), [VS Code](https://code.visualstudio.com/docs/copilot/customization/mcp-servers), [Lovable](https://docs.lovable.dev/integrations/mcp-servers), [Replit](https://docs.replit.com/replitai/integrations) or [v0](https://v0.app/docs/MCP)
- A Sanity account

### Quick install via Sanity CLI

The easiest way to get started is using the [Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli). It detects the most common AI-powered editors (Cursor, VS Code, Claude Code) and automatically configures the MCP server for you.

**npm**

```shell
npx sanity@latest mcp configure
```

**pnpm**

```shell
pnpm dlx sanity@latest mcp configure
```

**yarn**

```shell
yarn dlx sanity@latest mcp configure
```

**bun**

```shell
bunx sanity@latest mcp configure
```

This command uses your logged-in CLI user for authentication, so you don't need to manually authenticate or manage API tokens.

### Claude Code

Run the following command in your terminal to add the Sanity MCP server. The next time you run Claude Code, it will have access to the MCP and you can authenticate with OAuth.

```sh
claude mcp add Sanity -t http https://mcp.sanity.io --scope user
```



### Cursor

Use the link below to directly install the Sanity MCP server in Cursor. Once installed, you'll be prompted to authorize access.

[Cursor](https://www.sanity.iocursor://anysphere.cursor-deeplink/mcp/install?name=Sanity&config=eyJ1cmwiOiJodHRwczovL21jcC5zYW5pdHkuaW8iLCJ0eXBlIjoiaHR0cCJ9Cg==)

You can confirm the server is running by opening the **Command Palette** (`Cmd+Shift+P` / `Ctrl+Shift+P`) and running **View: Open MCP Settings**.

Alternatively, you can manually update your configuration:

1. Open the **Command Palette** and run **View: Open MCP Settings**.
2. Select **+ New MCP Server** in the settings pane. This will open your `mcp.json` file.
3. Add the following configuration:

**mcp.json**

```json
{
  "mcpServers": {
    "Sanity": {
      "type": "http",
      "url": "https://mcp.sanity.io"
    }
  }
}
```

Once you save the file, Cursor detects the new server and prompts you to authenticate via OAuth to complete the connection.



### VS Code

1. Open Visual Studio Code.
2. In the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`), run: **MCP: Open User Configuration**.
3. Update the `mcp.json` file with the following configuration and save the file:

**mcp.json**

```json
{
  "servers": {
    "Sanity": {
      "type": "http",
      "url": "https://mcp.sanity.io"
    }
  }
}
```

Once you save the file, VS Code detects the new server and prompts you to authenticate via OAuth to complete the connection.



### OpenCode

You can add Sanity as a remote MCP server in your OpenCode configuration.

1. Open your OpenCode config file.
2. Add the following configuration to the `mcp` section:

**opencode.json**

```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "sanity": {
      "type": "remote",
      "url": "https://mcp.sanity.io",
      "oauth": {}
    }
  }
}
```

Save the file and authenticate with Sanity by running: `opencode mcp auth sanity`

Once authenticated, you can use Sanity tools in your prompts by mentioning `sanity`. For more details, see the [OpenCode MCP documentation](https://opencode.ai/docs/mcp-servers/).



### v0

[v0](https://v0.app) is an AI agent from Vercel that helps anyone create real code and full-stack apps. Ship features, refine designs, update copy, and create live prototypes – all with a prompt. Here's how you add the Sanity MCP:

1. In the v0 prompt input field, click **Prompt Tools** (bottom left).
2. Select **MCPs**, then click **Add New**.
3. Select **Sanity**.
4. Click **Authorize**.
5. Follow the prompt to authenticate with your Sanity account via OAuth.



### Lovable

You can add Sanity as a "Personal connector" in Lovable.

1. In Lovable, go to **Settings** > **Connectors** > **Personal connectors**.
2. Click **New MCP server**.
3. Enter `Sanity` as the name and `https://mcp.sanity.io` as the Server URL.
4. Click **Add & authorize**.
5. Follow the prompt to authenticate with your Sanity account via OAuth.

For more details on managing connectors, see the [Lovable MCP documentation](https://docs.lovable.dev/integrations/mcp-servers).



### Replit

You can add Sanity as a custom MCP server in Replit Agent.

1. Go to the [Integrations Page](https://replit.com/integrations), then scroll down to **MCP Servers for Replit Agent**.
2. Click **Add MCP server**.
3. Enter `Sanity` as the name and `https://mcp.sanity.io` as the Server URL.
4. Click **Test & Save**.
5. Follow the prompt to authenticate with your Sanity account via OAuth.

Once saved, you can ask Replit Agent to use Sanity by mentioning it in your chat. For more details, see the [Replit MCP documentation](https://docs.replit.com/replitai/mcp/overview).



### Other clients

If your client does not support remote MCP servers, you may be able to use a proxy such as `mcp-remote`.

```json
{
  "mcpServers": {
    "Sanity": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://mcp.sanity.io",
        "--transport",
        "http-only"
      ]
    }
  }
}
```



### Authorization

The Sanity MCP server uses OAuth by default to perform operations on your behalf. You may instead provide an API token by setting the `Authorization` header in your MCP config. When configured with the header, the server will not use OAuth. Tool calls will use the API token in accordance with its role and scoped to its permissions.

**mcp.json**

```json
{
  "mcpServers": {
    "Sanity": {
      "url": "https://mcp.sanity.io",
      "headers": {
        "Authorization": "Bearer sk..."
      }
    }
  }
}
```

You can create [API tokens](https://www.sanity.io/docs/content-lake/http-auth) from [sanity.io/manage](https://www.sanity.io/manage) or with the `sanity` CLI's** **[tokens command](https://www.sanity.io/docs/cli-reference/tokens). You can also provide a personal token, which will share your role and permissions, as well as link you to any changes in the revision history.

## Run commands (or tools)

Once configured and started, authenticate with your Sanity credentials if prompted. You can then use natural language to work with Sanity development tasks, such as:

- Help me migrate this project to Sanity.
- Run a GROQ query for all articles written by Mark.
- Add localization to my article document type.
- Help me migrate existing content to a new schema shape.
- List all releases in this dataset.

`mcp.sanity.io` provides both editorial and development-focused tools for content operations, schema exploration, GROQ query execution, project management tasks such as creating and managing resources like datasets and API keys, and migration assistance. These tools allow your AI assistant to interact with your Sanity data directly.

### Available tools

The following is a list of available tools and their uses:

#### Properties

**dataset_assets_upload**

Provide local Sanity CLI guidance for uploading an image or file asset to a Content Lake dataset. This tool does not read or upload the file.

**get_schema**

Fetch a deployed schema. By default, resolves by workspace name using the existing precedence: MCP-managed, then Studio-deployed, then legacy `system.schema`. Use `list_workspace_schemas` first when a workspace name has multiple source records, then pass the advertised `schemaId` to inspect that exact schema.

**list_workspace_schemas**

List every deployed schema for a project and dataset, grouped by source (MCP-managed, Studio-deployed, or legacy). Duplicate workspace names and multiple Studio applications are expected; each entry includes a schemaId for exact reads with get_schema.

**deploy_schema**

Directly deploy schema types to the cloud.

**deploy_studio**

Deploy a managed Sanity Studio bound to an MCP-managed schema. Creates a hosted Studio whose URL follows the current environment — `sanity.studio` on production, `studio.sanity.work` on staging — and returns the concrete `studioUrl` in the response.

Requires an existing MCP-managed schema at the same `(projectId, dataset, workspaceName)` address — call `deploy_schema` first if none exists. Re-run after subsequent `deploy_schema` calls so the deployed Studio picks up the latest schema.

**create_documents**

Create one or more draft documents by directly providing structured content. Creates drafts (drafts.* prefix) unless releaseId is specified for version creation.

**create_version**

Create a version document (versions.{releaseId}.* prefix) for a specific release. Versions are separate from drafts and published documents, and are used for scheduled release workflows.

**patch_documents**

Update or edit one or more existing documents by applying precise modifications using @sanity/client patch() operations. Patches for each document are applied as a single transaction (all succeed or all fail). Edits are saved to the draft or release version; published content is never modified directly.

**query_documents**

Query documents from Sanity using GROQ query language

**generate_image**

Trigger async AI image generation for a document field.

**transform_image**

Trigger async AI transformation of an existing image.

**get_document**

Fetch a single document by its exact ID. This is a direct ID lookup only - it does not search, filter, or query. Use when you have a specific document ID and need its full content.

**publish_documents**

Publish one or more draft documents to make them live

**unpublish_documents**

Unpublish one or more published documents (moves them back to drafts)

**discard_drafts**

Discard one or more draft documents (deletes drafts while keeping published documents intact)

**version_discard**

Discard one or more document versions from a release

**list_organizations**

Lists all organizations the user has access to in Sanity

**list_projects**

Lists all Sanity projects associated with your account

**get_project_studios**

Retrieves all studio applications linked to a specific Sanity project

**create_project**

Creates a new Sanity project and initializes it with a dataset and API tokens

**cors_origins_list**

Lists all CORS origins configured for a Sanity project

**add_cors_origin**

Adds CORS origin(s) to allow client-side requests to a Sanity project

**cors_origins_delete**

Deletes a CORS origin from a Sanity project

**whoami**

Returns information about the currently authenticated Sanity user. Useful for verifying authentication and troubleshooting access issues.

**list_datasets**

Lists all datasets in your Sanity project

**create_dataset**

Creates a new dataset with specified name and access settings

**update_dataset**

Modifies a dataset's name or access control settings

**create_release**

Create a new release for grouping content changes. Optionally provide releaseId; if omitted, one is generated.

**list_releases**

List releases in a dataset. By default returns active and scheduled releases. Use the state filter to find published or archived releases.

**list_embeddings_indices**

List all available embeddings indices for a dataset

**semantic_search**

Perform a semantic search on an embeddings index

**run_sanity_cli**

Run a limited subset of Sanity CLI commands and return their output. Use `--help` to list available commands or `<command> --help` for command details. Commands run without a shell and cannot access the filesystem, prompt for input, run in the background, or change authentication. Dedicated Sanity MCP tools may provide more structured responses, but equivalent CLI commands are also available.

**search_docs**

Search Sanity docs

**read_docs**

Fetch a specific documentation article.

**list_sanity_rules**

List available best-practice development rules.

**get_sanity_rules**

Load specific best-practice development rules.

**give_sanity_feedback**

Submit feedback about Sanity when you encounter issues while working with a Sanity codebase or project.
Use this when:
- A Sanity MCP tool returned an unexpected error or confusing result
- You needed a Sanity capability that doesn't exist or is hard to use
- Sanity docs, MCP tool descriptions, or examples were unclear or incorrect
- Common Sanity surfaces such as @sanity/client, the HTTP API, schemas, Studio, or deployment were confusing or blocked progress
- You had to use a workaround for something in Sanity that should be simpler

Provide a specific, detailed message about what you were trying to do,
what happened, and what you expected instead.

### AI credit usage

Most MCP tools are standard API calls and don't consume AI credits. The following tools invoke Sanity's AI inference endpoints and consume AI credits:

- `generate_image`
- `transform_image`
- `create_version` – only when the `instruction` parameter is provided

You can disable these tools in your MCP client if you want to avoid credit usage.

Learn more about pricing and quotas in [How AI credits work](https://www.sanity.io/docs/platform-management/how-ai-credits-work).

## Troubleshooting

### Authentication issues

If you encounter authentication errors (e.g., `401 Unauthorized`), the solution depends on how you installed the server:

**Installed via CLI (using token auth)**

If you installed the MCP via the Sanity CLI, your authentication relies on a generated token that may have expired or been revoked. To fix this, simply run the configuration command again and re-select your code editor with `space`:

**npm**

```shell
npx sanity@latest mcp configure
```

**pnpm**

```shell
pnpm dlx sanity@latest mcp configure
```

**yarn**

```shell
yarn dlx sanity@latest mcp configure
```

**bun**

```shell
bunx sanity@latest mcp configure
```

This will generate a fresh auth token and update your editor's configuration file automatically.

**Manually configured (using OAuth)**

If you configured the server manually, you are likely using OAuth. Sessions typically expire after 7 days. Your client should prompt you to re-authenticate, but if it gets stuck:

- **VS Code:** Run `Authentication: Remove Dynamic Authentication Providers` from the Command Palette, select the Sanity provider, and restart the server.
- **Cursor:** Run `Cursor: Clear All MCP Tokens` from the Command Palette to reset your session.

### Tool availability

If specific tools (like `query_documents`) are missing or failing, verify that your account has the correct permissions for the project and dataset you are trying to access. The set of available tools may also vary as we release updates to the MCP server.

## Support

[Join us in the Sanity community](https://snty.link/community) to ask questions and discuss our MCP server with other developers in the [#mcp-server](https://discord.com/channels/1304483263171264613/1446564219423035533) channel.



# Agent Skills

Agent skills are folders of instructions, scripts, and resources that AI agents can discover and use to complete tasks more accurately and efficiently. They follow the open [Agent Skills](https://agentskills.io/) format, supported by tools like Cursor, Claude Code, VS Code, GitHub Copilot, OpenCode, and others.

Think of agents skills as giving your agent the same context an expert would have. Best practices, architectural patterns, and domain-specific knowledge, loaded on demand instead of explained from scratch every conversation.

## Why skills matter for Sanity development

AI coding tools are good at generating code, but without guidance they produce generic output that misses what makes Sanity different.

Agent skills give agents the context to write performant GROQ queries, design solid schemas, and integrate correctly with your framework of choice. When an agent encounters one of these tasks it loads the relevant skill and applies the same patterns our engineers recommend.

- **Better code on the first pass.** Agents follow Sanity conventions instead of guessing.
- **Fewer iterations.** The right patterns are loaded before generation, not corrected after.
- **Consistent quality.** Everyone on your team gets the same best practices, whether they’re a Sanity veteran or just getting started.

## Where to find agent skills

Sanity publishes skills in a few different places, depending on what they cover.

### Agent Toolkit

The [Sanity Agent Toolkit](https://github.com/sanity-io/agent-toolkit) is the main repository for agent skills. It includes skills covering Sanity development best practices, content modeling, SEO/AEO, and content experimentation. These are general-purpose, useful for any project that uses Sanity regardless of which products or frameworks you're working with.

The fastest way to install these agent skills is with the `skills` CLI tool:

**npm**

```shell
npx skills add sanity-io/agent-toolkit
```

**pnpm**

```shell
pnpm dlx skills add sanity-io/agent-toolkit
```

**yarn**

```shell
yarn dlx skills add sanity-io/agent-toolkit
```

**bun**

```shell
bunx skills add sanity-io/agent-toolkit
```

The Sanity agent toolkit also bundles the MCP server configuration and plugins with slash commands for Claude Code and Cursor. See the [Agent Toolkit repository](https://github.com/sanity-io/agent-toolkit) for the full list of what’s included and what each skill covers.

### Product-specific skills

Some Sanity products ship their own skills alongside the product, covering workflows and patterns specific to that product.

Agent Context includes skills that walk you through setting up the Agent Context Studio plugin, building AI agents that can query and reason over your Sanity content, writing effective system prompts, and tuning your agent’s instructions.

You can install the Agent Context skills directly:

**npm**

```shell
npx skills add sanity-io/agent-context
```

**pnpm**

```shell
pnpm dlx skills add sanity-io/agent-context
```

**yarn**

```shell
yarn dlx skills add sanity-io/agent-context
```

**bun**

```shell
bunx skills add sanity-io/agent-context
```

### Community and custom skills

The Agent Skills format is open, so anyone can create and share skills. If your team has Sanity patterns specific to your project (custom schema conventions, deployment workflows, content modeling standards) we recommend you package them as skills and share them across your team. See the [Agent Skills specification](https://agentskills.io/specification) for how skills are structured.

## How skills work with the MCP server

Skills and the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server) complement each other:

- **The MCP server** gives agents access to your Sanity content. It can query content, manage documents and deploy schemas.
- **Skills** give agents knowledge to work effectively with Sanity. Best practices, patterns, and guides to help agents.

You don’t need to choose between them. Most setups benefit from both. The MCP server for project interaction and skills for reliable baseline knowledge.

For a full walkthrough of setting up both, see the [AI-powered development quickstart](https://www.sanity.io/docs/ai/get-started).



# Get to know Sanity Context

Sanity Context is a hosted Model Context Protocol (MCP) server that gives AI agents structured, read-only access to your content. It serves your live dataset in GROQ mode, or material you have indexed ahead of time in Knowledge Base mode.

With Sanity Context, you can:

- **Answer questions from your own content.** Build assistants that respond from your documentation or help center rather than from a model's training data.
- **Recommend from your catalog.** Give a shopping assistant schema-aware access to products so it filters on real fields instead of guessing at them.
- **Surface related work for editors.** Let an editorial helper find existing coverage before someone writes a duplicate.
- **Ground an agent in curated knowledge.** Build a Knowledge Base from datasets, websites, and files, and serve it as one indexed source.

[Connect your first agent](https://www.sanity.io/docs/ai/sanity-context-quick-start)
Go from nothing to a working agent in a few steps.

[Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base)
Build an index from your material and serve it to agents.

![Dark mode UI of a 'Context' application's overview page, displaying sections for 'MCP endpoints' and 'Knowledge bases' with lists of associated items and their statuses.](https://cdn.sanity.io/images/3do82whm/next/1ab1ef0b069bc1bbcebb78e50fe58b266f316dde-2450x1506.png)

## What Sanity Context provides, and what you bring

Sanity hosts Context MCP, the server your agent connects to. You bring:

- **An MCP-capable AI harness.** Your own application built with the Vercel AI SDK, or anything else that speaks MCP.
- **A Context MCP, created in the Context app in the Sanity Dashboard.** The configuration that defines what the agent can access, plus optional instructions that shape how it behaves.

Sanity Context provides the scoped, schema-aware window into your content. It does not run the agent loop itself, and it cannot write back to your dataset. If you need tools for an agent that creates or modifies content, see the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server), which is a separate server, not a write mode of this one. If you want an editorial assistant with its own harness that runs in the Dashboard, on Slack, or through an API, see [Content Agent](https://www.sanity.io/docs/content-agent).

## Requirements

To set up Sanity Context, you'll need:

- **Context enabled for your organization.** An organization admin can enable it from the Apps page of your organization in Manage.
- **An organization API token with Context Viewer permissions.** Create it under Manage > API > Tokens at the organization level, and keep it server-side. Viewer is the least privilege that works; Editor also works.
- **A model and API key.** Simple schemas and questions work with small, fast models. If the agent picks the wrong tool or writes malformed GROQ, move to a more capable model.
- **Optionally, a frontend application to host the agent.** Next.js, for example.

GROQ mode additionally requires:

- **A Sanity project** with content.
- **Sanity Studio 5.1.0 or later** for server-side schema support.
- **A deployed schema.** Run `sanity schema deploy`, or open your hosted Studio once if you deploy with `sanity deploy`.

Knowledge Base mode additionally requires at least one Knowledge Base that your token can read.

## Core concepts

### Retrieval modes

Context serves content in one of two modes. GROQ mode queries your dataset at request time and suits structured, consistent content the schema can point an agent at. Knowledge Base mode serves an index built ahead of time and suits answers spread across prose from several sources. The mode determines which tools the endpoint serves. See [Context retrieval modes](https://www.sanity.io/docs/ai/sanity-context-retrieval-modes).

### Context MCPs

A Context MCP is the configuration an agent connects to: what content it can reach, and any instructions that shape its behavior. You create and manage MCPs in the Context app, so you can change what an endpoint serves without redeploying the agent. See [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp).

### Initial context

At the start of a conversation the agent orients itself through initial context, which is mode-aware: a compressed schema overview in GROQ mode, or the Knowledge Base outline in Knowledge Base mode. If you control the system prompt you can fetch it over HTTP and skip the tool call. See [Inline initial context into your system prompt](https://www.sanity.io/docs/ai/sanity-context-initial-context).

### Knowledge Bases

A Knowledge Base is a pre-built index over material you choose: datasets, websites, uploaded files. A build reads the material ahead of time, resolves conflicts between sources, and writes entries an agent can retrieve directly. See [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases) and [Knowledge Base source types](https://www.sanity.io/docs/ai/sanity-context-source-types). Once a Knowledge Base is live, you [keep it current](https://www.sanity.io/docs/ai/sanity-context-maintain-knowledge-base) and [resolve the issues a build raises](https://www.sanity.io/docs/ai/sanity-context-resolve-issues).

### Content access

Access is decided when the agent connects: your organization token authorizes the connection, the MCP's sources decide what it serves, and a GROQ filter scopes dataset reads. Context MCP is read-only in both modes. See [Content access and security](https://www.sanity.io/docs/ai/sanity-context-security).

## Limitations

- Context MCP is read-only. It cannot create or update documents.
- It does not run the agent loop. You bring the harness and the model.
- Knowledge Bases are an opt-in early access feature, and limits may change before general availability. If you are on an Enterprise plan and need higher limits, talk to your Sanity representative.

## Next steps

- [Quick start: connect an agent to Sanity Context](https://www.sanity.io/docs/ai/sanity-context-quick-start). The shortest path to something working.
- [Context MCP](https://www.sanity.io/docs/ai/sanity-context-mcp). The reference for endpoints, parameters, and configuration.
- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools). Which tools an endpoint serves in each mode, and what each one does.
- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns). Scoping, routing, and instructing agents once the basics work.
- [Add insights to Sanity Context](https://www.sanity.io/docs/ai/sanity-context-insights). Track and analyze agent conversations.



# Quick start

Sanity Context gives an agent read-only, schema-aware access to your content through a hosted MCP server. This quick start uses the setup skill, which inspects your project and generates the schema, configuration, and code you need. By the end you'll have an MCP endpoint and an agent that can answer questions about your content.

> [!NOTE]
> Prefer a manual setup?
> If you’d rather not use the skill, follow the instructions in the [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp) guide.

## Prerequisites

- **Context enabled for your organization.** An organization admin can enable it from the Apps page of your organization in Manage.
- **A Sanity project with content.**
- **A deployed schema** for the project and dataset the endpoint reads, from a Studio on v5.1.0 or later. Run `sanity schema deploy`, or open your hosted Studio once if you deploy with `sanity deploy`. An MCP endpoint with a dataset source will not serve without one.
- **An organization API token** with Context Viewer permissions, created in [Manage](https://www.sanity.io/manage) under API > Tokens at the organization level, not the project level, and kept server-side. Context Editor also works, but Viewer is the least privilege that will do.
- **A model and API key** for the agent you're building.
- **A coding agent** such as Claude Code or Cursor, plus Node.js 20.19+ or 22.12+ to run `npx`.

The setup spans Studio, schema, and application code, so run it with a capable coding model rather than a small one.

## Step 1: Install the Sanity Context skills

The `--all` flag installs three skills: `create-agent-with-sanity-context` for setup, plus `dial-your-context` for tuning the Instructions field and `shape-your-agent` for crafting a system prompt. From your project directory:

**npm**

```shell
npx skills add sanity-io/context --all
```

**pnpm**

```shell
pnpm dlx skills add sanity-io/context --all
```

**yarn**

```shell
yarn dlx skills add sanity-io/context --all
```

**bun**

```shell
bunx skills add sanity-io/context --all
```

## Step 2: Run the setup skill

The `create-agent-with-sanity-context` skill asks about your goal, inspects your project, and walks you through configuration, building an example agent, and optionally adding a frontend UI. Prompt your coding agent:

**Example prompt**

```text
Use the create-agent-with-sanity-context skill to help me build an agent in this project.
```

## Step 3: Verify the connection

The example uses the Vercel AI SDK's MCP client. Pin its major. `@ai-sdk/mcp` shares an internal provider dependency with `ai`, so their majors have to match. If your agent code uses `ai@6`, a bare install resolves a newer `@ai-sdk/mcp` and produces type errors on `model` and `tools` that name neither package:

**npm**

```shell
npm install @ai-sdk/mcp@^1
```

**pnpm**

```shell
pnpm add @ai-sdk/mcp@^1
```

**yarn**

```shell
yarn add @ai-sdk/mcp@^1
```

**bun**

```shell
bun add @ai-sdk/mcp@^1
```

Set `SANITY_CONTEXT_MCP_URL` to your endpoint URL and `SANITY_ORGANIZATION_TOKEN` to your organization API token, then list the tools the endpoint serves. You should see `initial_context` and `groq_query` among them.

**index.ts**

```typescript
import {createMCPClient} from '@ai-sdk/mcp'

// https://api.sanity.io/v1/context/organizations/YOUR_ORGANIZATION_ID/mcp/YOUR_ENDPOINT_NAME
const url = process.env.SANITY_CONTEXT_MCP_URL
const token = process.env.SANITY_ORGANIZATION_TOKEN
if (!url || !token) {
  throw new Error('Set SANITY_CONTEXT_MCP_URL and SANITY_ORGANIZATION_TOKEN first')
}

const mcpClient = await createMCPClient({
  transport: {
    type: 'http',
    url,
    headers: {Authorization: `Bearer ${token}`},
  },
})

const tools = await mcpClient.tools()
console.log(tools)
```

Then ask the agent a question whose answer you already know, and check that it answers from your content rather than guessing.

> [!NOTE]
> Empty schema or no results?
> Context MCP reads your schema from the server, not your local machine, and an endpoint with a dataset source will not serve until the schema is deployed. Run `sanity schema deploy` (Studio v5.1.0 or later), then retry the connection. If the schema is deployed but queries still return nothing, check whether a GROQ filter is excluding everything the agent tries to read.

## Next steps

- [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp). Set up the endpoint by hand and scope what it serves.
- [Context retrieval modes](https://www.sanity.io/docs/ai/sanity-context-retrieval-modes). Decide between querying your dataset and building a Knowledge Base.
- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns). Scoping, routing, and instructing agents once the basics work.



# Retrieval modes

Sanity Context has two retrieval modes, and they solve different problems. GROQ mode queries your dataset at request time. Knowledge Base mode serves an index built from your material ahead of time. The mode determines which tools Context MCP serves and how an agent finds an answer.

## When GROQ mode fits

Use GROQ mode when the content is structured and consistent, and the schema tells the agent where to look. "Size L latex gloves, under $200 a pallet" is a filter over product documents. It stays exact across hundreds of thousands of records, with no build step and nothing else to keep in sync.

If the content model is simple and you give the agent good schema hints, even a small model can be good at this.

One prerequisite comes with it: an MCP with a dataset source needs a deployed schema for that project and dataset (run `sanity schema deploy` from a Studio on v5.1.0 or later), since that is where the GROQ tools read the schema. An MCP with only Knowledge Base sources doesn't.

## When Knowledge Base mode fits

Use a Knowledge Base when locating the answer is the hard part. "Is this industrial latex food-safe?" may depend on a specification, a compliance memo, and a support article. The pre-generated index gives the agent a strong hypothesis about where the answer lives before it starts reading.

Building from Sanity data also gives you somewhere to apply corrections. When an issue comes from conflicting content, fix the content in the dataset. The next build inherits the correction. See [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases).

## Content that sits between the two

Use GROQ mode for tabular data and Knowledge Bases for knowledge. For cases in between, such as a catalog where useful details live in prose fields, enable [dataset embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) and stay in GROQ mode. GROQ can then combine structured filters with semantic similarity in the same query.

Enabling embeddings is how you cover that middle ground, not mixing source types. An MCP serves one source type: if you attach both a dataset source and Knowledge Base sources, the dataset source wins and the Knowledge Base sources are ignored. The agent gets the GROQ tools, no way to read the Knowledge Bases, and no error explaining why.

## When to enable embeddings

Enable embeddings when your agent needs to query content and won't be able to guess accurately the words or phrases to search for. Product catalogs, help content, and editorial articles are good candidates. Dataset embeddings carry their own costs and quotas; see [Dataset Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) for details.

> [!NOTE]
> Semantic search only ranks; it doesn't filter
> The `text::semanticSimilarity()` function is only valid as an argument to `score()`. Used anywhere else it returns an error. Narrow the candidate set with a filter first, then rank what's left.

## Switching modes

You don't set the mode directly. An MCP's sources determine it: attach a dataset source and the endpoint serves GROQ mode; attach only Knowledge Base sources and it serves Knowledge Base mode. Changing the sources changes what the endpoint serves without touching the agent. To override the mode for a single connection, add `?mode=groq` or `?mode=knowledge_base` to the endpoint URL. See [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp).

> [!WARNING]
> A skipped source can flip the mode
> Because the mode is derived from sources, a source that doesn't resolve changes what the endpoint serves. A dataset source id must be `<projectId>.<datasetName>`; a malformed id is skipped, and an MCP with no other dataset source becomes a Knowledge Base mode endpoint. The GROQ tools are gone, and if there are no Knowledge Base sources either, the connection is refused with `Mode is set to "knowledge_base" but no knowledge bases are configured. Add knowledge-base sources to the MCP endpoint, or switch mode to "groq".`

## Next steps

- [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp). Create an endpoint and attach the sources that set its mode.
- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools). Which tools each mode serves.
- [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases). What a Knowledge Base holds and how it gets built.



# Inline initial context

Agents connected to a Context MCP endpoint call the `initial_context` tool first to orient themselves, which costs a round trip at the start of every conversation. If you control the system prompt (you're building a custom agent rather than plugging into a third-party client), you can fetch the same payload over HTTP, inline it, and drop the tool. This guide shows how.

## Prerequisites

- A configured Context MCP endpoint. See [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp).
- Your organization ID and the endpoint's URL name, both of which appear in the endpoint's URL.
- An organization API token with Context Viewer permissions, created under Manage > API > Tokens at the organization level. Viewer is the least privilege that works; Editor also works.
- A JavaScript or TypeScript agent whose system prompt you control, with `ai@^6`, `@ai-sdk/mcp@^1`, and `@ai-sdk/anthropic@^3` installed.

## What initial context contains

Initial context is mode-aware. In GROQ mode it returns a compressed schema overview along with instructions on how to query your content. In Knowledge Base mode it returns the outline of the Knowledge Bases the endpoint serves. Both modes also carry query-efficiency and grounding instructions plus a list of the tools the endpoint serves, so the text you inline contains behavioral instructions as well as data. Reconcile them with your own.

## Fetch initial context over HTTP

Append `/initial-context` to the MCP URL path, before any query parameters, using the same auth header:

**Terminal**

```sh
curl https://api.sanity.io/v1/context/organizations/YOUR_ORGANIZATION_ID/mcp/YOUR_ENDPOINT_NAME/initial-context \
  -H "Authorization: Bearer $SANITY_ORGANIZATION_TOKEN"
```

`YOUR_ORGANIZATION_ID` is your organization's ID. `YOUR_ENDPOINT_NAME` is the endpoint's URL name: the kebab-case name you gave it when you created it, which cannot be changed afterwards.

`SANITY_ORGANIZATION_TOKEN` holds the organization API token from the prerequisites. A project dataset-read token does not carry the organization-level permission this route requires, and the request fails with a 403. Keep the token server-side: it carries organization-level permissions, so never ship it in browser-visible code.

The response is `text/plain`. Its markdown headings are shifted down one level by default, so a top-level `#` arrives as `##` and nests under headings of your own. Pass `?heading_offset=0` to keep them as authored, or a higher number to nest them deeper.

## Inline the payload and drop the tool

Install the Vercel AI SDK packages first. All three share an internal provider dependency, so their majors have to match. Pairing `ai@6` with a newer `@ai-sdk/mcp` compiles to type errors on `model` and `tools` that name neither package, and look like a mistake in your own code.

**npm**

```shell
npm install ai@^6 @ai-sdk/mcp@^1 @ai-sdk/anthropic@^3
```

**pnpm**

```shell
pnpm add ai@^6 @ai-sdk/mcp@^1 @ai-sdk/anthropic@^3
```

**yarn**

```shell
yarn add ai@^6 @ai-sdk/mcp@^1 @ai-sdk/anthropic@^3
```

**bun**

```shell
bun add ai@^6 @ai-sdk/mcp@^1 @ai-sdk/anthropic@^3
```

Fetch the payload at startup, concatenate it with your own instructions into the `system` prompt, then remove `initial_context` from the tool set you hand over. With the Vercel AI SDK, the MCP client comes from `@ai-sdk/mcp`, which is a separate package from `ai`:

**index.ts**

```typescript
import {createMCPClient} from '@ai-sdk/mcp'
import {anthropic} from '@ai-sdk/anthropic'
import {generateText} from 'ai'

const endpoint =
  'https://api.sanity.io/v1/context/organizations/YOUR_ORGANIZATION_ID/mcp/YOUR_ENDPOINT_NAME'
const headers = {Authorization: `Bearer ${process.env.SANITY_ORGANIZATION_TOKEN}`}

// The same payload the curl command returned, fetched once at startup
const initialContext = await fetch(`${endpoint}/initial-context`, {headers}).then((res) =>
  res.text(),
)

const mcpClient = await createMCPClient({
  transport: {type: 'http', url: endpoint, headers},
})

const allMcpTools = await mcpClient.tools()
const {initial_context: _, ...mcpTools} = allMcpTools

const yourInstructions = 'You are a support agent for Acme safety equipment.'

const result = await generateText({
  model: anthropic('claude-sonnet-5'),
  system: `${yourInstructions}\n\n${initialContext}`,
  tools: mcpTools,
  prompt: 'Which glove grades are rated for solvent handling?',
})

console.log(result.text)
```

## Keep the inlined payload fresh

An inlined payload is a snapshot. It goes stale when your schema changes in GROQ mode, or when a build rewrites the outline in Knowledge Base mode. Refetch it on deploy at minimum, and after any Knowledge Base rebuild.

## Next steps

- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools). The rest of the tools an endpoint serves.
- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns). Shaping agent behavior once the basics work.



# Knowledge Bases

> [!WARNING]
> Early access
> Knowledge Bases are available as an opt-in early access feature. Features and limits may change before general availability. If you are on an Enterprise plan and need higher limits, talk to your Sanity representative.



A Knowledge Base is a pre-built index over material you choose, attached as sources and served to agents through Context MCP. A Knowledge Base belongs to an organization and can draw on sources from more than one project. Instead of reading and reconciling sources at query time, a build reads them ahead of time, resolves conflicts, and writes a set of entries an agent can retrieve from directly.

An agent is only as good as the knowledge it can find. Information spread through prose is the hard case (documents in a Sanity dataset, website subfolders, PDFs, and other files). A Knowledge Base raises that ceiling by reconciling it ahead of time rather than on every question. For when to reach for one instead of GROQ mode, see [Context retrieval modes](https://www.sanity.io/docs/ai/sanity-context-retrieval-modes). To build one, see [Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base).

## The purpose

Every Knowledge Base has a purpose: one or two sentences describing who it serves and what it should help with. You write it when you create the Knowledge Base, and it works at both ends of the pipeline.

During a build, the purpose steers the outline. The tree of topics is designed against it, and it decides how central each entry is: subjects the purpose names come out tagged `[core]`, supporting material is standard, and content that merely arrived with the sources is tagged `[peripheral]`.

Agents read the purpose too. It heads the outline in initial context, right after the title, so it frames what the Knowledge Base is for before the agent chooses what to read.

## The outline

The outline is the pre-generated index for a Knowledge Base. It contains every entry path and a one-line summary of what the entry covers. Entries that are more or less central to the purpose carry a `[core]` or `[peripheral]` tag.

**Outline**

```text
## Acme product knowledge — Product specs, shipping, and support policies
4 entries.

products/latex/gloves [core]
  Glove grades, sizes, and what each is rated for
  topics: Grades, Sizing, Ratings

products/latex/industrial [peripheral]
  Industrial latex specs and tolerances

shipping/import-routes
  Customs paperwork and lead times by region
  related: support/returns

support/returns
  Return windows, exceptions, and who pays the freight
```

The outline is small enough for an agent to hold in context for an entire conversation. When a question arrives, the agent checks the outline, selects the entries most likely to contain the answer, and fetches those entries. Asked about return windows, it goes straight to `support/returns`.

Context MCP serves the outline through the `initial_context` tool, the orientation step at the start of a conversation. Every path in the outline is a readable entry; the hierarchy lives in the slash-delimited paths rather than in separate grouping rows. See [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools).

## Entries

Each entry is a Markdown document written from the Knowledge Base's sources, with citations back to the original source.

Entries belong to a build. Every build can rewrite them, and they cannot be edited by hand. To change what a Knowledge Base says, update the source or add an instruction.

## Issues

The same fact often appears in more than one place, and the copies drift. A help center says returns are accepted within 30 days; a product page says 45. Leaving the decision until query time asks the agent to reconcile the conflict every time someone asks.

A Knowledge Base detects conflicts during the build and raises an issue. The issue shows the claims side by side with where each came from, and you resolve it by choosing which claim is ground truth. Issues also flag structural problems: a topic the outline misses, an entry whose sources are gone, or one that has grown to cover two subjects. You apply those rather than resolving them. Coverage gaps are recorded but not surfaced for review. For how to work through the issues you can act on, see [Resolve Knowledge Base issues](https://www.sanity.io/docs/ai/sanity-context-resolve-issues).

## Instructions

Instructions are standing decisions. Use them for anything Context should remember between builds, such as which source to trust when claims differ or how to describe a policy. Each instruction is tied to one or more sources, which is what lets a build retire it when those sources change. Resolving a conflict produces one.

You can also write instructions yourself in the **Instructions** view of the Context app: state the rule in plain language and choose the sources it anchors to. A rule shapes every entry that cites its sources, and it applies when those entries are next written, so rebuilding an entry from the outline applies a new rule right away. When you save a rule, Context checks the entries citing its sources and flags any that contradict it.

Building from Sanity data also gives you somewhere to apply corrections. When an issue comes from conflicting content, fix the content in the dataset. The next build inherits the correction, and an instruction the updated sources no longer support is archived automatically and surfaced for you to review.

## What a build does

A build reads the sources, creates a tree of topics, writes each entry, and checks the result. Contradictions and structural problems surface as issues for you to review.

The tree follows what the sources are about rather than folder structure, URL paths, or file names. Three overlapping PDF manuals and a documentation site can become one set of topics. Each fact should have one home, and everything the sources cover should appear somewhere in the tree.

This work happens during the build. When an agent receives a question, the outline is ready and ambiguities have already been handled.

## Next steps

- [Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base): build one from your sources and review the result.
- [Knowledge Base source types](https://www.sanity.io/docs/ai/sanity-context-source-types): what datasets, websites, and files each accept.
- [Keep a Knowledge Base current](https://www.sanity.io/docs/ai/sanity-context-maintain-knowledge-base): refresh schedules and rebuilds.



# Knowledge Base source types

> [!WARNING]
> Early access
> Knowledge Bases are available as an opt-in early access feature. Features and limits may change before general availability. If you are on an Enterprise plan and need higher limits, talk to your Sanity representative.



A Knowledge Base draws on three kinds of source. A dataset source reads documents from a Sanity dataset, a website source crawls from a starting URL, and a file source ingests uploaded documents. One Knowledge Base can combine all three kinds, and it needs at least one source. Dataset and website sources are re-checked on the Knowledge Base's refresh schedule. That schedule is one setting for the whole Knowledge Base rather than a setting per source, and uploaded files are never re-checked.

## Dataset sources

Documents from a Sanity dataset, selected with a complete GROQ query such as `*[_type == "article"]`. Add a projection to control what each document carries, as in `*[_type == "article"]{title, body}`. The query has to select documents from the dataset, so a bare filter such as `_type == "article"` is rejected. Start with a narrow query; you can widen it later.

A Knowledge Base binds one dataset. To point at a different dataset, remove the dataset source and add a new one; the query itself stays editable. Website and file sources can be added repeatedly. A dataset source reads published documents only, and a query that matches nothing is rejected. Connecting one takes a role on the source project that can create datasets, which among the default roles means Administrator or Developer, plus unrestricted read access to the dataset itself. A role whose read grant is filtered to a subset of documents is not enough. One dataset source matches at most 5,000 documents, so narrow the query if you exceed that.

## Website sources

A crawl starting from a URL. Crawls respect `robots.txt`. Use the most specific URL you can. A docs section is usually more useful than a whole domain.

## File sources

Uploaded files never re-sync. To update one, delete the import and upload the new version; deleting an import removes every source it produced. Archives are expanded and their contents ingested individually, each by its own format.

A file source reads the formats below, subject to the size limits shown.

##### File formats and size limits

| Format | Maximum size |
| --- | --- |
| PDF | 500 MB |
| DOCX | 100 MB |
| PPTX | 100 MB |
| XLSX | 50 MB |
| HTML | 25 MB |
| PNG, JPEG, WebP, TIFF | 25 MB. Images under 50 KB skip text extraction. |
| AsciiDoc | 25 MB |
| Markdown, plain text | Ingested directly |
| Source code, JSON, XML, CSV, TSV, YAML, and other plain-text formats | Ingested verbatim |
| ZIP, TAR, and TAR.GZ archives | Expanded, then each file ingested by its own format |

Around forty further text formats are ingested verbatim in the same way, so the table is not the full list of what a file source reads. A file in a format with no handler is not rejected: it is recorded with its metadata, but its contents are not read. Every upload is capped at 5 GiB, and for the formats listed above the per-format limit applies first.

## Choosing between connecting and uploading

If the material changes regularly, connect it as a dataset or website source rather than uploading a snapshot. Uploads are right for material that is fixed, or that has no addressable source, such as a signed policy PDF or an export from a system Context cannot reach.

## Next steps

- [Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base). Add a source and run the first build.
- [Keep a Knowledge Base current](https://www.sanity.io/docs/ai/sanity-context-maintain-knowledge-base). Which sources refresh, and how often.



# Create a Knowledge Base

> [!WARNING]
> Early access
> Knowledge Bases are available as an opt-in early access feature. Features and limits may change before general availability. If you are on an Enterprise plan and need higher limits, talk to your Sanity representative.



A Knowledge Base turns material you already have (a dataset, a website, a set of files) into an index an agent can retrieve from. This guide takes you from an empty Knowledge Base to a built one you have reviewed and connected to an agent.

## Prerequisites

- **Context enabled for your organization.** An organization admin can enable it from the Apps page of your organization in Manage.
- **Material to build from:** a website, files, or documents in a Sanity dataset. See [Knowledge Base source types](https://www.sanity.io/docs/ai/sanity-context-source-types) for what each accepts.
- **Room on your plan:** your organization's plan caps how many Knowledge Bases it can hold. Creating one beyond that cap fails with a plan-limit error.

## Title the Knowledge Base and write a purpose

In the Sanity Dashboard, open **Context** and click **New knowledge base**. Enter a short, human-readable **Title** such as `Vandelay support`, then a **Purpose** of one or two sentences describing the audience and the job it should help with.

Click **Create knowledge base**. Sanity creates the Knowledge Base and opens it so you can add sources.

The purpose is the starting point for the outline, before Context has read any material. "Customer-facing support Knowledge Base for Vandelay Industries. Covers importing, exporting, product specs, ordering, and returns" gives the build more to work with than "Vandelay docs." The purpose also decides which entries the build tags as core, and agents read it at the head of the outline.

## Add a first source

A Knowledge Base needs at least one source. Click **Add source** and choose **Dataset**, **Website**, or **Files**.

For the first build, use a focused set of current material you trust. This makes the result easier to review and leaves stale duplicates out of the build. If the material changes regularly, connect it as a dataset or website source rather than uploading a snapshot. Your organization's plan also caps how many sources a Knowledge Base can hold, enforced when you build.

## Build the entries

Click **Build entries** after adding the material. Larger builds take longer.

The build is done when the status line reads **Entries up to date**. If it reads **Build failed** or reports that no sources could be processed, remove the failed sources or add new ones, then build again.

## Review what the build produced

When the build finishes, open **Entries** to see the tree and what was written. Check that the topics you expected are present and that the summaries describe them accurately. **Issues** contains conflicts and other questions that need a decision. Start with the ones that could change important answers. See [Resolve Knowledge Base issues](https://www.sanity.io/docs/ai/sanity-context-resolve-issues).

![A screenshot of entries from a knowledge base, showing the entries structure and the contents of a single entry.](https://cdn.sanity.io/images/3do82whm/next/b485b4a7c6cea69177fd24fdd105d843abf00e0c-2776x1800.png)

Then test the Knowledge Base through an agent with questions people will actually ask. "What is the return window on bulk orders?" gives you something specific to check. "Tell me about Vandelay" does not.

## Serve a Knowledge Base to an agent

Agents read Knowledge Bases through Context MCP. Two query parameters switch the endpoint to Knowledge Base mode:

- `mode=knowledge_base` serves Knowledge Base tools instead of GROQ tools.
- `knowledgeBases=KNOWLEDGE_BASE_ID` selects a Knowledge Base by its public id, which begins with `kb`. Separate several ids with commas.

For a deployed application, add the Knowledge Base as a source on the MCP in the Context app instead of passing these in the URL. An endpoint whose sources are all Knowledge Bases serves Knowledge Base tools automatically, and you can change what it serves without updating the agent configuration. An endpoint that also has a dataset source serves GROQ tools and ignores its Knowledge Base sources. See [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp).

The endpoint needs an organization API token with Context Viewer permissions, created under **Manage > API > Tokens** at the organization level, not a project read token. Test the connection with specific questions whose answers you already know.

## Next steps

- [Keep a Knowledge Base current](https://www.sanity.io/docs/ai/sanity-context-maintain-knowledge-base). Refresh schedules, change detection, and rebuilds.
- [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases). How the outline, entries, and instructions fit together.



# Keep a Knowledge Base current

> [!WARNING]
> Early access
> Knowledge Bases are available as an opt-in early access feature. Features and limits may change before general availability. If you are on an Enterprise plan and need higher limits, talk to your Sanity representative.



Your sources keep changing after you build. Dataset and website sources can refresh on a schedule; uploaded files stay as they are until you replace them. This guide covers keeping a built Knowledge Base in step with its sources.

## Prerequisites

A Knowledge Base with at least one completed build. See [Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base).

## Set a refresh schedule

Website sources and connected datasets can refresh on a schedule. In **Settings**, set **Refresh interval** to **Weekly**, **Monthly**, or **Off**. New Knowledge Bases default to **Weekly**, and the setting saves as soon as you select it. The field appears only when the Knowledge Base has a website or a dataset source.

A refresh does not rewrite your entries. It recrawls the sources, compares them against the last build, and files issues describing what needs to change. Your entries change when you apply those issues, so a schedule on its own does not keep a Knowledge Base current. While issues are open, the Knowledge Base sits in **Review** and keeps serving the entries from the last build.

## Respond to detected changes

When the material changes, the Knowledge Base overview shows **Changes detected**. Click it to see what changed in each source, or click **Check for changes** to run a refresh now instead of waiting for the next scheduled run. Applying the issues a refresh files keeps unchanged entries where they are: the outline changes only where the material has changed shape.

Agents receive refreshed content through the same outline, so no change is needed on the agent side. If you have inlined initial context into a system prompt, refetch it after a rebuild. See [Inline initial context into your system prompt](https://www.sanity.io/docs/ai/sanity-context-initial-context).

## Replace an uploaded file

Uploaded files remain unchanged until you replace them. If a document is revised regularly, connect its source instead of uploading a snapshot so refreshes pick the change up on their own.

## Decide between a refresh and a full rebuild

A refresh files issues; applying them updates only the entries the changed material affects. A full rebuild reruns the whole pipeline over the current sources and replaces the outline. Every entry is written again, so anything the previous build decided is reconsidered.

Rebuild when the Knowledge Base overview shows a **Rebuild required** callout, when you have changed the Knowledge Base's purpose, when the overview reports that pipeline improvements are available, or when the Knowledge Base has only uploaded files and so has no refresh path. A purpose change is the one case nothing else flags: the refresh compares sources only, so **Check for changes** reports nothing after you edit the purpose. Adding a source does not need a rebuild, since new material arrives as issues once the upload finishes ingesting.

A build you regret is not final. In **Entries**, open an earlier version of the outline and select **Restore this version** to republish that build's entries. The restore holds until the next build overwrites it, so use it to buy time while you fix the purpose, sources, or instructions that caused the bad build.

## Next steps

- [Resolve Knowledge Base issues](https://www.sanity.io/docs/ai/sanity-context-resolve-issues). Handle the conflicts a refresh or a rebuild surfaces.
- [Knowledge Base source types](https://www.sanity.io/docs/ai/sanity-context-source-types). Which sources can refresh and which cannot.



# Resolve Knowledge Base issues

> [!WARNING]
> Early access
> Knowledge Bases are available as an opt-in early access feature. Features and limits may change before general availability. If you are on an Enterprise plan and need higher limits, talk to your Sanity representative.



When the same fact appears in more than one source and the copies disagree, a build raises a conflict rather than guessing. Resolving it once turns your decision into an instruction that carries into every future build. Other issues arrive as a single proposed change to an entry, which you apply or dismiss. This guide covers both paths through the queue.

## Prerequisites

A Knowledge Base with at least one completed build. See [Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base).

## Read a conflict and its sources

In the Context app in the Sanity Dashboard, open your Knowledge Base and select **Issues**. A conflict shows the two claims side by side, together with where each one came from. A help center saying returns are accepted within 30 days and a product page saying 45 is a typical example.

![A screenshot showing how a conflict is presented as an issue, and the options to resolve the conflict.](https://cdn.sanity.io/images/3do82whm/next/3f96dec5ef57d1698d2f95ec6197de3c7e0a28ed-1402x1092.png)

## Choose which claim is correct

Select **Keep the current entry** or **Accept the incoming claim**, then click **Resolve issue**. A conflict has no third answer, so when neither source is right, correct the source material instead of resolving the issue.

The app then confirms which of two things happened. "The entry is being updated now, and saved as an instruction for future rebuilds." means a rewrite of the affected entry is already running as a background job. "Saved as an instruction that shapes your content on the next rebuild." means nothing changes until the next build. Either way, the instruction stands until the source material changes or you reopen the issue.

## Apply a suggested fix

Only conflicts are resolved by picking a claim. An issue that proposes adding, removing, or splitting an entry carries one suggested fix instead, described as "Apply makes this change to your knowledge base." Click **Apply** to make it: a job rewrites the affected entries and commits a new revision. Click **Dismiss** to reject the proposal, or **Edit manually** to open the entry and change it yourself.

Not every issue kind can be actioned in the app. One that cannot shows "Manual action required. This issue type is not executable yet." and offers only **Dismiss** and **Edit manually**.

## Dismiss or reopen an issue

Dismissing an issue rejects it without applying anything, and dismissing twice is safe. The confirm dialog reads "It won't be applied and moves to the dismissed list. You can still reopen the dismissed filter to find it later." Open a dismissed conflict and the resolution panel reads "This issue was dismissed, so no resolution will be applied." It cannot be resolved until it is reopened.

To undo a decision, click **Reopen and change your mind**. Reopening returns an accepted conflict to triage, clears its resolution, and deletes the instruction that resolution created.

## Correct the source instead of resolving the issue

When the conflict comes from content you control, correcting the source is the more durable fix. Update the material and the next build inherits the correction. An instruction the updated material no longer supports is archived automatically, with a reason, and surfaced for you to delete or re-scope rather than removed silently.

## Decide which issues to act on

You do not need to empty the queue before using a Knowledge Base. The queue holds conflicts, plus proposals to add, remove, split, or merge an entry. Issues of other kinds are not surfaced for review. Issues marked **Critical** affect a fact an agent is likely to state, so start there.

## Next steps

- [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases). How issues and instructions relate to entries and the outline.
- [Keep a Knowledge Base current](https://www.sanity.io/docs/ai/sanity-context-maintain-knowledge-base). Refresh schedules and rebuilds.



# Add insights to Sanity Context

Sanity Context Insights captures conversations between your users and your AI agent and classifies them with an LLM. Once set up, Insights appears in the Context app in the Sanity Dashboard, showing where the agent succeeds, where it struggles, and what content is missing. Use this data to improve the agent over time.

![A screenshot of the Context insights dashboard showing trends, scores, sentiments, recent conversations, activity and KPIs.](https://cdn.sanity.io/images/3do82whm/next/2bc26893a293ea0c5c3a22ba82c14587bbe88a87-2460x1692.png)

## How it works

Sanity Context insights has two parts that work together:

- **Telemetry**: saves conversations from your chat application to your organization's Context store.
- **Classification**: a scheduled function that analyzes saved conversations with AI, extracting success scores, sentiment, and content gaps.

Telemetry alone stores raw conversations. Classification populates Insights in the Context app. You need both.

## Classification metrics

##### Classification metrics

| Metric | Type | Description |
| --- | --- | --- |
| successScore | 1–10 | How well the agent resolved the user's needs |
| sentiment | positive / neutral / negative | Overall user tone |
| contentGaps | string[] | Topics where the agent lacked information |

## Prerequisites

- **Code running Sanity Context**: [Follow the setup instructions](https://www.sanity.io/docs/ai/sanity-context). The code examples below will add to your existing implementation.
- Your **organization ID**: find it in [Manage](https://www.sanity.io/manage) or in your organization's URL.
- An **organization API token**, created under Manage > API > Tokens at the organization level. Conversations are saved to your organization's Context store, not to a project dataset, so no dataset write token is involved. Keep the token server-side.
- **LLM API key**: For classifying conversations, you'll need an API key from an LLM provider (Anthropic, OpenAI, etc.).

## Setup

### Step 1: Enable telemetry integration

#### AI SDK

Add `sanityInsightsIntegration` to your existing `streamText` calls:

**chat/route.ts**

```typescript
import {createClient} from '@sanity/client'
import {sanityInsightsIntegration} from '@sanity/context/ai-sdk'
import {streamText} from 'ai'

// Org-scoped client. Keep the token server-side only.
const client = createClient({
  apiVersion: 'v2025-11-27',
  token: process.env.SANITY_API_TOKEN,
  context: {organizationId: process.env.SANITY_ORGANIZATION_ID},
  useCdn: false,
  useProjectHostname: false,
})

const result = streamText({
  model: yourModel,
  messages,
  experimental_telemetry: {
    isEnabled: true,
    integrations: [
      sanityInsightsIntegration({
        client,
        threadId: chatId, // Any unique string per conversation
        // The well-known mcpEndpoints key tags the conversation with an MCP endpoint name
        metadata: {mcpEndpoints: process.env.SANITY_CONTEXT_ENDPOINT_NAME ?? []},
      }),
    ],
  },
})
```



#### Custom integration

If you're not using Vercel's AI SDK, save the transcript directly with `client.context.conversations.save`. Call it after each turn with the full conversation history; repeated saves with the same `threadId` update the same conversation.

**chat/route.ts**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  apiVersion: 'v2025-11-27',
  token: process.env.SANITY_API_TOKEN, // Keep server-side only
  context: {organizationId: process.env.SANITY_ORGANIZATION_ID},
  useCdn: false,
  useProjectHostname: false,
})

await client.context.conversations.save({
  threadId: chatId, // Any unique string per conversation
  messages: [
    {role: 'user', content: 'How do I return an item?'},
    {role: 'assistant', content: 'You can return items within 30 days...'},
  ],
  // Optional: tag with the MCP endpoint name(s) the agent used
  metadata: {mcpEndpoints: ['support-agent']},
})
```



The `metadata` option tags a conversation with your own dimensions: up to 20 keys, each holding a string or an array of strings, such as plan, environment, or app version. The `mcpEndpoints` key is well-known: set it to the name of the MCP endpoint the agent used, and the endpoint filter in the Context app groups the conversation under that endpoint.

### Step 2: Deploy the classification function

The classification function is a scheduled job that runs outside your app using [Sanity Functions](https://www.sanity.io/docs/functions/scheduled-function-quickstart). It finds unclassified conversations and analyzes them with an LLM of your choice. The classification interval, how often the function runs, is up to you. You may want it to run once a day to accommodate daily updates, or more frequently if your agent receives more traffic.

Here's an example function:

**functions/classify-conversations/index.ts**

```typescript
import {anthropic} from '@ai-sdk/anthropic'
import {createClient} from '@sanity/client'
import {classifyConversations} from '@sanity/context/insights'
import {scheduledEventHandler} from '@sanity/functions'

export const handler = scheduledEventHandler(async () => {
  const client = createClient({
    apiVersion: 'v2025-11-27',
    token: process.env.SANITY_API_TOKEN,
    context: {organizationId: process.env.SANITY_ORGANIZATION_ID},
    useCdn: false,
    useProjectHostname: false,
  })

  const result = await classifyConversations({
    client,
    // Optional: only classify conversations tagged with this MCP endpoint
    mcpEndpoint: process.env.SANITY_CONTEXT_ENDPOINT_NAME,
    model: anthropic('claude-haiku-4-5'),
  })

  console.log(
    `Classified ${result.successCount}/${result.totalFound} conversations${result.errorCount > 0 ? ` (${result.errorCount} failed)` : ''}`,
  )
})
```

For blueprint configuration, deployment, and token setup, see the [Sanity Functions documentation](https://www.sanity.io/docs/functions/scheduled-function-quickstart).

## Primitives reference

##### Primitives reference

| Primitive | Import | Purpose |
| --- | --- | --- |
| sanityInsightsIntegration | @sanity/context/ai-sdk | AI SDK telemetry integration |
| client.context.conversations.save | @sanity/client | Save a conversation transcript directly |
| getConversationsToClassify | @sanity/context/insights | Fetch conversations ready for classification |
| getPreviousContentGaps | @sanity/context/insights | Fetch known content gaps to avoid duplicates |
| classifyConversation | @sanity/context/insights | Classify a conversation and write results back |
| classifyConversations | @sanity/context/insights | Classify all pending conversations and write results back. Wraps the three primitives above. |

## Telemetry sharing

You can opt in to share conversation telemetry with Sanity. Both levels are off by default. Set `sharing` where you save conversations, on the telemetry integration or on direct saves:

- `metrics`: shares classification metrics (scores, sentiment, content-gap and message-shape counts), model info, and token usage. No conversation content is included.
- `conversations`: also shares full transcripts, and implies metrics. Provide a contact so the team can reach out and help dial in your agent.

**chat/route.ts**

```typescript
sanityInsightsIntegration({
  client,
  threadId: chatId,
  sharing: {
    metrics: true,
    conversations: true,
    contact: 'you@company.com',
  },
})
```



# Sanity Context patterns and best practices

Sanity Context gives an AI agent schema-aware, read-only access to your content through a hosted MCP server. This guide skips installation and prerequisites and focuses on the patterns that matter once you build for production: a public assistant, a personalized assistant for signed-in users, and an agent that uses Sanity Context alongside other backends. For setup, the MCP endpoint shape, and the tools Sanity Context exposes, see [Sanity Context](https://www.sanity.io/docs/ai/sanity-context).

## Best practices

These tips apply to every Sanity Context implementation, whether your users are logged out or logged in.

1. **Configure through an MCP in the Context app, not just URL query params.** The MCP holds `title`, `name`, `sources`, `instructions`, and `groqFilter`. Storing config in the Context app lets your team tune agent behavior without a deploy. Reserve query params for runtime overrides.
2. **Write a real instructions field.** This is the domain knowledge the schema can't express: misleading field names, filters that should always apply, non-obvious relationships, and good query patterns. The schema tells the agent what fields exist. The instructions tell it how your business actually uses them.
3. **Write a focused system prompt of roughly 200 to 400 words.** Cover audience, tone, boundaries, and fallback behavior, such as "if you can't find it, say so, don't guess." Keep retrieval guidance in the instructions field and behavior and voice in the system prompt. Don't mix the two.
4. **Render structured results as UI, not prose.** Results come back as structured data, so stream them into real components such as product cards, order rows, and document links instead of letting the model re-narrate them. This keeps prices and inventory exact and avoids re-hallucination. The [agent-directives](https://www.npmjs.com/package/@sanity/agent-directives) package can help with this.
5. **Prefer the initial context endpoint over the tool. **If you control the system prompt, you should include the initial context in your prompt to save a tool call for every conversation. This reduces user-facing latency.
6. **Use Agent Insights to close the loop.** The toolkit logs and classifies conversations for success score, sentiment, and content gaps. Use the gaps to improve your content and instructions over time.

### Security rules that are non-negotiable

- Pass the organization API token as a Bearer token, server-side only. Never ship it to the browser.
- The browser talks to your server, which talks to the MCP. The client never holds the MCP URL or token.
- `groqFilter` is your access-control boundary. Treat any user-scoped value in it as trusted and server-derived only, taken from the session, never from client input.

## The public assistant pattern (logged-out)

Use this pattern for a support-and-browse assistant on a public marketing or storefront site. Every visitor is anonymous, so everyone sees the same scope of content. The filter is static and contains nothing sensitive.

### Configure the Context document

Store the scope and the domain knowledge in the Context document so editors can tune it without a deploy.

**MCP configuration**

```text
title:        "Public Site Assistant"
name:         "public-assistant"
sources:      your project's production dataset
groqFilter:   _type in ["product", "article", "faq"] && status == "published"
instructions: |
  You answer questions about our catalog and help docs.
  - "price" is the list price in USD. Use `salePrice` when `onSale == true`.
  - Stock lives on `inventory.available` (a number). 0 means out of stock, say so.
  - A product's brand is a reference: dereference with brand->{name}.
  - For "similar to X" or vibe-based queries, rank with
    text::semanticSimilarity() over title + description.
  - Never invent SKUs, prices, or stock. If a query returns nothing, say you
    couldn't find a match and suggest broadening.
```

`groqFilter` is a filter expression, not a full query: `_type == "product"`, not `*[_type == "product"]`. Published documents are visible by default, so the `status == "published"` clause here is your own editorial flag, not the perspective control.

### Add the server route

The token and the MCP URL stay server-side. The browser only ever talks to this route.

**app/api/assistant/route.ts**

```typescript
// app/api/assistant/route.ts
import {createMCPClient} from '@ai-sdk/mcp'
import {streamText, convertToModelMessages} from 'ai'
import {anthropic} from '@ai-sdk/anthropic'

const MCP_URL =
  'https://api.sanity.io/v1/context/organizations/' +
  `${process.env.SANITY_ORGANIZATION_ID}/mcp/public-assistant`

const SYSTEM_PROMPT = `
You are the assistant for Acme's public website. You help anonymous visitors
find products and answer support questions from our published content.

Audience: prospective customers, no account, varying technical level.
Tone: friendly, concise, never pushy.

Rules:
- Answer only from retrieved content. Use the tools for every factual claim
  about products, prices, stock, or policies.
- If retrieval returns nothing, say you couldn't find it and offer to broaden
  the search. Do not guess or invent details.
- When you list products, return them as structured data for the UI to render
  as cards, do not re-describe prices in prose.
- Don't discuss anything outside Acme's catalog and help content.
`.trim()

export async function POST(req: Request) {
  const {messages} = await req.json()

  // Token and URL stay server-side. The browser never sees either.
  const mcp = await createMCPClient({
    transport: {
      type: 'http',
      url: MCP_URL,
      headers: {Authorization: `Bearer ${process.env.SANITY_ORGANIZATION_TOKEN}`},
    },
  })

  const tools = await mcp.tools()

  const result = streamText({
    model: anthropic('claude-sonnet-4-5'),
    system: SYSTEM_PROMPT,
    messages: convertToModelMessages(messages),
    tools,
    stopWhen: ({steps}) => steps.length >= 8, // allow multi-step tool use
    onFinish: () => mcp.close(),
  })

  return result.toUIMessageStreamResponse()
}
```

That's the whole logged-out pattern: one static document, one server route, and a token that never leaves the server. Visitors are interchangeable.

## The personalized assistant pattern (logged-in)

Use this pattern when a signed-in user asks something like "what should I wear for a winter trail run?" You want recommendations from your catalog, which Sanity owns, tailored to this user: their sizes, the brands and categories they buy, what they already own, and their budget tier.

Here is the architectural point. Sanity is not where the user's orders or profile live. Those belong to your commerce or order management system and your CRM. So the logged-in pattern is not "scope Sanity to the user's private records." Instead, use the session to gather user signals from the systems that own them, then feed those signals into the Sanity agent as context that shapes catalog queries and ranking. Sanity stays the catalog layer, and personalization rides on top.

### Where each signal comes from

##### Signal sources

| Signal | Source (owns it) | How the agent uses it |
| --- | --- | --- |
| Identity | Auth session | Trusted key to look up the rest, never from client input |
| Past purchases, owned SKUs | Commerce / OMS | Exclude owned items, infer taste |
| Sizes, preferred brands | CRM / profile service | Hard filters on catalog queries |
| Budget tier, loyalty status | CRM | Bias price range and perks |
| The catalog itself | Sanity (Sanity Context) | Source of recommendable products |

### Configure the Context document

There is no per-user scoping here, because the catalog is public content. Reuse the public catalog document or keep a recommendations-tuned one.

**MCP configuration**

```text
title:        "Recommendations Assistant"
name:         "recommendations-assistant"
sources:      your project's production dataset
groqFilter:   _type == "product" && status == "published" && inStock == true
instructions: |
  You recommend products from the catalog. Personalization signals about the
  current shopper are provided in the system prompt, treat them as inputs.
  - Respect any stated size: filter on variants[].size.
  - Exclude SKUs the shopper already owns (provided as a list).
  - For taste-based queries, rank with text::semanticSimilarity() over
    title + description using the shopper's taste summary.
  - Stay within the shopper's budget tier unless they ask to see more.
```

### Enrich the session, then inject the signals

Identify the user from the verified session, pull their signals from the systems that own them, then inject those signals into the system prompt as inputs the model uses to form catalog queries.

**app/api/recommendations/route.ts**

```typescript
// app/api/recommendations/route.ts
import {createMCPClient} from '@ai-sdk/mcp'
import {streamText, convertToModelMessages} from 'ai'
import {anthropic} from '@ai-sdk/anthropic'
import {auth} from '@/lib/auth'
import {getUserSignals} from '@/lib/commerce' // talks to OMS/CRM, NOT Sanity

const MCP_URL =
  'https://api.sanity.io/v1/context/organizations/' +
  `${process.env.SANITY_ORGANIZATION_ID}/mcp/recommendations-assistant`

export async function POST(req: Request) {
  // 1. Identify the user from the verified session (not the request body).
  const session = await auth(req)
  if (!session?.user?.id) return new Response('Unauthorized', {status: 401})

  // 2. Pull signals from the systems that OWN them. Sanity is not involved here.
  const {sizes, ownedSkus, preferredBrands, budgetTier, tasteSummary} =
    await getUserSignals(session.user.id)

  const {messages} = await req.json()

  // 3. Connect to the public catalog. No per-user groqFilter needed,
  //    personalization is about shaping queries, not hiding data.
  const mcp = await createMCPClient({
    transport: {
      type: 'http',
      url: MCP_URL,
      headers: {Authorization: `Bearer ${process.env.SANITY_ORGANIZATION_TOKEN}`},
    },
  })
  const tools = await mcp.tools()

  // 4. Inject the signals as context the model uses to form catalog queries.
  const system = `
You are Acme's personal shopping assistant for a signed-in customer.
Recommend products from the catalog (via the Sanity tools), tailored to the
shopper profile below. Order and account questions are not yours, defer those.

Shopper profile (server-provided, trusted):
- Sizes: ${sizes.join(', ') || 'unknown'}
- Preferred brands: ${preferredBrands.join(', ') || 'none on file'}
- Budget tier: ${budgetTier}
- Taste summary: ${tasteSummary}
- Already owns (exclude these SKUs): ${ownedSkus.join(', ') || 'none'}

Rules:
- Use the shopper's sizes and brands as filters; exclude owned SKUs.
- For "something like X" or vibe requests, rank by semantic similarity to the
  taste summary.
- Recommend only real catalog items returned by the tools, never invent.
- Render results as product cards for the UI.
`.trim()

  const result = streamText({
    model: anthropic('claude-sonnet-4-5'),
    system,
    messages: convertToModelMessages(messages),
    tools,
    stopWhen: ({steps}) => steps.length >= 8,
    onFinish: () => mcp.close(),
  })

  return result.toUIMessageStreamResponse()
}
```

### What changed from scoping Sanity to the user

- **Sanity stays the catalog layer.** It's queried for recommendable products, not for the user's private records, which it doesn't hold.
- **Signals flow in as context, not as access scope.** Personalization shapes which catalog queries the agent writes and how it ranks, rather than restricting what it can see.
- **Privacy lives at the app layer.** Purchase history and profile are sensitive, so they're handled server-side and injected into the prompt. They never reach the client and shouldn't be written to verbose logs. There's no Sanity-side data-leakage boundary to fail closed, because the catalog is public.

**When does per-user visibility scoping apply?** If you genuinely store user-owned documents in Sanity, such as saved items, personalized landing pages, or a SaaS where each customer's content lives in the dataset, then a per-request `groqFilter` scoped to a session-derived ID is the right tool. Add a fail-closed base filter like `_id == "never-matches"`, and take the ID only from the verified session. For the common ecommerce case, the enrichment-driven personalization above is the better fit.

## Sanity Context in a multi-backend agent

Most real agents talk to more than one backend: Sanity Context for structured content, plus a commerce API for live inventory, a payments system, a support-ticket tool, internal microservices, web search, and so on. The first thing to understand is how the agent decides which one to call.

### There is no built-in router

Every backend you connect exposes its capabilities as tools, and all of those tools land in one flat list that the model sees on each turn. The model chooses which tool to call by matching each tool's name and description against the conversation. That's the whole mechanism. There is no layer underneath that inspects a question and routes it to the right system. You are the router, and you express routing through tool descriptions, the system prompt, and how you scope each backend.

This matters specifically for Sanity Context because its three tools, `groq_query`, `schema_explorer`, and `initial_context`, are intentionally generic. They describe a query mechanism, not a domain. Sanity Context will never advertise itself as "the product catalog." So if you also connect a commerce MCP with a `search_products` tool, the model now sees two plausible ways to find products and will route inconsistently unless you disambiguate. The fix is to supply the domain framing the generic tools lack.

### Decide ownership first

Before writing any prompt, write down which system is the source of truth for each kind of data. This table is the thing you'll encode everywhere else.

##### Source-of-truth ownership

| Data | Source of truth | Why |
| --- | --- | --- |
| Catalog, articles, help/FAQ, marketing copy | Sanity (Sanity Context) | Structured, editorially owned, queryable |
| Live inventory, order status, shipping | Commerce / OMS API | Real time, changes by the second |
| Payments, refunds | Billing system | System of record, side-effecting |
| Support tickets | Helpdesk tool | Owns the conversation history |

The rule of thumb: Sanity owns "what is this thing and how do we describe it." Operational systems own "what is its live state right now." Keep that split clean and most routing questions answer themselves.

### Four levers, cheapest to most powerful

1. **A routing table in the system prompt.** This is the highest-leverage, do-it-first move. Turn the ownership table into explicit instructions:

```text
Data ownership, pick the right tool:
- Catalog, articles, help/FAQ content -> Sanity tools (groq_query, etc.)
- Live order status and inventory counts -> Commerce API tools
- Refunds and billing -> Billing tools
- Support tickets -> Helpdesk tools

When a question spans systems, get canonical IDs and descriptions from
Sanity first, then look up live state in the system of record.
Never answer about live inventory or order status from Sanity content.
```

1. **Make the boundaries real, not just described.** Use Sanity Context's `groqFilter` so Sanity cannot return things it shouldn't own, and use the instructions field to say what isn't in the dataset ("live stock is not stored here, use the inventory tool"). Now a misroute to Sanity returns nothing and the model self-corrects. Structural scoping beats prompt instructions because it fails safe: the agent can't leak across a boundary that doesn't physically exist.
2. **Shape the tool set in your app before handing it to the model.** `mcp.tools()` returns a plain object keyed by tool name. You can subset it, merge backends deliberately, and, because the keys and descriptions are just data, give Sanity Context's generic tools clearer, domain-loaded framing:

```typescript
const sanityTools = await sanityMcp.tools()
const commerceTools = await commerceMcp.tools()

// Re-describe the generic Sanity tools so the model knows their domain.
const tools = {
  ...commerceTools, // live order/inventory tools, already domain-named
  query_content: {
    ...sanityTools.groq_query,
    description:
      'Query the CONTENT catalog (products, articles, help docs) in Sanity. ' +
      'Use for descriptions, specs, copy, and canonical IDs, NOT live stock or orders.',
  },
  explore_content_schema: sanityTools.schema_explorer,
}
// streamText({ tools, system, messages, ... })
```

Fewer, more distinct tools route better than many overlapping ones.

1. **For real complexity, don't use one mega-agent.** Two patterns scale better than dumping every MCP into a single loop. With pre-classification, a cheap first model call decides the domain ("content," "orders," or "billing"), and you expose only that backend's tools for the actual answer. With a supervisor plus sub-agents, a router agent delegates to a Sanity sub-agent and a commerce sub-agent, each holding only its own tools. Small, unambiguous tool lists improve accuracy and cut cost, because every connected MCP adds its tool definitions to the token bill on every turn.

### Cross-system answers are a feature

The handoff is the point, not just a hazard to avoid. The clean pattern resolves a canonical ID and editorial detail from Sanity, then hands off to the operational system for live state:

```text
User: "Is the Trailblazer jacket in stock in medium, and what's it made of?"

1. Sanity (query_content):   find the jacket -> { sku: "TJ-001", material: "recycled nylon", ... }
2. Commerce API (get_stock): live inventory for SKU "TJ-001", size M -> 4 in stock
3. Agent composes:           material from Sanity, stock count from the live system
```

Sanity Context is well suited to being the "what is this thing" layer that resolves a canonical ID and editorial detail, then hands off to the operational system for live state. Guide the agent to do exactly that ordering in the prompt.

### Routing checklist

- Keep the tool count per agent low. Split into sub-agents before the list gets long.
- Give every tool a description that names its domain and its boundary ("...not live stock").
- Encode the source-of-truth table in the system prompt verbatim.
- Scope each backend so overlap is physically impossible, not just discouraged.
- Test the ambiguous cases explicitly. The classic trap is "where's my order?" when both a CMS order archive and a live order API exist.
- Watch latency and token cost. Each connected MCP is paid for on every turn.

## Next steps

If you haven’t already, follow the setup guide for [Sanity Context](https://www.sanity.io/docs/ai/sanity-context). To scaffold a project, the Context helper skills set up the MCP configuration, an agent, a chat UI, and walk you through writing the instructions field and the system prompt:

**npm**

```shell
npx skills add sanity-io/context --all
```

**pnpm**

```shell
pnpm dlx skills add sanity-io/context --all
```

**yarn**

```shell
yarn dlx skills add sanity-io/context --all
```

**bun**

```shell
bunx skills add sanity-io/context --all
```





# Connect Sanity Context with OpenAI Agents SDK

This example connects [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) to a Python agent using the [OpenAI Agents SDK](https://github.com/openai/openai-agents-python). The SDK has built-in MCP support and discovers tools automatically.

## Before you start

You need a Sanity Context MCP endpoint. If you haven't set one up yet, start with [Sanity Context](https://www.sanity.io/docs/ai/sanity-context). You'll need:

- **MCP endpoint URL**: Shown on the MCP in the Context app in the Sanity Dashboard.
- **Organization API token**: create one in [Manage](https://www.sanity.io/manage) under API > Tokens at the organization level.
- **OpenAI API key**: The SDK reads it from `OPENAI_API_KEY`.
- **Python 3.10 or later**: Required by `openai-agents`.

## Install dependencies

**Terminal**

```sh
pip install openai-agents httpx python-dotenv
```

## Set environment variables

Create a `.env` file next to `agent.py`. `load_dotenv()` reads it. Replace each placeholder with your own value:

**.env**

```sh
SANITY_CONTEXT_MCP_URL=YOUR_MCP_ENDPOINT_URL
SANITY_ORGANIZATION_TOKEN=YOUR_ORGANIZATION_TOKEN
OPENAI_API_KEY=YOUR_OPENAI_API_KEY
```

## Full example

Connect to the MCP endpoint, fetch initial context, and run the agent:

**agent.py**

```python
import asyncio
import os
from urllib.parse import urlparse, urlunparse

import httpx
from dotenv import load_dotenv
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter

load_dotenv()

MCP_URL = os.environ["SANITY_CONTEXT_MCP_URL"]
API_TOKEN = os.environ["SANITY_ORGANIZATION_TOKEN"]


async def main():
    # 1. Fetch initial context via HTTP
    parsed = urlparse(MCP_URL)
    initial_context_url = urlunparse(
        parsed._replace(path=parsed.path.rstrip("/") + "/initial-context")
    )

    async with httpx.AsyncClient() as http:
        resp = await http.get(
            initial_context_url,
            headers={"Authorization": f"Bearer {API_TOKEN}"},
        )
        resp.raise_for_status()
        initial_context = resp.text

    # 2. Connect to Sanity Context MCP
    async with MCPServerStreamableHttp(
        name="sanity",
        params={
            "url": MCP_URL,
            "headers": {"Authorization": f"Bearer {API_TOKEN}"},
        },
        # The default is 5 seconds, which is tight for a large schema
        client_session_timeout_seconds=30,
        tool_filter=create_static_tool_filter(
            blocked_tool_names=["initial_context"],
        ),
    ) as server:
        # 3. Create the agent and run it
        agent = Agent(
            name="Assistant",
            instructions=(
                "You are a helpful assistant.\n\n"
                "# Data reference\n\n"
                "Use this to understand what's available and write better queries.\n\n"
                + initial_context
            ),
            mcp_servers=[server],
        )

        result = await Runner.run(agent, "What content do we have?")
        print(result.final_output)


asyncio.run(main())
```

## How it works

Every Sanity Context integration follows three steps:

1. **Fetch initial context** via the `/initial-context` HTTP endpoint and inject it into your system prompt. This gives the agent a compressed schema overview so it can write accurate queries from the start — and saves a tool call on every conversation.
2. **Connect to MCP** using `MCPServerStreamableHttp`. The SDK discovers tools automatically when you pass `mcp_servers` to the agent. Use `create_static_tool_filter` to block the `initial_context` tool since you've already fetched it.
3. **Create the agent and run it** with `Runner.run`. The agent will make tool calls as it explores your content. Without a `model` argument, `Agent` uses the SDK's default model, and credentials come from `OPENAI_API_KEY`, so a missing key fails at this step, after the MCP connection has already succeeded.

## Common errors

Three failures account for most first runs:

- `KeyError: 'SANITY_CONTEXT_MCP_URL'`: The `.env` file is missing, or isn't in the directory you run the script from. `load_dotenv()` returns without error when it finds no file.
- `httpx.HTTPStatusError: Client error '401 Unauthorized'` raised by `resp.raise_for_status()`: The value in `SANITY_ORGANIZATION_TOKEN` isn't a valid organization API token.
- `openai.OpenAIError: Missing credentials. Please pass an `api_key`, `workload_identity`, `admin_api_key`, or set the `OPENAI_API_KEY` or `OPENAI_ADMIN_KEY` environment variable.`: This surfaces at `Runner.run`, after the MCP connection and the initial-context fetch have already succeeded.

## Next steps

- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns): Production patterns for public assistants, personalized agents, and multi-backend setups.
- [Add insights to Sanity Context](https://www.sanity.io/docs/ai/sanity-context-insights): Track and analyze agent conversations.
- [AI shopping assistant walkthrough](https://www.sanity.io/agent-context-ecommerce): A full reference implementation using Next.js and the Vercel AI SDK.



# Connect Sanity Context with Vercel AI SDK

This example connects [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) to a TypeScript agent using the [Vercel AI SDK](https://sdk.vercel.ai). It fetches initial context for schema awareness, connects to the MCP endpoint, and runs an agent that can query your content.

## Before you start

You need a Sanity Context MCP endpoint. If you haven't set one up yet, start with [Sanity Context](https://www.sanity.io/docs/ai/sanity-context). You'll need:

- **MCP endpoint URL**: Shown on the MCP in the Context app in the Sanity Dashboard.
- **Organization API token**: create one in [Manage](https://www.sanity.io/manage) under API > Tokens at the organization level.
- **Anthropic API key**: The AI SDK reads it from `ANTHROPIC_API_KEY`.
- **Node.js 22.18 or later**: Runs TypeScript files directly. Earlier versions need `npx tsx` and a package such as `dotenv`.

## Install dependencies

**npm**

```shell
npm install @ai-sdk/mcp @ai-sdk/anthropic ai
npm install -D typescript @types/node
```

**pnpm**

```shell
pnpm add @ai-sdk/mcp @ai-sdk/anthropic ai
pnpm add -D typescript @types/node
```

**yarn**

```shell
yarn add @ai-sdk/mcp @ai-sdk/anthropic ai
yarn add --dev typescript @types/node
```

**bun**

```shell
bun add @ai-sdk/mcp @ai-sdk/anthropic ai
bun add --dev typescript @types/node
```

All three packages are ESM-only. Set the module type in `package.json` so the top-level `await` calls in the example compile:

**package.json**

```json
{
  "type": "module"
}
```

## Set environment variables

Create a `.env` file next to `agent.ts`. Replace each placeholder with your own value:

**.env**

```sh
SANITY_CONTEXT_MCP_URL=YOUR_MCP_ENDPOINT_URL
SANITY_ORGANIZATION_TOKEN=YOUR_ORGANIZATION_TOKEN
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
```

## Full example

Connect to the MCP endpoint, fetch initial context, and run the agent:

**agent.ts**

```typescript
import {createMCPClient} from '@ai-sdk/mcp'
import {anthropic} from '@ai-sdk/anthropic'
import {generateText} from 'ai'

const MCP_URL = process.env.SANITY_CONTEXT_MCP_URL!
const API_TOKEN = process.env.SANITY_ORGANIZATION_TOKEN!

// 1. Fetch initial context via HTTP — gives the agent schema awareness upfront.
// Append to the path, not the whole URL, so any query parameters survive.
const initialContextUrl = new URL(MCP_URL)
initialContextUrl.pathname = `${initialContextUrl.pathname.replace(/\/$/, '')}/initial-context`

const initialContext = await fetch(initialContextUrl, {
  headers: {Authorization: `Bearer ${API_TOKEN}`},
}).then((r) => r.text())

// 2. Connect to Sanity Context MCP and get tools
const mcpClient = await createMCPClient({
  transport: {
    type: 'http',
    url: MCP_URL,
    headers: {Authorization: `Bearer ${API_TOKEN}`},
  },
})

const {initial_context: _, ...tools} = await mcpClient.tools()

// 3. Call the LLM with tools and initial context in the system prompt
const systemPrompt = [
  'You are a helpful assistant.',
  '',
  '# Data reference',
  '',
  'Use this to understand what\'s available and write better queries.',
  '',
  initialContext,
].join('\n')

const {text} = await generateText({
  model: anthropic('claude-sonnet-4-6'),
  system: systemPrompt,
  tools,
  prompt: 'What content do we have?',
})

console.log(text)
```

## Run the agent

Node reads the `.env` file with `--env-file`:

**CLI**

```sh
node --env-file=.env agent.ts
```

## How it works

Every Sanity Context integration follows three steps:

1. **Fetch initial context** via the `/initial-context` HTTP endpoint and inject it into your system prompt. This gives the agent a compressed schema overview so it can write accurate queries from the start — and saves a tool call on every conversation.
2. **Connect to MCP and get tools**: Authenticate with your organization API token. Remove the `initial_context` tool from the set since you've already fetched it.
3. **Call the LLM** with the tools and system prompt. The agent will make tool calls as it explores your content.

## Common errors

Three failures account for most first runs:

- `error TS1309: The current file is a CommonJS module and cannot use 'await' at the top level`: Set `"type": "module"` in `package.json`.
- `TypeError: Cannot read properties of undefined (reading 'replace')`: The environment variables aren't loaded. The `!` assertions are erased at runtime, so a missing value surfaces at first use rather than at startup.
- `Anthropic API key is missing. Pass it using the 'apiKey' parameter or the ANTHROPIC_API_KEY environment variable.`: Add `ANTHROPIC_API_KEY` to the `.env` file.

## Next steps

- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns): Production patterns for public assistants, personalized agents, and multi-backend setups.
- [Add insights to Sanity Context](https://www.sanity.io/docs/ai/sanity-context-insights): Track and analyze agent conversations.
- [AI shopping assistant walkthrough](https://www.sanity.io/agent-context-ecommerce): A full reference implementation using Next.js and the Vercel AI SDK.



# Connect Sanity Context with LangChain

This example connects [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) to a Python agent using [LangChain](https://python.langchain.com) and [langchain-mcp-adapters](https://github.com/langchain-ai/langchain-mcp-adapters). The adapter library converts MCP tools to LangChain tools automatically.

## Before you start

You need a Sanity Context MCP endpoint. If you haven't set one up yet, start with [Sanity Context](https://www.sanity.io/docs/ai/sanity-context). You'll need:

- **MCP endpoint URL**: Shown on the MCP in the Context app in the Sanity Dashboard.
- **Organization API token**: create one in [Manage](https://www.sanity.io/manage) under API > Tokens at the organization level.
- **Anthropic API key**: `ChatAnthropic` reads it from `ANTHROPIC_API_KEY`.
- **Python 3.10 or later**.

## Install dependencies

**Terminal**

```sh
pip install langchain langchain-mcp-adapters langchain-anthropic httpx python-dotenv
```

## Set environment variables

Create a `.env` file next to `agent.py`. `load_dotenv()` reads it. Replace each placeholder with your own value:

**.env**

```sh
SANITY_CONTEXT_MCP_URL=YOUR_MCP_ENDPOINT_URL
SANITY_ORGANIZATION_TOKEN=YOUR_ORGANIZATION_TOKEN
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
```

## Full example

Connect to the MCP endpoint, fetch initial context, and run the agent:

**agent.py**

```python
import asyncio
import os
from urllib.parse import urlparse, urlunparse

import httpx
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain_anthropic import ChatAnthropic
from langchain_mcp_adapters.client import MultiServerMCPClient

load_dotenv()

MCP_URL = os.environ["SANITY_CONTEXT_MCP_URL"]
API_TOKEN = os.environ["SANITY_ORGANIZATION_TOKEN"]


async def main():
    # 1. Fetch initial context via HTTP
    parsed = urlparse(MCP_URL)
    initial_context_url = urlunparse(
        parsed._replace(path=parsed.path.rstrip("/") + "/initial-context")
    )

    async with httpx.AsyncClient() as http:
        resp = await http.get(
            initial_context_url,
            headers={"Authorization": f"Bearer {API_TOKEN}"},
        )
        resp.raise_for_status()
        initial_context = resp.text

    # 2. Connect to Sanity Context MCP and load tools
    mcp_client = MultiServerMCPClient({
        "sanity": {
            "transport": "http",
            "url": MCP_URL,
            "headers": {"Authorization": f"Bearer {API_TOKEN}"},
        },
    })
    tools = [t for t in await mcp_client.get_tools() if t.name != "initial_context"]

    # 3. Create the agent and call the LLM
    system_prompt = (
        "You are a helpful assistant.\n\n"
        "# Data reference\n\n"
        "Use this to understand what's available and write better queries.\n\n"
        + initial_context
    )

    llm = ChatAnthropic(model="claude-sonnet-4-6")
    agent = create_agent(llm, tools, system_prompt=system_prompt)

    result = await agent.ainvoke(
        {"messages": [("user", "What content do we have?")]}
    )
    print(result["messages"][-1].content)


asyncio.run(main())
```

## How it works

Every Sanity Context integration follows three steps:

1. **Fetch initial context** via the `/initial-context` HTTP endpoint and inject it into your system prompt. This gives the agent a compressed schema overview so it can write accurate queries from the start — and saves a tool call on every conversation.
2. **Connect to MCP and get tools**: Authenticate with your organization API token. `MultiServerMCPClient` handles the MCP-to-LangChain tool conversion. Filter out the `initial_context` tool since you've already fetched it.
3. **Create the agent and call the LLM** using `create_agent` from LangChain. The agent will make tool calls as it explores your content.

## Common errors

Three failures account for most first runs:

- `KeyError: 'SANITY_CONTEXT_MCP_URL'`: The `.env` file is missing, or isn't in the directory you run the script from. `load_dotenv()` returns without error when it finds no file.
- `httpx.HTTPStatusError: Client error '401 Unauthorized'` raised by `resp.raise_for_status()`: The value in `SANITY_ORGANIZATION_TOKEN` isn't a valid organization API token.
- `Anthropic authentication failed: no API key or authorization credentials were provided.`: Set `ANTHROPIC_API_KEY` in the `.env` file. `ChatAnthropic` constructs without a key and fails on the first call instead.

## Next steps

- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns): Production patterns for public assistants, personalized agents, and multi-backend setups.
- [Add insights to Sanity Context](https://www.sanity.io/docs/ai/sanity-context-insights): Track and analyze agent conversations.
- [AI shopping assistant walkthrough](https://www.sanity.io/agent-context-ecommerce): A full reference implementation using Next.js and the Vercel AI SDK.



# Content access and security

What an agent can read is decided when it connects, not per query. Your organization token authorizes the connection, the MCP's sources decide what the endpoint serves, and a GROQ filter scopes reads within a dataset source. Context MCP is read-only in both modes and cannot write to your dataset.

## Authentication

Every request carries a bearer token in the Authorization header:

```text
Authorization: Bearer <SANITY_ORGANIZATION_TOKEN>
```

The endpoint needs an organization API token with Context Viewer permissions, created under Manage > API > Tokens at the organization level. Viewer is the least privilege that works; Editor also works. A project token is refused, however broad its project permissions. Keep the token server-side and never embed it in client code. For custom roles, the grant behind Context Viewer is `sanity.knowledge-base.read`.

## Access is checked once, at connect

Every check runs on the agent's first request and fails loudly there; an insufficient token never half-works and fails later in tool calls. For every Knowledge Base the endpoint serves, your token must hold read access to that Knowledge Base, and a denied one refuses the connection with a 403 naming it. A check that cannot be answered refuses with a 502 rather than falling open.

## Dataset reads run as Sanity, not as your token

Once connected, queries against a dataset source run under Sanity's own service credential, not your token. Attaching the dataset to the MCP is the standing authorization: the attach requires admin of the source project plus unrestricted read access to the dataset, and that decision vets the whole dataset for every consumer of the endpoint, drafts included. The `perspective` parameter is caller-choosable, so anyone who can connect can pass `perspective=drafts` or `perspective=raw`. If a dataset's unpublished content is sensitive, don't attach that dataset.

## groqFilter scopes dataset reads

For the connected agent, `groqFilter` is a hard boundary: it applies server-side, so nothing in the conversation can widen what the agent reads. The filter configured on the MCP is also a floor for callers: a `?groqFilter=` URL parameter narrows it, combining the two with `&&`, and never replaces it. With no filter set, the agent reads the entire attached dataset in the chosen perspective. Common scopings: public products only, articles in a published state only, customer-facing FAQs and guides.

```groq
_type == "product" && public == true
_type == "article" && status == "published"
_type in ["faq", "guide"] && audience == "customer"
```

## What agents can read in GROQ mode

- **Your schema.** Document types, field definitions, and references.
- **Your content.** Published documents by default; the `perspective` parameter switches to drafts, raw, or a release id for any caller who can connect.
- **References.** Agents can follow references between documents during a query.

## Access in Knowledge Base mode

In Knowledge Base mode, `groqFilter` does not apply. The agent can read everything in the Knowledge Bases the endpoint serves, so scope access by choosing which Knowledge Bases an MCP serves rather than by filtering within them. Every consumer's token must also hold read access to each Knowledge Base the endpoint serves.

## Authentication errors

**401 Unauthorized** means the token is missing or malformed. Confirm it exists in your environment, is read by your agent code, and is sent as `Authorization: Bearer <token>` rather than as a query parameter or a different header.

**403 Forbidden** with code `contextGrantRequired` means the token is not an organization API token with Context Viewer permissions; create one in [Manage](https://www.sanity.io/manage) under API > Tokens at the organization level. A 403 naming a Knowledge Base means your token lacks read access to a Knowledge Base the endpoint serves.

**502 Bad Gateway** means a permission check could not be answered. Nothing is granted on failure; retry the connection.

## Mutations

Context MCP cannot write to your dataset. If you need an agent that creates or updates documents, run those mutations server-side in your own code after the agent decides what to do. For an MCP-based write path, see the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server).



# Configure an MCP

An MCP defines what an agent can access and how it should behave. Configuration lives in the Context app rather than in your agent code, so it's visible to your team, editable by non-developers, and shared across environments. This guide creates one and connects an agent to it.

> [!NOTE]
> Prefer to use AI?
> [Enable the agent skills](https://www.sanity.io/docs/ai/sanity-context-quick-start) to have AI help you configure the Context MCP.

## Prerequisites

Context enabled for your organization, an organization API token with Context Viewer permissions, created under Manage > API > Tokens, and a deployed schema (run `sanity schema deploy`, Studio v5.1.0 or later) if the endpoint has a dataset source. See [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) for the full list.

## Create the MCP

In the Context app in the Dashboard, create an MCP and fill in its fields. For what each field does, see [Context MCP](https://www.sanity.io/docs/ai/sanity-context-mcp). The Context app shows the endpoint URL once you save.

## Scope what the agent can read

A GROQ filter limits which documents the agent can reach in GROQ mode. It accepts a filter expression only — the part inside the `[ ... ]` of a full query, evaluating to true or false for one document at a time.

Three things that don't belong in a filter:

- Projection syntax such as `{ name, price }`. Move projections to the agent's queries instead.
- Ordering or slicing such as `order(...)` or `[0...10]`.
- A full query — anything starting with `*[...]`. Filters nested inside an expression are fine.

An invalid filter is rejected with a 422 and the parser error in the response body.

> [!NOTE]
> A filter that matches nothing looks like a broken connection
> A filter like `_type == "product" && public == true` returns no results if no product has `public: true`. If the agent reports empty results, check the filter before checking the connection.

## Connect an agent to the endpoint

Use the endpoint URL from the Context app to connect an MCP client. This example uses the Vercel AI SDK:

**index.ts**

```typescript
import {createMCPClient} from '@ai-sdk/mcp'

const mcpClient = await createMCPClient({
  transport: {
    type: 'http',
    url: 'https://api.sanity.io/v1/context/organizations/YOUR_ORGANIZATION_ID/mcp/YOUR_ENDPOINT_NAME',
    headers: {
      Authorization: `Bearer ${process.env.SANITY_ORGANIZATION_TOKEN}`,
    },
  },
})
```

Replace YOUR_ORGANIZATION_ID with your organization id and YOUR_ENDPOINT_NAME with the endpoint's name, which is chosen when you create the MCP and cannot be changed afterwards. SANITY_ORGANIZATION_TOKEN is the organization API token from your prerequisites; keep it server-side, since it carries organization-level permissions.

Verify the connection by listing the available tools:

**index.ts**

```typescript
const tools = await mcpClient.tools()
console.log(tools)
```

For an endpoint with a dataset source, the list includes `initial_context` and `groq_query`. If a tool is missing, check the endpoint's mode and any tools parameter: each tool is served only in the mode it belongs to, and a tools parameter narrows the list further. For the full tool list per mode, see [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools).

There are dedicated connect guides for the [Vercel AI SDK](https://www.sanity.io/docs/ai/sanity-context-vercel-ai-sdk), [OpenAI Agents SDK](https://www.sanity.io/docs/ai/sanity-context-openai-agents-sdk), and [LangChain](https://www.sanity.io/docs/ai/sanity-context-langchain).

## Next steps

- [Content access and security](https://www.sanity.io/docs/ai/sanity-context-security). How the token and the filter bound what an agent reads.
- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools). Every tool the endpoint serves, by mode.



# Context MCP reference

Context MCP is the hosted Model Context Protocol server behind Sanity Context. It gives agents structured, read-only access to your content: in GROQ mode, the schema and the documents your configuration allows; in Knowledge Base mode, the Knowledge Bases you choose to serve. It doesn't run the agent loop itself, and it can't write back to your dataset; see [Mutations](https://www.sanity.io/docs/ai/sanity-context-mcp). To connect your first agent, start with [Sanity Context](https://www.sanity.io/docs/ai/sanity-context).

![Flowchart showing an Agent interacting with Context MCP, which loads config and queries content from Sanity Dataset.](https://cdn.sanity.io/images/3do82whm/next/73caf2dbec8d723e5ea46e311d4c52e833c065e6-1040x219.png)

## MCP configuration fields

An MCP defines what an agent can access and how it should behave. You create and manage MCPs in the Context app in the Dashboard; see [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp) for the procedure. Each MCP has the following fields:

- **title**. Required. A short, human-readable title for the endpoint, up to 100 characters. Shown only in the Context app, and freely editable.
- **name**. Required. The identifier the endpoint URL uses. Lowercase letters, numbers, and hyphens only, up to 64 characters, unique within your organization, and immutable after creation. Set it to something short and stable, like `support-bot`. Two shapes are reserved and rejected: `by-name`, and any name of the form `mcp` plus eight characters.
- **sources**. Required. What the endpoint serves: between 1 and 100 entries. A Knowledge Base source is `{"type": "knowledge-base", "id": "KNOWLEDGE_BASE_ID"}`, where `KNOWLEDGE_BASE_ID` is the Knowledge Base's public id. Public ids begin with `kb` and are not derived from anything else. A dataset source is `{"type": "dataset", "id": "PROJECT_ID.DATASET_NAME"}`. If an endpoint has both, the dataset source wins and knowledge-base sources are ignored.
- **instructions**. Optional. Custom instructions for the agent, in plain language, up to 10,000 characters. For example: "Only answer questions about product documentation; for anything else, suggest contacting support."
- **groqFilter**. Optional. A GROQ filter expression, up to 10,000 characters, that limits which documents the agent can read. It scopes dataset sources only. See [Filtering content](https://www.sanity.io/docs/ai/sanity-context-mcp).

There is no `mode` field and no `knowledgeBases` field. An endpoint's mode is derived from its sources: an endpoint with a dataset source serves GROQ mode, and an endpoint whose sources are all Knowledge Bases serves Knowledge Base mode. To change it for a single request, pass `?mode=` on the endpoint URL.

### Filtering content

The `groqFilter` field accepts a GROQ filter expression, the part inside the `[ ... ]` of a full GROQ query. It restricts the agent to a subset of your dataset. It applies in GROQ mode only; in Knowledge Base mode, the agent can read everything in the Knowledge Bases the MCP serves.

Commonly used operators and functions:

##### Common groqFilter operators and functions

| Operator or function | Use |
| --- | --- |
| ==, != | Equality |
| >, <, >=, <= | Comparison |
| &&, \|\| | Boolean combination |
| in | Membership |
| defined() | Field existence check |
| match | Text matching, with * as a wildcard |
| references() | Reference to a given document |
| count() | Array length |
| pt::text() | Portable Text as plain text |

There is no operator allowlist. `groqFilter` accepts anything that parses as a GROQ filter expression, including sub-queries such as `_id in *[_type == "category"]._id`. A value that starts with `*`, a bare slice such as `[0...10]`, and a bare pipe function such as `order(title asc)` are rejected. Pass a predicate, not a projection: an object such as `{ name, price }` passes validation and then matches every document. Use it to scope, not to shape; the agent applies its own queries on top of whatever filter you set.

A filter that fails to parse is rejected when you save the MCP endpoint, with `422 Unprocessable Entity`, code `invalidGroqFilter`, and the parser message in the response body. The same check runs on a `?groqFilter=` override, where it surfaces as JSON-RPC error `-32602` rather than a REST error envelope.

**GROQ filter examples**

```groq
// Only products
_type == "product"

// Articles and authors
_type in ["article", "author"]

// Only products marked public
_type == "product" && public == true

// Articles whose title starts with "Summer"
_type == "article" && title match "Summer*"
```

## Authentication

Every request carries a bearer token in the `Authorization` header. An MCP endpoint needs an organization API token with Context Viewer permissions, created under Manage > API > Tokens at the organization level. Viewer is the least privilege that works; Editor also works. For custom roles, the grant behind Context Viewer is `sanity.knowledge-base.read`.

A project API token is not accepted, however broad its project permissions. Without an organization token the connection is refused with `403 Forbidden` and code `contextGrantRequired`. Reaching for a project read token is the most common reason a first connection fails.

## MCP endpoint

Once you save an MCP endpoint, the server is reachable at:

**MCP endpoint URL**

```text
https://api.sanity.io/v1/context/organizations/:organizationId/mcp/:mcpEndpointName
```

##### MCP endpoint segments

| Segment | Description |
| --- | --- |
| :organizationId | Your organization ID |
| :mcpEndpointName | Name of the MCP endpoint. Immutable after creation |

A GROQ mode connection also requires a deployed schema for the project and dataset. Run `sanity schema deploy` from a Studio on v5.1.0 or later. Without one the connection is refused with JSON-RPC error `-32004`: `Only datasets with deployed Studio applications are supported. Please deploy a Studio (v5.1.0+) for this project/dataset.`

A Knowledge Base mode endpoint with no readable Knowledge Bases is refused outright rather than serving an empty tool list, with JSON-RPC error `-32005`: `Mode is set to "knowledge_base" but no knowledge bases are configured. Add knowledge-base sources to the MCP endpoint, or switch mode to "groq".`

### URL parameters

The endpoint accepts the following query parameters. These apply at request time and are not stored on the MCP endpoint. If you pass a parameter that also exists on the endpoint, the URL parameter wins for that request, with one exception: `groqFilter` narrows the configured filter instead of replacing it.

##### MCP URL parameters

| Parameter | Description |
| --- | --- |
| instructions | Overrides the MCP's instructions for this request |
| groqFilter | Narrows the MCP endpoint's GROQ filter for this request. The configured filter always still applies; the two are combined with && |
| perspective | Content perspective to query. Defaults to published. Also accepts drafts, raw, or a release id |
| embeddings | Set to true to enable semantic search, or false to force keyword-only. Omit to auto-detect |
| workspace | Workspace name. Specify it whenever more than one workspace could match; without it the first workspace is used |
| mode | Overrides the mode implied by the endpoint's sources: groq serves GROQ tools; knowledge_base serves Knowledge Base tools |
| knowledgeBases | Comma-separated Knowledge Base public ids (they start with kb) to serve when mode is knowledge_base |
| tools | Comma-separated allowlist of tools to enable, for example groq_query,schema_explorer. Omitting it enables every tool available in the current mode. A valid tool name belonging to the other mode is dropped silently; a name that is not a tool at all is rejected with JSON-RPC -32602 |

For the tools each mode serves, see [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools).

## Text search

Text search applies in GROQ mode; Knowledge Base mode retrieves through the outline instead. On top of Content Lake, Context MCP supports keyword text search ranked with [BM25](https://en.wikipedia.org/wiki/Okapi_BM25), semantic search over dataset embeddings, and a hybrid of the two with selectable boosting. Keyword search matches exact tokens: there is no fuzzy matching and no stemming, so a misspelling returns nothing. Use `prefix*` to match variants.

Semantic search is available when embeddings are enabled on the dataset and have finished indexing (`status: ready`), and when the project has AI usage credits remaining. Both checks fail soft: the connection succeeds without semantic search rather than erroring. Semantic search works through the `text::semanticSimilarity()` GROQ function. The function is only valid as an argument to `score()`; used anywhere else it returns an error. The agent calls it inside a `groq_query`. To enable embeddings on the dataset, see [Dataset Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings).

> [!NOTE]
> Auto-detecting embeddings
> Context MCP detects embeddings itself: it reads the dataset's embeddings setting on your behalf, so nothing needs enabling on the MCP endpoint and no grant on your token affects detection. Pass `?embeddings=true` or `?embeddings=false` on the endpoint URL to force the behavior instead of auto-detecting.

For when semantic search is worth enabling, see [Context retrieval modes](https://www.sanity.io/docs/ai/sanity-context-retrieval-modes).

## Mutations

Context MCP cannot write to your dataset. If you need an agent that creates or updates documents, run those mutations server-side in your own code after the agent decides what to do. For an MCP-based write path, see the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server).

## Next steps

- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools). Every tool the endpoint serves, by mode.
- [Content access and security](https://www.sanity.io/docs/ai/sanity-context-security). What an agent can reach, and how to bound it.
- [Sanity Context patterns and best practices](https://www.sanity.io/docs/ai/sanity-context-patterns). Scoping, routing, and instructing agents once the basics work.



# Context MCP tools

Context MCP serves a different tool set depending on the mode the endpoint runs in. GROQ mode exposes tools for reading your schema and querying documents; Knowledge Base mode exposes tools for reading pre-built entries. The `initial_context` tool is served in both. Most agents use several over the course of a single conversation.

## GROQ mode tools

##### Tools served in GROQ mode

| Tool | Purpose |
| --- | --- |
| initial_context | Compressed schema overview plus instructions for querying your content. The /initial-context HTTP endpoint can replace it |
| schema_explorer | Returns detailed schema information for a specific type, including fields and references |
| groq_query | Executes a GROQ query against the dataset, subject to any groqFilter in effect |
| array_field_reader | Reads large array fields and Portable Text content from a single document |

## Knowledge Base mode tools

##### Tools served in Knowledge Base mode

| Tool | Purpose |
| --- | --- |
| initial_context | The outline of each Knowledge Base the endpoint serves. The /initial-context HTTP endpoint can replace it |
| knowledge_base_read | Reads the full content of one or more entries, by Knowledge Base id (the kb… value on the "Knowledge base id:" line above its outline in initial_context) and entry paths from the outline. Accepts up to 20 paths in one call |

Paths in the outline may carry a `[core]` or `[peripheral]` tag marking how central the entry is; untagged entries are standard. Read paths back verbatim. When several entries look relevant, read them in one call rather than sequentially. See [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases) for how a Knowledge Base is built and what the outline is.

## Tool parameters

Each tool takes its arguments as a JSON object. A call that omits a required parameter is rejected before the tool runs. Optional parameters fall back to the defaults below.

### `initial_context`

Takes no parameters. The endpoint's mode, its sources, and any `groqFilter` already determine what it returns.

### `groq_query`

#### Properties

**query** (string, required)

GROQ query to execute against the dataset. The endpoint's groqFilter is applied before the query runs.

A query that fails to parse comes back as an error result with the parser message included, not as a thrown exception. A successful response is an object with a `meta` block — `executedQuery`, `perspective`, `resultCount`, `returnedCount`, and, when content was cropped, `warnings` and `hint` — alongside `result`. Large arrays inside the returned documents are cropped individually rather than truncating the whole response; read the full field with `array_field_reader`.

### `schema_explorer`

#### Properties

**type** (string, required)

Schema type name, for example post.

**path** (string)

Navigate to a field within the type instead of returning the whole type, which is the default. Dot notation reaches nested objects (metadata.tags); content[] lists every item type in an object array and content[].language reaches one field across them; of[0] and content.of[0] index into an array type definition. Do not use brackets for reference arrays — query the referenced type directly with type instead.

Above 50 KB of JSON the tool returns a navigator to page through rather than the raw schema. It errors when the endpoint resolves no workspace, when the workspace has no schema, and when that schema declares no types.

### `array_field_reader`

#### Properties

**mode** ("range" | "filter" | "continue" | "outline", required)

range reads content by index, filter finds items matching criteria, outline returns a structural overview, and continue resumes an item a previous call cropped. There is no default; name one.

**documentId** (string, required)

The _id of the document to read from.

**field** (string, required)

Name of the array field to read, for example body or content.

**range** (object)

Used by range mode. startIndex is an integer, inclusive, defaulting to 0; endIndex is an integer, exclusive, defaulting to the array length. Both are clamped to the array's bounds rather than erroring, so an out-of-range index returns whatever exists.

**filter** (object)

Used by filter mode. Its fields are listed below.

**continue** (object)

Required by continue mode, which errors without it. blockIndex and offsetBytes are required integers and path is an optional string. Take all three from the continuationToken on the cropped response you are resuming.

The `filter` object accepts:

#### Properties

**textContains** (string)

Item text must contain this substring, case-insensitive.

**textContainsAny** (string[])

Item text must contain at least one of these substrings.

**textContainsAll** (string[])

Item text must contain all of these substrings.

**blockType** (string)

Match items by _type, for example block, image, or code.

**customType** (string)

Alias for blockType, for custom object types.

**hasImage** (boolean)

When true, the item must contain an image.

**key** (string)

Match a single item by its _key.

**minTextLength** (integer)

Minimum text length. No default.

**maxTextLength** (integer)

Maximum text length. No default.

**matchMode** ("any" | "all")

How multiple filters combine. Defaults to all, meaning every filter must match.

**context** (object)

Context window around each match: before and after, both integers defaulting to 0. Neighbouring items come back alongside the matches, deduplicated and in document order.

**limitBlocks** (integer)

Soft maximum number of items to return. Defaults to 50 and is capped at 50, so a larger value has no effect.

**pte** (object)

Filters specific to Portable Text blocks. styles is a string array matching blocks by style, such as h1 or h2; marksInclude is a string array where every listed mark must be present on at least one span.

`range` mode returns at most 30 items and crops each to 5,000 bytes; `filter` mode returns at most 50 items and crops each to 8,000 bytes. A cropped response carries a continuation token to pass back through `continue`. The tool errors when the document is not found, when the field is null or absent, and when the field is not an array of objects — which includes an empty array.

### `knowledge_base_read`

#### Properties

**knowledgeBase** (string, required)

The Knowledge Base to read from, identified by its id: the kb… value on the Knowledge base id: line above its outline in initial_context.

**paths** (string[], required)

One or more entry paths, taken verbatim from the outline. Minimum 1, maximum 20, and no path may be empty.

An id that matches no Knowledge Base on the endpoint errors with the list of ids that do match. Paths that resolve to nothing are reported in a note under the entries that did resolve, so a partly wrong call still returns content; only a call where every path misses is an error. Reading from an endpoint with no Knowledge Base attached errors outright.

The `arguments` object for each tool. Replace `DOCUMENT_ID` and `KNOWLEDGE_BASE_ID` with your own values; a Knowledge Base public id begins with `kb`.

**initial_context**

```json
{}
```

**groq_query**

```json
{
  "query": "*[_type == \"post\"][0...5]{_id, title}"
}
```

**schema_explorer**

```json
{
  "type": "post",
  "path": "content[]"
}
```

**array_field_reader**

```json
{
  "mode": "range",
  "documentId": "DOCUMENT_ID",
  "field": "body",
  "range": {"startIndex": 0, "endIndex": 20}
}
```

**knowledge_base_read**

```json
{
  "knowledgeBase": "KNOWLEDGE_BASE_ID",
  "paths": ["groq/functions", "studio/configuration"]
}
```

## Serve a subset of the tools

The `tools` URL parameter takes a comma-separated allowlist, for example `groq_query,schema_explorer`. Omitting it enables every tool the endpoint's mode serves. A name that is not a Context MCP tool is rejected with JSON-RPC error `-32602`. A valid tool name that the current mode does not serve is dropped silently, so an allowlist that names only out-of-mode tools yields an endpoint with no tools.

## List the tools on an endpoint

**Terminal**

```sh
curl -X POST https://api.sanity.io/v1/context/organizations/$ORGANIZATION_ID/mcp/$MCP_ENDPOINT_NAME \
  -H "Authorization: Bearer $SANITY_ORGANIZATION_TOKEN" \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

A successful response returns a JSON object with a `result.tools` array listing the tools available on that endpoint. A 401 means the token is missing or malformed. A 403 `contextGrantRequired` means the token is not an organization API token with Context Viewer permissions. Create one under Manage > API > Tokens at the organization level. See [Content access and security](https://www.sanity.io/docs/ai/sanity-context-security). An empty `result.tools` array means the `tools` allowlist named only tools the endpoint's mode does not serve. Drop the `tools` parameter to see everything the endpoint offers.



# Content Agent API

Build chat interfaces, automate content workflows, and create custom tools that read and write Sanity content through natural language. The [content-agent npm package](https://npmx.dev/package/content-agent) is a [Vercel AI SDK](https://sdk.vercel.ai/) provider that handles streaming, authentication, and thread management.

The package supports two interaction modes: **threads** for stateful, multi-turn conversations (`.agent()`) and **one-shot prompts** for stateless single-turn tasks (`.prompt()`). Both work with the standard Vercel AI SDK functions like `generateText` and `streamText`.

For the full API reference, see the [content-agent](https://reference.sanity.io/content-agent/) [reference docs](https://reference.sanity.io/content-agent/).

#### Related

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[Build with AI](https://www.sanity.io/docs/ai)
AI-powered tools to enhance your content and development workflows.

## Prerequisites

Before you start, you need:

- A Sanity project with a [deployed schema](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)
- A project-level API token with **Editor** role or above. Create one in [sanity.io/manage](https://sanity.io/manage) under Your Project → API → Tokens.
- Your organization ID (visible in your project settings)
- Node.js 18+
- A Sanity Studio (v5.1.0+) opened at least once after deployment. This registers the Studio with the Content Agent service.

> [!NOTE]
> Content Agent calls consume AI credits
> Every Content Agent API call uses [AI credits](https://www.sanity.io/docs/platform-management/how-ai-credits-work). Costs vary by operation: read-only queries cost less than write operations. Monitor your usage in your project settings.

## Quick start

First, install the packages:

**npm**

```shell
npm install content-agent ai
```

**pnpm**

```shell
pnpm add content-agent ai
```

**yarn**

```shell
yarn add content-agent ai
```

**bun**

```shell
bun add content-agent ai
```

### Generating text

This example sends a single prompt to the Content Agent and prints the response. It uses `generateText` from the Vercel AI SDK.

**quick-start.ts**

```
import { createContentAgent } from 'content-agent'
import { generateText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('my-thread')

const result = await generateText({
  model,
  prompt: 'What blog posts do I have?',
})

console.log(result.text)
```

### Streaming

Use `streamText` to display results as they arrive.

**quick-start-stream.ts**

```
import { createContentAgent } from 'content-agent'
import { streamText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const { textStream } = streamText({
  model: contentAgent.agent('my-thread'),
  prompt: 'Summarize my latest content',
})

for await (const text of textStream) {
  process.stdout.write(text)
}
```

## Installation and setup

### Install the packages

The [content-agent](https://npmx.dev/package/content-agent) package is available on npm.

**npm**

```shell
npm install content-agent ai
```

**pnpm**

```shell
pnpm add content-agent ai
```

**yarn**

```shell
yarn add content-agent ai
```

**bun**

```shell
bun add content-agent ai
```

The `content-agent` package is a [Vercel AI SDK](https://sdk.vercel.ai/) provider. The `ai` package is a peer dependency required for `generateText`, `streamText`, and other Vercel AI SDK functions.

### Create the provider

**provider.ts**

```
import { createContentAgent } from 'content-agent'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})
```

For all provider options, see the [createContentAgent](https://reference.sanity.io/content-agent/createContentAgent/) [reference](https://reference.sanity.io/content-agent/createContentAgent/).

### Authentication

All API requests require a **project-level** API token with the **Editor** role or above. Create one from sanity.io/manage → Your Project → API → Tokens. Organization-level tokens and viewer tokens will not work.

> [!WARNING]
> Keep tokens secure
> Don't expose authentication tokens in client-side code. For browser-based apps, proxy requests through your own backend.

> [!WARNING]
> Common authentication errors
> - `SIO-401-ANF` ("Session not found"): You are likely using an organization token or a robot token instead of a project-level API token.
> - `projectUserNotFoundError`: The token does not belong to the target project. Verify you created the token under the correct project in sanity.io/manage.
> - `NO_COMPATIBLE_APPLICATIONS`: No registered Studio found. Open your Sanity Studio in a browser at least once to connect it to the Content Agent service.

## Applications

Each application key uniquely identifies a deployed Sanity Studio workspace. Since multiple studios can share the same project ID and dataset, the application key targets the right one.

Use `.applications()` to list available studios for the authenticated user, then pass the key to `.agent()` or `.prompt()`:

**list-apps.ts**

```
const apps = await contentAgent.applications()

const app = apps.find((a) => a.title === 'My Studio')

const model = contentAgent.agent('my-thread', {
  application: { key: app.key },
})
```

## Configuration

The `config` object controls agent behavior. Pass it as part of the options to `.agent()` or `.prompt()`. For the full type definition, see the [Config](https://reference.sanity.io/content-agent/Config/) [reference](https://reference.sanity.io/content-agent/Config/).

**config.ts**

```
const model = contentAgent.agent('my-thread', {
  config: {
    capabilities: { read: true, write: false },
  },
})
```

Here are three common patterns:

**config-patterns.ts**

```
// Read-only: the agent can query but not modify content
config: { capabilities: { read: true, write: false } }

// Scoped: limit to specific document types
config: {
  capabilities: { read: true, write: false },
  filter: { read: '_type in ["post", "author"]' },
}

// Release-scoped: read and write within a specific release
config: {
  capabilities: { read: true, write: true },
  perspectives: { read: ['myRelease'], write: 'myRelease' },
}

```

For full details on each option, see the subsections below.

### Capabilities

Capabilities control what the agent can do. Configure `read` and `write` independently. Each accepts `true` (standard preset), `false` (no access), or an object with a preset name. For the full type definition, see the [Capabilities](https://reference.sanity.io/content-agent/Capabilities/) [reference](https://reference.sanity.io/content-agent/Capabilities/).

| Preset | Read features | Write features |
| --- | --- | --- |
| false | No access | No access |
| { preset: 'minimal' } | Document queries, web search | Simple mutations |
| true or { preset: 'standard' } | Document queries, sets (bulk analysis), web search | Simple and bulk mutations |

> [!NOTE]
> Drafts only
> The agent can't write to published documents directly. It can only create or update draft and versioned documents.

**capabilities.ts**

```
// Read-only with all read tools
const readOnly = {
  capabilities: { read: true, write: false },
}

// Minimal read (basic queries, no bulk analysis)
const minimalRead = {
  capabilities: { read: { preset: 'minimal' }, write: false },
}

// Full read, minimal write
const readWriteMinimal = {
  capabilities: { read: true, write: { preset: 'minimal' } },
}
```

Use `capabilities.features` to toggle individual features on or off, overriding the preset defaults:

**no-web-search.ts**

```
// Standard read but disable web search
const noWebSearch = {
  capabilities: {
    read: true,
    write: false,
    features: { webSearch: false },
  },
}

```

### Filters

Use GROQ boolean expressions to control which documents the agent can see and modify. For the full type definition, see the [Filter](https://reference.sanity.io/content-agent/Filter/) [reference](https://reference.sanity.io/content-agent/Filter/).

**filters.ts**

```
const model = contentAgent.agent('my-thread', {
  config: {
    filter: {
      // Only these document types are visible
      read: '_type in ["post", "author", "category"]',
      // Only posts can be modified
      write: '_type == "post"',
    },
  },
})

```

### Perspectives

Perspectives control which document versions the agent reads from and writes to. Values are Sanity perspective IDs: `"drafts"`, `"published"`, `"raw"`, or a release ID.

**perspectives.ts**

```
// Only read published documents
const publishedOnly = {
  perspectives: { read: ['published'] },
}

// Lock to a specific release for both reading and writing
const releaseScoped = {
  perspectives: { read: ['myRelease'], write: 'myRelease' },
}
```

When you set `read`, the agent's query tools are restricted to the listed perspectives. When you set `write`, new documents are created in the specified perspective (for example, `"drafts"` creates `drafts.*` IDs).

### User message context

The `userMessageContext` field passes contextual information that the agent appends to each user message. Each key becomes an XML tag with the value as content.

**msg-context.ts**

```
const config = {
  userMessageContext: {
    'slack-channel': '#marketing',
    'slack-user': '@john.doe',
  },
}
// Renders as: <slack-channel>#marketing</slack-channel>

```

### Custom instructions

The `instruction` field adds custom instructions to the agent's system prompt.

**instruction.ts**

```
const config = {
  instruction:
    'You are a Slack bot helping users manage blog content. Always respond in a friendly, concise tone.',
}
```

## Custom tools

You can extend the agent with your own tools using the [Vercel AI SDK tool pattern](https://sdk.vercel.ai/). Pass custom tools when calling `generateText` or `streamText`. The package forwards tool schemas to the agent and runs execution locally on your server.

**tool.ts**

```
import { generateText, tool } from 'ai'
import { z } from 'zod'

const model = contentAgent.agent('my-thread', {
  application: { key: '<your-application-key>' },
  config: { capabilities: { read: true, write: false } },
})

const { text } = await generateText({
  model,
  prompt: 'What is the weather in San Francisco?',
  tools: {
    getWeather: tool({
      description: 'Get the current weather for a location',
      parameters: z.object({
        location: z.string().describe('City name'),
      }),
      execute: async ({ location }) => {
        return { temperature: 72, condition: 'sunny' }
      },
    }),
  },
})

```

Custom tools run alongside the agent's built-in tools. The agent decides when to call them based on the message and the tool descriptions you provide.

## Examples

### Read-only document explorer

Restrict the agent to querying documents without making changes.

**read-only-explorer.ts**

```
import { createContentAgent } from 'content-agent'
import { generateText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('explorer-thread', {
  application: { key: '<your-application-key>' },
  config: {
    capabilities: {
      read: { preset: 'standard' },
      write: false,
    },
    filter: {
      read: '_type in ["post", "author", "page"]',
    },
  },
})

const { text } = await generateText({
  model,
  prompt: 'Show me all posts published this month',
})

console.log(text)

```

### Chat with user context

Pass contextual information about the current environment or workflow to the agent.

**chat.ts**

```
import { createContentAgent } from 'content-agent'
import { streamText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('slack-bot-thread', {
  application: { key: '<your-application-key>' },
  config: {
    instruction: 'You are a Slack bot helping users manage content.',
    userMessageContext: {
      'slack-channel': '#content-team',
      'slack-user': '@john.doe',
    },
    capabilities: {
      read: true,
      write: false,
    },
  },
})

const { textStream } = streamText({
  model,
  prompt: 'What content needs review this week?',
})

for await (const chunk of textStream) {
  process.stdout.write(chunk)
}

```

## Error handling

The package throws API errors as exceptions. Wrap your calls in try/catch blocks. For the full list of error types and status codes, see the [ErrorResponse](https://reference.sanity.io/content-agent/ErrorResponse/) [reference](https://reference.sanity.io/content-agent/ErrorResponse/).

**try-catch.ts**

```
try {
  const { text } = await generateText({ model, prompt: 'List all posts' })
  console.log(text)
} catch (error) {
  console.error('Content Agent error:', error.message)
}

```

## Limitations

- The agent can only write to draft and versioned documents.
- The prompt endpoint has a 10,000 character limit for the message field.
- The API version is currently `vX` (preview). Endpoints and behavior may change.
- The API manages thread history server-side. You cannot retrieve or modify past messages through the API.

#### Related

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[Build with AI](https://www.sanity.io/docs/ai)
AI-powered tools to enhance your content and development workflows.



# Add AI Assist to Sanity Studio

Sanity AI Assist puts the power of large language models (LLMs) right at your fingertips in the Studio, where your content lives. Write reusable instructions in natural human language to a document-aware AI assistant that can handle chores and repetitive tasks while you focus on the creative stuff.

> [!NOTE]
> Paid feature
> This article is about a feature currently available for all projects on the [Growth plan](https://www.sanity.io/pricing) and up.

[Create and run instructions with AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-working-with-instructions)

[Common instructions for AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-cheat-sheet)

[Content translation with AI Assist](https://www.sanity.io/docs/studio/ai-assist-content-translation)

[AI Assist plugin page](https://www.sanity.io/plugins/ai-assist)

If you're interested in running programmatic AI instructions, check out [Agent Actions](https://www.sanity.io/docs/agent-actions).

## Installing the AI Assist plugin

AI Assist is a [plugin](https://www.sanity.io/plugins/ai-assist) for Sanity Studio and is installed using your favorite package manager, such as `npm`, `yarn`, or `pnpm`. It’s a good idea to ensure your studio is up to date while you’re at it. AI Assist requires your studio to be v3.26.0 or later to work.

**npm**

```shell
npm install sanity@latest @sanity/assist

```

**pnpm**

```shell
pnpm add sanity@latest @sanity/assist

```

**yarn**

```shell
yarn add sanity@latest @sanity/assist

```

**bun**

```shell
bun add sanity@latest @sanity/assist

```

### Add the plugin to your studio configuration

Once installed in your project, you must activate the plugin by importing it and adding it to your main studio configuration. In `sanity.config.ts`, add `assist` to the `plugins` array:

```tsx
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist(),
  ]
})

```

We’ll look at some configuration options for the `assist` plugin further on in this article, but for now, this is everything you need to get started.

#### Additional configuration settings

You can also configure AI Assist for more control.

```typescript
assist({
  // Showing defaults
  assist: {
    localeSettings: () => Intl.DateTimeFormat().resolvedOptions(),
    maxPathDepth: 4,
    temperature: 0.3
  },
})
```

- `localeSettings`: Enables the AI to understand natural language date and time, and know what timezone the language refers to. See the next section for more details.
- `maxPathDepth`: The max depth for document paths AI Assist can write to. Increase if you need deeper traversal, but large and complex schemas may result in decreased performance.
- `temperature` (from 0 to 1): Influences how much the output of an instruction will vary between runs. Higher values result in more varied results, while lower values are more repeatable.

#### Date and datetime

Starting from v3.0.0, AI Assist can write to date and datetime fields. Instructions can use language like "tomorrow at noon" or "next year," and when AI Assist writes to the field, it will be converted to a field-compatible value.

Language about time is `locale` and `timeZone` dependent. By default, instructions will use the locale and timezone provided by the browser (`Intl.DateTimeFormat().resolvedOptions()`).

Alternatively, you can configure the plugin per user with an `assist.localeSettings` function that should return `LocaleSettings`.

Example

```typescript
assist({
  assist: {
    localeSettings: ({user, defaultSettings}) => {
      if (user.roles.some((role) => role.name === 'administrator')) {
        // forces locale and timeZone for admins
        return {
          locale: 'en-US',
          timeZone: 'America/New_York',
        }
      }
      // defaultSettings is the same as using:
      // const {locale, timeZone} = Intl.DateTimeFormat().resolvedOptions()
      return defaultSettings
    }
  }
})

```

For a list of allowed values for these parameters, see the following resources:

- `locale`: [Mozilla on Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/getCanonicalLocales)
- `timeZone`: [Wiki on time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)

### Enabling the AI Assist API

After installing the AI Assist package and importing and adding the plugin to your studio configuration, you need to create a token to allow the plugin to access the AI Assist API. This needs to be done by a project member with token creation permissions (typically someone with an admin or developer role):

1. Start the Studio and open any document.
2. Select the **sparkle icon** (✨) in the document header near the close document button, or in the top-right corner of any field when hovering the field. If you have custom actions enabled, you may see a popup where you must then select **manage instructions**.

![select the sparkle AI assist icon](https://cdn.sanity.io/images/3do82whm/next/aacf1c89699c0bbb483a6647c879587b2f139a1e-818x380.png)

Selecting the AI Assist button will open an inspector pane to the right side of the current document with a button prompting you to **Enable Sanity AI Assist**.

![Enable AI assist example panel.](https://cdn.sanity.io/images/3do82whm/next/a8a01ad145fae14a70bd229333f0e03c644b7ec2-2000x861.png)

Click the **Enable Sanity AI Assist** button to create a token and enable AI Assist for everyone accessing the project.

You will find that a new API token entry for your project named “Sanity AI” has been created in your project's API settings, which you can examine at [sanity.io/manage](https://sanity.io/manage).

AI Assist will now work for any dataset in your project.

> [!TIP]
> Protip
> You can revoke this token at any time to disable the Sanity AI Assist service. A new token has to be generated via the plugin UI for it to work again.

At this point, AI Assist should be operational and ready for your perusal. You might want to take a detour and check out the article linked below for a closer look at where and how you can interact with the assistant in the Studio interface, or keep reading to learn more about how AI Assist works.

[Create and run instructions with AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-working-with-instructions)

## Schema configuration

By default, unless otherwise specified, AI Assist is enabled for all compatible fields and document types. We will look at how you can selectively exclude fields or document types from being affected by the assistant further on in the article.

## Supported field types

AI Assist can use most fields in your schema as a context in an instruction.

These are the field types it can write content to, including custom schema types based on the following:

- String and text
- Objects and the fields within them
- Arrays with inline objects and references
- Portable Text, including formatting and custom blocks
- Image assets (and image fields)
- References (requires additional configuration)
- Booleans
- Numbers
- Slugs
- URLs
- Date and DateTime

### Conditionally hidden and read-only fields

Any field that has `hidden` or `readOnly` set to `true` when the relevant instruction starts running will be skipped. An important word in that previous statement is “starts.” If a field has its `hidden` or `readOnly` value changed while the assistant is doing its thing, the new value will not be considered for that running process, even if the assistant has yet to reach that field.

AI Assist does not re-evaluate the `hidden` and `readOnly` status of fields after the instruction has started running. This means that even if a field has its `hidden` property changed from `true` to `false` as a side effect of something the assistant does, it will still regard that field as hidden, even if the status was changed before the assistant "gets to it."

Fieldsets with `hidden` and `readOnly` states are also accounted for.

### Unsupported fields

There are some field types that AI Assist can use as context but not write content for:

- Geolocation
- Cross Dataset References
- File assets

### Image asset generation

AI Assist can create assets for images configured with a prompt field.

Image generation can be done directly using the **Generate image from prompt** command on the prompt field or indirectly whenever an AI Assist instruction modifies the image prompt field.

To enable image generation for an image field, you must:

- Set `options.aiAssist.imageInstructionField` to a child-path relative to the image
- Have a `string` or `text` field that corresponds to the `imageInstructionField` path

This will add a "Generate image from prompt" instruction to the image prompt field. Executing this instruction will generate an image.

```typescript
defineType({
  type: 'document',
  name: 'article',
  fields: [
    defineField({
      type: 'image',
      name: 'articleImage',
      fields: [
        defineField({
          type: 'text',
          name: 'promptForImage',
          title: 'Image prompt',
          rows: 2,
        }),
      ],
      options: {
        aiAssist: {
          imageInstructionField: 'promptForImage',
        },
      },
    })
  ]
})
```

An image will be generated each time an AI Assist instruction modifies the image prompt field. This modification could come from a document instruction, an instruction for the image field or parent object, or directly on the image prompt field.

### Enabling automatic image captions

In addition to generating images from a prompt field, the assistant can also be set to generate descriptions from image assets. To enable the assistant to auto-generate descriptions that can be used for alt text or captions, supply a valid field path in `options.aiAssist.imageDescriptionField`.

```tsx
defineType({
  type: 'document',
  name: 'article',
  fields: [
    defineField({
      type: 'image',
      name: 'articleImage',
      fields: [
        defineField({
          type: 'text',
          name: 'alt',
          title: 'Alternative text',
          rows: 2,
        }),
      ],
      options: {
        aiAssist: {
          imageDescriptionField: 'alt',
        },
      },
    })
  ]
})

```

This will add a **Generate image description** instruction to the configured field (`alt` in this example) that will produce a description of the image.

![The AI assist interface with 'generate caption' selected.](https://cdn.sanity.io/images/3do82whm/next/9a51f1a050f5202704f78a17ed43aad51575c46b-2000x1503.png)

> [!TIP]
> Limited by AI Assist's caption defaults?
> If AI Assist's default behavior is too limiting for your needs, you can also describe images with the [Transform Agent Action](https://www.sanity.io/docs/agent-actions/transform-quickstart). Use Transform with the [image-description operation](https://www.sanity.io/docs/agent-actions/transform-cheatsheet) to prompt the AI with additional instructions.

### Enabling support for related content in references

> [!WARNING]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. It has been replaced with the new [Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) feature, now natively available within Sanity datasets.
> At this time, we do not have a replacement solution available for using references with AI Assist.

To work with a `reference` field, the AI Assist plugin must consult an embedding index that includes the types it will refer to. To learn about embedding indexes and how to set them up, visit [this article](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview).

You can manage your indexes directly in the Studio using the [Embeddings Index Dashboard plugin](https://github.com/sanity-io/embeddings-index-ui#embeddings-index-api-dashboard-for-sanity-studio). Once you have an index configured, you can enable `reference` fields for AI Assist by setting `options.aiAssist.embeddingsIndex` to whatever you named your index.

```tsx
import { defineField } from 'sanity'

defineField({
  type: 'reference',
  name: 'articleReference',
  title: 'Article reference',
  to: [{ type: 'article' }],
  options: {
    aiAssist: {
      embeddingsIndex: 'all-our-stuff-index'
    },
  },
})

```

Reference fields with this option set can have instructions attached and will be included when running instructions for object fields and arrays. An example instruction might look like this:

```text
Given <Document field: Title> suggest a related article

```

AI Assist will use the embeddings index, filtered by the types specified in the field declaration, to look up contextually relevant references. One or more references can be added for arrays or Portable Text fields with references.

## Selectively exclude fields and document types

AI Assist defaults to inclusivity and will target every supported field it comes across. This may not always be desirable, so it comes with the option to exclude fields and document types selectively by setting the `options.aiAssist.exclude` option to `true`.

### Disable AI Assist for a schema type

```tsx
// disable AI assistance wherever it is used,
// ie: as field, document, array types
defineType({
  name: 'policy',
  type: 'document',
  options: {
    aiAssist: {exclude: true}
  },
  fields: [
    // ...
  ]
})

```

### Disable for a nested field type

```tsx
// this disables AI assistance only for the specific field
defineType({
  name: 'product',
  type: 'object',
  fields: [
    defineField({
      name: 'sku',
      type: 'string',
      options: {
        aiAssist: {exclude: true},
      },
    }),
  ],
})

```

### Disable for an array type

```tsx
// disables AI assistance for the specific array member
// if all types in the `of` array are excluded, the array type is also considered excluded
defineType({
  name: 'myArray',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'someType',
      options: {
        aiAssist: {exclude: true}
      }
    })
  ]
})

```

## The AI Context document type

This plugin adds an `AI Context` document type.

If your Studio uses [Structure Builder](https://www.sanity.io/docs/studio/structure-builder-introduction) to configure the studio structure, you might have to add this document type to your structure.

The document type name can be imported from the plugin:

```tsx
import {contextDocumentTypeName} from '@sanity/assist'

// add to your structure
S.documentTypeListItem(contextDocumentTypeName)

```

## Troubleshooting

> [!NOTE]
> Caveats
> Large Language Models (LLMs) are a new technology. Constraints and limitations are still being explored, but some common caveats to the field that you may run into using AI Assist are:
> - Limits to instruction length: Long instructions on deep content structures may exhaust model context
> - Timeouts: To be able to write structured content, we're using the largest language models. Long-running results may time out or intermittently fail
> - Limited capacity: The underlying LLM APIs used by AI Assist are resource constrained

There are limits to how much text the AI can process for an instruction. Under the hood, AI Assist will add information about your schema, which adds to what's commonly called “the context window.”

If you have a very large schema (many document and field types), it can be necessary to exclude types to limit how much of the context window is used for the schema itself.

We recommend excluding all types that would rarely benefit from automated workflows. A quick win is typically to exclude array types. It can be a good idea to exclude most non-block types from Portable Text arrays. This will ensure that AI Assist outputs mostly formatted text.

### Third-party sub-processors

A list of third-party sub-processors, as well as details on the terms of use for our AI products are [available here](https://www.sanity.io/legal/tos-ai).



# HTTP API Reference

#### Get started

[Authentication and tokens](https://www.sanity.io/docs/content-lake/http-auth)
How to create tokens and make authenticated requests.

[URL format](https://www.sanity.io/docs/content-lake/http-urls)
tl;dr: <projectId>.api.sanity.io/<version>/<path>

[Patches](https://www.sanity.io/docs/content-lake/http-patches)
The valid patch types when using the direct HTTP mutations api.

#### Popular endpoints

[Query API reference](https://www.sanity.io/docs/http-reference/query)
Reference documentation for the Query HTTP endpoint.

[Mutation API reference](https://www.sanity.io/docs/http-reference/mutation)
Reference documentation for the Mutatation HTTP reference.

[Actions API reference](https://www.sanity.io/docs/http-reference/actions)
Reference documentation for the Actions HTTP endpoint.

#### New endpoints

[Access API reference](https://www.sanity.io/docs/http-reference/access-api)
A centralized API to manage resource access control through roles and permissions.

[Media Library API reference](https://www.sanity.io/docs/http-reference/media-library)
HTTP endpoints reference for the Media Library API

[Agent Actions](https://www.sanity.io/docs/http-reference/agent-actions)
Reference documentation for the Agent Actions HTTP API.

[Live Content API reference](https://www.sanity.io/docs/http-reference/live)
Reference documentation for the Live HTTP endpoint.



# Actions

The Actions API is a higher-level alternative to the Mutations API. It is used by Studio in the course of regular authoring workflows, but can also be used directly. All requests must be authenticated.

#### Want to get started?

[Mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions)
The Actions API let you use the same system Sanity Studio uses to mutate documents in Content Lake.

[Introduction to document mutations](https://www.sanity.io/docs/content-lake/mutations-introduction)
Sanity's Content Lake offers a variety of methods for creating, editing, and deleting documents.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- Manipulating documents requires read+write access permission for the affected document type. In most cases, this includes the Editor, Developer, or Administrator roles.

## Actions types

Actions are identified by their `actionType`. For a complete list of properties to supply to each action, select the action type in the [actions property](https://www.sanity.io#actions-requestbody-application-json-actions) below.

### Document Actions

Document actions use an `actionType` that begins with `sanity.action.document`.

Most of the action types take a `versionId`, referring to the draft or release version, and a `publishedId`, referring to the published version of the document.

The `versionId` must have either `drafts.` or `versions.<release>.` as a prefix, and the portion following that prefix must match `publishedId`.

- `sanity.action.document.create`: Creates a new document in the dataset.
- `sanity.action.document.delete`: Deletes a document from the dataset.
- `sanity.action.document.edit`: Modifies an existing document using a patch.
- `sanity.action.document.publish`: Publishes a document, making it available in the published perspective.
- `sanity.action.document.unpublish`: Unpublishes a document, removing it from the published perspective.
- `sanity.action.document.discard`: [DEPRECATED] Discards a document (use version actions instead)
- `sanity.action.document.replaceDraft`: [DEPRECATED] Replaces a draft document (use version actions instead)

### Version Actions

Version actions use an `actionType` starting with `sanity.action.document.version`.

These actions operate solely on the versions of documents. They follow the same authoring model of `sanity.action.document` actions by requiring a `publishedId`, referring to the published version of the document. This is true even if the published version does not yet exist, such as when starting a draft or version of a new document.

- `sanity.action.document.version.create`: Creates a new version of a document associated with a release.
- `sanity.action.document.version.discard`: Discards a version of a document, optionally purging its history.
- `sanity.action.document.version.replace`: Replaces an existing version of a document.
- `sanity.action.document.version.unpublish`: Marks a version for unpublishing when the associated release is published.

### Release Actions

Release actions use an `actionType` starting with `sanity.action.release`.

Use release actions to interact with [Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration) and [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts).

- `sanity.action.release.create`: Creates a new release with optional metadata.
- `sanity.action.release.edit`: Modifies the metadata of an existing release.
- `sanity.action.release.publish`: Publishes all documents in a release.
- `sanity.action.release.archive`: Archives a release, removing it from active releases.
- `sanity.action.release.unarchive`: Restores an archived release to its pre-archived state.
- `sanity.action.release.schedule`: Schedules a release for publishing at a future time.
- `sanity.action.release.unschedule`: Cancels a scheduled release.
- `sanity.action.release.delete`: Deletes a published or archived release.
- `sanity.action.release.import`: Imports a release document.

> [!NOTE]
> You can not mix different types of actions, such as release and document actions, in a single transaction.



# Assets

Use the Assets API to upload and manage assets in your Content Lake datasets. For assets stored in Media Library, use the [Media Library API](https://www.sanity.io/docs/http-reference/media-library).

#### Want to get started with assets?

[Assets](https://www.sanity.io/docs/content-lake/assets)
Sanity provides extensible UI for managing assets, and an API for dealing with storage, resizing and deletion.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Copy

The Copy API, also known as Cloud Clone, allows you to make a copy of an existing dataset. It offers an alternative to exporting and importing a dataset.

#### Want to get started?

[How to use Cloud Clone for datasets](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets)
Copy a dataset inside Sanity's infrastructure using either the CLI or HTTP API.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Backups

The Backups API allows you to manage your saved backups.

#### Want to get started?

[Backups](https://www.sanity.io/docs/content-lake/backups)
Sanity offers a backup feature for data recovery and auditing, available for enterprise plans, managed via Sanity CLI.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Doc

Use the Doc API with caution as it bypasses caching and can lead to unexpected usage. Prefer the [Query API](https://www.sanity.io/docs/http-reference/query) for traditional fetching.



# Export

The Export API allows you to export all the non-deleted documents in a dataset, including drafts and asset documents.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# History

The History API lets you request document revisions by a timestamp or a revision ID. 

To find documents that have already been deleted, including when you don't know their IDs, see [Find and restore deleted documents](https://www.sanity.io/docs/developer-guides/find-and-restore-deleted-documents).

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- To read transactions for a document, you must have read access to the document's current version. 
- If your document is in a private dataset you must be authenticated.



# Jobs

The Jobs API allows you to monitor and manage processes running inside Sanity's infrastructure.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Listen

The listen endpoint can be used to receive events whenever documents are modified. This endpoint follows the server-sent events protocol using the mime-type text/event-stream. The backend will hold the connection open and stream events as they occur for any documents matching the GROQ query. 

> [!NOTE]
> In most cases, you should use the [Live Content API](https://www.sanity.io/docs/http-reference/live) instead for new projects.

## Authentication

- Any requests to private datasets must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Live

The Live Content API (LCAPI) is the underlying API that powers components like `<SanityLive>` and other *Live by default* functionality. 

It allows you to subscribe to a stream of sync tags as they become invalid, which you can then match up with the tags returned by the [Query API](https://www.sanity.io/docs/http-reference/query), or in queries made with the Sanity client.

#### Want to get started?

[Live Content API](https://www.sanity.io/docs/content-lake/live-content-api)
The Live Content API is perfect for fast-moving events like sports, news, and commerce. Deliver real-time experiences at scale.

[Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide)
Learn to use the Live Content API with Next.js or your own integration for real-time content updates in your app.

## Authentication

- [Authentication](https://www.sanity.io/docs/content-lake/http-auth) is not required for public data.
- Requests that use the `includeAllDocuments` option require a viewer token as noted below.



# Mutation

The Mutation API is a low-level interface for creating, modifying, and deleting documents in Content Lake. If you’re new to mutating documents, learn more in the [document mutation introduction](https://www.sanity.io/docs/content-lake/mutations-introduction).

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- Manipulating documents requires read+write access permission for the affected document type. In most cases, this includes the Editor, Developer, or Administrator roles.

## Mutation Types

The API supports several types of mutations:

- create: Creates a new document with a specified or generated ID.
- createOrReplace: Creates a new document or replaces an existing one.
- createIfNotExists: Creates a document only if it doesn't already exist.
- delete: Removes documents by ID or GROQ query.
- patch: Updates existing documents with various operations.

### Create Mutation

Creates a new document. The rules for the new document's identifier are:

- If _id is missing, a new random unique ID is generated.
- If _id ends with '.', it is used as a prefix for a new random unique ID.
- If _id is present, it is used as-is.

The operation will fail if a document by the provided ID already exists.

### CreateOrReplace Mutation

Creates a new document or replaces an existing one. If the document already exists:

- If the type is the same, the document will be completely replaced.
- If the type is different, it will act as a delete then create.
- If the document has hard references pointing to it, changing its type is not allowed.

### CreateIfNotExists Mutation

Creates a new document, but will silently fail if the document already exists.
Otherwise identical to create mutation.

### Delete Mutation

Deletes a document. Can delete by ID or by GROQ query.
The operation is considered successful even if the document did not exist.

When using a query to delete multiple documents:

- The query can only operate on up to 10,000 documents.
- For larger sets, split into multiple transactions.
- Recommended to paginate by _id using queries like `*[_type == "article" && _id > $lastId]`.

### Patch Mutation

Updates an existing document's contents through targeted changes. A patch will fail if the document does not exist. Can patch by ID or by GROQ query.

If multiple patches are included, the order of execution is:

1. set
2. setIfMissing
3. unset
4. inc
5. dec
6. insert

[Get started with patches](https://www.sanity.io/docs/content-lake/http-patches).

> [!NOTE]
> While you can use the HTTP API endpoint directly, we recommend using a client library.



# Query

The Query API lets you query Sanity Content Lake with GROQ.

You can also send queries to the CDN endpoint for edge-cached results:

```text
https://{projectId}.apicdn.sanity.io/v{YYYY-MM-DD}/data/query/{dataset}
```

Note: While you can use the HTTP API endpoint directly, we recommend using a client library if you can.

## Authentication

- Requests to drafts, versions, or content in private datasets must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Scheduling

The Scheduling API allows you to schedule documents using the legacy scheduling feature. 

> [!WARNING]
> This API is deprecated
> The Scheduling API was officially deprecated with the release of Scheduled Drafts. We suggest using [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) alongside the [Actions API](https://www.sanity.io/docs/http-reference/actions), or moving to [Content Releases](https://www.sanity.io/docs/content-lake/content-release-document-flow).

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).

## Rate / API limits

- The Scheduling API has the following limits:
- 100 requests per minute per project.
- 1000 requests per hour per project.

## Status and error codes

The API uses standard HTTP status codes:

- 200: Success
- 400: Bad Request
- 401: Unauthorized
- 403: Forbidden
- 404: Not Found
- 429: Too Many Requests

## Publishing rules

- Documents must exist in the dataset
- Documents must be valid according to their schema
- Documents must not be locked by another user

## Specifying dates

All dates must be in UTC format with a Z suffix, corresponding to the UTC+00:00 time zone.

Format: `YYYY-MM-DDTHH:mm:ss.sssZ`

Example: `2077-12-25T07:45:00.000Z`

## Schedules and your dataset

- Schedules are created in a specific dataset.
- Schedules can only publish/unpublish documents in that dataset.
- Schedules can be filtered by dataset.
- Schedules can be cancelled before they execute.

## Other caveats

- Only schedules with a `scheduled` state can be marked as `cancelled`.
- It's not possible to cancel already completed schedules.
- A schedule cannot have its state changed once in a `cancelled` state.
- Multiple schedule IDs can be specified as a comma-separated list when running schedules.

Note: While you can use the HTTP API endpoint directly, we recommend using a client library if you can.



# Webhooks

The Webhooks API allows you to programmatically interact with and monitor webhooks.

#### Want to get started?

[GROQ-powered webhooks](https://www.sanity.io/docs/content-lake/webhooks)
Send customized HTTP requests when something in your Content Lake has changed.

[Webhook best practices](https://www.sanity.io/docs/content-lake/webhook-best-practices)
Best practices for configuring webhooks and handling them in your system.

In addition to webhooks, you can also react to document changes with [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction).

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- Manipulating documents requires read+write access permission for the affected document type. In most cases, this includes the Editor, Developer, or Administrator roles.

## Webhook types

Sanity provides two types of webhooks, transaction and document. Document webhooks are preferred because they are more flexible and powerful.

### Document

A document webhook triggers every time a document is created, updated, or deleted. If a transaction updates 3 documents, 3 webhooks will be executed. Document webhook also allows for more granular filtering and customizable payloads with GROQ.

### Transaction

A transaction webhook triggers once per dataset, meaning if you batch together multiple document mutations in one transaction only one webhook will be executed.



# Agent Actions

The Agent Actions API allows you full access to Agent Actions through an HTTP endpoint instead of the Sanity client. We highly suggest using this only in situations where you cannot otherwise use the client.

> [!NOTE]
> Experimental feature
> Agent Actions are experimental and the API may change at any time. This API requires using `vX` for the API version.

#### Want to get started?

[Agent Actions](https://www.sanity.io/docs/agent-actions)
Get started with Agent Actions

## Authentication

All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Content Agent



# Embeddings Index

The Embeddings Index API allows you to create, manage, and query embeddings indexes for semantic search in your Sanity project.

> [!TIP]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. We recommend migrating to the new **Embeddings** feature, now natively available within Sanity datasets.
> The new Embeddings feature offers a more integrated experience with improved performance and full support going forward. No new features or fixes will be made to this package.
> **Migrate today:** [Dataset Embeddings documentation](https://www.sanity.io/docs/content-lake/dataset-embeddings)
> If you have questions or need migration support, please open a discussion or reach out in the [Sanity Community](https://snty.link/community).

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

Note: Using this feature requires Sanity to send data to OpenAI and Pinecone to store vector interpretations of documents.

#### Want to get started?

[Embeddings index introduction (deprecated)](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview)
Embeddings allow you to search for what your documents are about. Use the Embeddings Index API to build LLM agents or to enable semantic search.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).

## Known limitations

- Creating an embeddings index for very large datasets can be slow.
- The Embeddings Index HTTP API rate limit depends on the OpenAI rate limit, which sets a cap for the HTTP API at about 8,000 tokens per minute.
- The embeddings-index API does not support dataset aliases—you must use the real dataset name in all requests.



# Functions

#### New to Sanity Functions?

[Create a Document Function](https://www.sanity.io/docs/functions/function-quickstart)
Start building with Functions by deploying a new function to Sanity's infrastructure.

[Functions](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Media Library

The Media Library API lets you programmatically interact with assets in your organization’s Media Library.

#### Want to get started?

[Media Library introduction](https://www.sanity.io/docs/media-library/introduction)
Learn about Media Library, how to incorporate it into your workflow, and how to get started.

[Upload assets programmatically](https://www.sanity.io/docs/media-library/upload-assets)
Programmatically upload assets to your Media Library.

[Folders](https://www.sanity.io/docs/media-library/folders)
Organize Media Library assets into a navigable hierarchy with folders.

## Authentication

- All requests to private data must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth). Requests to public information, like public assets, are available without an authentication token.
- Manipulating documents requires read+write access permission for Media Library.



# Application management

The Applications API allows you to manage and deploy applications, such as Studios and SDK apps.

## Authentication

All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).



# Access

A centralized API to manage resource access control through roles and permissions.

#### Want to get started?

[Roles user guide](https://www.sanity.io/docs/user-guides/roles)
Configuring roles and permissions in the manage interface

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- See the individual endpoints below for required usage permissions.

## Terms

Throughout this document the terms `{resourceType}` and `{resourceId}` refer to the resource the API request is being applied to.

- `{resourceType}` can be an organization or project.
- `{resourceId}` is the ID of the resource the API request affects.

When a client uses this API, it acts in the context of a specific resource. For example:

- https://api.sanity.io/v2025-07-11/access/**organization/or0Bc1hcJ**/roles
- https://api.sanity.io/v2025-07-11/access/**project/c7ja4siy**/roles

## Key concepts

### Resource

A resource is an entity that can be managed and accessed through the API. Currently, the supported resources are `organization` and `project`.

### Permission

Every resource has a list of permissions. These permissions represent actions that can be performed on the resource. A user or robot must be granted a permission (through a role) in order to perform the action.

The permission typically takes the form of `{company}.{resourceType}.{objectName}.{action}` but this is not always the case due to legacy terms.

There are both pre-defined and custom permissions. Pre-defined permissions are included with the product and are not editable.

### Role

A role is a named collection of permissions that can be applied to a user or robot. Roles are in the scope of a resource and can only receive permissions that are in the same resource scope. For example, a project role can only include document permissions for that project.

A role is specific to a single resource and can only include permissions within that resource's scope. In the future, roles will be able to include permissions for child resources as well. For example, an organization role will be able to include document permissions for specific projects or all projects.

A user cannot be part of a resource without a role. Each user in an organization must have at least one role assigned to them.

There are both pre-defined and custom roles. Pre-defined roles are included with the product and are not editable, but can be removed from a resource if the feature is enabled.

### User

A user is a person that has one or more roles assigned to them.

A user is initially added to a resource via invitation or access request. A user that already has one role can be assigned roles in another project within the same organization or at the organization level without requiring a separate invite.

As an organization owns multiple resources (e.g., projects), any users with roles on these resources are also returned when reading the users of an organization.

If a user has roles in multiple projects, they are considered a single user and can be referenced by their `sanityUserId`. For example, inviting user A to project B and project C in the same organization will result in a single user with two memberships.

### Attribute

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

An attribute is a named piece of identity data associated with a user within an organization, such as `location="torrevieja"` or `department="front_desk"`.

Attributes can come from two sources: **SAML**, where they are automatically captured from assertions during SSO authentication, and **Sanity**, where they are set by administrators through the API or Manage interface.

Attributes can be referenced in GROQ filters within role definitions using the `user::attributes()` function, enabling parameterized access control that adapts dynamically to each user.

## Administrator Rules

### Changing administrators

Only administrators can assign or remove roles with admin permissions. This prevents unauthorized permission elevation.

This rule applies only to the default roles. Custom roles are fully managed by the organization and can be assigned to users without restriction.

### Last administrator

Each resource must have at least one role assigned to at least one user that can read users, read roles, and assign roles to users. This prevents an organization from losing control over their resources.

This is designed as a permission-level check and not a role-level check, so that the default roles can be removed from a resource. Customers with advanced roles management enabled can remove the default roles from a resource.

## Breaking changes from previous versions

See the [details of the existing API for a better understanding of the changes](https://www.sanity.io/docs/content-lake/roles-concepts).

Summary:

- `Access` is now the root path for managing access-based resources. Previously it was organization and project but these are now nested under the access root.
- Internal IDs are no longer exposed for permissions or roles. The `name` property is now used as the unique identifier for permissions and roles.
- `Permissions` now represent `grants`, `resources`, and `permissionResourceSchema`. These are now legacy terms.
- The endpoint `/organization/:organizationId/users` returns users for all resources owned by the organization. Previously, a client would have to make a request for each resource (e.g., project) individually.
- `users` replaces the term `ACL`. Users represents individuals assigned roles within the organization.
- The pre-defined roles can be completely removed from a resource. Previously, default roles could not be removed from a resource. E.g., a project required at least one project administrator, even if there were custom roles that would cover the same use case.



# Projects

The project API allows you to create and manage projects and datasets.

For role and permission management, we suggest using the newer [Access API instead](https://www.sanity.io/docs/http-reference/access-api).

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- Managing projects requires the Administrator or Developer role, or equivalent. 



# Roles

Use the Roles API to assign roles, grants, and permissions. 

> [!NOTE]
> We suggest using the [newer Access API for role management](https://www.sanity.io/docs/http-reference/access-api) instead of this legacy Roles API.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- Administrator or equal access is required to interact with this API.



# User attributes

## User Attribute

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

An [attribute](https://www.sanity.io/docs/user-guides/roles) is a named piece of identity data associated with a user within an organization, such as `location="torrevieja"` or `department="front_desk"`.

Attributes can come from two sources: **SAML**, where they are automatically captured from assertions during SSO authentication, and **Sanity**, where they are set by administrators through the API or Manage interface.

Attributes can be referenced in GROQ filters within role definitions using the `user::attributes()` function, [enabling parameterized access control](https://www.sanity.io/docs/user-guides/roles) that adapts dynamically to each user.



# Activity Log

Use the Activity Log API to retrieve management activity events for Sanity users, projects, and organizations. This API cannot be used to retrieve activities for content.

## Authentication

- All requests to private data must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth). Requests to public information, like public assets, are available without an authentication token.
- Your token only returns activity for organizations and projects that you or the robot token can access. If you do not pass `organizationId` or `projectId`, the API returns activity across your allowed organizations and projects.



# Libraries and tooling

#### Clients

[JavaScript Client](https://github.com/sanity-io/client)
The official Sanity JS Client

[PHP Client](https://github.com/sanity-io/sanity-php)
The official Sanity PHP Client

[Rust Client](https://github.com/Riley1101/sanity-rs)
Community-supported Sanity Rust Client

[LINQ (C#) Client](https://github.com/oslofjord/sanity-linq)
Community-supported Sanity LINQ Client

[Flutter Client](https://pub.dev/packages/sanity_client)
Community Supported Sanity Flutter Client

#### Portable Text

[Editor Playground](https://playground.portabletext.org/)
Explore the Portable Text Editor 

[Standalone Portable Text Editor](https://www.portabletext.org)
Portable Text Editor on demand!

[React serializer](https://github.com/portabletext/react-portabletext/)
Present Portable Text in React

[Vue serializer](https://github.com/portabletext/vue-portabletext/)
Present Portable Text in Vue

[Svelte serializer](https://github.com/portabletext/svelte-portabletext/)
Present Portable Text in Svelte

[HTML serializer](https://github.com/portabletext/to-html/)
Present Portable Text in plain HTML

#### Frontend tooling

[Next.js toolkit](https://github.com/sanity-io/next-sanity)
The all-in-one Sanity toolkit for production-grade content-editable Next.js applications.

[Nuxt module](https://sanity.nuxtjs.org/)
Just bring your sanity.config.ts - no additional configuration required

[Astro integration](https://github.com/sanity-io/sanity-astro)
The web framework for content-driven websites.



# Embeddings Index CLI reference (deprecated)

> [!TIP]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. We recommend migrating to the new **Embeddings** feature, now natively available within Sanity datasets.
> The new Embeddings feature offers a more integrated experience with improved performance and full support going forward. No new features or fixes will be made to this package.
> **Migrate today:** [Dataset Embeddings documentation](https://www.sanity.io/docs/content-lake/dataset-embeddings)
> If you have questions or need migration support, please open a discussion or reach out in the [Sanity Community](https://snty.link/community).

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

> Using this feature requires Sanity to send data to OpenAI and Pinecone to store vector interpretations of documents.

> [!WARNING]
> Gotcha
> Embeddings Index API is currently in **beta**. Features and behavior may change without notice.
> Embeddings Index API is available to users on the [Team plan and above](https://www.sanity.io/docs/platform-management/plans-and-payments).

> [!NOTE]
> [Embeddings Index API](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview) functionality is available through the [Embeddings Index CLI](https://www.npmjs.com/package/@sanity/embeddings-index-cli), the [Embeddings Index UI](https://www.npmjs.com/package/@sanity/embeddings-index-ui) for Sanity Studio, and the [Embeddings Index HTTP API](https://www.sanity.io/docs/http-reference/embeddings-index).

The Sanity Embeddings Index CLI offers commands to create, delete, fetch, and query embeddings indexes in a Sanity project.

You can install the Embeddings Index CLI:

- Globally, to make its commands available in the terminal regardless of the current directory path.
- Locally, on a per-project basis.

To execute the commands without installing the Embeddings Index CLI, invoke them through the [npx](https://www.npmjs.com/package/npx) package runner.

The Embeddings Index CLI commands work only in the context of a local Sanity project:

**npm**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
npx @sanity/embeddings-index-cli <command> [<arguments>]
```

**pnpm**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
pnpm dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**yarn**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
yarn dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**bun**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
bunx @sanity/embeddings-index-cli <command> [<arguments>]
```

## Prerequisites

- The Sanity CLI. The CLI ships with the [main Sanity package](https://www.npmjs.com/package/sanity).
You need it to log in to Sanity, which enables consuming the Embeddings Index CLI.
- The [Embeddings Index CLI](https://www.npmjs.com/package/@sanity/embeddings-index-cli). 

## Installing the Embeddings Index CLI

**npm**

```shell
# Installing the Embeddings Index CLI globally
npm install --save-dev --global @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
npm install --save-dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
npx @sanity/embeddings-index-cli <command> [<arguments>]
```

**pnpm**

```shell
# Installing the Embeddings Index CLI globally
pnpm add --save-dev --global @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
pnpm add --save-dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
pnpm dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**yarn**

```shell
# Installing the Embeddings Index CLI globally
yarn global add --dev @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
yarn add --dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
yarn dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**bun**

```shell
# Installing the Embeddings Index CLI globally
bun add --dev -g @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
bun add --dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
bunx @sanity/embeddings-index-cli <command> [<arguments>]
```

## Embeddings Index CLI commands

To view the built-in help, run:

**npm**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
npx @sanity/embeddings-index-cli --help
```

**pnpm**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
pnpm dlx @sanity/embeddings-index-cli --help
```

**yarn**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
yarn dlx @sanity/embeddings-index-cli --help
```

**bun**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
bunx @sanity/embeddings-index-cli --help
```

### Commands

#### Properties

**create**

Creates a new embeddings index in the current Sanity project.
It requires the following arguments:

--indexName: assign a descriptive name to the index.

--dataset: specify the name of an existing dataset. This is the target dataset to index. Note that the embeddings index API does not support dataset aliases.

--filter: specify the filtering criteria to include in the index only the selected subset of documents from the database.
The filter must be a valid GROQ filter without the square brackets that wrap the value assigned to _type.
Example: _type=='tutorial'

--projection: specify the projection criteria to include in the index only the selected subset of properties from the filtered documents.
The projection must be a valid GROQ projection, including curly brackets.
Example: {title, author}

Alternatively, you can create an embeddings index by passing a JSON manifest file with the --manifest argument:

--manifest <manifest-file-name>.json

For more information on creating a JSON manifest file, see the CLI manifest command in this reference.

**delete**

Deletes an existing embeddings index in the current Sanity project.
It requires the following argument:

--indexName: the name of the index to delete.

Alternatively, you can specify an existing JSON manifest file instead of indexName:

--manifest <manifest-file-name>.json

**get**

Retrieves status information about a specific embeddings index in the current Sanity project.
It requires the following argument:

--indexName: the name of the index whose status you want to retrieve.

Alternatively, you can specify an existing JSON manifest file instead of indexName:

--manifest <manifest-file-name>.json

**list**

Gets the status of all existing embeddings indexes in a Sanity project.

**manifest**

Creates a JSON manifest file with the configuration of an embeddings index, and saves the file to the specified location.

It requires the following arguments:

--out: specify the name of the JSON manifest file and, if necessary, the path to the directory to save it to.
If you don't specify a path, the JSON manifest file is saved to the current location in the Sanity project.
Example: <manifest-file-name>.json

--indexName: see the same argument under create.

--dataset: see the same argument under create.

--filter: see the same argument under create.

--projection: see the same argument under create.

**query**

Queries an embeddings index.
Returns an array of document IDs with their relevance score, based on the queried input string.

It requires the following arguments:

--indexName: the name of the index you want to query

--text: enter the content that you want to retrieve from the database using the embeddings index.
The content can be a string of text or a valid JSON-formatted document.

Examples

Query the embeddings index to retrieve relevant documents whose content matches the following text string:

"This is a song about vegetables."

Query the embeddings index to retrieve relevant documents whose content matches the following JSON document:

'{"_type": "lyrics", "title": "Call Any Vegetable"}'

### Options

#### Properties

**--debug**

Prints the stack trace. Useful to inspect errors.

**--help**

Prints the CLI built-in help.

**--silent**

Doesn't print any information or warning messages.
Use either --silent or --verbose. Don't specify both options.

**--verbose**

Logs extensive information and warning messages.
Use either --silent or --verbose. Don't specify both options.

**--version**

Prints the version number of the currently installed embeddings index CLI.

## Further reading

[embeddings-index-cli package on the npm registry](https://www.npmjs.com/package/@sanity/embeddings-index-cli)





# Specifications

#### Query language

[GROQ syntax](https://www.sanity.io/docs/specifications/groq-syntax)
Reference documentation for the GROQ syntax.

[GROQ data types](https://www.sanity.io/docs/specifications/groq-data-types)
Data types supported by GROQ

[GROQ functions reference](https://www.sanity.io/docs/specifications/groq-functions)
Reference documentation for GROQ functions.

[Full GROQ spec](https://spec.groq.dev/)
The base GROQ specification

#### Compute and AI

[Blueprint configuration reference](https://www.sanity.io/docs/blueprints/blueprint-config)
Reference documentation for the Blueprint configuration files.

[Function handler reference](https://www.sanity.io/docs/functions/function-wrapper)
Reference documentation for the shape of the function wrapper.

#### Beyond Sanity

[Portable Text](https://www.portabletext.org/)
The rich text editor and structured content specification that powers Studio's block editor.

[Content Source Maps](https://github.com/sanity-io/content-source-maps)
Associate rendered content with its original source.

[Mendoza](https://github.com/sanity-io/mendoza/tree/main)
The specification that powers the way Sanity diffs patches.



# Syntax

#### New to GROQ?
If you are just getting started with GROQ, check out the getting started guide first.
[Get started with GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)





A typical GROQ query has this form:

```groq
*[ <filter> ]{ <projection> }
```

1. `*`  returns all documents in the dataset that the current user has permissions to read. 
2. The documents are passed to a filter (`[]`), which retains documents for which the expression evaluates to `true`. 
3. The retained documents are passed to an optional projection. The projection determines how the result should be formatted. If no projection is specified, all data is returned.

A GROQ query of this form operates as a query pipeline, where the results from each component are passed as inputs to the next. The filter and projection are optional, and a query can have any number of them in any order.

In pipeline components, document attributes can be accessed by name. For example, this query would fetch directors born since 1970 and return their name, year of birth, and a list of their movies:

```groq
*[ _type == "director" && birthYear >= 1970 ]{
  name,
  birthYear,
  "movies": *[ _type == "movie" && director._ref == ^._id ]
}
```

For a complete introduction to GROQ, please see the [how-to](https://www.sanity.io/docs/content-lake/how-queries-work).

## JSON Superset

GROQ's syntax is a superset of JSON, so any valid JSON value is a valid GROQ query (that returns the given value). Below are a few examples of JSON values:

```json
"Hi! 👋"
```

```json
["An", "array", "of", "strings"]
```

```json
{
  "array": ["string", 3.14, true, null],
  "boolean": true,
  "number": 3.14,
  "null": null,
  "object": {"key": "value"},
  "string": "Hi! 👋"
}
```

For more information on JSON syntax, see the [JSON specification](https://tools.ietf.org/html/rfc8259).

## Whitespace

Whitespace is not significant in GROQ, except for acting as a token separator and comment terminator. Any sequence of the following characters is considered whitespace, with Unicode code points in parenthesis:

- Tab (`U+0009`)
- Newline (`U+000A`)
- Vertical tab (`U+000B`)
- Form feed (`U+000C`)
- Carriage return (`U+000D`)
- Space (`U+0020`)
- Next line (`U+0085`)
- Non-breaking space (`U+00A0`)

Whitespace inside a string literal is interpreted as-is.

## Comments

Comments serve as query documentation and are ignored by the parser. They start with `//` and run to the end of the line:

```groq
{
  // Comments can be on a separate line
  "key": "value" // Or at the end of a line
}
```

Comments cannot start inside a string literal.

## Expressions

An expression is one of the following:

- A literal, attribute lookup, parameter, or constant.
- An operator invocation (and, by extension, a pipeline).
- A function call.

Expressions can be used anywhere that a value is expected, such as object values, array elements, operator operands, or function arguments. The expression is in effect replaced by the value which it evaluates to.

> [!WARNING]
> Gotcha
> Due to parser ambiguity with filters, the following access operators can only take literals, not arbitrary expressions: array element access (e.g. `array[0]`), array slices (e.g. `array[1..3]`), and object attribute access (e.g. `object["attribute"]`).

### Selectors

A selector is a subset of an expression used to search for fields inside a document. You can only use them in certain functions—at this time, Delta GROQ functions, to select part of a document. See the [Delta GROQ functions](https://www.sanity.io/docs/specifications/groq-functions) and the Selectors section for a list of available functions and selectors.

## Literals

Literals are inline representations of constant values, e.g., `"string"` or `3.14`. GROQ supports all JSON literals, with a few enhancements and additional data types.

For more information on the data types themselves, see the [data types](https://www.sanity.io/docs/specifications/groq-data-types) reference.

### Boolean and Null Literals

The constants `true`, `false`, and `null`.

### Integer Literals

A sequence of digits, e.g., `42`. Leading zeroes are ignored.

### Float Literals

Floats have an integer part, a fractional part, and an exponent part. The integer part is required, and at least one of the fractional or exponent parts must be given.

The integer part is equivalent to an integer literal. The fractional part is a decimal point `.` followed by a sequence of digits. The exponent part is `e` or `E`, followed by an optional `+` or `-` sign followed by an integer specifying base-10 exponentiation.

The following are examples of float literals:

```json
3.0
3.14
3e6      // Equivalent to 3000000.0
3.14e0  // Equivalent to 3.14
3.14e-2  // Equivalent to 0.0314
```

### String Literals

A sequence of zero or more UTF-8 encoded characters surrounded by single or double quotes, e.g., `"Hello world! 👋"`. The following escape sequences are supported (mirroring JSON), all of which are valid in both single- and double-quoted string literals:

- `\\`: backslash
- `\/`: slash
- `\'`: single quote
- `\"`: double quote
- `\b`: backspace
- `\f`: form feed
- `\n`: newline
- `\r`: carriage return
- `\t`: tab
- `\uXXXX`: UTF-16 code point, where `XXXX` is the hexadecimal character code
- `\uXXXX\uXXXX`: UTF-16 surrogate pair

### Array Literals

A comma-separated list of values enclosed by `[]`, e.g. `[1, 2, 3]`. An optional trailing comma may follow the final element.

### Object Literals

A comma-separated list of key-value pairs enclosed by `{}`, where the key and value of each pair is separated by `:`, e.g. `{"a": 1, "b": 2}`. Keys must be strings. An optional trailing comma may follow the final pair.

### Pair Literals

Two values separated by `=>`, e.g. `"a" => 1`.

### Range Literals

Two values separated by `..` (right-inclusive) or `...` (right-exclusive), e.g. `1..3` or `1...3`.

## Identifiers

Identifiers name query entities such as attributes, parameters, functions, and some operators. Identifiers must begin with `a-zA-Z_`, followed by any number of characters matching `a-zA-Z0-9_`. Parameters are prefixed with `$`.

### Reserved Keywords

The following keywords are reserved and cannot be used as identifiers:

- `false`
- `null`
- `true`

## Attribute Lookup

A bare identifier looks up the value of the corresponding attribute in the document or object at the root of the current scope. For example, the following query `category` returns the value of the `category` attribute of the document currently being considered by the filter:

```groq
*[ category == "news" ]
```

If the attribute does not exist, or if the root value of the scope is not a document or object, then the identifier will return `null`.

> [!TIP]
> Protip
> JSON allows attribute keys to be any arbitrary UTF-8 string. In cases where the key is not a valid GROQ identifier, it can instead be accessed by using the `@` operator (typically returning the current document) and the `[]` attribute access operator, e.g. `@["1 illegal name 🚫"]`.

### Attribute Scope

Attribute lookups are scoped such that the same identifier may refer to different attributes in different contexts. New scopes are created by pipeline components, typically by iterating over the piped array elements and evaluating an expression in the scope of each element.

#### @ Operator – Access current scope

The `@` operator can be used to access the root value of the current scope.

```groq
// @ refers to the current number being evaluated
// Returns numbers in the array if they're greater than or equal to 10
numbers[ @ >= 10 ]

// @ refers to the myArray value
// This query returns the number of items in the myArray array
*{"arraySizes": myArray[]{"size": count(@)}} 
```

#### ^ Operator – Access the parent scope

Scopes can also be nested, in which case the `^` operator can be used to access the root value of the parent scope. Consider the following query:

```groq
*[ _type == "movie" && releaseYear >= 2000 ]{
  title,
  releaseYear,
  crew{name, title},
  "related": *[ _type == "movie" && genre == ^.genre ]
}
```

In the filter, `_type` and `releaseYear` access the corresponding attributes of each document passed from `*`. Similarly, in the projection, `title`, `releaseYear`, and `crew` access the corresponding attributes from each document passed from the filter. However, in the nested `crew` projection, `name` and `title` access the attributes of each object passed from the `crew` object - notice how the outer and inner `title` identifiers refer to different attributes (one is from the movie, the other is from the crew member).

The `related` pipeline components also create new scopes where `_type` and `genre` refer to the attributes of each document fetched from the preceding `*` operator, not those of the surrounding projected document. Notice how the `^` operator is used to access the document at the root of the parent (outer) scope and fetch its `genre` attribute.

## Operators

GROQ supports nullary, unary, and binary operators, which return a single value when invoked. Unary operators can be either prefix or postfix (e.g. `!true` or `ref->`), while binary operators are always infix (e.g. `1 + 2`). Operators are made up of the characters `=<>!|&+-*/%@^`, but identifiers can also be used to name certain binary operators (e.g., `match` which case they are considered reserved keywords.

## Functions

GROQ function calls are expressed as a function identifier immediately followed by a comma-separated argument list in parentheses, e.g., `function(arg1, arg2)`. An optional trailing comma may follow the final argument. Functions can take any number of arguments (including zero), and return a single value.

## Pipe Functions

Pipe functions ([order()](https://www.sanity.io/docs/specifications/groq-pipeline-components) and [score()](https://www.sanity.io/docs/specifications/groq-functions)) must be preceded by the [pipe operator](https://www.sanity.io/docs/specifications/groq-operators) (`|`). The left-hand expression will be an array that the pipe operator will pass to the right-hand pipe function, returning a new array.

`*[_type == "post"] | order(_createdAt desc)` will pass an array of all documents with a `_type` of `post` into the `order()` function, returning a new array of those documents sorted by the `_createdAt` property in descending order.



# Data types

#### New to GROQ?
If you are just getting started with GROQ, check out the getting started guide first.
[Get started with GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)





GROQ is strongly typed, meaning there is no implicit type conversion. Type conflicts (e.g. `1 + "a"`) will yield `null`.

For more information on how to express literal values for various data types, see the [Syntax section](https://www.sanity.io/docs/specifications/groq-syntax).

## Basic data types

### Boolean

Logical truth values, i.e.`, true` and `false`.

### Float

Signed 64-bit double-precision floating-point numbers, e.g., `3.14`, using the [IEEE 754 binary64 format](https://en.wikipedia.org/wiki/Double-precision_floating-point_format#IEEE_754_double-precision_binary_floating-point_format:_binary64). These have a magnitude of roughly 10⁻³⁰⁷ to 10³⁰⁸ and can represent 15 significant figures with exact precision - beyond this; significant figures are rounded to 53-bit precision. The special IEEE 754 values of infinity and NaN (not a number) are not supported and are coerced to `null`.

### Integer

Signed 64-bit integers, e.g., `42`, with a range of -2⁶³ to 2⁶³-1.

### Null

An unknown value expressed as `null`. This is the SQL definition of null, which differs from the typical definition of "no value" in programming languages, and implies among other things, that `1 + null` yields `null` (1 plus an unknown number yields an unknown number). See the [Operators section](https://www.sanity.io/docs/specifications/groq-operators) for further implications of this.

### String

A UTF-8 encoded string of characters, e.g., `"Hi! 👋"`. The maximum string length is undefined but is fundamentally limited by the maximum document size and maximum HTTP request size as listed in [Technical Limits](https://www.sanity.io/docs/content-lake/technical-limits).

## Composite data types

### Array

An ordered collection of values, e.g. `[1, 2, 3]`. Can contain any combination of other types, including other arrays.

### Object

An unordered collection of key/value pairs (referred to as attributes) with unique keys, e.g. `{"a": 1, "b": 2}`. Keys must be strings, while values can be any combination of other types, including other objects. If duplicate keys are specified, the last key is used.

### Pair

A pair of values, e.g. `"a" => 1`. Pairs can contain any combination of other types, including other pairs, and are mainly used internally with projection conditionals and [select()](https://www.sanity.io/docs/specifications/groq-functions). In returned JSON, pairs are represented as arrays with two values.

### Range

An interval containing all values ordered between the start and end values (for details on ordering, see "Comparison operators" in the [Operators section](https://www.sanity.io/docs/specifications/groq-operators)). The starting value is always included, while the end may be either included or excluded. A right-inclusive range is expressed as two values separated by `..`, e.g., `1..3` returns `1,2,3`, while a right-exclusive range is separated by `...`, e.g., `1...3` returns `1,2`.

Ranges can have endpoints of any basic data type, but both endpoints must be of the same type (except integers and floats, which can be used interchangeably). Ranges with incompatible or invalid endpoints types will yield `null`.

> [!CAUTION]
> Known issue
> Ranges currently may not work with all ordered types, and endpoint type conflicts may be handled incorrectly. Ranges also cannot be expressed in returned JSON.

Ranges are mainly used internally, e.g., with the `in` operator and array slice access operator. The endpoints may have context-dependent semantics, such as array slices with the range `[2..-1]` will cover the range from the third array element to the last element, while the same range is considered empty when used with `in`. For more details, see the documentation for [the relevant operators](https://www.sanity.io/docs/specifications/groq-operators).

## Subtypes

Subtypes are subsets of basic or composite types. Operators and functions that can act on the supertype can always act on the subtype as well, but the behavior may be modified, and some operators and functions can only act on the subtype.

### Datetime

Datetimes are strings with ISO 8601-formatted date/time combinations, e.g., `2018-11-04T13:45:21Z`.

> [!CAUTION]
> Known issue
> Datetimes are currently treated as plain strings, so some operations may not work as expected, e.g. comparisons will not take the time zone into account.

### Document

Documents are objects which contain the following special attributes (in addition to other arbitrary attributes):

- `_id` (path, required): The unique ID of the document. [IDs](https://www.sanity.io/docs/content-lake/ids) must begin with the characters `a-zA-Z0-9_`, followed by any of the characters `a-zA-Z0-9_.-`, and end with `a-zA-Z0-9_-`. IDs can have a maximum length of 128 characters and may not contain more than a single consecutive `.` character.
- `_type` (string, required): An arbitrary document type. Types may not be longer than 255 characters, may not contain `,` or `#`, and may not begin with `.` or `_`.
- `_rev` (string): A randomly generated revision ID corresponding to the transaction ID which generated this revision.
- `_createdAt` (datetime): The time when the document was created.
- `_updatedAt` (datetime): The time when the document was last modified.

### Path

Paths are strings that represent a node or branch in a tree (i.e., hierarchy). They are typically used for document IDs, where each path segment is separated by `.`, e.g., `articles.business.finance.4861`.

Paths can also be glob patterns, using `*` wildcards which do not cross `.` and `**` wildcards which do cross `.`. For example, the path `articles.business.finance.4861` is matched by the path `articles.business.**`, but not `articles.business.*`.

Paths are most commonly used in conjunction with the `in` operator when filtering documents, e.g., `_id in path("articles.business.**")`.

### Reference

References are objects that represent a [reference to a different document](https://www.sanity.io/docs/content-lake/how-queries-work). They have the following special attributes (in addition to other arbitrary attributes):

- `_ref` (path, required): The ID of the referenced document.
- `_weak` (boolean): If `true`, referential integrity is not enforced, i.e., the reference is allowed to point to a non-existent document. Defaults to `false`.



# Parameters



Parameters are client-provided values that are substituted into queries before execution. Their names must begin with `$` followed by a valid identifier, and their values must be JSON literals of any type (take care to quote strings). Since they are JSON literals they can only contain values, not arbitrary GROQ expressions, and are safe to pass from user input.

For example, the following query may be given parameters such as `$type="myType"` and `$object={"title": "myTitle", "value": 3}`:

```groq
*[ _type == $type && title == $object.title && value > $object.value ]
```

In the HTTP API, parameters are passed via URL query parameters, see the [HTTP API documentation](https://www.sanity.io/docs/http-reference/query) for details.

## Predefined Parameters

> [!WARNING]
> Gotcha
> Predefined parameters are deprecated. Use the functions `identity()` and `now()` instead.

The following parameters are predefined and available for use in all GROQ queries:

- `$identity` (string): The ID of the current user, or `<anonymous>` for unauthenticated users.
- `$now` ([datetime](https://www.sanity.io/docs/specifications/groq-data-types)): The current server time (UTC).



# Operators



## Logical Operators

GROQ supports the use of the following logical operators:

- AND (`&&`)
- OR (`||`) 
- NOT (`!`)

The operand – the value that the operator evaluates – must be a boolean or `null`. If the evaluation yields an invalid type, it will be coerced to `null`.

### `&&` – Logical AND

`&&` returns `true` if both operands are `true`.

Specifically, `&&` follows the evaluation order:

1. returns `false` if either operand is `false`
2. otherwise, returns `null` if either operand is `null` (or not a boolean)
3. otherwise, returns `true`

#### Truth table

```groq
true && true   // returns true

true && false  // returns false

false && true  // returns false

false && false // returns false

true && null   // returns null

false && null  // returns false

null && true   // returns null

null && false  // returns false

null && null   // returns null
```

#### GROQ examples

```groq
// Checks if a document: 
// is of _type "author"
// AND has a name value of "John Doe"
// If both are true, returns all documents matching
*[_type == "author" && name == "John Doe"]


// Checks if a document:
// is of _type "movie"
// AND has a title of "Arrival"
// If both are true, returns all documents matching
*[_type == "movie" && title == "Arrival"]

// Checks if a document:
// is of _type "movie"
// AND includes the string 'sci-fi' in its genres field
// If both are true, returns all documents matching
*[_type == "movie" && "sci-fi" in genres]
```

### `||` – Logical OR

`||` returns `true` if either operand is `true`.

Specifically, `||` follows the evaluation order:

1. returns `true` if either operand is `true`
2. otherwise, returns `null` if either operand is `null` (or not a boolean)
3. otherwise, returns `false`

#### Truth Table

```groq
true || true   // Returns true

true || false  // Returns true

false || true  // Returns true

false || false // Returns false

true || null   // Returns true

false || null  // Returns null

null || true   // Returns true

null || false  // Returns null

null || null   // Returns null
```

#### GROQ examples

```groq
// Checks if a document:
// has a number property `popularity` greater than 15
// OR has a releaseDate after 2016-04-25
// Returns all documents that match EITHER condition
*[popularity > 15 || releaseDate > "2016-04-25"]

// Checks if a document:
// has a postCount greater than 20
// OR has a boolean property `featured` equal to true
// Returns all documents that match EITHER condition
*[postCount > 20 || featured]

// Checks if a document:
// has a name value of "John Doe"
// OR has a slug.current property containing the word "forever"
// Returns all documents that match EITHER condition
*[name == "John Doe" || slug.current match "forever"]
```

### `!` – Logical Not

`!` returns the logical negation of the value of the operand.

> [!WARNING]
> Gotcha
> `!value` will always return `null` unless `value` is either `true` or `false`.

#### Truth table

```groq
!true  // Returns false

!false // Returns true

!null  // Returns null
```

#### GROQ examples

```groq
// Returns all docs that don't start with a.b.
*[!(_id in path("a.b.**"))]

// Returns all documents where the boolean `awardWinner` is false
*[!awardWinner]
```

## Comparison Operators

Comparison operators compare two values, returning `true` if the comparison holds or `false` otherwise. The operands must be of the same type (except for integers and floats, which are interchangeable). If the operands are of different types, the comparison returns `null`. If any operand is `null`, the comparison returns `null`.

Comparisons are only supported for booleans, integers, floats, and strings. Comparisons using any other data types will return `null`.

Equality comparisons (`==` and `!=`) have the following semantics:

- **Booleans**: identical logical truth values.
- **Integers and floats**: identical real number values.
- **Strings**: identical lengths and Unicode code points (case sensitive).
- **Nulls**: always yield `null`.

Ordered comparisons (`>`, `<`, `>=`, and `<=`) use the following order:

- **Booleans**: `true` is greater than `false`.
- **Integers and floats**: numerical order.
- **Strings**: numerical Unicode code point order (i.e., case-sensitive), compared character-by-character. For overlapping strings, shorter strings are ordered before longer strings.
- **Nulls**: always yield `null`.

> [!CAUTION]
> Known issue
> String fields that are longer than 1024 characters are not available for search using equality operators (`==`, `!=`, `<`, `<=`, `>`, and `>=`) and are not sortable.
> The `match` operator works on a string field of any length.

### `==` Equality

Returns `true` if the operands are considered equal.

### `!=` Inequality

Returns `true` if the operands are considered not equal.

### `>` Greater Than

Returns `true` if the left-hand operand is greater than (ordered after) the right-hand operand.

### `<` Lesser Than

Returns `true` if the left-hand operand is lesser than (ordered before) the right-hand operand.

### `>=` Greater Than or Equal

Returns `true` if the left-hand operand is considered greater than or equal to the right-hand operand.

### `<=` Lesser Than or Equal

Returns `true` if the left-hand operand is considered lesser than or equal to the right-hand operand.

### `in` Compound Type Membership

```groq
// Returns true if document's _type
// is included in the array (either "movie" or "person")
*[_type in ["movie", "person"]]

// Returns true if "myTag" is in tags array
*["myTag" in tags]
```

Returns `true` if the left-hand operand is contained within the right-hand operand. The right-hand operand may be an [array](https://www.sanity.io/docs/specifications/groq-data-types), [range](https://www.sanity.io/docs/specifications/groq-data-types), or [path](https://www.sanity.io/docs/specifications/groq-data-types).

If the right-hand operand is an array, the left-hand operand may be of any type. The left-hand operand is compared for equality (`==`) with each element of the array, returning `true` as soon as a match is found. If no match is found, it returns `null` if the array contains a `null` value, otherwise `false`.

If the right-hand operand is a range, the left-hand operand must be of the same type as the range endpoints. Returns `true` if the left-hand operand is ordered between the range endpoints, otherwise `false`.

If the right-hand operand is a path, the left-hand operand must be a string or path. Returns `true` if the left-hand operand is matched by the right-hand path pattern, otherwise `false`.

#### not `in`

To find results where the left-hand operand is **not** contained within the right-hand operand, the entire expression can be negated with the [logical not operator](https://www.sanity.io/docs/specifications/groq-operators), `!`. Parentheses are required to apply the logical not operator to the entire expression.

```groq
// Returns true if document's _type
// is *not* included in the array (neither "movie" nor "person")
*[!(_type in ["movie", "person"])]

// Returns true if "myTag" is in tags array
*[!("myTag" in tags)]
```

> [!WARNING]
> Gotcha
> In GROQ `v1`, it was possible to use `==` to compare a string against an array of strings (e.g., `someArray[].tags == "something"`). This behaviour was inconsistent with the GROQ specification and was fixed in `v2021-03-25` (it will no longer return `true`).
> The `in` operator correctly compares the left hand operand to each element in an array for equality, returning `true` when a match is found (e.g., `"something" in someArray[].tags`). This approach is consistent with the GROQ specification and is the ideal way to compare a string against an array of strings.

## Access Operators

Because of the nested nature of data, it's often important to access members of compound data types like objects and arrays. Access operators allow the use of nested content in operations.

Access operators will return `null` if the member does not exist or the operator is incompatible with the left-hand operand's type. Access operators can be chained. Each operator accesses the result of the preceding chain, e.g., `object.ref->array[1]`.

### `*` Everything

Takes no operands and returns an array of all stored documents that the current user has access to. It is typically used at the beginning of a GROQ query pipeline, such as `*[ _type == "movie" ]{ title, releaseYear }`.

```groq
// Returns all items in the root array
*[]

// Returns all items from the root array
// matching the filter provided (documents with _type of "movie")
*[_type == "movie"]
```

> [!WARNING]
> Gotcha
> Not to be confused with the `*` multiplication operator, which takes two operands (the factors to be multiplied).

### `@` This

Takes no operands and returns the root value of the current scope, or `null` if no root value exists. 

For example, in the document filter `*[ @.attribute == "value" ]` it refers to the currently filtered document, and in the expression `numbers[@ >= 10]` it refers to the currently filtered number of the `numbers` array.

```groq
// @ refers to the root value (document) of the scope
*[ @["1"] ] 

// @ refers to the myArray array
// Returns the total number of items in my Array
*{"arraySizes": myArray[]{"size": count(@)}} 
```

### `^` Parent

Takes no operands and returns the root value of the parent scope, or `null` if no parent scope exists.

```groq
// Value of ^ is the current doc in the "someParent" array
*[_type == "someParent"]{ 
  "referencedBy": *[ references(^._id) ]
}

// Using the ^ operator to refer to the enclosing document. Here ^._id refers to the id
// of the enclosing person record.
*[_type=="person"]{
  name,
  "relatedMovies": *[_type=='movie' && references(^._id)]{ title }
}

// person.someObj.parentName returns root name value
*[_type=="person"]{
  name,
  someObj{
    name,
    "parentName": ^.name
  }
}
```

#### Accessing higher scopes

Multiple parent operators can be chained together to access two or more scopes up.

```groq
*[_type == "content"]{
  "children": *[references(^._id)]{
    "grandchildren": *[references(^._id) && references(^.^._id)]
  }
}
```

In the example above, the `"children"` filter will return documents that reference the parent document (each one returned from `*[_type == "content"]`).

In the `"grandchildren"` filter, we want to return documents that reference the parent document (which will be returned from the `"children"` filter) as well as the grandparent document (which will be returned from `*[_type == "content"]`). The chained parent operator (`^.^`) is required to go two levels up the scope.

To move higher in scope, additional parent operators can be added.

### `.<identifier>` Object Attribute Access

Returns the value of the object attribute given by the right-hand identifier, e.g. `object.attribute`.

```groq
// Returns the name string from someObject
*[_type == "document"] {
  "nestedName": someObject.name
}
```

### `[<string>]` Object Attribute Access

Returns the object attribute with the given key, e.g., `object["attribute"]`. This is equivalent to the `.` access operator, but useful when the attribute name is not a legal GROQ identifier.

```groq
// Returns the illegalIdentifier value from someObject
*[_type == "document"] {
  "nestedName": someObject["illegalIdentifier"]
}
```

> [!WARNING]
> Gotcha
> The attribute name must be a string literal due to parser ambiguity with filters.

### `->` Reference Access (dereference)

Returns the document referenced by the left-hand reference instead of the reference values. It may optionally be followed by an attribute identifier or data projection, in which case it returns the value of the given attribute(s) of the referenced document. If the reference points to a non-existent document (for a weak reference), it returns `null`.

```groq
*[_id == "someDocument"]{
  referencedDoc->, // Returns all data
  "referenceName": referencedDoc->name, // Returns the name value
  "referenceProjection": referencedDoc->{
    title,
    description
  } // Returns the title and description
}
```

### `[<integer>]` Array Element Access

Returns the array element at the given zero-based index, e.g., `array[2]` yields the third array element. Negative indices are based at the end of the array, e.g., `array[-2]` yields the second-to-last element.

> [!WARNING]
> Gotcha
> The element index must be an integer literal due to parser ambiguity with filters.

### `[<range>]` Array Slice

Returns a new array containing the elements whose indices fall within the range, e.g., `array[2..4]` yields the new array `[array[2], array[3], array[4]]`. Ranges may extend beyond array bounds.

- `..` includes the right-index, e.g. `1..3` returns 4 items
- `...` excludes the right-index, e.g. `1...3` returns 3 items.

Negative range endpoints are based at the end of the array, e.g., `array[2..-1]` yields all elements from the third to the last. If the right endpoint falls before the left endpoint the result is an empty array.

> [!WARNING]
> Gotcha
> The range must be a range literal due to parser ambiguity with filters.

### `[<boolean>]` Array Filter

Returns a new array with the elements for which the filter expression evaluates to `true`, e.g., `people[birthYear >= 1980]`. The filter is evaluated in the scope of each array element.

> [!TIP]
> Protip
> This operator is actually the filter pipeline component in disguise, since it has an implicit `|` operator before it.

### `[]` Array Traversal

Traverses the left-hand array, applying the optional right-hand access operator to each element and collecting the resulting values in a flat array - e.g., `array[].attribute` yields a flat array of the `attribute` attribute value of each array element, and `array[]->name` yields a flat array of the `name` attribute values of each document referenced by `array`. If no right-hand access operator is given, it defaults to returning a flat array containing each traversed element.



```groq
// "cast" loops through the castMembers array
// each person dereferences to return each person's name
*[_type=='movie']{title,'cast': castMembers[].person->name}

// Returns
{
  title: "Interstellar",
  cast: [
    "Matthew McConaughey",
    "Anne Hathaway",
    "Matt Damon",
    ...
  ]
}
```

### `...` Array/Object Expansion

Expands the right-hand array or object into the surrounding literal array or object.

```groq
// In an array
[ ...[1,2], 3, ...[4,5] ]
// Returns
[1,2,3,4,5]
 
// In an object
{ ...{"a": 1}, "b":2, ...{"c":3} }
//returns
{ "a": 1, "b": 2, "c":3 }
```

## Arithmetic Operators

Arithmetic operators accept any combination of float and integer operands. If any operand is a float, or if the result has a non-zero fractional part, the result is a float; otherwise, it is an integer.

The `+` operator is also used to concatenate two strings, arrays, or objects.

> [!WARNING]
> Gotcha
> Floating-point arithmetic is fundamentally imprecise, so operations on floats may produce results with very small rounding errors, and the results may vary on different CPU architectures. For example, `3.14+1` yields `4.140000000000001`. The `round()` function can be used to round results.

### `+` Addition and Concatenation

Adds two numbers, e.g., `3+2` yields `5`. Also acts as a prefix operator for positive numbers, e.g., `+3` yields `3`.

Also concatenates two strings, arrays, or objects, e.g., `"ab"+"cd"` yields `"abcd"`. If two objects have duplicate keys, the key from the right-hand object replaces the key from the left-hand one.

### `-` Subtraction

Subtracts two numbers, e.g., `3-2` yields `1`. Also acts as a prefix operator for negative numbers, e.g., `-3`.

### `*` Multiplication

Multiplies two numbers, e.g., `3*2` yields `6`.

### `/` Division

Divides two numbers, e.g., `3/2` yields `1.5`. Division by `0` yields `null`.

### `**` Exponentiation

Raises the left-hand operand to the power of the right-hand operand, e.g., `2**3` yields `8`.

Fractional and negative exponents follow the normal rules of roots and inverse exponentiation, so e.g., the square root of `4` is taken with `4**(1/2)` (yielding `2`), and the inverse square root of `4` is taken with `4**-(1/2)` (yielding `0.5`).

### `%` Modulo

Returns the remainder of the division of its operands, e.g.,, `5%2` yields `1`. The remainder has the sign of the dividend and a magnitude less than the divisor.

## Full-Text Search Operators

Full-text search operators perform searches of text content using inverted search indexes. Content is tokenized as words (i.e. split on whitespace and punctuation), with no stemming or other processing.

### `match` Full-text Search

Searches the left-hand operand for individual words that match the text pattern(s) given in the right-hand operand, returning `true` if a match is found; otherwise it returns `false`. If the right-hand operand contains `null` then `match` will return `null`.

Patterns are strings that use `*` as wildcards, and any number of wildcards can be used at any position. For example, `foo*` matches any word starting with `foo`, and `foo*bar` matches any word starting with `foo` and ending with `bar`. If the pattern does not contain any wildcards, it must exactly match a whole word in the left-hand operand.

Both the left-hand and right-hand operands can be either strings or arrays of strings. All patterns in the right-hand operand must match anywhere in the left-hand operand, e.g. `["foobar", "baz"] match ["foo*", "*bar"]` returns `true`. The right-hand operand is also tokenized in the same way as the underlying content (by splitting on whitespace and punctuation) so that, e.g. `"foo bar"` is equivalent to `["foo", "bar"]`. 

```groq
// title contains a word starting with "wo"
*[title match "wo*"] 

// title and body combined contains a word starting with "wo" and the full word "zero"
*[[title, body] match ["wo*", "zero"]]

// title must contain both the full words "hello" and "goodbye"
*[title match ["hello", "goodbye"]]
```

> [!CAUTION]
> Known issue
> `match` with left-hand arrays currently only work as documented with array traversal expressions, e.g. `array[].value match "pattern"`, and then only in certain cases. If the left-hand operand is an array attribute, e.g. `array match "pattern"`, then it never matches. If the left-hand operand is a literal array, e.g. `[a, b] match ["x","y"]`, then all right-hand patterns must match any *single* string (instead of anywhere in all strings).

> [!TIP]
> Protip
> To match against a body of portable text, one approach is to use the `pt::text()` function. This will strip out all the marks and return just the joined body of the portable text (almost as if it were plain text). Function details and an example using match can be found [here](https://www.sanity.io/docs/specifications/groq-functions).

### Tokenization behavior

The `match` operator tokenizes both operands by splitting on whitespace and punctuation characters. The following table shows how specific characters are handled:

- `.` (dot): word boundary. `"1.2.3"` becomes tokens `['1', '2', '3']`
- `-` (hyphen): word boundary. `"sci-fi"` becomes tokens `['sci', 'fi']`
- `@`: word boundary. `"user@sanity.io"` becomes tokens `['user', 'sanity', 'io']`
- `()` (parentheses): stripped. `"Google-Pixel (9)"` matches `"Google Pixel 9"`
- `®`, `™`: separated into their own tokens.
- Accented characters: kept as-is. "configurá" and "configura" are different tokens and do not match each other.

The `match` operator folds case, but not diacritics: `"CONFIGURA" match "configura"` returns `true`, while `"configura" match "configurá"` returns `false`.

### When to use match vs. alternatives

The `match` operator is designed for searching human-language text (titles, descriptions, article bodies). Because it tokenizes on punctuation, it is not suited for matching structured strings like version numbers, filenames, email addresses, or slugs.

**Use match for:** searching article titles, body text, names, and other natural-language content.

**Use string functions for structured strings:** version numbers, filenames, email addresses, slugs, and IDs.

```groq
// Find documents where slug starts with a prefix
*[string::startsWith(slug.current, "my-pretty")]

// Compare version numbers as exact strings
*[version == "1.2.3"]

// Split and compare parts of structured strings
*[count(string::split(version, ".")) == 3]
```

## Pipe Function Call Expression

GROQ comes with built-in pipe functions ([order()](https://www.sanity.io/docs/specifications/groq-pipeline-components) and [score()](https://www.sanity.io/docs/specifications/groq-functions)) that provide additional features. Pipe functions always accept an array on the left-hand side and return another array. The syntax is optimized for being able to chain pipe functions together with other compound expressions.

### `|` Pipe Operator

Pipe functions must be preceded with the pipe operator (`|`). The left-hand expression will be an array that's passed to the right-hand function:

```groq
// Left-hand expression | Right-hand function

* | order(_id asc)
*[_type == "post"] | order(date desc)
*[]{ _id, title } | order(_createdAt asc)

// Note in the last example that the expression used
// in the order function does not need to be passed
// in the projection.
```

The pipe operator may also precede a projection, though in that case it is optional.

> [!TIP]
> Protip
> When using the `score()` function, pipe it in after the filter, but before a projection. Score works off the raw, unmodified data returned from the filter so it's best to use it before other functions or projections.

## Operator Precedence

Operator precedence is listed below, in descending order and with associativity in parenthesis:

- `.` (left), `|` (left)
- `->` (left)
- `**` (right)
- `*` (left), `/` (left), `%` (left)
- `+` (left), `-` (left), `!` (right)
- `...` (right)
- `==` , `!=`, `>`, `>=`, `<`, `<=` (all left), `in` (left), `match` (left)
- `&&` (left)
- `||` (left)
- `*` (none), `@` (none), `^` (none)

Precedence can be overridden by grouping expressions with `()`, e.g. `(1+2)*3`.



# Functions

#### New to GROQ?
If you are just getting started with GROQ, check out the getting started guide first.
[Get started with GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)

Functions in GROQ take a set of arguments of specific types and return a single value of a specific type. They may be polymorphic, i.e., accept several argument type variations possibly returning different types, and may take a variable number of arguments. Function calls return `null` if arguments have invalid types, and an error if the function does not exist. For quick-reference examples of using these functions in queries, see the [GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet).

## Function namespaces

Namespaces allow for a stronger grouping of functionality within [the GROQ specification](https://sanity-io.github.io/GROQ/). They create dedicated scopes for global functions, as well as safer distinctions for specific implementations of GROQ.

### Accessing functions in a namespace

All functions exist within a namespace and can be accessed via a call to the function with a prefix a string of the namespace name, followed by two colons and then the function name.

```groq
// The pt namespace contains functions related to Portable Text
// This function returns a plain text version of a Portable Text object
pt::text(ptNode)

// The geo namespace contains functions related to geolocation
// This function returns true if the second argument is fully contained in the first
geo::contains(polygon, point)
```

## Global functions

Functions that exist for all implementations of GROQ exist in the global namespace. They can be accessed without using the namespace string.

```groq
// Non-namespaced
references('someId')
// equates to
global::references('someId')

```

### `coalesce`

`coalesce(<any>...) <any>`

Takes a variable number of arguments of any type and returns the first non-`null` argument if any, otherwise `null` - e.g., `coalesce(null, 1, "a")` returns `1`.

```groq
// If title.es exists return title.es
// Else return title.en
// If neither exist, return null
*[_type == "documentWithTranslations"]{
  "title": coalesce(title.es, title.en)
} 


// If rating exists, return rating,
// Else return string of 'unknown'
*[_type == 'movie']{
  'rating': coalesce(rating, 'unknown')
}

```

### `count`

`count(<array>) <integer>`

Returns the number of elements in the passed array, e.g. `count([1,2,3])` returns `3`.

```groq
// Returns number of elements in array 'actors' on each movie
*[_type == 'movie']{"actorCount": count(actors)} 

// Returns number of R-rated movies
count(*[_type == 'movie' && rating == 'R']) 
```

### `dateTime`

`dateTime(<string>) <datetime>`

Accepts a string in [RFC3339](https://tools.ietf.org/html/rfc3339) format (e.g. `1985-04-12T23:20:50.52Z`) and returns a DateTime. This is also the format used in the `_createdAt` and `_updatedAt` fields. Typically used to let GROQ know to treat a string as a date, especially useful when you need to compare them or perform time arithmetic operations.

Subtracting two DateTimes returns the number of seconds between those time stamps. Adding a number to a `DateTime` returns the `DateTime` that amount of seconds later (or earlier if the number is negative).

```groq
*[_type == "post"]{
  title,
  publishedAt,
  "timeSincePublished": dateTime(now()) - dateTime(publishedAt)
}
```

> [!TIP]
> New to GROQ?
> You can create RFC3339-dateTime strings in JavaScript with the [Date.prototype.toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) method.

### `defined`

`defined(<any>) <boolean>`

Returns `true` if the argument is non-`null`, otherwise `false`.

```groq
// Returns all documents if awardWinner has any value (of any type)
*[defined(awardWinner)] 
```

> [!CAUTION]
> Known issue
> String fields that are longer than 1024 characters will not provide the expected result from `defined()`.
> The `match` operator works on a string field of any length.

### `identity`

`identity() <string>`

Returns the project user ID of the user performing the current action, or the special values `<anonymous>` for unauthenticated users and `<system>` for system-initiated actions.

The returned ID is project-scoped: the same person has a different project user ID in every project, and is stable across the datasets within a project. It is the `projectUserId` in a project's access control list, and the value returned by `client.users.getById('me')`.

It is not the same as the global Sanity user ID (`sanityUserId`). The global ID identifies a person across the whole platform and is what the [management API](https://www.sanity.io/docs/http-reference/access-api) and the App SDK's [useCurrentUser().id](https://reference.sanity.io/_sanity/sdk-react/exports/useCurrentUser/) return. A GROQ filter that compares `identity()` against global IDs will never match. To map between them, look up the project user with the [project user retrieval API](https://www.sanity.io/docs/http-reference/projects-api), which returns both the project `id` and its `sanityUserId`.

When you scope content to specific users (for example, in a custom role's document filter or an "owned by me" query), store project user IDs in the documents or grant definition so they compare correctly against `identity()`.

> [!NOTE]
> Note
> A document's creator is not stored on the document by default; it is only available through the History API, which is not queryable with GROQ. To compare `identity()` against the creator in a filter, store the creator's project user ID on the document yourself when it is created (for example, in a `createdBy` field).

```groq
// Documents created by the current user, assuming you store the
// creator's project user ID in a `createdBy` field on each document
*[_type == "release" && createdBy == identity()]

// Custom-role document filter scoping records to a team of users,
// where engineerUserIds holds project user IDs
*[_type == "release" && identity() in engineerUserIds]
```

### `length`

`length(<array|string>) <integer>`

Returns the length of the argument, either the number of elements in an array or the number of Unicode characters in a string, e.g., `length([1,2,3])` returns `3`, and `length("Hi! 👋")` returns `5`.

```groq
// Return posts with more than 2 authors
*[_type == "post" && length(authors) > 2]{
  title,
  authors[]->{
    name
  }
}
```

> [!WARNING]
> Gotcha
> While `length()` works on arrays, you should consider using `count()` as it's optimized for arrays.

### `lower` / `upper`

`lower(<string>) <string>`

`upper(<string>) <string>`

The `lower()` and `upper()` functions take a string and return back the string in all lowercase characters or all uppercase characters.

```groq
*{
  "upperString": upper("Some String"), // Returns "SOME STRING"
  "lowerString": lower("Some String")  // Returns "some string" 
}
```

### `now`

`now() <string>`

Returns the current time in [RFC3339](https://tools.ietf.org/html/rfc3339) format with microsecond resolution in the UTC time zone, e.g., `2021-08-19T15:51:24.846513Z`. The current time is stable within an operation such that multiple calls return identical values. This generally refers to the start time of the operation except for listener queries. This refers to the event's transaction time. This is the equivalent of `dateTime::now()` from the official GROQ spec. If you need to be more explicit, you can wrap it as `dateTime(now())`.

> [!NOTE]
> Caching now() in APICDN
> Using `now()` and our APICDN creates a conundrum: how long should `now()` be cached?
> We have created a special caching rule that says `now()` is only valid for 120 seconds, plus the normal 60 seconds stale-while-revalidate after that. This gives us the opportunity to cache queries not containing `now()` much longer.

> [!WARNING]
> Gotcha
> Mutations using `query` parameters trigger two separate operations internally: first execution of queries to determine which documents to update, then a transaction to actually update the documents. `now()` will return different times for these two operations, referring to the start time of each operation.

```groq
// Give me all posts with a publish date in the future
*[_type == "post" && dateTime(now()) < dateTime(publishedAt)]
```

### `path`

`path(<string>) <path>`

Coerces the passed string to a path, e.g. `"a.b" in path("a.*")`.

```groq
// _id matches a.b.c.d but not a.b.c.d.e
*[_id in path("a.b.c.*")] 

// _id matches a.b.c.d and a.b.c.d.e
*[_id in path("a.b.c.**")] 

// All draft documents
*[_id in path("drafts.**")]

// Only published documents
*[!(_id in path("drafts.**"))]
```

### `references`

`references(<path|string|array>) <boolean>`

Implicitly takes the document at the root of the current scope and recursively checks whether it contains any references to the given document ID(s). It is typically used in query filters, e.g., `*[ references("abc")]` will return any documents that contain a reference to the document `abc`. If providing the function with an array of document ids, it will return `true` if any of the ids are referenced. [Learn more about references](https://www.sanity.io/docs/content-lake/how-queries-work).

```groq
// Using the ^ operator to refer to the enclosing document. Here ^._id refers to the id
// of the enclosing person record.
*[_type=="person"]{
  name,
  "relatedMovies": *[_type=='movie' && references(^._id)]{ title }
}

```

### `round`

`round(<integer|float>[, <integer>]) <integer|float>`

Rounds the given number to the nearest integer, or to the number of decimal places given by the second, optional argument - e.g. `round(3.14)` yields `3` and `round(3.14, 1)` yields `3.1`.

### `select`

`select(<pair|any>...) <any>`

Used for conditionals, i.e. "if-else" expressions. Takes a variable number of arguments that are either pairs or any other type and iterates over them. When encountering a pair whose left-hand value evaluates to `true`, the right-hand value is returned immediately. When encountering a non-pair argument, that argument is returned immediately. Falls back to returning `null`.

```groq
// If age is 18+, return "adult" string
// Else if age is 13+, return "teen" string
// Else return "child" string
select(
  age >= 18 => "adult",
  age >= 13 => "teen",
  "child"
)

// If popularity integer is more than 20, return "high" string
// Else if popularity is more than 10, return "medium" string
// Else if popularity is less than or equal to 10, return "low"
*[_type=='movie']{
  ..., 
  "popularity": select(
    popularity > 20 => "high",
    popularity > 10 => "medium",
    popularity <= 10 => "low"
)}

// You can also use select in a shorter from
// Let's say we want to conditionally join references 
// inside a Portable Text field
*[_type == "article"]{
  ...,
  body[]{
    ...,
    _type == "product" => {
      ...,
      @->{
        name,
        price
      }
    }
  }
}
```

### `score`

`score()` can be used as a pipe operator function to assign a score to each document. See [the section on scoring](https://www.sanity.io#k4798b2cba8df) at the end of this document.

### `string`

`string(<integer|float|boolean|datetime|string>) <string>`

Returns the string representation of a given scalar value. Returns `null` when passed an invalid value, including `null`.

```groq
{
  "stringInteger": string(21),         // Returns "21"
  "stringFloat": string(3.14159),      // Returns "3.14159"
  "stringSciNotation": string(3.6e+5), // Returns "360000"
  "stringTrue": string(true),          // Returns "true"
  "stringFalse": string(false),        // Returns "false"
  "stringString": string("A string"),  // Returns "A string"
}
```

One use case for `string()` is to combine a string with a scalar type, which would otherwise return `null`.

```groq
*[0] {
  'secondsAgo': dateTime(now()) - dateTime(_createdAt),
} {
  'minutesSinceCreated': 'Created ' + string(secondsAgo / 60) + ' minutes ago.'
}
```

Another use case is to coerce a date field into a string in [RFC3339](https://tools.ietf.org/html/rfc3339) format, which is useful when you need to compare datetime values or perform time arithmetic operations.

```groq
// Let's imagine a document with a year field,
// which contains a four-digit number – in this example, 2009
*[0] {
  year    // Returns 2009
}

// To compare year to a datetime field, such as now(), _createdAt,
// or _updatedAt, the year field must be converted to a string in RFC3339 format,
// but trying to append a string to the year field returns null
*[0] {
  'constructedYear': year + "-01-01T00:00:00Z"    // Returns null
}

// Using the string() function, year can be coerced to a string
// and structured in RFC3339 format
*[0] {
  'constructedYear': string(year) + "-01-01T00:00:00Z"    // Returns "2009-01-01T00:00:00Z"
}

// In this way, the year field can be used to perform time arithmetic operations
*[0] {
  'secondsSinceYear': dateTime(now()) - dateTime(string(year) + "-01-01T00:00:00Z")
}
```

## Geolocation functions

The `geo` namespace contains a number of useful functions for creating and querying against locations in your data. Each function must be prefixed with the `geo::` namespace syntax.

> [!WARNING]
> Gotcha
> The `geo()` function and functions in the `geo::` namespace require `v2021-03-25` or later of the GROQ API.

### GeoJSON

The functions in this section expect [GeoJSON](https://geojson.org/)-style objects and interpret them as a `geo` type internally. Functions that accept a `geo` type will attempt to coerce non-`geo`-type data into the proper format following the `geo()` constructor rules.

### `geo(object)`

The `geo()` function accepts an object as a parameter and, if possible, coerces the value to a geo-type shape by a set of rules. These objects are represented in JSON as GeoJSON.

- If the object is already in the correct shape, return the object.
- If the object has a set of `lat` and `lng` (or `lon`) keys, return a `geo` object for the given point. If additional data exists on the object it will be removed from the final geo document.
- If the object contains the key `type` (*note: not _type*), and the value of `type` matches one of the following strings then return a `geo` object with those values:- Point
- LineString
- Polygon
- MultiPoint
- MultiLineString
- MultiPolygon
- GeometryCollection


- If none of the conditions are met, return `null`.

### `geo::latLng(latFloat, lngFloat)`

The `latLng` function is a short-hand for creating a new geo object for a singular point. Returns a geo object from the latitude and longitude floats provided.

```groq
// Returns a geo object corresponding to the center of Oslo
geo::latLng(59.911491, 10.757933)
```

### `geo::distance(geo-point, geo-point)`

The `distance()` function takes points and returns a numeric value for the distance between in meters.

> [!WARNING]
> Gotcha
> The function only works between points. If lines or polygons are provided, the function will return `null`.

```groq
// Returns the distance in meters between Oslo and San Francisco
// 7506713.963060733
geo::distance(
  geo::latLng(59.911491, 10.757933),
  geo::latLng(37.7749, 122.4194)
)

// Returns all documents that are storefronts
// within 10 miles of the storefront geopoint
*[
  _type == 'storefront' &&
  geo::distance(geoPoint, $currentLocation) < 16093.4
]
```

### `geo::contains(geoPolygon, geo)`

The `contains()` function returns true when the first geographic geography value fully contains the geographic geometry value. If either parameter is not a geo object – or not able to be coerced to a geo object following the rules of the `geo()` constructor function – the function returns `null`.

```groq
// Returns true if the neighborhood region is fully contained by the city region
geo::contains(cityRegion, neighborhoodRegion)

// For a given $currentLocation geopoint and deliveryZone area
// Return stores that deliver to a user's location
*[
  _type == "storefront" &&
  geo::contains(deliveryZone, $currentLocation)
]
```

### `geo::intersects(geo, geo)`

The `intersects()` function returns true when the two areas overlap or intersect. If either parameter is not a geo object – or not able to be coerced to a geo object following the rules of the `geo()` constructor function the function returns `null`.

```groq
// Creates a "marathonRoutes" array that contains
// all marathons whose routes intersect with the current neighborhood
*[_type == "neighborhood"] {
  "marathonRoutes": *[_type == "marathon" && 
                        geo::intersects(^.neighborhoodRegion, routeLine)  
                      ]
}
```

## Portable Text functions

The `pt` namespace contains functions for parsing Portable Text. Each function must be prefixed with the `pt::` namespace syntax.

> [!WARNING]
> Gotcha
> Functions in the `pt::` namespace require `v2021-03-25` or later of the GROQ API.

### `pt::text(<Portable Text array|object>) <string>`

The `text()` function is a Sanity Content Lake GROQ filter that takes in document fields which are either a Portable Text block or an array of blocks, and returns a string in which blocks are appended with a double newline character (`\n\n`). Text spans within a block are appended without space or newline. 

The function exists within the `pt` namespace and must be prefixed with `pt::`. 

> [!WARNING]
> Gotcha
> The `text()` function only works on text spans in the root children, i.e., alt text in an Image block will not be in the final plain text.

```groq
// Returns the body Portable Text data as plain text
*[_type == "post"] 
  { "plaintextBody": pt::text(body) }
  
// Scores posts by the amount of times the string "GROQ"
// appears in a Portable Text field
*[_type == "post"]
  | score(pt::text(body) match "GROQ")
```

## Sanity functions

The `sanity` namespace contains functions for querying against the current environment. Each function must be prefixed with the `sanity::` namespace syntax.

> [!WARNING]
> Gotcha
> Functions in the `sanity::` namespace require `v2021-03-25` or later of the GROQ API unless otherwise noted.

### `sanity::projectId() <string>`

The `projectId()` function returns the project ID of the current studio environment.

### `sanity::dataset() <string>`

The `dataset()` function returns the dataset of the current studio environment.

```groq
{
  'projectId': sanity::projectId(),  // Returns 'hm31oq0j', for example
  'dataset': sanity::dataset()       // Returns 'production', for example
}
```

> [!WARNING]
> Gotcha
> sanity::versionOf and sanity::partOfRelease require API version `2025-02-19` or later.

### `sanity::versionOf(<string>) <boolean>`

Implicitly takes the document at the root of the current scope and returns whether the document is a version (whether draft, published or release version) of the supplied document ID. This ID should be the root ID, without any path prefixes.

```groq
*[sanity::versionOf("document-id")]
```

For example, the above query when given a document ID of `foo` may return documents such as `foo`, `drafts.foo`, `versions.a.foo`, `versions.b.foo`.

### sanity::partOfRelease(<string>) <boolean>

Implicitly takes the document at the root of the current scope and returns whether it is a member of the release with the given name.

> [!TIP]
> Protip
> Release names are the final piece of a release ID. For example, a release with an id of `_.releases.rEGM2JqQ3` has a name of `rEGM2JqQ3`. Use just the name final portion of the ID when referencing releases by name.

For example, `*[sanity::partOfRelease("a")]._id` would return `versions.a.foo`, `versions.a.bar`. `versions.a.baz`, etc.

## Document functions

The `documents` namespace contains functions pertaining to document operations. Each function must be prefixed with the `documents::` namespace syntax.

### `documents::get(gdrReference)` <document>

Resolves [Global Document References](https://www.sanity.io/docs/studio/global-document-reference-type), similar to the way `->` resolves references.

```groq
*[_type == "post"][0]{
  _id,
  mainImage{
    "url": asset->url,
    "aspects": documents::get(media).aspects
  }
}
```

### `documents::`incomingGlobalDocumentReferenceCount`() <number>`

The `incomingGlobalDocumentReferenceCount()` function returns the number of  [Global Document References](https://www.sanity.io/docs/studio/global-document-reference-type) that are pointing to the document in the current scope.

```groq
// Find all documents being referenced by at least 1 GDR, and return the document's ID and reference count
*[documents::incomingGlobalDocumentReferenceCount() > 0] { 
  _id, 
  "incomingGDRCount": documents::incomingGlobalDocumentReferenceCount() 
}
```

This only returns the number of Global Document References, not standard references or cross-dataset references.

## Media functions

The `media` namespace contains functions to interact with media elements

### `media::aspect(mediaLibraryAssetReference, string)`

Resolve the value of a public aspect for a media library asset

**GROQ**

```groq
*[_type == "post"][0]{
  _id,
  mainImage{
    "url": asset->url,
    "copyright": media::aspect(media, "copyright")
  }
}
```

## Delta functions

Delta-GROQ is an extension of GROQ which makes it possible to reason about *changes* done to a document. For example, in the context of [webhooks](https://www.sanity.io/docs/content-lake/webhooks) or [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction). The following functions are available:

- A `before()` function which returns the attributes done *before* the change.
- An `after()` function which returns the attributes *after* the change.
- `delta::changedAny()` which returns true if certain attributes have changed.
- `delta::changedOnly()` which returns true if *only* some attributes have changed.
- `delta::operation()` which returns a string value of `create`, `update` or `delete` according to which operation was executed.

> [!WARNING]
> Gotcha
> These functions are only available in *delta mode. *This limits their availability to [webhooks](https://www.sanity.io/docs/content-lake/webhooks) and [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction).

### `before()` and `after()`

The functions `before()` and `after()` return the attributes before and after the change. When the change is a create operation then `before()` is null, and when the change is a delete operation then `after()` is null.

These allow you to create expressive filters (`after().score > before().score` will only match when the score *increases*) and let you refer to both old and new values in projections (`'Title changed from ' + before().title + ' to ' + after().title`).

### `changedAny()` and `changedOnly()`

These diff functions are used with the namespace prefix.

```groq
delta::changedAny(selector) -> bool
delta::changedOnly(selector) -> bool

// Example: Return true when title has changed
delta::changedAny(title)
```

Notice that these functions accept a *selector* and not a full GROQ expression. See the next section for how they work. `delta::changedAny()` uses the selector to search for values and returns `true` if any of them have changed. `delta::changedOnly()` uses the selector to search for values and returns `true` if there are no changes anywhere else.

These are very useful for filtering: You can use `delta::changedAny(title)` to only match changes done to a specific field.

> [!TIP]
> Filtering on changes only works if there's something to change
> `changedAny` filtering will only work when a field previously existed. For this reason, newly created documents won't act as expected. 
> For example, if you use `delta::changedAny` in a function trigger that acts on create and update, you'll also need to account for the document creation state differently than the update state.

We've also added variants of these functions which are available inside regular GROQ and works on provided objects:

```groq
diff::changedAny(before, after, selector) -> bool
diff::changedOnly(before, after, selector) -> bool

// Example: This returns true because last name has changed.
diff::changedAny(
  {"firstName":"Bob","lastName":"Odenkirk"},
  {"firstName":"Bob","lastName":"Holm"},
  (firstName, lastName)
) 
```

> [!TIP]
> Editor experience
> Since the `_rev` and `_updatedAt` fields will always change when there is a change to a document, they are automatically ignored with the `delta::changedOnly()` function. This allows you to use `delta::changedOnly(title)` rather than needing to specify `delta::changedOnly((title, _rev, _updatedAt))`. Note that it *does not* automatically ignore `_system`.

### Selectors

Selector is a new concept in GROQ which represents parts of a document. These are the currently supported selectors (shown used with `changedAny`):

```groq
// One field:
delta::changedAny(title)

// Multipe fields:
delta::changedAny((title, description))

// Nested fields:
delta::changedAny(slug.current)

// Fields on arrays:
delta::changedAny(authors[].year)

// Nested fields on arrays:
delta::changedAny(authors[].(year, name))

// Filters:
delta::changedAny(authors[year > 1950].name)
```

## Array functions

The `array` namespace contains functions for processing arrays. Each function must be prefixed with the `array::` namespace syntax.

### `array::join(source <array[string|number|boolean]>, separator<string>) <string>`

Concatenates the elements in `source` into a single string, separating each element with the given `separator`. Each element will be converted to its string representation during the concatenation process.

Returns `null` if `source` is not an array, or if `separator` is not a string.

If any element in `source` does not have a string representation the string `<INVALID>` will be used in its place.

```groq
// Returns "a.b.c"
array::join(["a", "b", "c"], ".")

// Returns `null`
array::join(1234, ".")
array::join([1, 2, 3], 1)

// Returns "a.b.<INVALID>.d"
array::join(["a", "b", c, "d"], ".")
```

### `array::compact(<array>) <array>`

Returns a copy of the original array with all `null` values removed.

```groq
// Returns [1, 2, 3]
array::compact([1, null, 2, null, 3])
```

### `array::unique(<array>) <array>`

Returns a copy of the original array with all duplicate values removed. There is no guarantee that the returned array preserves the ordering of the original array.

Only values that can be compared for equality are considered for uniqueness, specifically `string`, `number`, `boolean`, and `null` values.

For example, `array::unique([[1], [1]])` will return `[[1], [1]]` since arrays cannot be compared for equality.

```groq
// Returns [1, 2, 3, 4, 5]
array::unique([1, 2, 2, 2, 3, 4, 5, 5])
```

### `array::intersects(<array>, <array>)  <boolean>`

Compares two arrays, returning true if they have any elements in common.

Only values that can be compared for [equality](https://www.sanity.io/docs/specifications/groq-operators) are considered when determining whether there are common values.

```groq
// Returns true
array::intersects([1, 2, 3], [3, 4, 5])

// Returns false
array::intersects([1, 2, 3], ['foo', 'bar', 'baz'])
```

## Math functions

The `math` namespace contains functions that perform mathematical operations on numerical inputs. Each function must be prefixed with the `math::` namespace syntax.

### `math::avg(<array[number]>) <number|null>`

Returns the average value (arithmetic mean) of an array of numbers.

Returns `null` if the array does not contain at least one numeric value, or if any element is a non-numeric value. `null` values are ignored.

```groq
// Returns 2.5
math::avg([1, 2, 3, 4, null])

// Returns `null`
math::avg([1, 2, 3, 4, "5"])
```

### `math::max(<array[number]>) <number|null>`

Returns the largest numeric value of an array of numbers.

Returns `null` if the array does not contain at least one numeric value, or if any element is a non-numeric value. `null` values are ignored.

```groq
// Returns 1000
math::max([1, 10, 100, 1000])

// Returns `null`
math::max([1, "10", 100, 1000])

// Returns `null`
math::max([])
```

### `math::min(<array[number]>) <number|null>`

Returns the smallest numeric value of an array of numbers.

```groq
// Returns 1
math::min([1, 10, null, 100, 1000])

// Returns `null`
math::min([1, "10", 100, 1000])

// Returns `null`
math::min([])
```

### `math::sum(<array[number]>) <number|null>`

Returns the sum of an array of numbers.

Returns `0` for an empty array.

Returns `null` if the array does not contain at least one numeric value, or if any element is a non-numeric value. `null` values are ignored.

```groq
// Returns 10
math::sum([1, 2, 3, 4, null])

// Returns `null`
math::sum([1, 2, 3, 4, "5"])

// Returns 0
math::sum([])
```

## String functions

The `string` namespace contains functions that search and process strings. Each function must be prefixed with the `string::` namespace syntax.

### `string::startsWith(searchString<string>, prefix<string> ) <true|false>`

Returns `true` if the first N characters of `searchString` exactly match the N characters of `prefix`, otherwise `false`.

Returns `true` if `prefix` is an empty string.

```groq
// Returns `true`
string::startsWith("alphabet", "alpha")
string::startsWith("alphabet", "")

// Returns `false`
string::startsWith("alphabet", "bet")
```

### `string::split(original<string>, separator<string> ) <array[string]>`

Returns an array of substrings of `original` that are separated by `separator`.

If `separator` is an empty string, it returns an array containing each individual character of `original`, according to Unicode character splitting rules.

If `original` or `separator` are not strings, return `null`.

```groq
// Returns ["Split", "this", "sentence", "up"]
string::split("Split this sentence up", " ")

// Returns ["a", "b", "c"]
string::split("abc", "")

// Returns `null`
string::split(12, "1")
string::split("This is 1 way to do it", 1)
```

## Query scoring functions

### `score(scoreExp)`

The `score()` function takes an arbitrary number of valid GROQ expressions and assigns a score to each result as a new field called `_score`. The `_score` field can be used to sort and filter items in an array.

> [!WARNING]
> Gotcha
> `score()` is a pipe function and must be separated from expressions, filters, projections, and other pipe functions that precede it with the pipe operator (`|`). 
> `score()` operates on unmodified data. Place it after a filter, `*[_type == "post"]`, but before any projections.

The score is calculated depending on the expressions used. For a `match` expression, `_score` is impacted by:

- the **frequency of matches per expression** ("One Fish, Two Fish, Red Fish, Blue Fish" matches "fish" more frequently than "The Rainbow Fish" matches "fish");
- the **frequency of matches overall** (matching three terms in a `score()` function expression versus matching one term);
- the **relevance of matches**, including:- **word count** ("Big Fish" – two words – matches "fish" with greater relevance than "A Fish Called Wanda" – four words – matches "fish") and
- **word length** (matching a shorter term will also return a higher `_score` than matching a longer term – "Blue" matches "blue" with greater relevance than "Clouds" matches "clouds").



A logical OR is identical to comma-separated terms and a logical AND is identical to a match with combined terms:

```groq
// These score() functions behave identically
* | score(title match "Red" || title match "Fish")
* | score(title match "Red", title match "Fish")

// These score() functions behave identically
* | score(title match "Red" && title match "Fish")
* | score(title match "Red Fish")
```

For each matched boolean expression, `_score` is incremented.

```groq
// Each term that is true will increment _score by 1
* | score(featured, internal, _type == 'post')

// A 'post' document with featured and internal fields
// that are both true would receive a _score of 3
```

> [!WARNING]
> Gotcha
> Documents that don't match the score expression(s) return a `_score` value of `0`, but are not automatically removed from the array. The third example in the following code block provides a solution to remove results with a `_score` value of `0`.

```groq
// Adds points to the score value depending 
// on the use of the string "GROQ" in each post's description 
// The value is then used to order the posts 
*[_type == "post"] 
  | score(description match "GROQ") 
  | order(_score desc) 
  { _score, title }
  
// Adds a point for matches in the title OR description
*[_type == "post"] 
  | score(title match "GROQ" || description match "GROQ") 
  | order(_score desc) 
  { _score, title }
  

// Orders blog posts by GROQ matches
// Then filters the results for only items that matched
// by checking for _score values greater than 0
*[_type == "post"] 
  | score(description match "GROQ") 
  | order(_score desc) 
  { _score, title }
  [ _score > 0 ]
```

> [!WARNING]
> Gotcha
> `score()` cannot take a complex expression, including the use of functions (besides `boost()`), dereferencing, or subqueries.

### `boost(scoreExp, boostValue)`

The `boost()` function can be used to create a sense of weight in a scoring algorithm. It accepts two required arguments: an expression and the amount to boost the score if the expression returns true.

Like in the `score()` function, a matched expression will increase `_score` for each instance of a match, but by a multiple of the boost value. For example, a `boostValue` of `3` would increase `_score` by three times the amount it would increment by default (that is, without `boost()`).

The `boost()` function is used inside a `score()` function.

Boost values greater than `1` will give that expression a greater-than-normal impact on `_score`. Boost values less than `1` (but greater than `0`) will give that expression a lesser-than-normal impact on `_score`. A boost value of `0`, while permitted, has the same effect as removing that expression from the `score()` function.

> [!WARNING]
> Gotcha
> The `boost()` function accepts only constant positive integers and floats.

```groq
// Adds 1 to the score for each time $term is matched in the title field
// Adds 3 to the score if (movie > 3) is true
*[_type == "movie" && movieRating > 3] | 
  score(
    title match $term,
    boost(movieRating > 8, 3)
  )
```

Providing multiple boosts of different values in one `score()` can create robust sorting.

```groq
// Creates a scoring system where $term matching in the title
// is worth more than matching in the body
*[_type == "movie" && movieRating > 3] | score(
  boost(title match $term, 4),
  boost(body match $term, 1),
  boost(movieRating > 8, 3)
)

// Scores games by the "impressive" difference in goals
*[_type == "game"] | score(
		boost(pointDifference > 5, 5),
		boost(pointDifference > 10, 10)
	)
```

Boost values between `0` and `1` can be used to affect `_score` to a lesser extent than a default match would. This might be useful when there is a need to finely differentiate `_score` values that might otherwise be equal.

```groq
// Boosts _score for matches in the title OR description,
// but a match on the description now has less of an impact on _score
*[_type == "post"] 
  | score(title match "GROQ" || boost(description match "GROQ", 0.3)) 
  | order(_score desc) 
  { _score, title }
```

## Text functions

The `text` namespace contains functions for text search and semantic text operations. Each function must be prefixed with the `text::` namespace syntax.

### text::semanticSimilarity(<string>) <number>

Converts the given search term into a vector and ranks results by proximity to each document's embedding. Returns a numeric score used for ranking results relative to each other within a single query. The score is opaque and unitless; do not compare scores across different queries.

This function requires [dataset embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) to be enabled on the target dataset.

> [!WARNING]
> Gotcha
> `text::semanticSimilarity()` is only valid as an argument to `score()`. Using it elsewhere returns an error.

```groq
// Semantic search: rank all documents by meaning
* | score(text::semanticSimilarity("how to handle user authentication"))

// Filtered semantic search: restrict which documents are scored
*[_type == "product" && category == "footwear"]
    | score(text::semanticSimilarity("leather waterproof boots"))

// Hybrid search: combine keyword matching with semantic scoring
*[_type == "product"]
    | score(
        @ match text::query("leather waterproof boots"),
        text::semanticSimilarity("leather waterproof boots")
      )
```

### text::query()

Parses a search query string and returns a structured query value for use with the `match` operator. Supports words, quoted phrases, prefix wildcards, inner wildcards, and negation. Multiple words are combined with AND logic; all terms must match, but they can appear in different fields.

The left-hand side of `match` can be a single attribute, an array of attributes, a nested object (which searches all string fields within it), or `@` to match against the entire document.

#### Query syntax

The query string supports the following syntax:

- `dracula`: single word match.
- `"Google Pixel 9"`: exact phrase match (word order matters).
- `go*`: prefix match (matches "Google", "goofy", etc.).
- `*ion`: Inner/suffix wildcard match.
- `"Find o*"`: Phrase with trailing wildcard on the last word.
- `-term` or `-”exact phrase”`: negation (excludes documents matching the term).

```groq
// Match on a single attribute
*[_type == "book" && title match text::query("frankenstein")]

// Match across multiple attributes
*[_type == "book" && [title, details.blurb] match text::query("vampires")]

// Match on a nested object (searches all string fields within)
*[_type == "book" && details match text::query("vampires")]

// Negation: exclude results matching a term
*[_type == "phone" && description match text::query("google -pro")]

// Phrase with trailing wildcard
*[_type == "phone" && description match text::query("\"Find o*\"")]

// Use with score() for relevance ranking
*[_type == "book"]
  | score(
    title match text::query("dracula"),
    boost([details.blurb, details.keywords[]] match text::query("dracula"), 1.5)
  )
// Match any string field in the document
*[@ match text::query("hello world")]
```

#### Behavior notes

- An empty string `""` matches all documents, even if the target field doesn't exist. A bare `*` behaves the same way.
- Multiple words use AND logic: all terms must match, but they can appear across different fields in the match target. Inside `score()` blocks, this changes to OR logic, where any matching term contributes to the rank.
- Word-breaking characters (parentheses, hyphens, punctuation) are stripped from phrases: `"Google-Pixel (9)"` matches "Google Pixel 9". Note that the hyphen is treated as punctuation inside phrases, not negation: `"google -pro"` matches the phrase "google pro".
- Negation requires no space after `-`: `-term` excludes matches, but `- term` treats the hyphen as a literal word.
- Currency symbols and special characters like `®` and `™` are treated as separate tokens during matching.
- Wildcards inside phrases (not at the end) are not supported and return no results.

### `text::highlight()`

`text::highlight()` returns information about which parts of a document matched the search query, enabling you to show relevant snippets in search results.

> [!NOTE]
> Experimental
> The `text::highlight()` function is experimental and only supported on API version `vX` at this time.

#### Basic usage

```groq
*[_type == "article"] | score([title] match text::query("summer fashion")) {
  title,
  _score,
  "highlights": text::highlight(@, text::query("summer fashion"))
}
```

#### Return shape

`text::highlight()` returns a map of field paths to highlight information:

```json
{
  "highlights": {
    "body": {
      "fragments": [{
        "end": 9,
        "matches": [[0,9]],
        "start": 0
      }],
      "matchLevel": "full",
      "matchedWords": ["fashion"],
      "score": 1
    }
  }
}
```

#### How it works

Highlighting is performed after the search query returns results:

1. The system walks through every string field in each result document.
2. For each field, it checks if the search terms appear in the text.
3. Fields with matches are included in the highlights map.
4. The match level indicates how well the field matched (full, partial, or no match).

#### Limitations

- Highlighting only works with `text::query()`. It uses the parsed query to identify matches.
- When highlighting is enabled, the full document is fetched (even if you only project specific fields).

## User functions

### user::attributes()

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

Allows you to inspect the [user attributes](https://www.sanity.io/docs/http-reference/user-attributes) of the logged in user. The name of the attribute should be selected after the function.

```groq
*[_type == "post" && branch == user::attributes().branch]
```

Attribute values can be string, integer, number, boolean, and array types.

## Additional functions

### `releases::all()`

An alias to return all releases. This is the same as performing a check on the document type. For example:

```groq
// Note: it's used without the filter, so no *[]
releases::all()

// This is equivalent
*[_type == "system.release"]
```

Releases are part of the [Content Releases feature](https://www.sanity.io/docs/content-lake/content-release-document-flow).

## Custom functions

Custom functions are reusable, modular, user-defined functions. 

```groq
fn myFunctions::unfurl($ref) = $ref->{...};

*[] {
  title,
  "author": myFunctions::unfurl(author)
}
```

Custom GROQ functions must be defined at the beginning of a query, and must end with a semicolon (`;`). They are made up of the following parts:

- `fn`: All functions must start with the `fn` declaration.
- Namespace: Custom functions must live on their own namespace. In the example above, the namespace is `myFunctions`.
- Name: The function's name, `unfurl` in the example above, identifies the function within the namespace.
- Parameters: Like functions in other languages, parameters pass data into the function. Parameters are denoted with `$`-prefixed keys, like `$ref` in the example. Custom functions are limited to one parameter at this time.
- Function body: The body of the function is the content after the equal (`=`) sign. Function bodies are limited to the formats below.

At this time, functions do not support:

- Recursion.
- Accessing the parent scope.
- Passing multiple parameters.
- Using a parameter more than once in the body.

### Function body formats

#### Standard projection

Create a reusable projection.

Format: `$param{...}`

```groq
// Function definition
fn user::bio($param) = $param{name, age}; 

// Usage
*[_type == "person"] { "info": user::bio(@) }
```

#### Follow a reference

Create a reusable projection that follows a reference.

Format: `$param->{...}`

```groq
// Function definition
fn user::bio($param) = $param->{name, age}; 

// Usage
*[_type == "post"] { "author": user::bio(author) }
```

#### Access an array

Create a reusable projection for items in an array.

Format: `$param[]{...}`

```groq
// Function definition
fn user::children($param) = $param[]{name}; 

// Usage
*[_type == "person"] { "children": user::children(children) }
```

#### Array of references

Create a reusable projection that follows references in an array.

Format: `$param[]->{...}`

```groq
// Function definition
fn user::children($param) = $param[]->{name}; 

// Usage
*[_type == "person"] { "children": user::children(children) }
```



# Pipeline components



A pipeline connects a set of components by passing the left-hand array's value to the pipeline component given by the right-hand operand and yielding its return value. Some components must be adjacent (e.g., `*` and `[filter]` must be `*[filter]`), some must be separated with the pipe operator (e.g., `* | order()`), and others may optionally use the pipe operator (e.g., projections can be `* {projection}` or `* | {projection}`).

> [!TIP]
> Protip
> White space is not significant in GROQ except in a string literal, when terminating a comment, or when acting as a token separator (e.g., the spaces are required around `match` in `title match "movie"`), which means `*[filter]` can be `* [filter]`, `* | order()` can be `*|order()`, etc.

The following query pipeline fetches all documents from the data store, passes them to a filter, then a projection, and finally orders the results:

```groq
*[ _type == "movie" && releaseYear >= 1980]{
  title,
  releaseYear,
  genre
} | order(releaseYear desc, title asc)
```

A pipeline component typically iterates over the array elements and evaluates an expression in the scope of each iterated value (as described in "Attribute Scope" in the [Syntax section](https://www.sanity.io/docs/specifications/groq-syntax)). The evaluated expression typically determines the component's return value.

> [!CAUTION]
> Known issue
> The behavior of piping non-array values is currently undefined.

## `[<boolean>]` Filter Component

Iterates over the elements of the piped array, evaluates the given boolean expression in the scope of each element and returns an array of elements for which the expression evaluated to `true`.

For example, the following filter will retain documents of type `movie` where the `releaseYear` attribute is greater than or equal to 1980:

```groq
*[ _type == "movie" && releaseYear >= 1980]
```

> [!CAUTION]
> Known issue
> Filters may retain elements for which the expression evaluates to other values as well, e.g. integers and strings.

## `[<range>]` Slice Component

Returns an array containing the elements of the piped array whose zero-based indices fall within the range, e.g. `*[2..4]` yields elements 3, 4, and 5 from the piped array. The range may extend beyond the array bounds.

Negative range endpoints are based at the end of the piped array. The syntax `array[2..-1]` yields all elements from the third to the last. If the right endpoint falls before the left endpoint, the result is an empty array.

> [!WARNING]
> Gotcha
> The range must be a literal range due to parser ambiguity with filters.

## `[<integer>]` Subscript Component

Returns the element at the given zero-based index of the piped array, e.g., `*[2]` returns the third element of the piped array, or `null` if not found. Negative indices are based at the end of the array, e.g., `array[-2]` yields the second-to-last element.

> [!WARNING]
> Gotcha
> The index must be a literal integer due to parser ambiguity with filters.

## `{}` Projection Component

Projections iterate over the elements of the piped array, generate an object of the given form evaluated in the scope of each element, and appends it to the output array. The projection may optionally be preceded by a pipe operator (`|`).

For example, the projection `*{"key": value}` iterates over all documents, and for each document generates an object with a single key `key` whose value is set to the value of the `value` attribute of the document, if any.

Attribute values in projections can be arbitrary GROQ expressions, e.g. `*{"name": firstName + " " + lastName}`.

> [!WARNING]
> Gotcha
> In `v1` of the GROQ API, any attribute whose value evaluates to `null` is not included in the projection. As of `v2021-03-25`, an explicitly-named attribute whose value evaluates to `null` **is** included in the projection.

If bare attributes are given in a projection, this inserts the corresponding attribute from the input element in the output object - e.g., the projection `{_id, name}` is exactly equivalent to `{"_id": _id, "name": name}`.

> [!WARNING]
> Gotcha
> Generated projection keys do not modify the scope, so e.g. `{"key": "value", "other": key}` will evaluate to `"other": null`, since inserting `"key"` does not change the object at the root of the scope. If this is desired, it can be accomplished by chaining projections, e.g `{"key": "value} {key, "other": key}`.

Other objects can be expanded into the projection with the `...` operator. For example, `{name, ...properties}` will take all attributes from the `properties` object and place them in the root of the projected object along with the root `name` attribute.

A bare `...` is syntactic sugar for `...@`, i.e., it inserts all attributes from the currently iterated element into the projection. For example, `{..., "key": "value"}` generates an object with all of the object's original attributes in addition to the generated `key` attribute.

If multiple keys with the same name are given, then the latest key wins. 

> [!WARNING]
> Gotcha
> In `v1` of the GROQ API, the expansion operator is always evaluated first regardless of its position in the projection (i.e., the projection `{"name": "someName", ...}`, will replace the original `name` attribute of the object, if any, even though the named key is used first).
> As of `v2021-03-25` of the API, when the expansion operator (`...`) is used after an explicitly-named key, a duplicate key will overwrite the value of the named key.

Since projection values are arbitrary GROQ expressions, nested projections are supported (and encouraged).

```groq
*[ _type == "book" ]{
  title,
  "authors": authors[]{
    "name": firstName + " " + lastName,
    birthYear,
  }
}
```

In this case, the nested `authors` projection takes its input from the `authors` array and generates an output array of projected objects as usual. This projection is evaluated in a new scope (as described in "Attribute Scope" in the [Syntax section](https://www.sanity.io/docs/specifications/groq-syntax)), and the parent scope can be accessed via the `^` operator.

Projections also have syntactic sugar for conditionals, expressed as `condition => {}`. If `condition` evaluates to `true`, the object on the right-hand side of `=>` is expanded into the projection. For example, the following projection will include the `movies` attribute containing a list of related movies if the person is a director. Otherwise, the attribute will be omitted:

```groq
{
  name,
  role == "director" => {
    "movies": *[ _type == "movie" && director._ref == ^._id ]
  }
}
```

This syntax is exactly equivalent to `...select(condition => {})`. Each conditional in a projection is evaluated separately - for cases where multiple conditions overlap and only a single result (the first) should be included. The full `select()` syntax must be used instead.

## `order(<expr>...) <array>` Order Component

`order()` sorts the piped array according to the given expression and returns an array of sorted elements, e.g., `* | order(name asc, age desc)`. The direction can be either `asc` or `desc`, defaulting to `asc` if not given. Any number of sort expressions can be given, which specify sorting first-to-last (e.g., the expression above would be sorted first by ascending `name` and then by descending `age`).

For details on the ordering of various data types, see "Comparison Operators" in the [Operators section](https://www.sanity.io/docs/specifications/groq-operators).



# Joins

#### New to GROQ?
If you are just getting started with GROQ, check out the getting started guide first.
[Get started with GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)

Joins are supported via the reference access operator `->`, via subqueries that use the parent scope operator `^`, and via the `references()` function. The reference data type can be used in documents to explicitly reference other documents and enforce referential integrity, but joins can be made using arbitrary join conditions not involving reference fields at all.

Consider the following documents representing an employee and a department:

```json
{
  "_id": "alice",
  "_type": "employee",
  "name": "Alice Anderson",
  "department": {"_ref": "engineering"}
}
{
  "_id": "engineering",
  "_type": "department",
  "name": "Engineering"
}
```

The employee can be joined with the department by applying the `->` reference access operator to the `department` field, which fetches the referenced `engineering` document and inserts it into the projected document under the `department` attribute:

```groq
*[ _type == "employee" ]{ ..., department-> }

{
  "_id": "alice",
  "_type": "employee",
  "name": "Alice Anderson",
  "department": {
    "_id": "engineering",
    "_type": "department",
    "name": "Engineering"
  }
}

```

The department can also be joined with its employees by using a subquery that refers to the department's ID via the  `^` parent scope operator:

```groq
*[ _type == "department" ]{
  ...,
  "employees": *[ _type == "employee" && department._ref == ^._id ]
}

{
  "_id": "engineering",
  "_type": "department",
  "name": "Engineering",
  "employees": [
    {
      "_id": "alice",
      "_type": "employee",
      "name": "Alice Anderson",
      "department": {"_ref": "engineering"}
    }
  ]
}

```

Or the `references()` function can be used to fetch any employees that contain a reference to the department anywhere in their content:

```groq
*[ _type == "department" ]{
  ...,
  "employees": *[ _type == "employee" && references(^._id) ]
}

{
  "_id": "engineering",
  "_type": "department",
  "name": "Engineering",
  "employees": [
    {
      "_id": "alice",
      "_type": "employee",
      "name": "Alice Anderson",
      "department": {"_ref": "engineering"}
    }
  ]
}

```

These mechanisms can be used to perform arbitrarily complex joins, see their respective reference documentation for more details.

The rest of this section will demonstrate how GROQ can be used to implement joins equivalent to those in relational algebra and SQL databases. In the following examples, the "left relation" will refer to the current document, while the "right relation" will refer to the joined documents.

## Outer Joins

### Left Outer Join

A left outer join combines each document in the left relation with any documents in the right relation that match the join condition. Documents in the left relation that do not have any corresponding matches in the right relation are still included in the result, typically with a `null` value or similar.

The simplest left outer join is made with the reference access operator `->`, which joins the referenced document from the right relation into the left relation, or `null` if the referenced document does not exist. For example, the following query fetches all employees and joins them with their referenced department, if any:

```groq
*[ _type == "employee" ]{ ..., department-> }
```

Left outer joins can also use arbitrary join conditions through subqueries. For example, the following query joins all departments which reference an employee in its `employees` array:

```groq
*[ _type == "employee" ]{
  ...,
  "departments": *[ _type == "department" && ^._id in employees[]._ref ],
}
```

### Right Outer Joins

Right outer joins are identical to left outer joins, except that the left and right relations are swapped. Right outer joins are not directly supported in GROQ, but the same effect is easily accomplished by using a left outer join with the left and right relations swapped.

### Full Outer Joins

Full outer joins fetch all documents in both the left and right relations, and combine them on matching join conditions. Full outer joins do not directly translate to a document database, since the result is not a two-dimensional set of tuples. However, a similar effect can be obtained by fetching all documents in both relations and joining any matches using projection conditionals.

For example, the following query fetches all `employee` documents and all `department` documents, then joins referenced departments into employees via the `->` operator, and employees into departments via a subquery using `^`:

```groq
*[ _type in ["employee", "department"]]{
  ...,
  _type == "employee" => {
    department->,
  },
  _type == "department" => {
    "employees": *[ _type == "employee" && department._ref == ^._id ],
  },
}

```

## Inner Joins

### Equijoins

Equijoins combine documents in the left relation with documents in the right relation that match on an equality condition. Documents in the left relation that do not match any documents in the right relation are not included. This is accomplished in GROQ by using a left outer join and then filtering out any documents which did not have any matches - for example:

```groq
*[ _type == "employee" ]{ ..., department-> }[ !defined(department) ]
```

The equality can be made explicit by using a subquery instead of the `->` access operator:

```groq
*[ _type == "employee" ]{
  ...,
  "department": *[ _type == "department" && _id == ^.department._ref ][0],
}[ !defined(department) ]
```

### Non-Equijoins

Non-equijoins are similar to equijoins, except they join on inequalities rather than equalities (e.g. `>`, `<`, or `!=`). For example, the following query joins employees with all departments whose `baseSalary` field is greater than the employee's `salary` field, and removes employees which do not match the join condition:

```groq
*[ _type == "employee" ]{
  ...,
  "betterDepartments": *[ _type == "department" && baseSalary > ^.salary ],
}[ count(betterDepartments) > 0 ]

```

### θ-joins

θ-joins are similar to equijoins and non-equijoins, except they join documents on any arbitrary join condition. For example, the following query joins employees with any departments whose `baseSalary` is higher than the employee's `salary`, whose `manager` is not equal to the employee's `boss`, and whose `city` field is equal to the employee's `city` field:

```groq
*[ _type == "employee" ]{
  ...,
  "betterDepartments": *[
    _type == "department"
    && baseSalary > ^.salary
    && manager != ^.boss
    && city == ^.city
  ],
}[ count(betterDepartments) > 0 ]

```

### Semijoins

A semijoin checks whether a document in the left relation matches one in the right relation on the join condition, but does not actually include any contents from the right relation in the result. Semijoins are performed by including joins in a GROQ filter, for example in the following query that fetches all employees that belong to the `finance` department:

```groq
*[ _type == "employee" && department->_id == "finance" ]
```

Semijoins can also be done with subqueries, like the following example which fetches employees that are contained within the `employees` array of at least one department.

```groq
*[ _type == "employee" && count(*[ _type == "department" && ^._id in employees[]._ref ]) > 0 ]
```

### Antijoins

Antijoins are similar to semijoins, except they check whether the document in the left relation does *not* match any documents in the right relation on the join condition. For example, the following query fetches all employees that do not belong to any department:

```groq
*[ _type == "employee" && !defined(department->) ]
```

Antijoins can also be done with subqueries, like the following example which fetches employees that are not contained within the `employees` array of any department.

```groq
*[ _type == "employee" && count(*[ _type == "department" && ^._id in employees[]._ref ]) == 0 ]
```

### Natural Joins

Natural joins combine documents that have common fields with equal values. Natural joins as traditionally defined are not supported by GROQ, but a similar effect can be accomplished using the `references()` function which checks whether a document contains a reference to a given document anywhere within it.

For example, the following query fetches any employees that contain any references to the department anywhere in their structure, and removes departments with no employees:

```groq
*[ _type == "department" ]{
  ...,
  "employees": *[ _type == "employee" && references(^._id) ],
}[ count(employees) > 0 ]
```

## Other Joins

### Self-joins

Self-joins join documents against other documents in the same relation. For example, the following query fetches employees whose `salary` values are greater than the currently considered employee:

```groq
*[ _type == "employee" ]{
  ...,
  "betterPaid": *[ _type == "employee" && salary > ^.salary ],
}
```

Self-joins can even join against the same document, like in the following contrived example:

```groq
*[ _type == "employee" ]{ ..., "self": *[ _id == ^._id ][0] }
```

### Cross Joins

Cross joins (or Cartesian products) join all documents in the left relation with all documents in the right relation. For example, the following query fetches all employees and joins them with all departments:

```groq
*[ _type == "employee" ]{..., "allDepartments": *[ _type == "department" ]}
```






# Handler reference

[Overview](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

[Quick start](https://www.sanity.io/docs/functions/function-quickstart)
Start building with Functions by deploying a new function to Sanity's infrastructure.

Every Function must export a `handler`. Handlers contain the logic that the Function infrastructure runs when your document changes trigger the function.

Create a function handler with the `sanity blueprints add function` command. Every handler receives an object containing `context` and `event` parameters. The function does not require a return value.

## `context` properties

#### Properties

**clientOptions** (object)

Provides properties for configuring the Sanity client (@sanity/client). Most commonly used to pass details about the invoking project dataset to a client configuration. See the configuring @sanity/client in Functions guide for details.

**local** (boolean)

The context.local value is set to true for functions invoked with sanity functions test and sanity functions dev. This can be helpful when you want code to only execute in local environments.

It is undefined for functions in production.

**eventResourceType** (string)

The resource type that triggered the function. For Document functions, this would be dataset. For Media Library functions, this would be media-library.

**eventResourceId** (string)

The resource ID that triggered the function. For Document functions, this would be the ID of a dataset in the form <project-id>.<dataset-name>. For Media Library functions, this would be the Media Library id.

### `clientOptions` properties

#### Properties

**projectId** (string)

The ID of the project that triggered this function.

**dataset** (string)

The dataset name of the project that triggered this function. 

The sanity functions test command won't include a dataset by default. Run with the --dataset flag to pass a dataset to clientOptions. For example: sanity functions test log-event --dataset production

**apiHost** (string)

Defaults to https://api.sanity.io.

**token** (string)

A token with access to your Sanity project. It is recommended to define a Robot Token Blueprint resource yourself with permissions with explicit permissions and assign the token to your Function resource. For sanity.function.document Functions, this token is automatically generated with the editor role and added to your project when deploying the blueprint. For other function types, you must explicitly define a Robot Token resource. See Using robot tokens with Functions for more details.

The sanity functions test command won't include a token by default. Run with the --with-user-token flag to pass a the logged-in user's token.

Note: the token is obfuscated in logs for security. You can directly use it to configure the Sanity client or to make API calls.

### Example context

```javascript
{
  clientOptions: {
    apiHost: 'https://api.sanity.io',
    projectId: 'abc123',
    dataset: 'production',
    token: '***************'
  }
}
```

## `event` properties

Contains the shape of the event, which depends on the event:

- In the case of `document` and `media-library` Function events, like `publish`, the event shape is the document. This will vary based on your schema.
- In the case of `sync-tag-invalidate` Function events, the sync tags will be present under `event.data.syncTags`.

### Example `document` event

```javascript
{
  data: { 
    _id: '1234',
    _type: 'article',
    title: 'Functions quick start',
    _createdAt: '2025-04-24T16:26:58.901Z',
    _publishedAt: '2025-04-24T16:26:58.901Z',
  }
}
```

### Example `sync-tag-invalidate` event

```javascript
{
  data: { 
    syncTags: ['s1:1023', 's3:3021']
  }
}
```

## Example handler

**index.ts (TypeScript)**

```
import { documentEventHandler } from '@sanity/functions'

export const handler = documentEventHandler(async ({ context, event }) => {
  console.log("Context: ", context)
  console.log("Event: ", event)
})
```

**index.js (JavaScript)**

```javascript
export async function handler({context, event}) {
  console.log("Context: ", context)
  console.log("Event: ", event)
}
```

## Type support

When you create a new TypeScript function with `sanity blueprint add`, you'll be prompted to add types. 

If you did not add types as part of the init process, they are available in the [@sanity/functions](https://www.npmjs.com/package/@sanity/functions) package:

**npm**

```shell
npm install -D @sanity/functions
```

**pnpm**

```shell
pnpm add -D @sanity/functions
```

**yarn**

```shell
yarn add --dev @sanity/functions
```

**bun**

```shell
bun add --dev @sanity/functions
```

You can then import and use the `documentEventHandler` helper to provide type support. See the example TS handler above for implementation details.

### Basic usage

Import `documentEventHandler`.

**index.ts**

```
import {documentEventHandler} from '@sanity/functions'

export const handler = documentEventHandler(async ({context, event}) => {
  // Your function implementation
  console.log('Document updated:', event.data)
})
```

### Pass type for event data

If you need to type `event.data`, and you know the shape of your incoming data, you can provide it to `documentEventHandler`.

**index.ts**

```
import {documentEventHandler} from '@sanity/functions'

interface NotificationData {
  documentId: string
  text: string
}

export const handler = documentEventHandler<NotificationData>(async ({event}) => {
  console.log(event.data.text) // Typed as `string`
  console.log(event.data.notSet) // Will yield type error
})
```

### Type only (TypeScript)

Import the `DocumentEventHandler` type.

**index.ts**

```
import {type DocumentEventHandler} from '@sanity/functions'

export const handler: DocumentEventHandler = async ({context, event}) => {
  // …
}

// …you can also define the data type:
export const handler: DocumentEventHandler<{text: string}> = async ({event}) => {
  console.log(event.data.text)
}
```

### Type only (JavaScript)

Use the `@type` comment syntax.

**index.js**

```javascript
/** @type {import('@sanity/functions').DocumentEventHandler} */
export const handler = async ({context, event}) => {
  console.log(event.data.text)
}

// …you can also define the data type:
/** @type {import('@sanity/functions').DocumentEventHandler<{text: string}>} */
export const handler = async ({event}) => {
  console.log(event.data.text)
}
```





# Configuration file reference

[Overview](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

The Blueprints configuration file (`sanity.blueprint.ts`) defines resources, like Functions, for deployment to Sanity's infrastructure.

Interact with Blueprints by using the `npx sanity blueprints` [CLI command](https://www.sanity.io/docs/cli-reference/cli-blueprints).

The top-level of the blueprint configuration file contains the following properties:

#### Properties

**blueprintVersion** (string, required)

Defines the version of the Blueprints specification to use when parsing the configuration. Uses the YYYY-MM-DD format.

**resources** (array, required)

An array of Sanity resources. Right now this is limited to Function resources, but will expand in the future.

Some configuration properties, like `blueprintVersion`, are handled automatically when using the `defineBlueprint` helper.

## Top-level fields

The file default-exports a call to `defineBlueprint`. Most files set `resources` and `values`.

##### Top-level fields

| Key | Type | Notes |
| --- | --- | --- |
| resources | BlueprintResource[] | The resources to manage. Each entry is the output of a definer. |
| values | Record<string, string> | Reusable constants, referenced with $.values.<key>. Values are always strings. |

> [!NOTE]
> Automatic fields
> `defineBlueprint` also sets `blueprintVersion` and `$schema` for you, so you don't write them. They matter only if you hand-author a `sanity.blueprint.json` file instead of using `defineBlueprint`.

## Resources

The following properties are shared across all resources. Additional resource-specific properties follow in the sections below.

#### Properties

**name** (string, required)

A unique function name. Must be an alphanumeric string that can contain dashes or underscores.

**type** (string, required)

A resource type. For Sanity resources, this is made up of the sanity namespace, category, subcategory, and resource types separated by single periods. For example: sanity.function.document or sanity.function.media-library.asset.

> [!WARNING]
> There's no such thing as a rename.
> Changing a resource's name deletes the old resource and creates a new one.

Blueprints works by comparing your declared resources against what's already in the Stack, matching everything by name. It has no concept of an in-place rename. When you change a name, two things happen: the new name doesn't exist yet so Blueprints creates it, and the old name is gone from your blueprint so Blueprints destroys it.

From your perspective it's one rename. From Blueprints' perspective it's two unrelated resources: one created, one destroyed. There's no internal ID linking them, so nothing carries over. For a Function, that means the deployed infrastructure gets torn down and rebuilt, execution logs and history don't migrate, and any IDs or URLs that downstream systems depend on can change. There's no automatic migration and no undo.

> [!WARNING]
> Changing a resource's type under the same name does the same thing.
> Resources are matched only by name. If the name matches but the type differs, Blueprints replaces the resource by destroying the old one and creating a new one.

If you need to change a name or type, treat it as a destroy-and-recreate:

1. Run `blueprints plan` first. It's a safe, read-only preview that shows exactly what will change without touching anything.
2. Confirm the plan shows a destroy of the old resource and a create of the new one. That's how you know things are working as expected.
3. Run `blueprints deploy` to apply. Note that `deploy` doesn't show a preview on its own, so always plan first.
4. Expect a brief gap while the resource is replaced, and handle any state migration yourself.

If keeping the resource matters more than its name, leave the name alone.

### Functions

In addition to the [required common resource properties](https://www.sanity.io/docs/blueprints/blueprint-config) above, functions also contain the following properties.

#### Properties

**src** (string)

The path, relative to the blueprint configuration file, of the individual function directory. Will be inferred from the name if omitted. For example, functions/myFunction.

**type** (string)

Specifies the Function type. Supported Function types are:

sanity.function.document: this Function will react to changes in your dataset documents, like when a document is created, updated or deleted.

sanity.function.media-library.asset: this Function will react to changes in your Media Library, like when an asset is uploaded, updated or deleted. Note that your plan must have access to the Media Library to use this Function type. 

sanity.function.sync-tag-invalidate: this Function will react to changes in your Live Content. It is very similar to the document  and involves calling back into Sanity. More details can be found in the our Sync Tag Invalidate Function guide.

**event** (object)

Configuration options for the triggering event. See the event properties section below for details.

**timeout** (integer)

The max invocation time, in seconds, of the function.

Default: 10

Minimum: 1

Maximum: 900

**memory** (integer)

Sets the max memory allocation, in GBs.

Default: 1

Min: 1

Max: 10

**env** (object)

Set environment variables for the function. The env object accepts custom keys with string values. This is an alternative approach to using the sanity functions env CLI command. Note: Setting environment variables in this manner is only additive. It can create/update variables, but in order to remove an environment variable you must use the sanity functions env remove command.

**transpile** (boolean)

If false, you will need to transpile any TypeScript code yourself and output the results to the individual function's .build directory. Defaults to true.

**autoResolveDeps** (boolean)

If false, disables the automatic dependency resolution. Defaults to true.

A complete list of available properties can be found in the [defineDocumentFunction reference documentation](https://reference.sanity.io/_sanity/blueprints/defineDocumentFunction/).

#### `event` properties

#### Properties

**on** (string)

Defines the types of events that trigger your Function. You can include more than one, but you cannot combine publish with other events. The options are:

create: Activates when a document is created. Defaults to includeDrafts: false and includeAllVersions: false.

delete: Activates when a document is deleted. Defaults to includeDrafts: false and includeAllVersions: false.

update: Activates when a document is updated. Defaults to includeDrafts: false and includeAllVersions: false.

publish (deprecated): Activates when a document is published. Essentially a shorthand for: create + update with includeAllVersions: true. Use explicit create/update events instead.

These actions trigger on individual documents with unique _id values.

Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**filter** (string)

A valid GROQ filter. Learn more about GROQ Filters.



Only include the contents of the filter, not any other surrounding syntax.

✅ Do this: _type == "article"

❌ Not this: [_type == "article"]

Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**projection** (string)

A valid GROQ projection. Example: {title, _id, slug}

Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**includeDrafts** (boolean)

Determines whether events on draft documents (drafts.**) trigger the function. Defaults to false. When false: draft edits are ignored; only published document changes trigger. When true: every draft edit triggers the function. Please note that turning this on can quickly have your Function hit rate limits.



Only applies to the following Function types:

sanity.function.document

sanity.function.media-library.asset

**includeAllVersions** (boolean)

Determines whether events on version documents (versions.**) trigger the function. This includes documents in Content Releases and Scheduled Drafts. Defaults to false. When false: version edits are ignored; the function only triggers when versions are published. When true: every version edit triggers the function. Please note that turning this on can quickly have your Function hit rate limits.



Only applies to the following Function types:

sanity.function.document

**resource** (object)

Defines the resource from which changes will trigger your function. If defined, you must specify a type and id. If not set, the resource will default to all datasets for the Blueprint's linked project.

Accepted values depend on what type of Function you are defining:

Optional if your Function type is sanity.function.document or sanity.function.sync-tag-invalidate. If not specified, will react to changes in all datasets in your function’s housing project.

If defined, the resource.type must be dataset and resource.id is specified in the form <projectId>.<datasetName>. 

You can set <datasetName> to * to signify "all datasets in the project with ID <projectId>."

Required if your Function type is sanity.function.media-library.*. The resource.type must be media-library and resource.id should equal your Media Library ID.

#### Example

**sanity.blueprint.ts (TypeScript / JavaScript)**

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

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: "log-event",
      event: {
        on: ["update"],
        filter: "_type == 'post'",
        projection: "{title, _id, _type}",
        resource: {
          type: 'dataset',
          id: 'myProject.myDataset'
        }
      },
      env: {
        example: 'value'
      }
    }),
    // Helper introduced in @sanity/blueprints v0.4.0
    defineMediaLibraryAssetFunction({
      name: "image-title-updated",
      event: {
        on: ["update"],
        filter: "delta::changedAny(title)",
        projection: "{title, _id, versions}",
        resource: {
          type: 'media-library',
          id: 'mlAbcd1234'
        }
      }
    }),
    // Helper introduced in @sanity/blueprints v0.15.0
    defineSyncTagInvalidateFunction({
      name: "invalidate-cache",
      event: {
        resource: {
          type: 'dataset',
          id: 'myProjectId.myProductionDataset'
        }
      }
    })
  ]
})

```

**sanity.blueprint.json (JSON)**

```json
{
  "blueprintVersion": "2024-10-01",
  "resources": [
    {
      "name": "log-event",
      "src": "functions/log-event",
      "type": "sanity.function.document",
      "event": {
        "on": [
          "update"
        ],
        "filter": "_type == 'post'",
        "projection": "{title, _id, _type}",
        "resource": {
          "type": "dataset",
          "id": "myProject.myDataset"
        }
      },
      "env": {
        "example": "value"
      }
    },
    {
      "name": "image-created",
      "src": "functions/image-created",
      "type": "sanity.function.media-library.asset",
      "event": {
        "on": [
          "create"
        ],
        "filter": "assetType == 'sanity.imageAsset'",
        "projection": "{title, _id, versions}",
        "resource": {
          "type": "media-library",
          "id": "mlAbcd1234"
        }
      }
    }
  ]
}
```

### Additional resources

Reference documentation for additional Blueprint resources is available in the `@sanity/blueprints` documentation.

- [CORS reference](https://reference.sanity.io/_sanity/blueprints/defineCorsOrigin/)
- [Webhooks reference](https://reference.sanity.io/_sanity/blueprints/defineDocumentWebhook/)
- [Media Library Asset Function reference](https://reference.sanity.io/_sanity/blueprints/defineMediaLibraryAssetFunction/)
- [Robot token reference](https://reference.sanity.io/_sanity/blueprints/defineRobotToken/)
- [Role reference](https://reference.sanity.io/_sanity/blueprints/defineRole/)

## Common resource fields

Every resource has a unique `name` and an optional `lifecycle`. You set `name`; the definer sets the resource's `type` for you, so you rarely write it directly. The `name` is unique within the Stack and is the resource's identity for matching.

## The lifecycle field

`lifecycle.deletionPolicy` controls what happens to a resource when it's removed from the file or the Stack is destroyed:

##### Deletion policies

| Policy | On a normal deploy | Removed from the file | On destroy |
| --- | --- | --- | --- |
| allow | Updated in place | Destroyed | Destroyed |
| retain | Updated in place | Deploy fails | Kept (detached) |
| replace | Destroyed and recreated | Destroyed | Destroyed |
| protect | Skipped | Deploy fails | Deploy fails |

Stateless resources default to `allow`; stateful resources such as datasets default to `retain`. `lifecycle.ownershipAction` covers attaching, detaching, and cross-stack references, and `lifecycle.dependsOn` orders deployments when there is no parameter reference between resources.

**sanity.blueprint.ts**

```typescript
defineDataset({
  name: 'production',
  project: '$.values.projectId',
  lifecycle: { deletionPolicy: 'retain' },
})
```

## Reference syntax

Resources refer to constants and to each other with a small `$` syntax, passed as a plain string:

##### Reference syntax

| Syntax | Meaning |
| --- | --- |
| $.values.<key> | A value from the values block, resolved when the file runs. |
| $.resources.<name> | Another resource in the file. Creates a dependency edge. |
| $.resources.<name>.id | The generated ID of another resource, usable as a string. |

## TypeScript / JavaScript helpers

You can configure Blueprints with TypeScript and JavaScript. If you select either during `sanity blueprints init`, the CLI prompts you to install the [@sanity/blueprints](https://github.com/sanity-io/blueprints-node) package. You can also add it to an existing project by adding it to your Blueprints-level project directory.

**NPM**

```sh
npm i @sanity/blueprints
```

**PNPM**

```sh
pnpm add @sanity/blueprints
```

The helpers provide defaults and allow you to omit some configuration options. You can always override these defaults by explicitly setting the values as you would with the JSON format.



# Visual Editing

#### Get started

[Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)
Set up visual editing between Sanity Studio and a Next.js App Router frontend, including the Sanity client, Draft Mode, Visual Editing, and Live Content.

[Visual Editing with Next.js Pages Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-pages-router)
Get started with Sanity Visual Editing in a new or existing Next.js application using the Pages Router. 

[Visual Editing with React Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-react-router)
Get started with Sanity Visual Editing in a new or existing React Router (Remix) application.

[Visual Editing with Nuxt](https://www.sanity.io/docs/visual-editing/visual-editing-with-nuxt)
Get started with Sanity Visual Editing in a new or existing Nuxt application. 

[Visual Editing with Astro](https://www.sanity.io/docs/visual-editing/astro-visual-editing)
Configure Sanity’s Presentation Tool, draft mode, and visual editing overlays to work with an Astro 7 server-rendered frontend.

#### Core concepts

[Configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool)
Configure the Presentation Tool: previewUrl, document location resolvers, allowed origins, components, and navigation for multi-environment setups.

[Visual editing architecture overview](https://www.sanity.io/docs/visual-editing/visual-editing-architecture)
Understand how Sanity's visual editing works across seven architectural layers: Content Source Maps, stega encoding, overlays, live updates, preview mode, and the Presentation Tool.

[Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps)
Content source maps allow tools to annotate content with metadata and use it to connect tools to content.

#### Dive deeper

[Build a complete visual editing integration](https://www.sanity.io/docs/visual-editing/build-a-visual-editing-integration)
Build a complete framework-agnostic visual editing integration step-by-step with Vite and a Node.js HTTP server.

[Presentation Resolver API](https://www.sanity.io/docs/visual-editing/presentation-resolver-api)
Programmatically generate shortcuts from document forms to relevant routes in the Presentation Tool



# Introduction

Visual editing bridges Sanity Studio and your frontend. Editors see drafts render on the live site or a preview environment, click any element to jump to the right field, and watch content update as they type. It works with any modern frontend stack.

## What you can do

Visual editing streamlines content workflows across three capabilities:

- **Live preview**: see draft content render on your frontend as editors make changes.
- **Click-to-edit**: jump directly from the preview to the field being edited in the Studio.
- **Drag-and-drop page building**: rearrange content sections directly in the preview, with updates reflected in the Studio.

## Framework quickstarts

Most teams should start here. The framework-specific guides give you a working visual editing setup in minutes.

- [Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)
- [Next.js Pages Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-pages-router)
- [Remix / React Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-react-router)
- [Astro](https://www.sanity.io/docs/visual-editing/astro-visual-editing)
- [SvelteKit](https://www.sanity.io/docs/visual-editing/visual-editing-with-sveltekit)
- [Nuxt](https://www.sanity.io/docs/visual-editing/visual-editing-with-nuxt)
- [React Native](https://www.sanity.io/docs/visual-editing/visual-editing-with-react-native)

## Build from scratch

If you're using a framework without official support, building a custom integration, or want to understand how visual editing works end-to-end, follow the framework-agnostic series below. All examples use plain TypeScript, standard Web APIs, and minimal libraries.

### Conceptual foundation

- [Visual editing architecture overview](https://www.sanity.io/docs/visual-editing/visual-editing-architecture): seven-layer mental model of how everything fits together.
- [Client setup for visual editing](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega): configure the Sanity client with stega, content source maps, perspectives, and token handling.
- [Implementing draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode): build secure preview endpoints from scratch.
- [Overlays and click-to-edit](https://www.sanity.io/docs/visual-editing/visual-editing-overlays): enable overlays, understand detection methods, and integrate your router.
- [Real-time content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates): progressive enhancement with the core loader and Live Content API.
- [Configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool): set up the Studio plugin that hosts the preview.

### End-to-end example

- [Build a complete visual editing integration](https://www.sanity.io/docs/visual-editing/build-a-visual-editing-integration): step-by-step with Vite and a Node.js HTTP server.

## Related topics

Task-focused guides for specific features and integrations:

- [Using the Presentation Tool](https://www.sanity.io/docs/visual-editing/preview-and-page-building): editor-facing guide to the preview experience.
- [Enabling drag and drop](https://www.sanity.io/docs/visual-editing/enabling-drag-and-drop): page building with array reordering.
- [Custom overlay components](https://www.sanity.io/docs/visual-editing/custom-overlay-components): extend the overlay system with plugins.
- [Custom preview header and navigation](https://www.sanity.io/docs/visual-editing/customizing-preview-header-and-navigation): customize the Presentation Tool UI.
- [Content Source Maps specification](https://www.sanity.io/docs/visual-editing/content-source-maps): the JSON format and GROQ compatibility matrix.
- [Presentation Resolver API](https://www.sanity.io/docs/visual-editing/presentation-resolver-api): advanced DocumentLocationResolver reference.
- [Studio edit intent links](https://www.sanity.io/docs/visual-editing/studio-edit-intent-links): construct URLs to open documents in the Studio directly.
- [Vercel protection bypass](https://www.sanity.io/docs/visual-editing/vercel-protection-bypass): integration guide for deployment protection.
- [useOptimistic hook reference](https://www.sanity.io/docs/visual-editing/useoptimistic-reference): React hook for optimistic drag-and-drop UI.



# Next.js (App Router)

This guide walks through the specific wiring that makes Sanity's visual editing work with a Next.js application.

By the end, editors will be able to open the Presentation Tool in the Studio, see the frontend in a live preview, click on any text element to jump to the corresponding field, and see changes reflected in real time as they type.

**What you'll set up:**

- A Sanity client configured for Content Source Map encoding.
- `defineLive` for real-time content fetching and live updates.
- Draft Mode routes to toggle between published and draft content.
- The Presentation Tool with document-to-URL mapping.
- Click-to-edit overlays powered by `<VisualEditing />`.

The guide assumes you already have document types defined in your Studio and pages that render them. The focus is purely on the integration layer: the files and configuration that connect the two apps.

## Prerequisites

- Node.js 20+.
- Next.js 16.x with the [App Router](https://nextjs.org/docs/app). This guide uses route handlers, `generateStaticParams`, `generateMetadata`, and Draft Mode, all of which are App Router features. It also expects that your app uses `next-sanity` v13.1.5 or later.
- A Sanity project with a dataset. [Create one](https://www.sanity.io/manage) if you don't have one.
- [An API token](https://www.sanity.io/docs/content-lake/http-auth) with **Viewer** permissions for that project. Create one under **API** → **Tokens** in your project settings.
- `http://localhost:3000` added as a [CORS origin](https://www.sanity.io/docs/content-lake/browser-security-and-cors) with **Allow credentials** checked.

You can create a basic Next.js app with the following command.

**npm**

```shell
# In a directory, outside your studio directory
npx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

**pnpm**

```shell
# In a directory, outside your studio directory
pnpm dlx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

**yarn**

```shell
# In a directory, outside your studio directory
yarn dlx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

**bun**

```shell
# In a directory, outside your studio directory
bunx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

You can create a new Studio with the following command.

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio
cd studio
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

If you’re setting up a new Next.js app and Studio from scratch, we suggest following our [Next.js quick start](https://www.sanity.io/docs/next-js-quickstart). The schemas, routes, and file layout in this guide follow the structure set up in the quick start.

## How the pieces fit together

Before diving into the code, here's what happens at runtime when an editor opens the Presentation Tool:

1. The Studio loads the Next.js frontend inside an iframe. The URL it loads comes from the `origin` field in the Presentation Tool configuration.
2. The Studio hits the Draft Mode enable route on the frontend (`/api/draft-mode/enable`). This activates Next.js Draft Mode in the iframe session.
3. With Draft Mode active, `sanityFetch` returns strings with invisible characters embedded in them. These invisible characters (called "stega") encode Content Source Map data: which document and field each string came from, along with the Studio URL.
4. The `<VisualEditing />` component (which only renders during Draft Mode) reads those encoded strings from the DOM and draws click-to-edit overlays on every text element.
5. When an editor clicks an overlay, the Studio navigates to that document and field.
6. When an editor changes a field, the `<SanityLive />` component picks up the mutation and the frontend re-renders with the new content.

> [!NOTE]
> Contracts between the two apps.
> If you change one side, check the other.
> - The Studio's `previewMode.enable` path (`/api/draft-mode/enable`) must match an actual route in the Next.js app.
> - The URLs returned by `resolve.ts` (e.g., `/posts/${slug}`) must match actual routes in `web/src/app/`.
> - The `stega.studioUrl` in the Next.js client must point to the running Studio.
> - The Sanity project must have the frontend's origin in its CORS settings with **Allow credentials** enabled.

## Environment variables

The Next.js app needs three environment variables. The Studio doesn't need any since the project ID and dataset are hardcoded in `sanity.config.ts`. However, you can use [environment variables in Studio](https://www.sanity.io/docs/studio/environment-variables) if you need the flexibility.

**web/.env.local**

```bash
NEXT_PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=your-viewer-token
```

`NEXT_PUBLIC_SANITY_PROJECT_ID` and `NEXT_PUBLIC_SANITY_DATASET` are public because the Sanity client needs them in the browser for live subscriptions.

`SANITY_API_READ_TOKEN` is server-only and never exposed to the client bundle directly. It's passed to `defineLive`, which handles sharing it with the browser securely when Draft Mode is active.

## Studio setup

These files live in `studio/`. If you’re setting up a new Studio from scratch, these examples use the schema and conventions found in the [Next.js quick start](https://www.sanity.io/docs/next-js-quickstart).

### Presentation Tool configuration

The Presentation Tool is a Studio plugin that renders your frontend inside an iframe and enables the visual editing workflow. Configure it in `sanity.config.ts`:

**studio/sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {presentationTool} from 'sanity/presentation'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './src/schemaTypes'
import {resolve} from './src/presentation/resolve'

export default defineConfig({
  name: 'default',
  title: 'Blog Studio',

  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',

  plugins: [
    structureTool(),
    presentationTool({
      resolve,
      previewUrl: {
        origin: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
        },
      },
    }),
    visionTool(),
  ],

  schema: {
    types: schemaTypes,
  },
})
```

The important fields here:

- **resolve**: This defines the document location resolver. You’ll set this up in the next section.
- **previewUrl.origin**: The full URL of the Next.js app. The Presentation Tool loads this in the iframe. When the Studio and frontend are separate apps (as they are here), this is required. If you embedded the Studio inside the Next.js app at `/studio`, the origin would be implicit and you could omit it.
- **previewUrl.previewMode.enable**: The path (relative to `origin`) that the Studio calls to activate Draft Mode. The Studio makes a GET request to `http://localhost:3000/api/draft-mode/enable` with authentication parameters. This is what flips the switch that makes the frontend return draft content with stega encoding.

### Document locations

Document locations tell the Presentation Tool which frontend URLs correspond to which document types. This powers two things: when you select a document in the Studio, the iframe navigates to the right page; and documents show location badges linking to their frontend URLs.

**studio/src/presentation/resolve.ts**

```typescript
import {defineLocations, type PresentationPluginOptions} from 'sanity/presentation'

export const resolve: PresentationPluginOptions['resolve'] = {
  locations: {
    // The key is the document type name from your schema
    post: defineLocations({
      select: {
        title: 'title',
        slug: 'slug.current',
      },
      resolve: (doc) => ({
        locations: [
          {
            title: doc?.title || 'Untitled',
            href: `/posts/${doc?.slug}`,
          },
          {title: 'All posts', href: '/posts'},
        ],
      }),
    }),
  },
}
```

`select` uses GROQ-like field paths to pull data from the document. `resolve` receives that data and returns an array of `{title, href}` objects. The first location is treated as the primary one. You can add multiple locations if a document appears on several pages (for example, a post appears on its own page and on the posts index).

### CORS

The Sanity project needs `http://localhost:3000` added as a CORS origin with **Allow credentials** enabled. If you already added this in the prerequisites, you're set. If not, add it in your project settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**, or add it with the CLI.

**npm**

```shell
npx sanity cors add http://localhost:3000 --credentials
```

**pnpm**

```shell
pnpm dlx sanity cors add http://localhost:3000 --credentials
```

**yarn**

```shell
yarn dlx sanity cors add http://localhost:3000 --credentials
```

**bun**

```shell
bunx sanity cors add http://localhost:3000 --credentials
```

For production, you'd add your deployed frontend URL as well.

## Next.js setup

These files live in `frontend/`. If you’re setting up a new Next.js project from scratch, these examples use the schema and conventions found in the [Next.js quick start](https://www.sanity.io/docs/next-js-quickstart).

### The Sanity client

**frontend/src/sanity/lib/client.ts**

```typescript
import {createClient} from 'next-sanity'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET

if (!projectId) throw new Error('Missing NEXT_PUBLIC_SANITY_PROJECT_ID')
if (!dataset) throw new Error('Missing NEXT_PUBLIC_SANITY_DATASET')

export const client = createClient({
  projectId,
  dataset,
  apiVersion: '2026-02-01',
  useCdn: true,
  stega: {
    studioUrl: 'http://localhost:3333',
  },
})
```

Most of this is standard Sanity client setup. The critical field for visual editing is **stega.studioUrl**.

When Draft Mode is active, `sanityFetch` (which we'll set up next) asks the Content Lake for Content Source Maps alongside the query results. It then encodes these source maps as invisible characters into string values.

The `<VisualEditing />` overlay component reads these encoded strings from the DOM to create click-to-edit links. Without `stega.studioUrl`, it has the document and field information but doesn't know where to send the editor. The overlays render but don't connect to anything.

For production, you'd point this to your deployed Studio URL.

### The Live Content API

**frontend/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,
})
```

`defineLive` is the main integration point between Sanity and Next.js. It returns two things:

- **sanityFetch**: A server-side function you use in page components instead of `client.fetch()`. It handles caching, revalidation, stega encoding, and perspective switching (published vs. draft or version content) automatically based on whether Draft Mode is active.
- **SanityLive**: A React component that subscribes to real-time content updates. When an editor changes a field in the Studio, this component picks up the mutation and triggers a re-render.

The two tokens:

- **serverToken**: Used for server-side fetches. This is what lets `sanityFetch` read draft content when Draft Mode is active. Without it, the frontend can only return published content.
- **browserToken**: Shared with the browser during Draft Mode to enable live subscriptions. This is the token that powers real-time updates. It should have Viewer permissions only since it's exposed to the client.

> [!NOTE]
> Why have the same token twice?
> While most apps are fine with a shared “Viewer” role token, enterprise customers with custom roles may choose to narrow the read permissions of the browser token further.

### Fetching data in pages

Here's a page component that shows the three different fetch modes you'll use:

**frontend/src/app/posts/[slug]/page.tsx**

```typescript
import {notFound} from 'next/navigation'
import {sanityFetch} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'

// Update with your own queries
const POST_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
    _id,
    title,
    "slug": slug.current,
    publishedAt,
    body
  }
`)

const POST_SLUGS_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)]{
    "slug": slug.current
  }`)

type Props = {
  params: Promise<{slug: string}>
}

// 1. Static params: published perspective, no stega
export async function generateStaticParams() {
  const {data} = await sanityFetch({
    query: POST_SLUGS_QUERY,
    perspective: 'published',
    stega: false,
  })
  return data
}

// 2. Metadata: stega disabled to keep invisible characters out of <title>
export async function generateMetadata({params}: Props) {
  const {data} = await sanityFetch({
    query: POST_QUERY,
    params: await params,
    stega: false,
  })
  return {title: data?.title ?? 'Post not found'}
}

// 3. Page component: default settings (stega active in Draft Mode)
export default async function PostPage({params}: Props) {
  const {data: post} = await sanityFetch({
    query: POST_QUERY,
    params: await params,
  })

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}
```

Three modes, three different configurations:

- **generateStaticParams**: Uses `perspective: 'published'` so it only generates pages for published posts (not drafts). Uses `stega: false` because these values are used as URL segments, not rendered text.
- **generateMetadata**: Uses `stega: false` because stega characters in `<title>` or `<meta>` tags corrupt your SEO. Invisible characters in a page title look fine in the browser tab but break search engine results.
- **The page component**: Uses default settings. When Draft Mode is off, it returns clean published content. When Draft Mode is on, it returns draft content with stega encoding, which is exactly what the overlays need.

The data returned by `sanityFetch` is fully typed only after you generate types with Sanity TypeGen. Run `npx sanity typegen generate` after changing queries.

### The root layout

**frontend/src/app/layout.tsx**

```typescript
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
import {SanityLive} from '@/sanity/lib/live'
import {DisableDraftMode} from '@/components/disable-draft-mode'

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
        {(await draftMode()).isEnabled && (
          <>
            <VisualEditing />
            <DisableDraftMode />
          </>
        )}
      </body>
    </html>
  )
}
```

Two components are doing the visual editing work here:

- **<SanityLive />** renders on every request, whether Draft Mode is active or not. It establishes a connection to the Content Lake and listens for content changes. When someone publishes a document, this component triggers revalidation so the page updates without a full deploy.
- **<VisualEditing />** renders only when Draft Mode is enabled. It scans the DOM for stega-encoded strings, decodes the Content Source Map data embedded in them (document ID, field path, Studio URL), and draws transparent overlays on top of each element. Clicking an overlay sends a message to the parent Studio window (via `postMessage`) telling it to navigate to that document and field.
- **<DisableDraftMode />** renders a button for users to manually disable draft mode. You’ll create this shortly.

### VisualEditing props

The `<VisualEditing />` component accepts optional props to control clipboard behavior and stega diagnostics. Both props require `@sanity/visual-editing` 5.5.0 or later, which is included with `next-sanity` v13.1.5 and later.

#### `keepStegaOnCopy`

Type: `boolean`. Optional. Default: `false` (stega is stripped from the clipboard by default).

By default, `<VisualEditing />` intercepts copy events and removes stega encoding from both `text/plain` and `text/html` clipboard payloads. This means editors copying text from the preview page won't get invisible stega characters in their clipboard. Pass `keepStegaOnCopy` to opt out of this behavior and preserve stega encoding in clipboard data.

#### `onSuspiciousStega`

Type: callback. Optional. Opt-in.

Reports stega found in unsafe DOM placements. When provided, `<VisualEditing />` audits the DOM for stega in locations where invisible characters can cause problems:

- Element attributes (`class`, `id`, `href`, `src`, `style`, `data-*`, etc.)
- Inside `<head>` (`title`, `meta[content]`, JSON-LD)
- Inside `<script>` or `<style>` text content
- In `textarea` form values
- In the page URL

Each report includes the `kind`, `element`, `attribute` (if applicable), `value`, and `cleaned`.

**frontend/src/app/layout.tsx**

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

> [!WARNING]
> Development and debugging only
> The `onSuspiciousStega` callback runs a full DOM audit using a TreeWalker and MutationObserver, which has a real performance cost. Use it during development and debugging to identify stega leaking into unsafe locations. Do not enable it in production.

#### Props reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| keepStegaOnCopy | boolean | false | Opt out of automatic stega stripping from clipboard on copy events. By default, stega encoding is removed from both text/plain and text/html clipboard payloads so editors don't copy invisible characters. |
| onSuspiciousStega | (reports: SuspiciousStegaReport[]) => void | undefined | Callback that receives reports of stega found in unsafe DOM placements (element attributes, <head>, <script>/<style> content, textarea values, page URL). Runs a full DOM audit. Use in development only. Reports may also include a sanity field with decoded node info. |

The `(await draftMode()).isEnabled` check is the gate. Outside of Draft Mode, the page renders clean published content with no overlays and no invisible characters. Inside Draft Mode, you get draft content, stega encoding, and click-to-edit overlays.

> [!TIP]
> Don't want Live Content?
> If you don’t want the Live Content API’s auto-refresh capabilities, perhaps if you have more granular caching and revalidation needs, see the [section below on replacing sanityFetch with your own helper](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router).

### Draft Mode routes

These two routes are the bridge between the Studio and the frontend.

**Enable route:**

**frontend/src/app/api/draft-mode/enable/route.ts**

```typescript
import {client} from '@/sanity/lib/client'
import {defineEnableDraftMode} from 'next-sanity/draft-mode'

export const {GET} = defineEnableDraftMode({
  client: client.withConfig({
    token: process.env.SANITY_API_READ_TOKEN || ''
  }),
})
```

When an editor opens the Presentation Tool, the Studio makes a GET request to this route with authentication parameters. `defineEnableDraftMode` handles the handshake: it verifies the request came from a legitimate Studio session (not a random visitor), then calls `draftMode().enable()` to activate Draft Mode for that browser session. From that point on, every `sanityFetch` call in the session returns draft content with stega encoding.

The `client.withConfig` part gives the handler an authenticated client to verify the request against the Sanity API.

**Disable route:**

**frontend/src/app/api/draft-mode/disable/route.ts**

```typescript
import {draftMode} from 'next/headers'
import {NextResponse} from 'next/server'

// set redirect to your preferred location
export async function GET() {
  ;(await draftMode()).disable()
  return NextResponse.redirect(
    new URL('/', 'http://localhost:3000')
  )
}
```

This turns off Draft Mode and redirects to the homepage. It's called by the "**Disable Draft Mode**" button (covered next).

### The "Disable Draft Mode" button

**frontend/src/components/disable-draft-mode.tsx**

```typescript
'use client'

import {useIsPresentationTool} from 'next-sanity/hooks'

export function DisableDraftMode() {
  const isPresentationTool = useIsPresentationTool()

  // Hide the button when inside the Presentation Tool
  if (isPresentationTool) return null

  return (
    <a
      href="/api/draft-mode/disable"
      className="fixed bottom-4 right-4 z-50 rounded-full bg-gray-900 px-4 py-2 text-sm text-white"
    >
      Disable Draft Mode
    </a>
  )
}
```

This component renders a floating button to exit Draft Mode, but only when the user is viewing the frontend directly (not inside the Presentation Tool's iframe). Inside the Presentation Tool, the Studio controls Draft Mode, so the button would be redundant.

`useIsPresentationTool` returns `true` when the frontend is loaded inside a Presentation Tool iframe and `false` when it's loaded directly in a browser tab. This is how you distinguish between the two contexts.

## Run both apps

With everything set up, you can now run both apps to test the functionality. If you’re using npm with two separate directories as described in this guide, run the `dev` command in each directory.

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun run dev
```

## The full flow

Now that you've seen every file, here's the complete sequence when an editor uses visual editing. This is the same flow described in "How the pieces fit together," but now you can trace each step back to the specific file that handles it:

1. The editor opens the **Presentation Tool** in the Studio (`sanity.config.ts`).
2. The Studio loads `http://localhost:3000` (the `origin`) in an iframe and uses `resolve.ts` to map the current document to a frontend URL.
3. The Studio hits `http://localhost:3000/api/draft-mode/enable` with authentication parameters (`enable/route.ts`).
4. The enable route verifies the request and activates **Draft Mode** in the iframe session.
5. The page re-renders. `sanityFetch` (`live.ts`) detects Draft Mode and returns draft content with **stega-encoded strings**: each string value has invisible characters that encode the document ID, field path, and Studio URL (`client.ts`).
6. `<VisualEditing />` (`layout.tsx`, only mounted during Draft Mode) reads the DOM, finds the stega-encoded strings, and renders transparent **click-to-edit overlays** on each text element.
7. The editor clicks an overlay. The overlay sends a `postMessage` to the parent Studio window with the document ID and field path. The Studio navigates to that field.
8. The editor changes a field. The mutation propagates through the Content Lake.
9. `<SanityLive />` (`layout.tsx`) picks up the mutation via its real-time subscription and triggers a re-render. The page updates with the new content.

## Next steps

- **Deploy to production.** Update `stega.studioUrl`, the Presentation Tool `origin`, and your CORS origins to point to your deployed URLs instead of `localhost`. It’s common to use environment variables for these values with local fallbacks.
- **Add more document types to resolve.ts.** Any document type that has a corresponding frontend route can get visual editing. Add entries to the `locations` object for each type.
- **Customize overlay behavior.** The `<VisualEditing />` component accepts props for filtering which elements get overlays. See the [next-sanity visual editing reference](https://reference.sanity.io/next-sanity/visual-editing/client-component/VisualEditingProps/) for details.

## Troubleshooting

### Visual Editing without the Live Content API

The instructions above rely on the Live Content API, but if your revalidation needs are different, you can substitute the live functionality with a custom `sanityFetch`, and remove the `<SanityLive />` component.

Remove `live.ts` and create `fetch.ts`.

You’ll also need a `token.ts` that exports the read token:

**frontend/src/sanity/lib/token.ts**

```typescript
export const token = process.env.SANITY_API_READ_TOKEN

if (!token) {
  throw new Error('Missing SANITY_API_READ_TOKEN')
}
```

**frontend/src/sanity/lib/fetch.ts**

```typescript
import {draftMode} from 'next/headers'
import {client} from './client'
import {token} from './token'

export async function sanityFetch<T>({
  query,
  params = {},
  revalidate = 60,
  tags = [],
  stega: stegaOverride,
  perspective: perspectiveOverride,
}: {
  query: string
  params?: Record<string, unknown>
  revalidate?: number | false
  tags?: string[]
  stega?: boolean
  perspective?: 'published' | 'drafts' | 'raw'
}): Promise<{data: T}> {
  const isDraftMode = (await draftMode()).isEnabled

  const perspective = perspectiveOverride ?? (isDraftMode ? 'drafts' : 'published')
  const stega = stegaOverride ?? isDraftMode
  const useCdn = !isDraftMode

  const data = await client
    .withConfig({useCdn, stega: stega ? {studioUrl: 'http://localhost:3333'} : false})
    .fetch<T>(query, params, {
      token: isDraftMode ? token : undefined,
      perspective,
      next: {
        revalidate: isDraftMode ? 0 : tags.length ? false : revalidate,
        tags: isDraftMode ? [] : tags,
      },
    })

  return {data}
}
```

Then, import this new `sanityFetch` instead of the `live.ts` one.

**frontend/src/app/posts/[slug]/page.tsx**

```tsx
import {notFound} from 'next/navigation'
import {sanityFetch} from '@/sanity/lib/fetch'

type Props = {
  params: Promise<{slug: string}>
}

/* ...omitted */

// Page component: default settings (stega active in Draft Mode)
export default async function PostPage({params}: Props) {
  const {data: post} = await sanityFetch({
    query: POST_QUERY,
    params: await params,
  })

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}
```

Pass in any overrides you need to handle revalidation as needed.

Next, remove `SanityLive` from the layout component.

**frontend/src/app/layout.tsx**

```tsx
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
import {DisableDraftMode} from '@/components/disable-draft-mode'

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body>
        {children}
        {(await draftMode()).isEnabled && (
          <>
            <VisualEditing />
            <DisableDraftMode />
          </>
        )}
      </body>
    </html>
  )
}
```

The VisualEditing and DisableDraftMode components will handle the rest. Learn more about [revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs) for more details on configuring a custom sanityFetch helper.

### Overlays appear but clicking does nothing

**Cause:** `stega.studioUrl` is missing from the Sanity client in `frontend/src/sanity/lib/client.ts`.

**Fix:** Add `stega: { studioUrl: 'http://localhost:3333' }` to `createClient`.

### Presentation Tool shows a blank iframe

**Cause:** `origin` is missing from the Presentation Tool config in `studio/sanity.config.ts`. This only happens when the Studio and frontend run as separate apps. When the Studio is embedded inside the Next.js app, the origin is implicit.

**Fix:** Add `origin: 'http://localhost:3000'` to `previewUrl` in the `presentationTool()` config.

### Page titles or meta tags contain garbled text

**Cause:** Stega encoding is active in `generateMetadata`. The invisible source map characters end up in `<title>` and `<meta>` tags. The page looks fine in the browser, but search engines see corrupted text.

**Fix:** Always pass `stega: false` when calling `sanityFetch` inside `generateMetadata`.

### Live preview doesn't update, 403 errors in browser console

**Cause:** The frontend's origin is missing from the Sanity project's CORS settings, so the browser can't reach the Content Lake.

**Fix:** Add `http://localhost:3000` (with **Allow credentials** checked) in your project's CORS settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**.

### String comparisons fail in Draft Mode

**Cause:** Stega encoding adds invisible characters to string values. An equality check like `align === 'center'` returns `false` even when the visible value is `"center"` because the encoded string contains extra characters.

**Fix:** Use `stegaClean()` to strip the encoding before comparing:

```typescript
import {stegaClean} from 'next-sanity'

const cleanAlign = stegaClean(align)
if (cleanAlign === 'center') {
  // ...
}
```

## Reference

### Key packages

| Package | Version | Purpose |
| --- | --- | --- |
| sanity | 6.x | Sanity Studio |
| next | 16.x | Next.js framework |
| next-sanity | 13.x | Sanity integration for Next.js |
| @portabletext/react | 6.x | Portable Text rendering |
| @sanity/image-url | 2.x | Image URL generation |

### File map

Every file involved in the visual editing integration, what it does, and what it depends on:

| File | Role | Depends on |
| --- | --- | --- |
| studio/sanity.config.ts | Configures the Presentation Tool with the frontend's origin and previewMode.enable path | studio/src/presentation/resolve.ts |
| studio/src/presentation/resolve.ts | Maps document types to frontend URLs for iframe navigation and location badges | Schema type names, frontend route structure in web/src/app/ |
| frontend/src/sanity/lib/client.ts | Sanity client with stega.studioUrl so overlays resolve back to the Studio | NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET |
| frontend/src/sanity/lib/token.ts | Exports the API read token for the Draft Mode enable route | SANITY_API_READ_TOKEN |
| frontend/src/sanity/lib/live.ts | defineLive returns sanityFetch (data fetching) and SanityLive (real-time subscriptions) | client.ts, SANITY_API_READ_TOKEN |
| frontend/src/app/layout.tsx | Root layout: renders <SanityLive /> always, <VisualEditing /> in Draft Mode only | live.ts, disable-draft-mode.tsx |
| frontend/src/app/api/draft-mode/enable/route.ts | Activates Draft Mode when called by the Presentation Tool | client.ts, SANITY_API_READ_TOKEN |
| frontend/src/app/api/draft-mode/disable/route.ts | Deactivates Draft Mode and redirects to homepage | Nothing |
| frontend/src/components/disable-draft-mode.tsx | "Disable Draft Mode" button, hidden when inside the Presentation Tool | Nothing |

### Import paths (next-sanity 13.x)

These changed significantly from earlier versions. If you're referencing older tutorials or blog posts, the paths below are the ones that work with v13:

| Export | Import from |
| --- | --- |
| createClient, defineQuery, groq, stegaClean | next-sanity |
| defineLive | next-sanity/live |
| VisualEditing | next-sanity/visual-editing |
| defineEnableDraftMode | next-sanity/draft-mode |
| useIsPresentationTool, useOptimistic | next-sanity/hooks |
| PortableText | @portabletext/react (not re-exported from next-sanity) |



# Next.js (Pages Router)

Following this guide will enable you to:

- Render overlays in your application, allowing content editors to jump directly from Sanity content to its source in Sanity Studio.
- Edit your content and see changes reflected in an embedded preview of your application in Sanity’s Presentation Tool.
- Provide instant updates and seamless switching between draft and published content.

> [!WARNING]
> Gotcha
> This guide is for the Next.js Pages Router. See [the guide for the Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router).

## Prerequisites

- A Sanity project with [a hosted or embedded Studio](https://www.sanity.io/docs/studio/deployment).
- A Next.js application using Pages Router. Follow [this guide](https://nextjs.org/docs/pages/building-your-application) to set one up.

## Next.js application setup

The following steps should be performed in your Next.js application.

### Install dependencies

Install the dependencies that will provide your application with data fetching and Visual Editing capabilities.

**npm**

```shell
npm install next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

**pnpm**

```shell
pnpm add next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

**yarn**

```shell
yarn add next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

**bun**

```shell
bun add next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

## Add environment variables

Create a `.env` file in your application’s root directory to provide Sanity-specific configuration.

You can use [Manage](https://www.sanity.io/manage) to find your project ID and dataset, and to create a token with Viewer permissions which will be used to fetch preview content.

The URL of your Sanity Studio will depend on where it is [hosted](https://www.sanity.io/docs/studio/deployment) or [embedded](https://www.sanity.io/docs/studio/embedding-sanity-studio).

**.env**

```text
# Public
NEXT_PUBLIC_SANITY_PROJECT_ID="YOUR_PROJECT_ID"
NEXT_PUBLIC_SANITY_DATASET="YOUR_DATASET"
NEXT_PUBLIC_SANITY_STUDIO_URL="YOUR_STUDIO_URL"
# Private
SANITY_VIEWER_TOKEN="YOUR_VIEWER_TOKEN"

```

## Application setup

### Configure the Sanity client

Create a Sanity client instance to handle fetching data from Content Lake.

Configuring the `stega` option enables automatic overlays for basic data types when preview mode is enabled. You can read more about [how stega works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

**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: "2026-07-01",
  useCdn: true,
  token: process.env.SANITY_VIEWER_TOKEN,
  stega: {
    studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL,
  },
});

```

### Draft mode

Draft mode allows authorized content editors to view and interact with draft content. Presentation Tool and sharing communicate with your Next.js app to enable or disable draft mode.

Create an API endpoint (in `src/pages/api`) to enable draft mode when viewing your application in Presentation Tool.

**src/pages/api/enable-draft.ts**

```typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { client } from "@/sanity/client";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (!req.url) {
    return res.status(500).json({ message: "Missing request URL" });
  }

  const { isValid, redirectTo = "/" } = await validatePreviewUrl(
    client.withConfig({
      token: process.env.SANITY_VIEWER_TOKEN,
    }),
    req.url
  );

  if (!isValid) {
    return res.status(401).json({ message: "Invalid secret" });
  }

  // Enable Draft Mode
  res.setDraftMode({ enable: true });
  res.writeHead(307, { Location: redirectTo });
  res.end();
}

```

Similarly, create an API endpoint to disable draft mode.

**src/pages/api/disable-draft.ts**

```typescript
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handle(
  _req: NextApiRequest,
  res: NextApiResponse<void>,
): void {
  // Exit the current user from "Draft Mode".
  res.setDraftMode({ enable: false })

  // Redirect the user back to the index page.
  res.writeHead(307, { Location: '/' })
  res.end()
}
```

Create a new component with a link to the disable endpoint. We add conditional logic to only render this for content authors when viewing draft content in a non-Presentation context. The code in this example uses minimal styling, but you may wish to create a more suitable banner that fits your layout.

**src/components/DisableDraftMode.tsx**

```tsx
import { useEffect, useState } from "react";

export function DisableDraftMode() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    setShow(window.top === window);
  }, []);

  return show && <a href={"/api/disable-draft"}>Disable Draft Mode</a>;
}
```

### Enable Visual Editing

Create a Visual Editing wrapper component.

The `<VisualEditing>` component handles rendering overlays, enabling click to edit, and refreshing pages in your application when content changes. Render it alongside the `<DisableDraftMode>` component you created above.

> [!WARNING]
> Embedded studios
> The approach below adds the VisualEditing components to the App layout. If you’re using an embedded studio (one that renders on a route in your Next.js app), you should only include VisualEditing components in your content layouts.
> Our recommendation is that you create dedicated layout components for your content and studio routes.

We provide a basic refresh mechanism that will reload the page when changes are made in Presentation Tool. You can optionally use loaders to provide seamless updates.

**src/components/SanityVisualEditing.tsx**

```tsx
import { VisualEditing } from "@sanity/visual-editing/next-pages-router";
import { useLiveMode } from "@sanity/react-loader";
import { DisableDraftMode } from "@/components/DisableDraftMode";
import { client } from "@/sanity/client";

const stegaClient = client.withConfig({ stega: true });

export default function SanityVisualEditing() {
  useLiveMode({ client: stegaClient });

  return (
    <>
      <VisualEditing />
      <DisableDraftMode />
    </>
  );
}

```

#### <VisualEditing /> props

The `<VisualEditing />` component accepts the following optional props introduced in `@sanity/visual-editing` 5.5.0.

#### `keepStegaOnCopy` (boolean, optional)

Default: `false`. By default, `<VisualEditing />` intercepts copy events and automatically strips stega encoding from both `text/plain` and `text/html` clipboard payloads. This ensures that users copying text from the preview page do not get invisible stega characters in their clipboard. Pass `keepStegaOnCopy` to opt out of this behavior and preserve stega encoding in clipboard output.

#### `onSuspiciousStega` (callback, optional)

An opt-in callback that reports stega encoding found in unsafe DOM placements. When provided, `<VisualEditing />` audits the DOM for stega in locations where it should not appear, including:

- Element attributes such as `class`, `id`, `href`, `src`, `style`, `data-*`, and others
- Inside `<head>`: `title`, `meta[content]`, and JSON-LD script blocks
- Text content inside `<script>` or `<style>` elements
- Textarea form values
- The page URL

Each report includes the `kind`, `element`, `attribute` (if applicable), `value`, `cleaned`, and `sanity` (decoded edit information).

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

> [!WARNING]
> Development use
> The `onSuspiciousStega` callback audits the DOM using TreeWalker and MutationObserver. We recommend using it in development and debugging contexts rather than in production.

#### `onPerspectiveChange` (callback, optional)

Fires when the perspective changes in the Studio that is driving Visual Editing. The callback receives a `ClientPerspective`, which is an array of release IDs when an editor selects a release. Live updates in Presentation follow the selected perspective automatically. Applying it to the initial server-rendered payload means reading it per request, which the getStaticProps setup above cannot do.

In the root layout file, dynamically import and render the `<SanityVisualEditing>` wrapper component when draft mode is enabled.

**src/pages/_app.tsx**

```tsx
import type { AppProps } from "next/app";
import dynamic from "next/dynamic";

const SanityVisualEditing = dynamic(() => import("@/components/SanityVisualEditing"));

export default function App({ Component, pageProps }: AppProps) {
  const { draftMode } = pageProps;
  return (
    <>
      <Component {...pageProps} />
      {draftMode && <SanityVisualEditing />}
    </>
  );
}

```

### Set up loaders

Create a new file to configure loaders. Call `setServerClient`, with the client instance which should be used to fetch data on the server.

We also create a helper function to return fetch options based on the draft mode state, and export this alongside `loadQuery` for convenience.

**src/sanity/ssr.ts**

```tsx
import * as serverOnly from "@sanity/react-loader";
import { client } from "./client";
import { ClientPerspective } from "next-sanity";

const { loadQuery, setServerClient } = serverOnly;

setServerClient(
  client.withConfig({
    token: process.env.SANITY_VIEWER_TOKEN,
  })
);

const loadQueryOptions = (context: { draftMode?: boolean }) => {
  const { draftMode } = context;
  return draftMode
    ? {
        // Sets the perspective for the initial server-rendered payload only.
        // Once Presentation connects, useLiveMode applies the perspective
        // currently selected in the Studio, including a release.
        perspective: "drafts" as ClientPerspective,
        stega: true,
        useCdn: false,
      }
    : {};
};

export { loadQuery, loadQueryOptions };

```

### Render a page in preview mode

In `getStaticProps` use the `loadQuery` function created above. The `initial` data returned here is passed to `useQuery` in the page component.

When in Presentation Tool, `useQuery` will handle live updates as content is edited.

**src/pages/index.tsx**

```tsx
import { loadQuery, loadQueryOptions } from "@/sanity/ssr";
import { useQuery } from "@sanity/react-loader";
import type { GetStaticProps, InferGetStaticPropsType } from "next";

const query = `*[_type == "page"][0]{title}`;

export const getStaticProps = (async (context) => {
  const { draftMode = false } = context; 
  const options = loadQueryOptions({ draftMode });
  const initial = await loadQuery<{ title?: string }>(query, {}, options);
  return { props: { initial, draftMode } };
}) satisfies GetStaticProps;

export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;

export default function Page(props: PageProps) {
  const { initial } = props;
  const { data } = useQuery(query, {}, { initial });
  return <h1>{data.title}</h1>;
}

```

## Studio setup

To set up Presentation Tool in your Sanity Studio, import the tool from `sanity/presentation`, add it to your `plugins` array, and set `previewUrl` to the base URL of your application.

We similarly recommend using environment variables loaded via a `.env` file to support development and production environments.

**sanity.config.ts**

```tsx
import { defineConfig } from "sanity";
import { presentationTool } from "sanity/presentation";

export default defineConfig({
  // ... project configuration
  plugins: [
    presentationTool({
      previewUrl: {
        // Add a new ENV var to your Studio codebase if needed to accommodate live vs local preview.
        initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
        previewMode: {
          enable: "/api/enable-draft",
        },
      },
    }),
    // ... other plugins
  ],
});

```

## Optional extras

### Add data attributes for overlays

`useQuery` also returns an `encodeDataAttribute` helper method for generating `data-sanity` attributes. These attributes give you direct control over rendering [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) in your application, and are especially useful if not using stega encoding.

**src/pages/index.tsx**

```tsx
import { loadQuery, loadQueryOptions } from "@/sanity/ssr";
import { useQuery } from "@sanity/react-loader";
import type { GetStaticProps, InferGetStaticPropsType } from "next";

const query = `*[_type == "page"][0]{title}`;

export const getStaticProps = (async (context) => {
  const options = loadQueryOptions(context);
  const initial = await loadQuery<{ title?: string }>(query, {}, options);
  return { props: { initial } };
}) satisfies GetStaticProps;

export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;

export default function Page(props: PageProps) {
  const { initial } = props;
  const { data, encodeDataAttribute } = useQuery(query, {}, { initial });
  return <h1 data-sanity={encodeDataAttribute(["title"])}>{data.title}</h1>;
}

```

## Next steps

You now have a Next.js Pages Router application with click-to-edit overlays, live updates in Presentation Tool, and draft mode switching. To go deeper, learn [how stega encoding works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega) or take direct control of [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) in your application.



# Nuxt.js

Following this guide will enable you to:

- Render overlays in your application, allowing content editors to jump directly from Sanity content to its source in Sanity Studio.
- Edit your content and see changes reflected in an embedded preview of your application in Sanity’s Presentation tool.
- Provide instant updates and seamless switching between draft and published content.

## Prerequisites

- A Sanity project with a hosted or embedded Studio. Read more about [hosting the Studio](https://www.sanity.io/docs/studio/deployment).
- A Nuxt application with SSR. Follow the [Nuxt installation guide](https://nuxt.com/docs/getting-started/installation) to set one up.

### Nuxt application setup

The following steps should be performed in your Nuxt application.

### Install dependencies

Install the Sanity module, which provides your application with data fetching and Visual Editing capabilities.

**npm**

```shell
npx nuxi@latest module add sanity
```

**pnpm**

```shell
pnpm dlx nuxi@latest module add sanity
```

**yarn**

```shell
yarn dlx nuxi@latest module add sanity
```

**bun**

```shell
bunx nuxi@latest module add sanity
```

### Environment variables

Create a `.env` file in your application’s root directory to provide Sanity-specific configuration.

You can use [Manage](https://www.sanity.io/manage) to find your project ID and dataset, and to create a token with [Viewer permissions](https://www.sanity.io/docs/user-guides/roles) which will be used to fetch preview content.

The URL of your Sanity Studio will depend on where it is [hosted](https://www.sanity.io/docs/studio/deployment) or [embedded](https://www.sanity.io/docs/studio/embedding-sanity-studio).

```bash
# .env
# Public
SANITY_PROJECT_ID="YOUR_PROJECT_ID"
SANITY_DATASET="YOUR_DATASET"
SANITY_STUDIO_URL="YOUR_STUDIO_URL"
# Private
SANITY_VIEWER_TOKEN="YOUR_VIEWER_TOKEN"
```

## Application setup

### Sanity module

Configure the Sanity module to handle fetching data from Content Lake.

Configuring the `stega` option enables automatic overlays for basic data types when preview mode is enabled. Read more about [how stega works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

```typescript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sanity'],
  sanity: {
    projectId: process.env.SANITY_PROJECT_ID,
    dataset: process.env.SANITY_DATASET,
    apiVersion: '2026-07-01',
    visualEditing: {
      token: process.env.SANITY_VIEWER_TOKEN,
      studioUrl: process.env.SANITY_STUDIO_URL,
      stega: true
    }
  }
})
```

### VisualEditing component props

The `<VisualEditing />` component rendered by the `@nuxtjs/sanity` module accepts additional props introduced in `@sanity/visual-editing` 5.5.0. Make sure your project uses version 5.5.0 or later.

For example, use `onSuspiciousStega` to log reports of stega found in unsafe DOM placements. The module serializes the callback into the build, so it must be self-contained. Don't reference imports or variables defined outside the function body.

```typescript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sanity'],
  sanity: {
    projectId: process.env.SANITY_PROJECT_ID,
    dataset: process.env.SANITY_DATASET,
    apiVersion: '2026-07-01',
    visualEditing: {
      token: process.env.SANITY_VIEWER_TOKEN,
      studioUrl: process.env.SANITY_STUDIO_URL,
      stega: true,
      // Report stega found in unsafe DOM placements
      onSuspiciousStega: (reports) => {
        for (const report of reports) {
          console.warn(`Stega found in ${report.kind}`, report)
        }
      },
    }
  }
})
```

> [!WARNING]
> Performance note
> The `onSuspiciousStega` callback performs an initial full DOM audit (TreeWalker and MutationObserver), then incremental idle-time checks as the DOM changes. It is intended for debugging rather than production use.

#### Properties

**keepStegaOnCopy** (boolean)

Optional. Default: false. By default, <VisualEditing /> intercepts copy events and removes stega encoding from both text/plain and text/html clipboard payloads so users do not get invisible characters when copying text from the preview page. Copies without stega are left untouched. Set keepStegaOnCopy to true to disable this behavior.

**onSuspiciousStega** ((reports: SuspiciousStegaReport[]) => void)

Optional. Opt-in callback that reports stega found in unsafe DOM placements: element attributes (class, id, href, src, style, data-*, and others), inside <head> (title, meta[content]), in <script> or <style> text content (including JSON-LD), in textarea form values, or in the page URL. Each report includes kind, element (if applicable), attribute (if applicable), value, cleaned, and, when the encoded value resolves to a Sanity node, sanity. Performs an initial full DOM audit (TreeWalker and MutationObserver), then incremental idle-time checks. Intended for debugging rather than production use.

### Rendering pages

First, set up the queries you will use to fetch data from Content Lake.

```typescript
// queries.ts
export type PageResult = { title: string }

export const pageQuery = /* groq */`*[_type == "page"][0]{title}`
```

```vue
// pages/index.vue
<script setup lang="ts">
import {pageQuery, type PageResult} from '../queries'

const {data, pending} = await useSanityQuery<PageResult>(pageQuery)
</script>

<template>
  <div v-if="pending">Loading...</div>
  <h1 v-else>{{ data?.title }}</h1>
</template>
```

## Studio setup

To set up the Presentation Tool in your Studio, import the tool from `sanity/presentation`, add it to your `plugins` array, and configure `previewUrl` with an initial preview URL and the endpoint used to enable preview mode.

We similarly recommend using environment variables loaded via a `.env` file to support development and production environments.

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'
import {presentationTool} from 'sanity/presentation'

export default defineConfig({
  // ... project configuration
  plugins: [
    presentationTool({
      previewUrl: {
        initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN,
        previewMode: {
          enable: '/preview/enable',
        },
      }
    }),
    // ... other plugins
  ],
})
```

## Optional extras

### Adding data attributes

`useSanityQuery` also returns an `encodeDataAttribute` helper method for generating `data-sanity` attributes. These attributes give you direct control over rendering [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) in your application, and are especially useful if not using stega encoding.

```vue
// pages/index.vue
<script setup lang="ts">
import {pageQuery, type PageResult} from '../queries'

const {data, pending, encodeDataAttribute} = await useSanityQuery<PageResult>(pageQuery)
</script>

<template>
  <div v-if="pending">Loading...</div>
  <h1 v-else :data-sanity="encodeDataAttribute(['title'])">{{ data?.title }}</h1>
</template>
```

## Next steps

You now have a Nuxt application that renders overlays, updates content instantly, and previews drafts in the Presentation Tool. To keep going, learn more about [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool), [customizing overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays), and [how stega encoding works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).



# SvelteKit

Following this guide will enable you to:

- Render overlays in your application, allowing content editors to jump directly from Sanity content to its source in Sanity Studio.
- Edit your content and see changes reflected in an embedded preview of your application in Sanity’s Presentation Tool.
- **Optional:** Provide instant updates and seamless switching between draft and published content.

## Prerequisites

- A Sanity project with a hosted or embedded Studio. Read more about [hosting the Studio](https://www.sanity.io/docs/studio/deployment).
- A SvelteKit application using Svelte 5 with SSR. Follow [this guide](https://kit.svelte.dev/docs/creating-a-project) to set one up.

## SvelteKit application setup

The following steps should be performed in your SvelteKit application.

### Install dependencies

Install the Sanity SvelteKit package that will provide your application with data fetching and Visual Editing capabilities.

**npm**

```shell
npm install @sanity/sveltekit
```

**pnpm**

```shell
pnpm add @sanity/sveltekit
```

**yarn**

```shell
yarn add @sanity/sveltekit
```

**bun**

```shell
bun add @sanity/sveltekit
```

### Set environment variables

Create a `.env` file in your application’s root directory to provide Sanity-specific configuration.

In the [project management area](https://www.sanity.io/manage), find your project ID and dataset, and create a token with [Viewer permissions](https://www.sanity.io/docs/user-guides/roles) which will be used to fetch preview content.

The URL of your Sanity Studio will depend on where it is [hosted](https://www.sanity.io/docs/studio/deployment) or [embedded](https://www.sanity.io/docs/studio/embedding-sanity-studio).

**.env**

```sh
# Public
PUBLIC_SANITY_PROJECT_ID="YOUR_PROJECT_ID"
PUBLIC_SANITY_DATASET="YOUR_DATASET"
PUBLIC_SANITY_STUDIO_URL="YOUR_STUDIO_URL"
# Private
SANITY_VIEWER_TOKEN="YOUR_VIEWER_TOKEN"
```

### Sanity client

Create a [Sanity client](https://github.com/sanity-io/client) instance to handle fetching data from Content Lake.

Configuring the `stega` option enables automatic overlays for basic data types when preview mode is enabled. You can read more about how stega works [here](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

**src/lib/sanity.ts**

```typescript
import {createClient} from '@sanity/sveltekit'
import {
  PUBLIC_SANITY_DATASET,
  PUBLIC_SANITY_PROJECT_ID,
  PUBLIC_SANITY_STUDIO_URL
} from '$env/static/public'

export const client = createClient({
  projectId: PUBLIC_SANITY_PROJECT_ID,
  dataset: PUBLIC_SANITY_DATASET,
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: PUBLIC_SANITY_STUDIO_URL
  }
})

```

Create a server-only Sanity client instance using the Viewer token and client created above. This will be used to fetch draft content when in preview mode.

**src/lib/sanity.server.ts**

```typescript
import {SANITY_VIEWER_TOKEN} from '$env/static/private'
import {client} from '$lib/sanity'

export const serverClient = client.withConfig({
  token: SANITY_VIEWER_TOKEN
})
```

### Preview mode

Preview mode allows authorized content editors to view and interact with draft content.

In the [server hooks](https://kit.svelte.dev/docs/hooks#server-hooks) file, sequence the `handlePreviewMode` [handle function](https://kit.svelte.dev/docs/hooks#server-hooks-handle), which adds preview mode to your application.

**src/hooks.server.ts**

```typescript
import {handlePreviewMode} from '@sanity/sveltekit'
import {redirect} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {serverClient} from '$lib/sanity.server'

export const handle = sequence(
  handlePreviewMode({
    client: serverClient,
    preview: {redirect}
  })
)
```

The `handle` function implemented above adds a `sanity` property to the `locals` object, exposing the status of preview mode on the server. The server layout file lets you expose this value for use in a Svelte layout file.

> [!NOTE]
> TypeScript
> If using TypeScript, you should [augment your application’s ambient types](https://www.sanity.io/docs/visual-editing/visual-editing-with-sveltekit) to provide correct typings for the `locals.sanity` object.

**src/routes/+layout.server.ts**

```typescript
import type {LayoutServerLoad} from './$types'

export const load: LayoutServerLoad = ({locals: {sanity}}) => {
  const {previewEnabled} = sanity
  return {previewEnabled}
}
```

Render the `PreviewMode` wrapper component in the Svelte layout file to ensure the correct preview context is available in child components.

**src/routes/+layout.svelte**

```typescript
<script lang="ts">
  import {PreviewMode} from '@sanity/sveltekit'
  import type {LayoutProps} from './$types'

  const {children, data}: LayoutProps = $props()
  const {previewEnabled} = $derived(data)
</script>

<PreviewMode enabled={previewEnabled}>
  {@render children()}
</PreviewMode>
```

### Rendering pages

First, define the [GROQ](https://www.sanity.io/docs/content-lake/groq-introduction) queries you will use to fetch data from Content Lake. In the following example we are fetching the `title` of the first document of type `page` returned.

**src/lib/queries.ts**

```typescript
import {defineQuery} from '@sanity/sveltekit'

export const pageQuery = defineQuery(`*[_type == "page"][0]{title}`)
```

Next, define a [load function](https://kit.svelte.dev/docs/load) that uses your query to fetch and return data.

When fetching content using the Sanity client in an application that implements visual editing using [stega](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega), make sure to set `stega` to `false` when preview mode is disabled.

**src/routes/+page.server.ts**

```typescript
import {pageQuery} from '$lib/queries'
import type {PageServerLoad} from './$types'

export const load: PageServerLoad = async ({locals: {sanity}}) => {
  const {client, previewEnabled} = sanity
  const options = {stega: previewEnabled}
  const page = await client.fetch(pageQuery, {}, options)

  return {page}
}
```

The load function’s return value will be available in the corresponding `.svelte` file via the `data` prop. Use a `$derived` rune to ensure the page remains reactive.

**src/routes/+page.svelte**

```typescript
<script lang="ts">
  import type {PageProps} from './$types'

  const {data}: PageProps = $props()
</script>

<h1>{data.page.title}</h1>
```

You should now see the page render with the correct page title, confirming that your query and data binding are working as expected.

### Enable Visual Editing

The `<VisualEditing>` component handles rendering overlays, enabling click to edit, and refreshing pages in your application when content changes.

Providing the component with the current preview mode status ensures these features are only enabled for content editors, while your application remains unchanged for regular users.

**src/routes/+layout.svelte**

```typescript
<script lang="ts">
  import {
    PreviewMode,
    VisualEditing
  } from '@sanity/sveltekit'
  import type {LayoutProps} from './$types'

  const {children, data}: LayoutProps = $props()
  const {previewEnabled} = $derived(data)
</script>

<PreviewMode enabled={previewEnabled}>
  <VisualEditing enabled={previewEnabled}>
    {@render children()}
  </VisualEditing>
</PreviewMode>
```

#### `<VisualEditing>` props

The following additional props are available on the `<VisualEditing>` component as of `@sanity/sveltekit` 2.1.0 (`@sanity/visual-editing` 5.5.0).

#### `keepStegaOnCopy`

Type: `boolean` | Optional | Default: `false`

By default, `<VisualEditing>` intercepts copy events and removes stega encoding from both `text/plain` and `text/html` clipboard payloads, so users copying text from the preview page do not get invisible characters in their clipboard. Pass `keepStegaOnCopy` to opt out of this behavior and preserve stega encoding in clipboard content.

#### `onSuspiciousStega`

Type: callback | Optional | Opt-in

Reports stega found in unsafe DOM placements: element attributes (`class`, `id`, `href`, `src`, `style`, `data-*`, and others), inside `<head>` (`title`, `meta[content]`, JSON-LD), in `<script>` or `<style>` text content, in `textarea` form values, or in the page URL. Each report includes the `kind`, `element`, `attribute` (if applicable), `value`, and `cleaned`, along with an optional `sanity` field containing decoded edit info.

**src/routes/+layout.svelte**

```typescript
<script lang="ts">
  import {
    PreviewMode,
    VisualEditing
  } from '@sanity/sveltekit'
  import type {LayoutProps} from './$types'

  const {children, data}: LayoutProps = $props()
  const {previewEnabled} = $derived(data)
</script>

<PreviewMode enabled={previewEnabled}>
  <VisualEditing
    enabled={previewEnabled}
    keepStegaOnCopy
    onSuspiciousStega={(reports) => {
      for (const report of reports) {
        console.warn(`Stega found in ${report.kind}`, report)
      }
    }}
  >
    {@render children()}
  </VisualEditing>
</PreviewMode>
```

> [!NOTE]
> Opt-in scanning
> The `onSuspiciousStega` callback is opt-in: no scanning runs unless the callback is provided. When enabled, the DOM audit is deferred to browser idle time, and only changed nodes are re-checked.

That’s it for setup in your Svelte application for now. Next, enable Visual Editing in your Studio project.

## Studio setup

To set up [Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool) in your Studio, import the tool from `sanity/presentation`, add it to your `plugins` array, and configure `previewUrl`, passing the `initial` URL of your application and an endpoint to `enable` preview mode.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {presentationTool} from 'sanity/presentation'

export default defineConfig({
  // ... project configuration
  plugins: [
    presentationTool({
      previewUrl: {
        initial: process.env.SANITY_STUDIO_PREVIEW_URL,
        previewMode: {
          enable: '/preview/enable'
        }
      }
    })
    // ... other plugins
  ]
})
```

**.env**

```sh
SANITY_STUDIO_PREVIEW_URL="https://YOUR_APP.com"
```

At this point, you should have Visual Editing set up in your SvelteKit app and connected to your Sanity Studio. In the Presentation Tool, you can view your application in an embedded preview and click content to edit in context. The next steps introduce advanced features like faster content updates and perspective switching.

## Using Loaders (optional)

Loaders enhance the Visual Editing experience by providing faster content updates and perspective switching.

The **Query Loader** offers instant updates when previewing content in the Presentation Tool, while the **Live Loader** connects to Sanity’s [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) to deliver continuous updates to both editors using the Presentation Tool and end users.

> [!WARNING]
> Loaders
> Loaders should be used independently. Use one loader per application, or per layout for advanced use cases.

### Query Loader

The Query Loader provides instant content updates and perspective switching when using the Presentation Tool.

#### 1. Update server hooks

Update your server hooks file to call `setServerClient` and sequence the `handleQueryLoader` [handle function](https://kit.svelte.dev/docs/hooks#server-hooks-handle). This sets up the `loadQuery` helper function which will be used for fetching content on the server.

**src/hooks.server.ts**

```typescript
import {
  handlePreviewMode,
  handleQueryLoader,
  setServerClient
} from '@sanity/sveltekit'
import {redirect} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {serverClient} from '$lib/sanity.server'

setServerClient(serverClient)

export const handle = sequence(
  handlePreviewMode({
    client: serverClient,
    preview: {redirect}
  }),
  handleQueryLoader()
)

```

#### 2. Update layout

In the layout component, render the `QueryLoader` wrapper component to enable instant updates. Pass a Sanity client instance and enable the loader when preview mode is active using props.

**src/routes/+layout.svelte**

```typescript
<script lang="ts">
  import {
    PreviewMode,
    QueryLoader,
    VisualEditing
  } from '@sanity/sveltekit'
  import {client} from '$lib/sanity'
  import type {LayoutProps} from './$types'

  const {children, data}: LayoutProps = $props()
  const {previewEnabled} = $derived(data)
</script>

<PreviewMode enabled={previewEnabled}>
  <VisualEditing enabled={previewEnabled}>
    <QueryLoader enabled={previewEnabled} {client}>
      {@render children()}
    </QueryLoader>
  </VisualEditing>
</PreviewMode>
```

#### 3. Use `loadQuery` and `useQuery`

In your page’s load function, you can now use the `loadQuery` function exposed by `locals.sanity` to ensure data is fetched from Content Lake with the correct perspective: draft content will be fetched if preview mode is enabled, otherwise published content is returned. Provide a result type to `loadQuery`, such as one generated by Sanity TypeGen, so the returned data is typed.

**src/routes/+page.server.ts**

```typescript
import {pageQuery} from '$lib/queries'
import type {PageServerLoad} from './$types'

export const load: PageServerLoad = async ({locals: {sanity}}) => {
  const {loadQuery} = sanity
  const initial = await loadQuery<{title: string | null}>(pageQuery)

  return {query: pageQuery, options: {initial}}
}
```

Structuring the load function’s return value in this way conveniently means you can pass the `data` value directly to the `useQuery` function. `useQuery` returns a [readable store](https://svelte.dev/docs/svelte/stores#svelte-store-readable). Prefix any references to the store with `$` to access its value when deriving state. The store’s `data` value can be `undefined` while loading, so guard access to its properties.

When your application is viewed in the Presentation Tool, `useQuery` provides instant content updates and seamless switching between draft and published content.

**src/routes/+page.svelte**

```typescript
<script lang="ts">
  import {useQuery} from '@sanity/sveltekit'
  import type {PageProps} from './$types'

  const {data}: PageProps = $props()
  const query = useQuery(data)
  const page = $derived($query.data)
</script>

<h1>{page?.title}</h1>
```

### Live Loader

The Live Loader provides content updates using the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) both in the Presentation Tool (draft and published content) and to end users of your application (published content only).

#### 1. Update server hooks

Update your server hooks file to sequence the `handleLiveLoader` [handle function](https://kit.svelte.dev/docs/hooks#server-hooks-handle). This sets up the `sanityFetch` helper function which will be used for fetching content on the server.

The `serverToken` is used to fetch draft content on the server and so must have permissions to query draft documents. The `browserToken` allows live previewing draft content outside of the Presentation tool.

The same token can be used as both `browserToken` and `serverToken`, as the `browserToken` is only shared with the browser when preview mode is enabled.

**src/hooks.server.ts**

```typescript
import {
  handlePreviewMode,
  handleLiveLoader
} from '@sanity/sveltekit'
import {redirect} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {serverClient} from '$lib/sanity.server'
import {SANITY_VIEWER_TOKEN} from '$env/static/private'

export const handle = sequence(
  handlePreviewMode({
    client: serverClient,
    preview: {redirect}
  }),
  handleLiveLoader({
    client: serverClient,
    browserToken: SANITY_VIEWER_TOKEN,
    serverToken: SANITY_VIEWER_TOKEN
  })
)
```

#### 2. Update layout

Update the server layout file to expose the `browserToken` and `previewPerspective` properties added by `handleLiveLoader`.

**src/routes/+layout.server.ts**

```typescript
import type {LayoutServerLoad} from './$types'

export const load: LayoutServerLoad = ({locals: {sanity}}) => {
  const {browserToken, previewEnabled, previewPerspective} = sanity
  return {browserToken, previewEnabled, previewPerspective}
}
```

In the layout component, render the `LiveLoader` wrapper component to enable live updates. Unlike other components exported by `@sanity/sveltekit`, `LiveLoader` doesn’t accept an `enabled` prop, as it provides live updates to both content editors and end users.

**src/routes/+layout.svelte**

```typescript
<script lang="ts">
  import {
    LiveLoader,
    PreviewMode,
    VisualEditing
  } from '@sanity/sveltekit'
  import {client} from '$lib/sanity'
  import type {LayoutProps} from './$types'

  const {children, data}: LayoutProps = $props()
  const {browserToken, previewEnabled, previewPerspective} = $derived(data)
</script>

<PreviewMode enabled={previewEnabled}>
  <VisualEditing enabled={previewEnabled}>
    <LiveLoader {client} {previewEnabled} {previewPerspective} {browserToken}>
      {@render children()}
    </LiveLoader>
  </VisualEditing>
</PreviewMode>

```

#### 3. Use `sanityFetch`

Import and use `sanityFetch` to fetch data using the Live Content API. The event object provided by a load function should be passed as the first argument.

**src/routes/+page.server.ts**

```typescript
import {sanityFetch} from '@sanity/sveltekit'
import {pageQuery} from '$lib/queries'
import type {PageServerLoad} from './$types'

export const load: PageServerLoad = async (event) => {
  return sanityFetch(event, {query: pageQuery})
}
```

The corresponding Svelte page will receive the result of the query. Use a `$derived` rune to ensure the page remains reactive.

**src/routes/+page.svelte**

```typescript
<script lang="ts">
  import type {PageProps} from './$types'

  const {data}: PageProps = $props()
  const page = $derived(data.data)
</script>

<h1>{page.title}</h1>
```

## Advanced features (optional)

### Adding data attributes

Along with the `createDataAttribute` function exported by `@sanity/sveltekit`, when using the Query Loader, `useQuery` also returns an `encodeDataAttribute` helper method for generating `data-sanity` attributes. These attributes give you direct control over rendering [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) in your application, and are especially useful if not using stega encoding.

**src/routes/+page.svelte**

```typescript
<script lang="ts">
  import {useQuery} from '@sanity/sveltekit'
  import type {PageProps} from './$types'

  const {data}: PageProps = $props()
  const query = useQuery(data)
  const {data: page, encodeDataAttribute} = $derived($query)
</script>

<h1 data-sanity={encodeDataAttribute(['title'])}>
  {page?.title}
</h1>
```

### Context functions and conditional rendering

Your application might need to conditionally render elements in preview mode, for example to notify content editors that they are viewing draft content, or to provide a mechanism for disabling preview mode.

`@sanity/sveltekit` exports several helper functions which return useful context for this purpose:

**getLoader()**: Returns a reactive object whose `value` property is the loader currently in use: `'live'`, `'query'`, or `undefined`.

**getIsPreviewing()**: Returns `true` if preview mode is enabled, otherwise returns `false`. Available in descendants of the `PreviewMode` component.

The example below shows how to use this function to implement a component for disabling preview mode, and could be added to your `+layout.svelte` file.

**DisablePreviewModeLink.svelte**

```typescript
<script lang="ts">
  import {getIsPreviewing} from '@sanity/sveltekit'
  import {page} from '$app/state'
  import {resolve} from '$app/paths'

  const isPreviewing = getIsPreviewing()
</script>

{#if isPreviewing}
  <a
    href={resolve('/preview/disable', {
      redirect: page.url.pathname
    })}
  >
    Disable preview mode
  </a>
{/if}
```

**getPerspective()**: Returns a reactive object whose `value` property is the current perspective. Available in descendants of the `LiveLoader` component.

**getEnvironment()**: Returns a reactive object whose `value` property is the currently detected preview environment. Available in descendants of the `LiveLoader` component.

### TypeScript: `event.locals`

The handler functions referenced in this guide add a `sanity` property to SvelteKit’s `event.locals` object. If your application is written in TypeScript, extend the `App.Locals` interface with the `SanityLocals` type to ensure type safety.

**app.d.ts**

```typescript
import type {SanityLocals} from '@sanity/sveltekit'

declare global {
  namespace App {
    interface Locals extends SanityLocals {}
  }
}

export {}

```

Additionally, if you are linking to any paths that `@sanity/sveltekit` adds to your application (for example, to create a link to disable preview mode), you may also want to overload SvelteKit’s `resolve` function.

**app.d.ts**

```typescript
import type {ResolvedPathname} from '$app/types'

declare module '$app/paths' {
  export function resolve(
    path: '/preview/disable',
    options?: {
      redirect?: string
    }
  ): ResolvedPathname
}

export {}
```



# React Router/Remix

Visual Editing connects your React Router front end to Sanity Studio, letting content editors preview draft content in real time and jump from the rendered page straight to the fields that produced it.

Following this guide will enable you to:

- Edit your content in drafts or releases and see changes reflected in an embedded preview of your React Router application in Sanity Studio's Presentation tool.
- **Optional**: Render overlays in your application, allowing content editors to jump directly from Sanity content to its source in Sanity Studio.

## Prerequisites

- A Sanity project with a hosted or embedded Studio. Read more about [hosting your Studio](https://www.sanity.io/docs/studio/deployment).
- A React Router application. This guide uses code from the [Displaying content section](https://www.sanity.io/docs/react-router-quickstart/displaying-content-in-a-react-router-front-end) of the quick start as a starting point. This guide uses React Router 7.9+.

## React Router application setup

The following steps should be performed in your React Router application.

### Install dependencies

Install the dependencies that will provide your application with data fetching and Visual Editing capabilities. You may already have some of them if you followed the quick start.

**npm**

```shell
npm install @sanity/client @sanity/visual-editing @sanity/preview-url-secret @sanity/react-loader @sanity/image-url @portabletext/react
```

**pnpm**

```shell
pnpm add @sanity/client @sanity/visual-editing @sanity/preview-url-secret @sanity/react-loader @sanity/image-url @portabletext/react
```

**yarn**

```shell
yarn add @sanity/client @sanity/visual-editing @sanity/preview-url-secret @sanity/react-loader @sanity/image-url @portabletext/react
```

**bun**

```shell
bun add @sanity/client @sanity/visual-editing @sanity/preview-url-secret @sanity/react-loader @sanity/image-url @portabletext/react
```

### Add environment variables

Create a `.env` file in your application’s root directory to provide Sanity-specific configuration.

You can use [Manage](https://www.sanity.io/manage) to find your project ID and dataset, and to create a token with Viewer permissions which will be used to fetch preview content.

The URL of your Sanity Studio will depend on where it is [hosted](https://www.sanity.io/docs/studio/deployment) or [embedded](https://www.sanity.io/docs/studio/embedding-sanity-studio). When working with a locally running Studio, you'll want to set the public URL to `http://localhost:3333`.

```bash
# .env

# Public
PUBLIC_SANITY_PROJECT_ID="YOUR_PROJECT_ID"
PUBLIC_SANITY_DATASET="YOUR_DATASET"
PUBLIC_SANITY_STUDIO_URL="YOUR_STUDIO_URL"
# Private
SANITY_API_READ_TOKEN="YOUR_VIEWER_TOKEN"

```

## Application setup

### Configure the Sanity client

Create a Sanity client instance (or edit your existing one) to handle fetching data from Content Lake.

Configuring the `stega` option enables automatic overlays for basic data types when preview mode is enabled. You can read more about [how stega works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

**app/sanity/client.ts**

```typescript
import { createClient } from "@sanity/client";

declare global {
  interface Window {
    ENV: {
      PUBLIC_SANITY_PROJECT_ID: string;
      PUBLIC_SANITY_DATASET: string;
      PUBLIC_SANITY_STUDIO_URL: string;
    };
  }
}

const env = typeof document === "undefined" ? process.env : window.ENV;

export const client = createClient({
  projectId: env.PUBLIC_SANITY_PROJECT_ID,
  dataset: env.PUBLIC_SANITY_DATASET,
  apiVersion: "2026-07-01",
  useCdn: false,
  stega: {
    studioUrl: env.PUBLIC_SANITY_STUDIO_URL,
  },
});

```

### Add preview mode logic

Preview mode allows authorized content editors to view and interact with draft content.

Create a preview helper file, named `session.ts` in the `app/sanity` directory, to manage preview sessions and return context about the current preview state. This helper exposes getters and setters to store preview and perspective context as a cookie.

**app/sanity/session.ts**

```typescript
// app/sanity/session.ts

import { createCookieSessionStorage } from "react-router";
import type { loadQuery } from "@sanity/react-loader"
import crypto from "node:crypto";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      httpOnly: true,
      name: "__sanity_preview",
      path: "/",
      sameSite: !import.meta.env.DEV ? "none" : "lax",
      secrets: [crypto.randomBytes(16).toString("hex")],
      secure: !import.meta.env.DEV,
    },
  });

async function getPreviewData(request: Request): Promise<{
  preview: boolean;
  options: Parameters<typeof loadQuery>[2]
}> {
  const session = await getSession(request.headers.get("Cookie"));
  const preview = session.get("previewMode") || false
  return {
    preview,
    options: preview ? {
      perspective: session.has("perspective") ? session.get("perspective").split(',') : "drafts",
      stega: true,
    } : {
      perspective: 'published',
      stega: false,
    }
  };
}

export { commitSession, destroySession, getSession, getPreviewData };

```

> [!WARNING]
> Vite impementations
> **If you’re using Vite or any browser-based build tool**, avoid importing Node.js-only modules like node:crypto directly in shared files (such as session.ts).
> You will need to use another hex generator that is compatible or utilize env variables for the `secrets` key.

Create an API endpoint to enable preview mode when viewing your application in Presentation tool. This performs some checks and commits some data into a cookie which enables preview mode for other parts of your app.

**app/routes/api.preview-mode.enable.tsx**

```tsx
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import type { ClientPerspective } from "@sanity/client";
import { client } from "~/sanity/client";
import { getSession, commitSession } from "~/sanity/session";
import type { Route } from "./+types/api.preview-mode.enable";

export async function loader({ request }: Route.LoaderArgs) {
  const token = process.env.SANITY_API_READ_TOKEN;
  
  if (!token) {
    throw new Response(
      "SANITY_API_READ_TOKEN environment variable is not set. Create a .env file with your Sanity read token.",
      { status: 500 }
    );
  }

  // The preview-url-secret library lets you confirm
  // that the preview command is coming from Studio.
  const clientWithToken = client.withConfig({ token });
  const { isValid, redirectTo = "/" } = await validatePreviewUrl(
    clientWithToken,
    request.url
  );

  if (!isValid) {
    return new Response("Invalid preview URL", { status: 401 });
  }

  // Get or create session
  const session = await getSession(request.headers.get("Cookie"));
  
  // Enable preview mode
  session.set("previewMode", true);
  
  // Get perspective from URL query params
  const url = new URL(request.url);
  const perspectiveParam = url.searchParams.get("sanity-preview-perspective");
  const perspective: ClientPerspective = (perspectiveParam as ClientPerspective)
  session.set("perspective", perspective);

  return new Response(null, {
    status: 307,
    headers: {
      Location: redirectTo,
      "Set-Cookie": await commitSession(session),
    },
  });
}
```

Similarly, create an API endpoint to disable draft mode. You may want to adjust how you handle redirects to better match your UI.

**app/routes/api.preview-mode.disable.tsx**

```tsx
import { getSession, destroySession } from "~/sanity/session";
import type { Route } from "./+types/api.preview-mode.disable";

export async function loader({ request }: Route.LoaderArgs) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect") || "/";

  // Get the session and destroy it
  const session = await getSession(request.headers.get("Cookie"));

  return new Response(null, {
    status: 307,
    headers: {
      Location: redirectTo,
      "Set-Cookie": await destroySession(session),
    },
  });
}
```

Next, add these routes as new entries in your application’s `routes` file. Don't forget your other routes.

**app/routes.ts**

```typescript
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  // Other routes
  route("api/preview-mode/enable", "routes/api.preview-mode.enable.tsx"),
  route("api/preview-mode/disable", "routes/api.preview-mode.disable.tsx"),
] satisfies RouteConfig;


```

Next, create a new component with a link to the disable endpoint. We add conditional logic to only render this for content authors when viewing draft content in a non-Presentation context. If they're inside Studio, they can use Presentation's built-in "Edit" toggle.

**app/components/DisablePreviewMode.tsx**

```tsx
import { useEffect, useState } from "react";

export function DisablePreviewMode() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    setShow(window === window.parent && !window.opener);
  }, []);

  return show && <a href="/api/preview-mode/disable">Disable Preview Mode</a>;
}


```

Finally, update the `root` to use our session logic and render the `DisablePreviewMode` component when preview mode is enabled.

Edit the `root.tsx` file to include the following:

**app/root.tsx**

```tsx
import {
  Outlet,
  Scripts,
  ScrollRestoration,
  useRouteLoaderData,
} from "react-router";

import type { Route } from "./+types/root";
import { getPreviewData } from "./sanity/session";
import { DisablePreviewMode } from "./components/DisablePreviewMode";

export async function loader({ request }: Route.LoaderArgs) {
  const { preview } = await getPreviewData(request);
  return { 
    preview,
    ENV: {
      PUBLIC_SANITY_PROJECT_ID: process.env.PUBLIC_SANITY_PROJECT_ID,
      PUBLIC_SANITY_DATASET: process.env.PUBLIC_SANITY_DATASET,
      PUBLIC_SANITY_STUDIO_URL: process.env.PUBLIC_SANITY_STUDIO_URL,
    }
  };
}

export function Layout({ children }: { children: React.ReactNode }) {
  const data = useRouteLoaderData<typeof loader>("root");
  
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        {data?.ENV && (
          <script
            dangerouslySetInnerHTML={{
              __html: `window.ENV = ${JSON.stringify(data.ENV)}`,
            }}
          />
        )}
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  const data = useRouteLoaderData<typeof loader>("root");
  const preview = data?.preview || false;

  return (
    <>
      <Outlet />
      {preview && (
        <>
          <DisablePreviewMode />
        </>
      )}
    </>
  );
}
```

### Set up loaders

Loaders vastly improve the server/client handoff experience compared to using the Sanity client on its own. First create a loader, then update your data fetching to use it.

Create a new `loader.server.ts` file alongside your other Sanity files.

**app/sanity/loader.server.ts**

```typescript
import {loadQuery, setServerClient} from '@sanity/react-loader'
import {client} from './client'

const serverClient = client.withConfig({ token: process.env.SANITY_API_READ_TOKEN })
setServerClient(serverClient)

export {loadQuery}
```

Next, update your routes to use the loader and the `getPreviewData` helper we created earlier.

We'll start with a home route and render a list of posts.

**app/routes/home.tsx**

```tsx
import type { SanityDocument } from "@sanity/client";
import { Link } from "react-router";
import type { Route } from "./+types/home";
import { getPreviewData } from "~/sanity/session";
import { loadQuery } from "~/sanity/loader.server";
import { useQuery } from "@sanity/react-loader";

const POSTS_QUERY = `*[_type == "post" && defined(slug.current)]|order(publishedAt desc)[0...12] {
  _id,
  title,
  slug,
  publishedAt,
}`

export async function loader({ request }: Route.LoaderArgs) {
  // Retrieve options based on the preview cookie
  const { options } = await getPreviewData(request);
  // Pass the preview options, including perspectives, to the query
  const data = await loadQuery<SanityDocument[]>(POSTS_QUERY, {}, options);
  return {
    initial: data
  }
}

export default function IndexPage({ loaderData }: Route.ComponentProps) {
  const { initial } = loaderData;
  // Pass the initial data from the loader in to a new query.
  // Note that we're now using `useQuery`.
  const { data: posts } = useQuery<SanityDocument[]>(POSTS_QUERY, {}, {initial});
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts?.map((post) => (
          <li key={post._id}>
            <Link to={`/posts/${post.slug.current}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  )
}
```

Repeat this for your other routes. We'll add one more in this example to render the individual posts. This example also includes some image rendering from the quick start, which you can remove if you aren't using it.

**app/routes/post.tsx**

```tsx
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";
import type { SanityDocument } from "@sanity/client";
import {PortableText} from "@portabletext/react";
import type { Route } from "./+types/post";
import { loadQuery } from "~/sanity/loader.server";
import { getPreviewData } from "~/sanity/session";
import { useQuery } from "@sanity/react-loader";
import { client } from "~/sanity/client";

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`

export async function loader({ params, request }: Route.LoaderArgs){
  const { options } = await getPreviewData(request);
  const data = await loadQuery<SanityDocument>(POST_QUERY, params, options);
  const { projectId, dataset } = client.config();

  return {
    params,
    projectId,
    dataset,
    initial: data
  }
}

export default function Component({ loaderData }: Route.ComponentProps) {
  const { projectId, dataset, initial, params } = loaderData;
  if (!params || !params.slug) {
    throw new Error("No slug, 404");
  }
  const { data: post } = useQuery<SanityDocument>(POST_QUERY, {}, {initial});

  const urlFor = (source: SanityImageSource) => {
    if (!projectId || !dataset) return null;
    const builder = createImageUrlBuilder({ projectId, dataset });
    return builder.image(source);
  };
  
  const postImageUrl = post?.mainImage
    ? urlFor(post.mainImage)?.width(550).height(310).url() : null;

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.publishedAt}</p>
      {postImageUrl && (
        <img src={postImageUrl} alt={post.title} />
      )}
      <PortableText value={post.body} />
    </div>
  )
}
```

If you're following along, your routes file should have at least these routes.

**app/routes.ts**

```typescript
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("api/preview-mode/enable", "routes/api.preview-mode.enable.tsx"),
  route("api/preview-mode/disable", "routes/api.preview-mode.disable.tsx"),
  route("posts/:slug", "routes/post.tsx"),
] satisfies RouteConfig;

```

At this point you can move to configuring your studio.

## Studio setup

To set up Presentation tool in your Sanity Studio, import the tool from `sanity/presentation`, add it to your `plugins` array, and set `previewUrl.initial` to the base URL of your application. You should also configure the location resolvers to improve the experience.

We similarly recommend using environment variables loaded via a `.env` file to support development and production environments.

**sanity.config.ts**

```typescript
import { defineConfig } from "sanity";
import { presentationTool, defineLocations } from "sanity/presentation";

export default defineConfig({
  // ... project configuration
  plugins: [
    presentationTool({
      previewUrl: {
        // Define a preview origin in your studio's .env
        // This is not the same as the react-router env
        initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:5173',
        previewMode: {
          enable: '/api/preview-mode/enable',
        }
      },
      resolve: {
        locations: {
          // These will differ depending on your schema
          // and rendering logic.
          post: defineLocations({
            select: {title: 'title', slug: 'slug.current'},
            resolve: (doc) => ({
              locations: [
                { title: doc?.title, href: `posts/${doc?.slug ?? ''}`},
                { title: 'Home', href: '/'},
              ]
            })
          })
        }
      }
    }),
    // ... other plugins
  ],
});

```

Now when you visit a document in Studio, a banner at the top shows where the document appears (for example, "Used on one page"). Use it to open the document in Presentation.

With both your app and Studio set up, you should now be able to test Presentation. Run both the app and studio, then visit Presentation in Studio to test the functionality. Learn more about [configuring the Presentation tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool).

## Additional features

### Optional: Visual Editing overlays

You can include a React Router-specific visual editing component that enables hover overlays and outlines that make the content editor experience more enjoyable. It adds overlays, click to edit buttons, and communicates with Presentation.

First, install the visual editing package.

**npm**

```shell
npm install @sanity/visual-editing
```

**pnpm**

```shell
pnpm add @sanity/visual-editing
```

**yarn**

```shell
yarn add @sanity/visual-editing
```

**bun**

```shell
bun add @sanity/visual-editing
```

Next, in your React Router app, create a new `SanityVisualEditing` component that will include the `DisablePreviewMode` component.

**app/components/SanityVisualEditing.tsx**

```tsx
import { VisualEditing } from "@sanity/visual-editing/react-router";
import { DisablePreviewMode } from "./DisablePreviewMode";

export function SanityVisualEditing() {
  return (
    <>
      <VisualEditing />
      <DisablePreviewMode />
    </>
  );
}

```

### <VisualEditing /> props

The VisualEditing component accepts the following optional props, introduced in @sanity/visual-editing 5.5.0.

#### `keepStegaOnCopy`

**Type:** `boolean` — optional, default: `false`

By default, `<VisualEditing />` intercepts copy events and removes stega encoding from both `text/plain` and `text/html` clipboard payloads, so users copying text from the preview page don't get invisible characters in their clipboard. Pass `keepStegaOnCopy` to opt out of this behavior and preserve stega encoding in clipboard data.

#### `onSuspiciousStega`

**Type:** callback — optional, opt-in

Reports stega found in unsafe DOM placements: element attributes (`class`, `id`, `href`, `src`, `style`, `data-*`, etc.), inside `<head>` (`title`, `meta[content]`, JSON-LD), in `<script>` or `<style>` text content, in `textarea` form values, or in the page URL. Each report includes the `kind`, `value`, and `cleaned` fields, plus `element` and `attribute` when applicable, and an optional `sanity` field with the decoded node information for tracing the source.

**app/components/SanityVisualEditing.tsx**

```tsx
import { VisualEditing } from "@sanity/visual-editing/react-router";
import { DisablePreviewMode } from "./DisablePreviewMode";

export function SanityVisualEditing() {
  return (
    <>
      <VisualEditing
        onSuspiciousStega={(reports) => {
          for (const report of reports) {
            console.warn(`Stega found in ${report.kind}`, report)
          }
        }}
      />
      <DisablePreviewMode />
    </>
  );
}

```

> [!WARNING]
> Performance warning
> The `onSuspiciousStega` callback runs a full DOM audit using TreeWalker and MutationObserver, which has a performance cost. Scanning only runs when you provide the callback. We recommend using it for development and debugging rather than enabling it in production.

Then, update the imports and `App` function in `root.tsx` to use this new component instead.

**app/root.tsx**

```tsx
import { SanityVisualEditing } from "./components/SanityVisualEditing";
// ...
// ...

export default function App() {
  const data = useRouteLoaderData<typeof loader>("root");
  const preview = data?.preview || false;

  return (
    <>
      <Outlet />
      {preview && (
        <>
          <SanityVisualEditing />
        </>
      )}
    </>
  );
}

```

## Next steps

- Learn more about [configuring the Presentation tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool).
- Read about [how stega encoding works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega) under the hood.



# React Native

Following this guide will enable you to implement**:**

- **Live preview**: See draft content updates rendered in the embedded front end in real-time.
- **Click-to-edit**: Interactive overlays for the embedded front end application that help content creators find and edit the right fields.
- **Page building**: Advanced capabilities for adding, moving, and removing content sections, directly from your embedded front end.
- **Preview sharing**: A way for content creators to share a preview of draft content with others.
- **Locations**: Shortcuts to open Presentation (the embedded front end view) for a document directly from where the content is defined the Structure tool.

Your deployed web build of the application will be loaded loaded into your deployed Sanity Studio via the Presentation plugin (and you can do the same for locally running versions of your app and your Studio, which streamlines development and debugging).

## Prerequisites

- A React Native application. Note that though the Expo framework is not required, this guide and our starter repo use it because it offers useful tools for local development, creating builds for web/native/simulators, and a convenient router package. If you're building a new React Native application and you want to use a different framework, there will be some small differences (noted in this guide where possible). However, if you want to match/follow the examples exactly, you can either: - Clone and start with our [React Native Starter repo](https://github.com/sanity-io/visual-editor-react-native)*, *which is built on Expo *(see the "React Native Starter" section)
- *OR
- Follow [this Expo documentation](https://docs.expo.dev/tutorial/create-your-first-app/) to set up a new React Native/Expo project. 


- A Sanity project with a [Sanity-hosted or self-hosted Sanity studio](https://www.sanity.io/docs/studio). - *(If you use the "React Native Starter" repo, see the "Dependencies" subsection of the "React Native Starter" section for a Studio setup that matches that starter).*



Note that because Presentation enables Visual Editing by loading your React Native application as a web build into the browser-based Sanity Studio, much of the code for enabling Visual Editing only runs when you're in the browser and in Presentation mode. 

The examples below (and the "React Native Starter" repo) take this into account using an `isWeb` util and a Sanity-provided util called `isMaybePresentation.`

**Content Security Policy**

You are not required to use EAS Hosting. However, certain services will prevent deployed web apps that they host from being loaded in an iframe on a different hostname. 

The Presentation tool opens your deployed web app in an iframe (and since that happens in your Sanity Studio, the hostname is different from the host of your web app). If this is prohibited by default by the hosting service, you may be required to customize the Content Security Policy header used by the web app.

> [!WARNING]
> Hosting Services and the Content Security Policy Header
> **Before choosing a hosting service, verify that either:**
> The hosting provider does not prevent its hosted apps from being opened in an iframe on a different host, or the provider allows customization of the CSP header.

A valid example Content Security Policy header is: 
`"frame-ancestors 'self' http://localhost:8081 https://www.sanity.io <INSERT WEB BUILD DEPLOYED URL HERE> <INSERT DEPLOYED SANITY STUDIO URL HERE>"`

In this example, the URLs (in order) are for: 

- The localhost/port combination where the web build server for the React Native app runs in local development.
- The deployed web build of your React Native app.
- The deployed instance of your Sanity Studio.
- The Sanity Dashboard (the centralized "content operating system" web application where deployed Studios and Sanity SDK applications are "installed" in a single organization-level view. [Learn more about the Dashboard](https://www.sanity.io/docs/dashboard)).

## React Native Starter Repo

If you prefer to start from a working example, remove the demo pages ("movies" and "people"), and add your own code, we have created a starting point repo for a React Native application (built on Expo) which is ready to be loaded into the Presentation tool out of the box. By default its native builds are created via the Expo build servers and its web builds are built on and hosted on Expo Application Services (but you are free to refactor this to use any hosting service which either does not prevent cross-origin loading of iframes or which allows you to set a custom "Content Security Policy" header, see above). 

This starter application includes:

- - The required code snippets for Visual Editing
- Example pages ("Movies", "People", etc) with layouts and routing already set up.
- Utility components for building your own views.



The repo is fully open source - it is [available on Github](https://github.com/sanity-io/visual-editor-react-native) and has a comprehensive Readme for development and deployment.

The "Implementation in a New or Existing React Native App" section explains how the Presentation mode and its features are implemented (both in the starter repo and the examples in this guide),** so reading the entire guide is helpful even if you use the starter repo!**

### Dependencies

**Sanity Project/Studio**

As mentioned in the repo's Readme, the starter repo works together with your own Sanity project/Sanity Studio.

**If you want to see the Movies/People pages/components load actual data in the React Native app,** **your Studio will need to be created with the content types and test data from the "movies" Sanity Studio starter template**. 

We recommend this approach because it lets you play with a functioning version of the presentation features in your dev environment to better understand how those features work and how they correspond to the code snippets that enable them (`useQuery`, `useLiveMode`, `dataSet`, etc -- all discussed in this documentation, see below).

To create a Sanity project/studio which includes the "movies" starter's content types/data (you can modify/remove them later):

1. Run `sanity init` in some repo/folder (easiest/cleanest option is a separate repo, since Sanity Studio is built on vanilla React, not React Native).
2. When that init script asks you to choose a project template, choose "Movie project (schema + sample data)"
3. When the init script asks "Add a sampling of sci-fi movies to your dataset on the hosted backend?", choose "yes".

Otherwise, if you feel comfortable with all aspects of implementing the Visual Editing features in your own components and want to rip out the movies/people code immediately, you may initialize your Sanity project/studio whatever way you prefer. 

See [the CLI Init command docs](https://www.sanity.io/docs/cli-reference/init) for more info on project initialization and templates.

**Presentation Plugin**

You must add configuration for the [Sanity Presentation Plugin](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool) to your Sanity Studio which matches the setup for your React Native application *(see "Sanity Studio Setup" section below)*

## Sanity Studio Setup

Before working on setting up Visual Editing in the React Native app, we need to set up the Presentation plugin in your Sanity Studio. 

First, include the library in your studio repo:

**npm**

```shell
npm install sanity@latest
// or install with npm or yarn
```

**pnpm**

```shell
pnpm add sanity@latest
// or install with npm or yarn
```

**yarn**

```shell
yarn add sanity@latest
// or install with npm or yarn
```

**bun**

```shell
bun add sanity@latest
// or install with npm or yarn
```

You will now have access to the `presentationTool` plugin from `sanity/presentation`. As shown below, import it, add it to your `plugins` array, and configure  `previewUrl` and `allowedOrigins` .

**sanity.config.js**

```javascript
import { presentationTool } from 'sanity/presentation'

export default defineConfig({
  ...rest of studio config,
  plugins: [
    ...other plugins,
    presentationTool({
      allowOrigins: [
        process.env.SANITY_STUDIO_REACT_NATIVE_APP_HOST,
      ],
      previewUrl: {
        initial: process.env.SANITY_STUDIO_REACT_NATIVE_APP_HOST
      }
    })
  ],
})
```

We recommend using environment variables loaded via a `.env` file to support development and production environments. In the code block above, `SANITY_STUDIO_REACT_NATIVE_APP_HOST` is the hostname of your front end React Native application that is going to be loaded into presentation mode (either running locally or deployed, depending on the env in question).

Note that if you are using the "React Native Starter Repo", you should add `resolve: locationResolver` to the presentationTool config (in the main config object) where `locationResolver` is: 

**locationResolver.js**

```javascript
export const locationResolver = {locations: {
  // Resolve locations using values from the matched document
  movie: defineLocations({
    select: {
      title: 'title',
      slug: 'slug.current',
    },
    resolve: (doc) => ({
      locations: [
        {
          title: 'Movies Directory',
          href: '/movies',
        },
        {
          title: `Movie Page: ${doc?.title}`,
          href: `/movie/${doc?.slug}`,
        },
      ],
    }),
  }),
  person: defineLocations({
    select: {
      name: 'name',
      slug: 'slug.current',
    },
    resolve: (doc) => ({
      locations: [
        {
          title: 'People Directory',
          href: '/people',
        },
        {
          title: `Person Page: ${doc?.name}`,
          href: `/person/${doc?.slug}`,
        },
      ],
    }),
  }),
}}
```

This adds the functionality where "location" links are added to the top of each document in the Studio Structure view. Each of these links for a given document opens the Presentation tool and automatically loads the page where that document is used, directly in that embedded front end. 

The locations are defined by the resolver function (e.g. each movie is used both in the Movies Directory at `/movies` and in its individual movie page at `/movie/:movie_slug)`. 

You can add additional location resolvers for your other content types (and/or remove the movies/people location resolvers if your are no longer using those content types). 

See the *"map content to front-end routes with locations resolver function"* section in the [Presentation Tool docs](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool) for examples and more info.



> [!WARNING]
> Locations Resolver in Deployed Projects
> The locations resolver can direct you to a non-root path in your embedded front end application and sometimes these routes are dynamic from the point of view of that application. For example, for the movie document Alien in the starter repo/above example, the locations link will be `/movie/alien` and the corresponding dynamic route component is `[movie_slug].tsx`. 
> Any routes used in a locations resolver must be accessible in your deployed web build via direct URL so that they can open in the Presentation tool. 
> You can test if a route is by opening a new browser tab and putting the route directly in the URL bar, (as opposed than going to the home page of your application and navigating via UX elements).
> Depending on your build framework/hosting platform some additional configuration, dynamic routes may not be findable out of the box. With the combination of tools used by the starter repo, Expo for creating the web build and Vercel for hosting it, there was no way for Vercel to know that `/movie/alien` should load the `[movie_slug]` code from the Expo build.
> There are two main ways you could solve this problem for a client-only application (server/client combinations might have other steps): 
> 1. Use your build framework to generate a single-page application (SPA) with and then configure your hosting framework to rewrite/redirect ALL routes to the single /`index.html` of that SPA.
> 2. Use your build framework to generate a static build and then configure your hosting framework to rewrite any dynamic routes to the index page for the page component that loads the dynamic route. This is what we do in the starter repo, so we end up with a folder structure in our Expo build that contains `/movie/[movie_slug].html` and we add a rewrite in our vercel.json like `{ "source": "/movie/:movie_slug", "destination": "/movie/[movie_slug].html"}`. (The same configuration is set for the dynamic `person/:person_slug` route/code.)

### Add CORS Origins

Because our React Native application (and our Sanity Studio) will make client-side requests to the Sanity Studio across domains, their URLs must be [added as valid CORS origins](https://www.sanity.io/docs/content-lake/cors).

This can be done inside [sanity.io/manage](https://sanity.io/manage). Use the following steps for your React Native front end application and then repeat for your Sanity Studio.

1. Navigate to the API tab, then add select **"Add CORS origin"**.1. For local development origins, enter `http://localhost:PORT` where `PORT` is the port number that is running the application in question.
2. For deployed origins, add the full hostname of the deployed React Native application or Sanity Studio.


2. Select **Save**.

> [!TIP]
> What about "Allow credentials"?
> If the calling origin needs to be able to send Sanity tokens, select ”Allow credentials.” In most cases, this is not necessary for front-end applications, but is necessary for Sanity Studios. However, if (for example) your front end application hits a back-end API to trade user login credentials for a Sanity token (to query a private dataset from the front end), you will need to Allow Credentials. Reverse proxy servers which perform the queries themselves on behalf of the front end do NOT need any CORS configuration as they do not run in the browser. 
> See the [CORS documentation](https://www.sanity.io/docs/content-lake/cors) for more info.

Only set up CORS origins for URLs where you control the code. Remember to perform the steps for each local development origin and each deployed origin for your front ends and your Sanity studio. One caveat is that deploying a Sanity-hosted studio will add the CORS config for that studio automatically. If you self-host the studio, you will need to add it yourself.

### A Note About Using This Guide

> [!WARNING]
> For the React Native Starter Repo -- Code that's done vs to-do
> The rest of this guide provides and explains all the code snippets required for enabling Visual Editing in your application. 
> The snippets that do the application-wide setup/enablement of Visual Editing are **already implemented ** in the React Native Starter Repo, so you will not need to write/modify those. 
> **BUT** in order to replace the "Movies" and "People" pages/components with your own application content, you will need to add the component-specific functionality that is used for data fetching to your own code (and possibly also the code for setting data attributes on your components, which enable certain optional features). 
> The code for data fetching and data attributes are discussed further in the "Query data from Sanity and render a page" section below.

## Implementation in a New or Existing React Native App:

### Install dependencies

Install the dependencies that will provide your application with data fetching and Visual Editing capabilities.

**npm**

```shell
npm install @sanity/client @sanity/react-loader @sanity/visual-editing @sanity/presentation-comlink
```

**pnpm**

```shell
pnpm add @sanity/client @sanity/react-loader @sanity/visual-editing @sanity/presentation-comlink
```

**yarn**

```shell
yarn add @sanity/client @sanity/react-loader @sanity/visual-editing @sanity/presentation-comlink
```

**bun**

```shell
bun add @sanity/client @sanity/react-loader @sanity/visual-editing @sanity/presentation-comlink
```

### Set environment variables

Create a `.env.local` in your application’s root directory to provide the configuration for connecting to your Sanity data. 

For platform-native builds, if you are using Expo as your build service, you will also need to create the variables in the Expo Environment Variables console for your project. For other build services, follow the appropriate environment variable specification process outlined in the documentation of the service in question.

For the web build, if you are using a hosting service where env variables are created in a browser UI (e.g. Expo hosting, Vercel, etc), create the variables in that UI. Other hosting services may just expect a .env file in your codebase or you might set the vars in a CI/CD pipeline, etc—this step is specific to your hosting implementation.

You can use [sanity.io/manage](https://sanity.io/manage) to find your project ID and dataset.

The URL of your Sanity Studio will depend on where it is [hosted](https://www.sanity.io/docs/studio/deployment) or [embedded](https://www.sanity.io/docs/studio/embedding-sanity-studio).

*(The "EXPO_PUBLIC" prefix can be abandoned if not using Expo and/or replaced with any required prefix for your build service).*

Define the following environment variables:

```bash
# .env.local or .env
EXPO_PUBLIC_SANITY_DATASET=Your dataset name
EXPO_PUBLIC_SANITY_PROJECT_ID=Your Sanity project ID
EXPO_PUBLIC_SANITY_STUDIO_URL=The URL of your Sanity Studio 
(running locally OR deployed, depending on env)

```

and import them into the runtime: 

**constants.ts**

```
export const SANITY_PROJECT_ID: string = 
  process.env.EXPO_PUBLIC_SANITY_PROJECT_ID || ''; 
export const SANITY_DATASET: string = 
  process.env.EXPO_PUBLIC_SANITY_DATASET || '';
export const SANITY_STUDIO_URL:string = 
  process.env.EXPO_PUBLIC_SANITY_STUDIO_URL || '';
```

### Add a Preview Utilities file

The `isWeb` utility determines if you are in the native or web context.  We will add more utilities to this file later.  

**/utils/preview.ts**

```
import { Platform } from "react-native";

export const isWeb = Platform.OS === 'web'
```

### Configure the Sanity Client

Create a Sanity client instance to handle fetching data from Content Lake.

The `stega` option enables automatic click-to-edit overlays for all text content in Presentation mode. You can read more about how `stega` works [in the docs](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega). 

**sanity/client.ts**

```tsx
import { SANITY_DATASET, SANITY_PROJECT_ID, SANITY_STUDIO_URL } 
  from "@/constants";
import { isWeb } from "@/utils/preview";
import { createClient } from "@sanity/client";

export const client = createClient({
    projectId: SANITY_PROJECT_ID,
    dataset: SANITY_DATASET,
    useCdn: true,
    apiVersion: '2025-05-30',
    stega: {
      enabled: !!isWeb,
      studioUrl: SANITY_STUDIO_URL
    }
  })
```

### Define the Sanity React Loader hooks for queries and live mode

You will fetch data in your pages/components (see below) with `useQuery` from the react-loader library. This handles querying your data when you are **NOT** in Presentation/Visual Editing mode, for example, in the actual React Native mobile app or in a web build loaded directly in a browser (rather than in Presentation mode in Sanity Studio). 

When you enter Presentation mode in the Sanity Studio, the `useLiveMode` hook will take over the data hydration responsibilities.

**We will see where/how to use these hooks in a moment, **but for now we just create the query store and export the resulting hooks. 

**hooks/useQueryStore**

```
// sanity.ts
import { createQueryStore } from '@sanity/react-loader';
import { client } from '../sanity/client';

const { useLiveMode, useQuery } = createQueryStore({ client, ssr:false })

export { useLiveMode, useQuery };

```

### Define the SanityVisualEditing component

The imported `enableVisualEditing` function from handles rendering overlays, enabling click to edit, and re-rendering elements in your application when you make content changes. Via its `history` and `refresh` properties, it also connects the URL bar of the presentation tool with the internal routing of the React Native app, keeping the two in sync.

As shown below, configure the `enableVisualEditing` function and wrap it in a parent `SanityVisualEditing` component (note that here and only here is where we call `useLiveMode`):

**app/components/SanityVisualEditing.tsx**

```tsx
import { useLiveMode } from '@/hooks/useQueryStore';
import { isWeb } from '@/utils/preview';
import { isMaybePresentation } from '@sanity/presentation-comlink';
import { enableVisualEditing } from '@sanity/visual-editing';
import { usePathname, useRouter } from 'expo-router';
import { useEffect } from 'react';
import { client } from '../sanity/client';

// This component only has an effect in presentation mode on the web -- it provides clickable overlays of content that enable Visual Editing in the studio.
export default function SanityVisualEditing() {
  const pathname = usePathname()
  const router = useRouter()

  useEffect(() => {
    const disable = isWeb && isMaybePresentation() ? enableVisualEditing({
      history: {
        // Handle user changes to the expo router pathname (e.g. clicking a link in the app) by updating the URL bar
        subscribe: (navigate) => {
          console.log('NAVIGATION EVENT:', {navigate, pathname})
          // We navigate to Expo Router's current pathname.
          navigate({
            type: 'push',
            url: pathname,
          })

          // Return cleanup function
          return () => {}
        },
        // Handle user changes to the contents of the Presentation modeURL bar by calling expo router functions
        update: (u: any) => {
          console.log('URL UPDATE:', u)
          switch (u.type) {
            case 'push':
              return router.push(u.url)
            case 'pop':
              return router.back()
            case 'replace':
              return router.replace(u.url)
            default:
              throw new Error(`Unknown update type: ${u.type}`)
          }
        }
      },
      zIndex: 1000,
      // Handle the refresh button in the Presentation mode URL bar. (show spinner for 1 sec, refresh doesn't do anything for client-side apps)
      refresh: (payload) => {
        console.log('REFRESH EVENT: ', payload)
        const { source } = payload
        if(source === 'manual') {
          return new Promise(resolve => setTimeout(() => resolve(undefined), 1_000))
        } else {
          return false
        }
      },
    }) : () => null
    return () => disable()
  }, [pathname])

  if(isWeb && isMaybePresentation()) {
    useLiveMode({client })
  }

  return null
}


```

> [!NOTE]
> If you do not use Expo Router
> In the code example above, we flush route changes that are made in the Presentation tool's URL bar from the Presentation tool to the Expo Router using the `update` handler inside of the `history` prop (so that the app routes to the new URL). This allows the React Native app to navigate from view to view in response to those URL bar changes. 
> If you do not use Expo Router, replace the calls to `router.push`,  `router.back`, and `router.replace` with the corresponding function calls from your chosen navigation methodology (e.g. the methods exposed as part of React Navigation's `navigation` object).  

### Render the SanityVisualEditing component

Add the `SanityVisualEditing` component to your root layout(s) outside of any "Stack" elements.

**app/layout**

```tsx
import { Stack } from 'expo-router';
// Example -- could be whatever context provider or parent component you want at the root. 
import SomeParent from "@/components/SomeParent"


export default function RootLayout() {
  return (
    <SomeParent>
      <Stack>
          <Stack.Screen name="(pages)" options={{ headerShown: false }} />
      </Stack>
      <SanityVisualEditing />
    </SomeParent>
  );
}

```

> [!NOTE]
> If you do not use Expo Router
> If you don't use Expo Router and its `Stack` component, replace the Stack functionality in the code example below with your preferred view rendered (e.g. the `Stack` component from React Navigation's `createNativeStackNavigator` function)

### Query data from Sanity and render a page

With the components included and loaders set up, you can call `useQuery` with each page's query and render the data. For example:

**app/pages/[page_slug]**

```tsx
import { useQuery } from "@/hooks/useQueryStore";
import { useLocalSearchParams } from "expo-router";
import groq from "groq";
import { Text, View } from "react-native";

export default function SomePage() {
  const { page_slug } = useLocalSearchParams();
  const query = groq`*[_type == "some_type" && slug.current == $page_slug]{...}`;
  const { data } = useQuery(query, { page_slug });

  return (
    <View>
      {data?.map((document: YourDocumentType) => {
        const { _id, title } = document;
        return (
          <View key={_id}>
            <Text>{title}</Text>
          </View>
        );
      })}
    </View>
  );
}

```

### Click-to-Edit Overlays

#### Out of the Box

Enabling the `stega` option in your Sanity client config ensures that all text content will automatically have click-to-edit overlays. In this example, we configured stega to be `true` in our Sanity client (see above) when the runtime of our application is the web.

These overlays allow you to click on any component that renders a piece of Sanity content and automatically open that piece of content for editing in the visual editor's form sidebar. 

Learn more about [Overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays).

#### Data Attributes

To enable the same functionality for non-text fields (e.g. images), you can add a `data-sanity` attribute to the component that renders them.

To add a `data-sanity` attribute, we create it using a helper,  `createDataAttributeProp`, which is a React Native-specific helper that wraps for the Sanity `createDataAttribute` method, checking to ensure we are in the web app context and returning a prop that we can spread onto React Native components. Let's add this to our `/utils/preview.ts` file from earlier: 

**/utils/preview.ts**

```
import { SANITY_DATASET, SANITY_PROJECT_ID, SANITY_STUDIO_URL } from '@/constants';
import { createDataAttribute, CreateDataAttributeProps } from '@sanity/visual-editing';
import { Platform } from "react-native";

export const isWeb = Platform.OS === 'web'

// Your Sanity configuration -- not technically required in Presentation mode, 
// but useful if you want to generate studio links in standalone preview implementations. 
const config = {
  projectId: SANITY_PROJECT_ID,
  dataset: SANITY_DATASET,
  baseUrl: SANITY_STUDIO_URL,
}

export const createDataAttributeProp = (attr: CreateDataAttributeProps) => {
  if (isWeb) {
    const attribute = createDataAttribute({...config, ...attr})?.toString()
    if (attribute) {
      return {dataSet: {sanity: attribute}}
    }
  }
  return undefined
}
```

> [!WARNING]
> Data Attributes in React Native
> React Native's components cannot directly use data attributes like `data-sanity` as props. Instead, they use the `dataSet` prop. The correct prop structure is generated using the `createDataAttributeProp` helper method outlined above.

Create the data attribute using the `createDataAttributeProp` util set up above, and spread that prop onto the React Native component. In this example, we are adding it to a react-native `Image` component, but it should work for any scalar React Native component that needs to render its underlying html tag with the  the `data-sanity` prop. In the example below, we add an attribute to the `SomePage` component we wrote earlier when learning to use `useQuery`:

```
import { useQuery } from "@/hooks/useQueryStore";
import { urlFor } from "@/utils/image_url";
import { createDataAttributeProp } from "@/utils/preview";
import { useLocalSearchParams } from "expo-router";
import groq from "groq";
import { Image, Text, View } from "react-native";

export default function SomePage() {
  const { page_slug } = useLocalSearchParams();
  const query = groq`*[_type == "some_type" && slug.current == $page_slug]{...}`;
  const { data } = useQuery(query, { page_slug });

  return (
    <View>
      {data?.map((document: YourDocumentType) => {
        const { _id, _type, title, hero_image } = document;

        const heroImageAttr = createDataAttributeProp({
          id: _id,
          type: _type,
          path: "hero_image",
        });
        return (
          <View key={_id}>
            <Image
              {...heroImageAttr}
              source={{ uri: urlFor(hero_image).url() }}
            />
            <Text>{title}</Text>
          </View>
        );
      })}
    </View>
  );
}
```

**Repeat this for any of your non-text components that need click-to-edit overlays.**

### Additional Presentation Mode Features

You can also: 

- [Customize your overlays](https://www.sanity.io/docs/visual-editing/custom-overlay-components) (e.g. to change the style or add more contextual information from the source content)
- Enable [drag-and-drop reordering](https://www.sanity.io/docs/visual-editing/enabling-drag-and-drop) for arrays of content objects in your front end.
- [Customize the preview header/navigation](https://www.sanity.io/docs/visual-editing/customizing-preview-header-and-navigation)

These are out of the scope of this guide, but the examples in docs above are the same for React Native, with the exception of the fact that you must use the `dataSet` attribute in place of directly using `data-sanity` (as discussed above).

*The React Native Starter repo does have an example of drag-and-drop in the [movie_slug].tsx component*

## A Note on Private datasets

> [!WARNING]
> Private Datasets
> The `useQuery` hook from `@sanity/react-loader` does not currently support a "token" parameter, so it does not currently support querying private data from the user-facing front end application outside of Presentation mode. Inside of Presentation mode, `useLiveMode` takes care of rendering whatever data matches the chosen Perspective in the Presentation UI.

**To query private data from user-facing applications**, create a private querying hook (call it `usePrivateQuery` or `useSanityQuery` or similar) that allows you to perform token-authorized queries. However, never add that token to the client side bundle/environment, **it is an API KEY**. Some example approaches for how to perform such queries:

1. Build an API that has custom auth (for however you authenticate your users) and returns a token for the Sanity client to use in calls to client.fetch. This is the simplest approach but has the negative side effect that it exposes the token to the client side, so any logged in user can take that token and take any action for which the token is authorized—usually at a minimum this means making ANY query to your data, but can also even include writing data, updating settings, etc depending on the token.
2. Have a proxy API that has custom auth and can make queries on your behalf from the server, which never exposes the token to client side users. This allows you to either allow arbitrary queries if all authorized users should be able to make any query or even allows you to lock down which queries can be made by exposing API routes for individual queries.

Once you have defined a private querying hook, decide at runtime whether to call the Sanity React Loader's `useQuery` or your own `usePrivateQuery/useSanityQuery/customQuery` depending on whether you are in Presentation mode. Determining whether you are likely in/not in Presentation mode can be done with a helper from `@sanity/presentation-comlink` called `isMaybePresentation`.


So an example conditional usage of the correct hook for the platform/context might be like:

```tsx
const { isMaybePresentation } = import "@sanity/presentation-comlink"
const usePrivateQuery = import "@/hooks/usePrivateQuery"
    
    <!-- In a real life example, put this "createQueryStore" call in its own module so that it is called ONLY once and imported into components where used -->

    const { useLiveMode, useQuery} = createQueryStore({ client, ssr:false })

    function SomeComponent {
        const { data } = isMaybePresentation() ? useQuery(query) : usePrivateQuery()

        return <div>...contents</div>
    }

```

## A Note About the Live Content API

The [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) can be used to receive and render real time updates in your application without refreshing the page, both:

- as used in Presentation mode in this guide -- immediately shows the latest data for whatever "Perspective" is currently chosen in that Presentation UI (Draft Perspective, Published Perspective, etc).
- in your *user-facing *production application outside Presentation mode -- shows the latest published data (without needing to reload the app).

**When you are in Presentation mode**, `useLiveMode` will use a cookie set by the Presentation plugin to authenticate live updates from the Live Content API and show you the latest content for whatever "Perspective" you choose in the Presentation UI itself. The most common Perspective used is "Drafts", because that will show you all edits to documents, rendered in your embedded front end, live and in real time. This is how we enable instantaneous "Visual Editing". However, you can also choose the "Published" perspective to see a view of all published changes.

The `useLiveMode` hook respects the user's role when determining which data/content types that user can access in Presentation mode (including Custom Roles).

**When you are not in Presentation mode**, you must implement a connection mechanism for it in your project in order to use the Live Content API.

The sample repo linked at the beginning of this guide wires up the Live Content API directly via a custom `useLiveQuery` hook. Use it exactly like `useQuery`:

`import { useLiveQuery } from '@/hooks/useLiveQuery'

const { data } = useLiveQuery<Movie[]>(query, params)`

The shared Live Content API logic lives in `/hooks/useLcapiLiveQuery.ts`. It:

1. fetches the query with `filterResponse: false` and remembers the `syncTags` returned in the response,
2. subscribes to `client.live.events()`, and
3. refetches whenever a live event's tags overlap the stored tags (and on stream `restart`/`reconnect`).

`useLiveQuery` picks the right strategy per platform/context so the screens can call a single hook everywhere:

##### Use Live Query Context Options

| Context | Mechanism |
| --- | --- |
| Native iOS/Android (/hooks/useLiveQuery.ts) | Live Content API (useLcapiLiveQuery) |
| Web outside Presentation, e.g. a normal localhost tab during development (/hooks/useLiveQuery.web.ts) | Live Content API (useLcapiLiveQuery) |
| Web inside Presentation (Studio iframe) | React Loader useQuery, so stega click-to-edit + useLiveMode drive updates |

Presentation is detected once at module load via `isMaybePresentation()` (it's fixed for a page's lifetime), which keeps the Rules of Hooks intact.

**Notes:**

- This streams **published** content and needs no token. To also stream **drafts**, create the client with a viewer token + `useCdn: false` and pass `{ includeDrafts: true }` to `client.live.events()` — but **never ship a token in a client bundle** (this pattern should be used only behind a user authorization/authentication flow)
- `client.live.events()` works in React Native because `@sanity/client` resolves its `react-native` export condition to an XHR-based EventSource polyfill (`event-source-polyfill`). No extra Metro shim is required. In web browsers it uses the native `EventSource`.
- For the web build outside Presentation, make sure the origin (e.g. `http://localhost:8081`) is in your project's CORS origins at [sanity.io/manage](https://sanity.io/manage), otherwise the `client.fetch` and live event stream will be blocked by the browser.
- Each `useLiveQuery` instance opens its own event stream. The Live Content API has connection limits, so if you fan out to many simultaneous queries consider sharing a single `client.live.events()` subscription; on a `goaway` event you may also want to fall back to polling.

> [!NOTE]
> Live Content API Docs
> For further example/starting point implementations, check the [lcapi-examples Github Repo](https://github.com/sanity-io/lcapi-examples/tree/main).
> Learn more about the [Live Content API here](https://www.sanity.io/docs/content-lake/live-content-api).

## Visual Editing is Now Enabled!

With your front end application and Sanity Studio both running locally (or deployed) and configured using the appropriate environment variables and the code snippets from this guide, you should be able to open the Studio, click "Presentation", and see your front end application embedded as a click-to-edit view with automatic live content updates.

Once both the front end application and the Sanity Studio are deployed (and configured correctly in the Presentation plugin config in the Studio code), you will see the same functionality enabled for your deployed application.

If you don't see the page as expected, confirm the code snippets related to presentation are as expected, and take a look at the [Visual Editing Troubleshooting guide](https://www.sanity.io/docs/visual-editing/troubleshooting-visual-editing) (modifying data attributes to the dataSet format and making any other changes that are relevant to React Native or your application).

For additional support, join our [Discord server](https://discord.com/servers/sanity-1304483263171264613), where Sanity support engineers are regularly active and assisting our community!



# Astro

This guide walks through the specific wiring that makes Sanity's visual editing work with an Astro application. It allows for automatic content refresh on edit, perspective switching, and more flexibility at the expense of more complexity.

> [!TIP]
> If you’re looking for a more drop-in, but less featured visual editing implementation, check out the [Building a blog with Sanity and Astro guide](https://www.sanity.io/docs/developer-guides/sanity-astro-blog).

By the end, editors will be able to open the Presentation Tool in the Studio, see the frontend in a live preview, click on any text element to jump to the corresponding field, and see changes reflected after each edit.

**What you'll set up:**

- The `@sanity/astro` integration, which provides a pre-configured Sanity client with Content Source Map encoding.
- A custom `loadQuery` function that switches between published and draft content based on cookies.
- Cookie-based draft mode routes to toggle between published and draft content.
- The Presentation Tool with document-to-URL mapping.
- A custom `<SanityVisualEditing />` React component that powers click-to-edit overlays, browser history sync, and content refresh.

The guide assumes you already have document types defined in your Studio and pages that render them. The focus is purely on the integration layer: the files and configuration that connect the two apps.

> [!NOTE]
> Astro and the Live Content API
> Next.js integrations use the Live Content API (`defineLive` / `<SanityLive />`) for real-time re-rendering without page reloads. Astro does not have an equivalent. Instead, when an editor changes a field, the `<SanityVisualEditing />` component triggers a full page reload to fetch fresh content from the server. This is the standard approach for Astro and works well in practice.

## Prerequisites

- Node.js 20+.
- Astro 7 with `output: "server"`. Visual editing requires server-side rendering because draft mode depends on per-request cookie checking. Static output mode will not work.
- `@sanity/astro` v3.5.0 or later, `@astrojs/react` v6+, and `@astrojs/node` v11+.
- A Sanity project with a dataset. [Create one](https://www.sanity.io/manage) if you don't have one.
- [An API token](https://www.sanity.io/docs/content-lake/http-auth) with **Viewer** permissions for that project. Create one under **API** → **Tokens** in your project settings.
- `http://localhost:4321` added as a [CORS origin](https://www.sanity.io/docs/content-lake/browser-security-and-cors) with **Allow credentials** checked.

You can create a basic Astro app by following the [Astro quickstart](https://docs.astro.build/en/install-and-setup/). Then, navigate to the Astro project’s frontend and make sure you have the latest packages by running the following command:

**npm**

```shell
npm install @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

**pnpm**

```shell
pnpm add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

**yarn**

```shell
yarn add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

**bun**

```shell
bun add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

In this example, we’re separating the Studio from the Astro app. You can create a new Studio by running the following command in your project root:

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio
cd studio
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

## How the pieces fit together

Before diving into the code, here's what happens at runtime when an editor opens the Presentation Tool:

1. The Studio loads the Astro frontend inside an iframe. The URL it loads comes from the `initial` field in the Presentation Tool configuration.
2. The Studio hits the draft mode enable route on the frontend (`/api/draft-mode/enable`). This sets a cookie that activates draft mode in the iframe session.
3. With draft mode active, `loadQuery` returns strings with invisible characters embedded in them. These characters are Content Source Maps (called "stega") that encode which document and field each string came from, along with the Studio URL.
4. The `<SanityVisualEditing />` component (which only renders during draft mode) reads those encoded strings from the DOM and draws click-to-edit overlays on every text element.
5. When an editor clicks an overlay, the Studio navigates to that document and field.
6. When an editor changes a field, the `<SanityVisualEditing />` component's `refresh` callback triggers a full page reload. The page re-fetches from the server with the updated draft content.

> [!NOTE]
> Contracts between the two apps.
> If you change one side, check the other.
> - The Studio's `previewMode.enable` path (`/api/draft-mode/enable`) must match an actual API route in the Astro app.
> - The URLs returned by `resolve.ts` (e.g., `/post/${slug}`) must match actual page routes in `frontend/src/pages/`.
> - The `stega.studioUrl` in the `@sanity/astro` integration config must point to the running Studio.
> - The Sanity project must have the frontend's origin in its CORS settings with **Allow credentials** enabled.

## Environment variables

Set up environment variables for your Astro app (`frontend`) and Studio (`studio`).

**frontend/.env**

```sh
PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID
PUBLIC_SANITY_DATASET=YOUR_DATASET
SANITY_API_READ_TOKEN=YOUR_VIEWER_TOKEN
```

**studio/.env**

```text
SANITY_STUDIO_PROJECT_ID=YOUR_PROJECT_ID
SANITY_STUDIO_DATASET=YOUR_DATASET
SANITY_STUDIO_PREVIEW_URL=http://localhost:4321
```

`PUBLIC_SANITY_PROJECT_ID` and `PUBLIC_SANITY_DATASET` are public because the `@sanity/astro` integration needs them in `astro.config.mjs` (loaded via Vite's `loadEnv`).

`SANITY_API_READ_TOKEN` is server-only and never exposed to the client bundle. It's passed to `loadQuery` only when draft mode is active, to authenticate requests for draft content.

Note that [Studio environment variables](https://www.sanity.io/docs/studio/environment-variables) should always start with `SANITY_STUDIO`. However, it’s safe to hard-code the projectId, dataset, and preview URL in `sanity.config.ts` if you prefer.

## Studio setup

These files live in `studio/`. If you're setting up a new Studio from scratch, these examples use a blog schema with `post`, `author`, and `category` document types.

### Presentation Tool configuration

The Presentation Tool is a Studio plugin that renders your frontend inside an iframe and enables the visual editing workflow. Configure it in `sanity.config.ts`:

**studio/sanity.config.ts**

```typescript
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { presentationTool } from "sanity/presentation";
import { schema } from "./schemaTypes";
import { resolve } from "./lib/resolve";

export default defineConfig({
  projectId: process.env.SANITY_STUDIO_PROJECT_ID || 'YOUR_PROJECT_ID',
  dataset: process.env.SANITY_STUDIO_DATASET || 'production',
  plugins: [
    structureTool(),
    presentationTool({
      resolve,
      previewUrl: {
        initial:
          process.env.SANITY_STUDIO_PREVIEW_URL || "http://localhost:4321",
        previewMode: {
          enable: "/api/draft-mode/enable",
        },
      },
    }),
  ],
  schema,
});
```

The important fields here:

- **resolve**: This defines the document location resolver. You'll set this up in the next section.
- **previewUrl.initial**: The full URL of the Astro app. The Presentation Tool loads this in the iframe. When the Studio and frontend are separate apps (as they are here), this is required.
- **previewUrl.previewMode.enable**: The path (relative to `initial`) that the Studio calls to activate draft mode. The Studio makes a GET request to `http://localhost:4321/api/draft-mode/enable` with authentication parameters. This is what activates draft mode so the frontend returns draft content with stega encoding.

### Document locations

Document locations tell the Presentation Tool which frontend URLs correspond to which document types. This powers two things: when you select a document in the Studio, the iframe navigates to the right page; and documents show location badges linking to their frontend URLs.

**studio/lib/resolve.ts**

```typescript
import { defineLocations } from "sanity/presentation";
import type { PresentationPluginOptions } from "sanity/presentation";

export const resolve: PresentationPluginOptions["resolve"] = {
  locations: {
    // The key is the document type name from your schema
    post: defineLocations({
      select: {
        title: "title",
        slug: "slug.current",
      },
      resolve: (doc) => ({
        locations: [
          {
            title: doc?.title || "Untitled",
            href: `/post/${doc?.slug}`,
          },
          {
            title: "Home",
            href: "/",
          },
        ],
      }),
    }),
  },
};
```

`select` uses GROQ-like field paths to pull data from the document. `resolve` receives that data and returns an array of `{title, href}` objects. The first location is treated as the primary one. You can add multiple locations if a document appears on several pages (for example, a post appears on its own page and on the posts index).

### CORS

The Sanity project needs `http://localhost:4321` added as a CORS origin with **Allow credentials** enabled. If you already added this in the prerequisites, you're set. If not, add it in your project settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**, or add it with the CLI:

**npm**

```shell
npx sanity cors add http://localhost:4321 --credentials
```

**pnpm**

```shell
pnpm dlx sanity cors add http://localhost:4321 --credentials
```

**yarn**

```shell
yarn dlx sanity cors add http://localhost:4321 --credentials
```

**bun**

```shell
bunx sanity cors add http://localhost:4321 --credentials
```

For production, you'd add your deployed frontend URL as well.

## Astro setup

These files live in `frontend/`. The structure follows a standard Astro project with server-side rendering enabled.

### Astro configuration

**frontend/astro.config.mjs**

```typescript
import { defineConfig } from "astro/config";

import sanity from "@sanity/astro";
import react from "@astrojs/react";
import node from "@astrojs/node";

import { loadEnv } from "vite";
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
  process.env.NODE_ENV,
  process.cwd(),
  "",
);

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  integrations: [
    sanity({
      projectId: PUBLIC_SANITY_PROJECT_ID,
      dataset: PUBLIC_SANITY_DATASET,
      useCdn: false,
      apiVersion: "2026-03-01",
      stega: {
        studioUrl: "http://localhost:3333",
      },
    }),
    react(),
  ],
  vite: {
    optimizeDeps: {
      include: [
        "react/compiler-runtime",
        "lodash/isObject.js",
        "lodash/groupBy.js",
        "lodash/keyBy.js",
        "lodash/partition.js",
        "lodash/sortedIndex.js",
      ],
    },
  },
});
```

There's a lot here, so let's break it down:

- **output: "server"**: Enables server-side rendering. This is required because draft mode depends on reading cookies from each incoming request to decide whether to return published or draft content. Static builds can't do this.
- **adapter: node({ mode: "standalone" })**: The Node.js adapter runs the Astro app as a standalone server. You could also use other adapters (Vercel, Cloudflare, etc.) for deployment.
- **sanity({ ... })**: The `@sanity/astro` integration configures a Sanity client that's available throughout your app via the `sanity:client` virtual module. No manual `createClient` call needed.
- **stega.studioUrl**: When draft mode is active and stega encoding is enabled, this URL is embedded in the invisible characters so the overlay knows where to send the editor when they click. For production, point this to your deployed Studio URL.
- **useCdn: false**: Disabled because we need fresh data for draft content. In a production setup, you might conditionally enable it for published content.
- **react()**: Required because the visual editing overlay components (`SanityVisualEditing`, `DisableDraftMode`) are React components that run in the browser.
- **vite.optimizeDeps.include**: Pre-bundles certain dependencies that Vite's dev server would otherwise fail to optimize on the fly. Without these entries, you may see module resolution errors in development. `@sanity/astro` 3.5.0 and later pre-bundles a related set of modules automatically for its embedded Studio setup, but that set doesn't cover the modules listed here, so keep these entries.

### The Sanity client

Unlike Next.js where you create the client manually with `createClient`, the `@sanity/astro` integration provides a pre-configured client via a virtual module. To use it, add the type references in your env file:

**frontend/src/env.d.ts**

```typescript
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
```

The second line tells TypeScript about the `sanity:client` virtual module, which you can then import anywhere:

```typescript
import { sanityClient } from "sanity:client";
```

The client is automatically configured with the `projectId`, `dataset`, `apiVersion`, and `stega` settings from `astro.config.mjs`.

### Draft mode helper

Astro doesn't have a dedicated draftMode like Next.js, so we implement draft mode with cookies. This small helper reads the draft mode state from `Astro.cookies`:

**frontend/src/sanity/lib/draft-mode.ts**

```typescript
import type { AstroCookies } from "astro";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
export function getDraftModeProps(cookies: AstroCookies) {
  return {
    perspectiveCookie: cookies.get(perspectiveCookieName)?.value ?? undefined,
  };
}
```

This reads a client-writable cookie that is set by the draft mode enable route and kept up to date by the `<SanityVisualEditing />` component. It stores the editor's current perspective preference (e.g., a specific content release). The Presentation Tool then interacts with this when the editor switches perspectives in the Studio.

### Fetching data

This is the central piece that replaces Next.js's `defineLive` / `sanityFetch`. It's a custom `loadQuery` function that handles perspective switching, stega encoding, and source maps based on whether draft mode is active:

**frontend/src/sanity/lib/load-query.ts**

```typescript
import type { ClientPerspective, QueryParams } from "@sanity/client";
import { sanityClient } from "sanity:client";

const token = import.meta.env.SANITY_API_READ_TOKEN;

function parsePerspective(
  raw: string | undefined,
): ClientPerspective | undefined {
  if (!raw) return undefined;
  const decoded = decodeURIComponent(raw);
  if (decoded.startsWith("[")) {
    try {
      return JSON.parse(decoded) as ClientPerspective;
    } catch {
      return undefined;
    }
  }
  return decoded as ClientPerspective;
}

export async function loadQuery<QueryResponse>({
  query,
  params,
  perspectiveCookie = undefined,
}: {
  query: string;
  params?: QueryParams;
  perspectiveCookie?: string | undefined;
}) {
  const draftMode = perspectiveCookie ? true : false;
  if (draftMode && !token) {
    throw new Error(
      "The `SANITY_API_READ_TOKEN` environment variable is required during Visual Editing.",
    );
  }

  const perspective: ClientPerspective = draftMode
    ? (parsePerspective(perspectiveCookie) ?? "drafts")
    : "published";

  const { result, resultSourceMap } = await sanityClient.fetch<QueryResponse>(
    query,
    params ?? {},
    {
      filterResponse: false,
      perspective,
      resultSourceMap: draftMode ? "withKeyArraySelector" : false,
      stega: draftMode,
      ...(draftMode ? { token } : {}),
    },
  );

  return {
    data: result,
    sourceMap: resultSourceMap,
    perspective,
  };
}
```

The function handles two modes:

- **Published mode** (default): Uses the `"published"` perspective, no stega encoding, no source maps, no token. This is what visitors see.
- **Draft mode**: Uses the `"drafts"` perspective (or a custom perspective from the cookie for Content Releases), enables stega encoding and source maps with `withKeyArraySelector`, and authenticates with the API token.

The `parsePerspective` helper deserializes the perspective cookie, which can be either a simple string like `"drafts"` or a JSON-encoded array for Content Release stacks. This exact implementation isn’t required, but works with the rest of the code.

The `filterResponse: false` option tells the client to return both the query result and the source map, rather than just the result.

### GROQ queries

Queries are defined using `defineQuery` from the `groq` package, which enables TypeGen to generate result types. If you don’t have it already, add `groq` to your project dependencies:

**frontend/src/sanity/lib/queries.ts**

```typescript
import { defineQuery } from "groq";

export const POSTS_QUERY = defineQuery(
  `*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
    _id,
    title,
    "slug": slug.current,
    publishedAt
  }`,
);

export const POST_QUERY = defineQuery(
  `*[_type == "post" && slug.current == $slug][0]{
    _id,
    _type,
    title,
    "slug": slug.current,
    publishedAt,
    mainImage {
      asset->{ _id, url, metadata { lqip, dimensions } },
      alt,
      hotspot,
      crop
    },
    body[]{
      ...,
      _type == "image" => {
        ...,
        asset->{ _id, url, metadata { lqip, dimensions } },
        alt
      }
    },
    author->{ _id, name, "slug": slug.current },
    categories[]->{ _id, title }
  }`,
);
```

With [TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) configured in the Studio's `sanity.cli.ts`, running `sanity typegen generate` produces typed result types (`POSTS_QUERY_RESULT`, `POST_QUERY_RESULT`) in `frontend/sanity.types.ts`. These are used as generics with `loadQuery<POST_QUERY_RESULT>()` for type-safe data access.

### The layout

The shared layout conditionally renders visual editing components when draft mode is active:

**frontend/src/layouts/Layout.astro**

```html
---
import SanityVisualEditing from "../components/SanityVisualEditing";
import DisableDraftMode from "../components/DisableDraftMode";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";

const draftMode = Astro.cookies.has(perspectiveCookieName);
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>Astro Basics</title>
  </head>
  <body>
    <slot />
    {draftMode && <>
      <SanityVisualEditing client:only="react" />
      <DisableDraftMode client:only="react" />
    </>}
  </body>
</html>
```

Two components are doing the visual editing work here:

- **<SanityVisualEditing />:** scans the DOM for stega-encoded strings, decodes the Content Source Map data embedded in them (document ID, field path, Studio URL), and draws transparent overlays on top of each element. It also handles browser history synchronization with the Studio and triggers page reloads when content changes.
- **<DisableDraftMode />:** renders a floating button to exit draft mode, but only when the user is viewing the frontend directly (not inside the Presentation Tool's iframe).

The `client:only="react"` directive is critical. It tells Astro to render these components exclusively on the client side using React, with no server-side rendering attempt. This is necessary because both components use browser-only APIs (`window`, `document.cookie`, `postMessage`).

The `Astro.cookies.has(perspectiveCookieName)` check is the gate. Outside of draft mode, the page renders clean published content with no overlays and no invisible characters.

### The `SanityVisualEditing` component

This is the most complex Astro-specific piece. In Next.js, `next-sanity` provides a `<VisualEditing />` component that handles everything. In Astro, we need a custom component because Astro doesn't have a client-side router, and the built-in `@sanity/astro` visual editing component doesn't expose perspective change handling.

The component has three responsibilities: browser history synchronization, perspective cookie management, and content refresh.

**History synchronization:** The Presentation Tool needs to keep its URL bar in sync with the iframe. In a Next.js or React SPA, the router provides navigation events. Astro uses full page loads, so we monkey-patch `pushState` and `replaceState` to detect navigation, and listen for `popstate` and `hashchange` events.

When the Studio navigates (e.g., the editor selects a different document, and `resolve.ts` maps it to a new URL), the `update` callback calls `window.location.assign()` to trigger a full navigation. This is the key difference from SPA frameworks, where navigation would happen client-side without a page reload.

**Perspective cookie management:** When an editor switches perspectives in the Studio (e.g., viewing a Content Release), the component writes the new perspective to a cookie so the server can use it in `loadQuery`.

The `<VisualEditing />` component from `@sanity/visual-editing/react` does the heavy lifting of reading stega-encoded strings and drawing overlays. Our wrapper provides the Astro-specific adapters:

- **history**: Tells the Studio what URL the iframe is showing, and handles navigation requests from the Studio.
- **portal={true}**: Renders the overlay outside the normal DOM tree so it doesn't interfere with page layout.
- **onPerspectiveChange**: Writes the new perspective to a cookie and reloads the page so the server can fetch content with the new perspective.
- **refresh**: Called when the Studio detects a content change. Triggers a full page reload to get fresh server-rendered content.
- **keepStegaOnCopy**: Optional boolean prop, default false. When omitted, <VisualEditing /> intercepts copy events and strips stega encoding from both text/plain and text/html clipboard payloads, so users copying text from the preview page don't get invisible characters in their clipboard. Pass keepStegaOnCopy to disable this behavior and preserve stega in clipboard content.
- **onSuspiciousStega**: Optional callback prop (opt-in). Reports stega found in unsafe DOM placements: element attributes (class, id, href, src, style, data-*, etc.), inside <head> (title, meta[content], JSON-LD), in <script> or <style> text content, in textarea form values, or in the page URL. Each report includes the kind, element, attribute (if applicable), raw value, and cleaned value.

Pass a callback to onSuspiciousStega to audit your page for stega in unsafe positions. The callback receives an array of report objects, each describing where the stega was found and what it contained.

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

> [!WARNING]
> The onSuspiciousStega callback runs a full DOM audit using TreeWalker and MutationObserver and has a performance cost. Use it in development and debugging only. Do not enable it in production.

Here’s the full component. This isn’t the only way to approach this, but it allows the component to react to perspective changes passed to it by Studio’s Presentation Tool.

**frontend/src/components/SanityVisualEditing.tsx**

```tsx
import { useEffect, useMemo, useRef } from "react";
import {
  VisualEditing,
  type HistoryAdapter,
  type HistoryUpdate,
} from "@sanity/visual-editing/react";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
import type { ClientPerspective } from "@sanity/client";

function serializePerspective(perspective: ClientPerspective): string {
  return typeof perspective === "string"
    ? perspective
    : JSON.stringify(perspective);
}

function getCookie(name: string): string | undefined {
  const match = document.cookie.match(
    new RegExp(`(?:^|; )${name}=([^;]*)`),
  );
  return match ? decodeURIComponent(match[1]) : undefined;
}

function setPerspectiveCookie(perspective: ClientPerspective): boolean {
  const next = serializePerspective(perspective);
  const current = getCookie(perspectiveCookieName);
  if (current === next) return false;
  // Match the attributes the enable route set. Inside the Presentation Tool this
  // page runs in a cross-site iframe, so the cookie has to carry Partitioned or
  // Safari drops the rewrite. In this guide's architecture the Studio and the
  // frontend are on different domains, so being framed implies cross-site.
  const partitioned = window.self !== window.top ? "; Partitioned" : "";
  document.cookie = `${perspectiveCookieName}=${encodeURIComponent(next)}; path=/; SameSite=None; Secure${partitioned}`;
  return true;
}

function currentUrl() {
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}

function applyHistoryUpdate(
  update: Pick<HistoryUpdate, "type" | "url">,
  currentHref: string,
) {
  switch (update.type) {
    case "push":
      if (currentHref !== update.url) window.location.assign(update.url);
      return;
    case "replace":
      if (currentHref !== update.url) window.location.replace(update.url);
      return;
    case "pop":
      window.history.back();
      return;
  }
}

export default function SanityVisualEditing() {
  type Navigate = Parameters<HistoryAdapter["subscribe"]>[0];
  const navigateRef = useRef<Navigate | undefined>(undefined);
  const lastUrlRef = useRef("");

  useEffect(() => {
    const sync = () => {
      const url = currentUrl();
      if (url !== lastUrlRef.current) {
        lastUrlRef.current = url;
        navigateRef.current?.({ type: "push", title: document.title, url });
      }
    };

    sync();
    window.addEventListener("popstate", sync);
    window.addEventListener("hashchange", sync);

    const origPush = window.history.pushState;
    const origReplace = window.history.replaceState;
    window.history.pushState = function (...args) {
      origPush.apply(window.history, args);
      sync();
    };
    window.history.replaceState = function (...args) {
      origReplace.apply(window.history, args);
      sync();
    };

    return () => {
      window.removeEventListener("popstate", sync);
      window.removeEventListener("hashchange", sync);
      window.history.pushState = origPush;
      window.history.replaceState = origReplace;
    };
  }, []);

  const history = useMemo<HistoryAdapter>(
    () => ({
      subscribe: (navigate) => {
        navigateRef.current = navigate;
        const url = currentUrl();
        lastUrlRef.current = url;
        navigate({ type: "push", title: document.title, url });
        return () => {
          if (navigateRef.current === navigate) {
            navigateRef.current = undefined;
          }
        };
      },
      update: (update) => {
        applyHistoryUpdate(update, window.location.href);
      },
    }),
    [],
  );

  return (
    <VisualEditing
      history={history}
      portal={true}
      onPerspectiveChange={(perspective) => {
        if (setPerspectiveCookie(perspective)) {
          window.location.reload();
        }
      }}
      refresh={() => {
        return new Promise((resolve) => {
          window.location.reload();
          resolve();
        });
      }}
    />
  );
}

```

### Draft mode routes

These two routes are the bridge between the Studio and the frontend.

**Enable route:**

**frontend/src/pages/api/draft-mode/enable.ts**

```typescript
import type { APIRoute } from "astro";
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { perspectiveCookieName } from "@sanity/preview-url-secret/constants";
import { sanityClient } from "sanity:client";

export const GET: APIRoute = async ({ request, cookies, redirect }) => {
  const token = import.meta.env.SANITY_API_READ_TOKEN;

  if (!token) {
    return new Response("Server misconfigured: missing read token", {
      status: 500,
    });
  }

  const clientWithToken = sanityClient.withConfig({ token });
  const { isValid, redirectTo = "/", studioPreviewPerspective } = await validatePreviewUrl(
    clientWithToken,
    request.url,
  );

  if (!isValid) {
    return new Response("Invalid secret", { status: 401 });
  }

  // Safari blocks third-party cookies that aren't partitioned. When the
  // Presentation Tool loads this route inside a cross-site iframe, add the
  // CHIPS Partitioned attribute so Safari stores the cookie under the Studio's
  // partition. Top-level requests stay unpartitioned, so the disable route can
  // still clear them.
  const partitioned =
    request.headers.get("sec-fetch-dest") === "iframe" &&
    request.headers.get("sec-fetch-site") === "cross-site";

  cookies.set(perspectiveCookieName, studioPreviewPerspective ?? "drafts", {
    httpOnly: false,
    sameSite: "none",
    secure: true,
    path: "/",
    partitioned,
  });

  return redirect(redirectTo, 307);
};

```

When an editor opens the Presentation Tool, the Studio makes a GET request to this route with authentication parameters. `validatePreviewUrl` (from `@sanity/preview-url-secret`) handles the handshake: it verifies the request came from a legitimate Studio session by checking a shared secret stored in the dataset. If valid, we set the cookie to the perspective value and redirect to the requested page.

The cookie settings are important:

- **httpOnly**: `false` allows client-side JavaScript to read and modify the cookie. Confirm this is what you want in your implementation. For this guide, it enables the perspective-switching mechanism to function.
- **sameSite: "none"**: Required because the request comes from the Studio (a different origin) loading the frontend in an iframe.
- **secure:** `true` required when `sameSite` is `"none"`.
- **partitioned**: Adds the CHIPS `Partitioned` attribute when the request comes from a cross-site iframe. Safari blocks third-party cookies that aren't partitioned, so without it the cookie is dropped and draft mode never activates. Top-level requests stay unpartitioned, because a partitioned cookie can't be cleared by a same-site request.

In Next.js, `defineEnableDraftMode` from `next-sanity/draft-mode` wraps this logic. In Astro, we use `validatePreviewUrl` directly.

**Disable route:**

**frontend/src/pages/api/draft-mode/disable.ts**

```typescript
import type { APIRoute } from "astro";
import { perspectiveCookieName } from "@sanity/preview-url-secret/constants";

export const GET: APIRoute = async () => {
  // A partitioned cookie is only cleared by an expiring cookie that carries the
  // same Partitioned attribute, and cookies.delete() emits a single Set-Cookie
  // header per cookie name. Expire both variants directly instead, since either
  // may have been set depending on the browser and context.
  const expired = [
    `${perspectiveCookieName}=`,
    "Path=/",
    "Secure",
    "SameSite=None",
    "Max-Age=0",
  ];

  const headers = new Headers();
  headers.append("Set-Cookie", expired.join("; "));
  headers.append("Set-Cookie", [...expired, "Partitioned"].join("; "));
  headers.set("Location", "/");

  return new Response(null, { status: 307, headers });
};
```

This clears the cookie and redirects to the homepage. It's called by the "Disable Draft Mode" button.

### The "Disable Draft Mode" button

**frontend/src/components/DisableDraftMode.tsx**

```tsx
import { useIsPresentationTool } from "@sanity/visual-editing/react";

export default function DisableDraftMode() {
  const isPresentationTool = useIsPresentationTool();

  // null = still detecting, true = inside Presentation tool
  if (isPresentationTool !== false) return null;

  return (
    <a
      href="/api/draft-mode/disable"
      style={{
        position: "fixed",
        bottom: "1rem",
        right: "1rem",
        zIndex: 50,
        padding: "0.5rem 1rem",
        borderRadius: "9999px",
        backgroundColor: "#101112",
        color: "#fff",
        fontSize: "0.875rem",
        textDecoration: "none",
      }}
    >
      Disable Draft Mode
    </a>
  );
}
```

This component renders a floating button to exit draft mode, but only when the user is viewing the frontend directly in a browser tab (not inside the Presentation Tool's iframe). Inside the Presentation Tool, the Studio controls draft mode, so the button would be redundant.

`useIsPresentationTool` returns `true` when the frontend is loaded inside a Presentation Tool iframe and `false` when it's loaded directly in a browser tab. It returns `null` while detection is in progress, and may briefly return `false` before the Studio connection is established.

### Fetching data in pages

With all the infrastructure in place, fetching data in page components is straightforward. The pattern is the same on every page: call `loadQuery` with the query and spread `getDraftModeProps(Astro.cookies)`.

**frontend/src/pages/index.astro**

```html
---
import type { POSTS_QUERY_RESULT } from "../../sanity.types";
import { POSTS_QUERY } from "../sanity/lib/queries";
import { loadQuery } from "../sanity/lib/load-query";
import { getDraftModeProps } from "../sanity/lib/draft-mode";
import Layout from "../layouts/Layout.astro";

const { data: posts } = await loadQuery<POSTS_QUERY_RESULT>({
  query: POSTS_QUERY,
  ...getDraftModeProps(Astro.cookies),
});
---

<Layout>
  <h1>Posts</h1>
  <ul>
    {posts.map((post) => (
      <li>
        <a href={`/post/${post.slug}`}>{post.title}</a>
      </li>
    ))}
  </ul>
</Layout>
```

**frontend/src/pages/post/[slug].astro**

```html
---
import type { POST_QUERY_RESULT } from "../../../sanity.types";
import { POST_QUERY } from "../../sanity/lib/queries";
import { loadQuery } from "../../sanity/lib/load-query";
import { getDraftModeProps } from "../../sanity/lib/draft-mode";
import Layout from "../../layouts/Layout.astro";
import PortableText from "../../components/PortableText.astro";

const { params } = Astro;

const { data: post } = await loadQuery<POST_QUERY_RESULT>({
  query: POST_QUERY,
  params,
  ...getDraftModeProps(Astro.cookies),
});

if (!post) {
  return new Response(null, { status: 404 });
}
---

<Layout>
  <h1>A post about {post.title}</h1>
  <PortableText portableText={post.body} />
</Layout>
```

The post page renders its body with a small `PortableText.astro` wrapper component built on `astro-portabletext`, which you installed in the prerequisites:

**frontend/src/components/PortableText.astro**

```html
---
import { PortableText as PortableTextRenderer } from "astro-portabletext";

const { portableText } = Astro.props;
---

<PortableTextRenderer value={portableText} />
```

> [!NOTE]
> **Note:** If you render queried content in `<title>` tags or `<meta>` descriptions, stega characters will be present during draft mode. This is harmless for editors (the characters are invisible), but if you want clean metadata even in draft mode, use `stegaClean()` from `@sanity/client/stega`.

## Run both apps

With everything set up, run both apps to test. In separate terminal windows:

**npm**

```shell
# Terminal 1: Start the Studio
cd studio
npm run dev
# Runs on http://localhost:3333
```

**pnpm**

```shell
# Terminal 1: Start the Studio
cd studio
pnpm run dev
# Runs on http://localhost:3333
```

**yarn**

```shell
# Terminal 1: Start the Studio
cd studio
yarn run dev
# Runs on http://localhost:3333
```

**bun**

```shell
# Terminal 1: Start the Studio
cd studio
bun run dev
# Runs on http://localhost:3333
```

**npm**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
npm run dev
# Runs on http://localhost:4321
```

**pnpm**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
pnpm run dev
# Runs on http://localhost:4321
```

**yarn**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
yarn run dev
# Runs on http://localhost:4321
```

**bun**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
bun run dev
# Runs on http://localhost:4321
```

Open `http://localhost:3333` in your browser and navigate to the Presentation Tool. You should see the Astro frontend loaded in the iframe with click-to-edit overlays on text elements.

## The full flow

Now that you've seen every file, here's the complete sequence when an editor uses visual editing. This is the same flow described in "How the pieces fit together," but now you can trace each step back to the specific file that handles it:

1. The editor opens the **Presentation Tool** in the Studio (`studio/sanity.config.ts`).
2. The Studio loads `http://localhost:4321` (the `initial` URL) in an iframe and uses `studio/lib/resolve.ts` to map the current document to a frontend URL.
3. The Studio hits `http://localhost:4321/api/draft-mode/enable` with authentication parameters (`frontend/src/pages/api/draft-mode/enable.ts`).
4. The enable route validates the secret via `validatePreviewUrl`, sets the cookie, and redirects to the requested page.
5. The page re-renders. `getDraftModeProps` (`frontend/src/sanity/lib/draft-mode.ts`) reads the cookie and passes the value to `loadQuery` (`frontend/src/sanity/lib/load-query.ts`). `loadQuery` fetches draft content with **stega-encoded strings**: each string value has invisible characters that encode the document ID, field path, and Studio URL (configured in `frontend/astro.config.mjs`).
6. `<SanityVisualEditing />` (`frontend/src/components/SanityVisualEditing.tsx`, mounted via `frontend/src/layouts/Layout.astro` only during draft mode) reads the DOM, finds the stega-encoded strings, and renders transparent **click-to-edit overlays** on each text element.
7. The editor clicks an overlay. The overlay sends a `postMessage` to the parent Studio window with the document ID and field path. The Studio navigates to that field.
8. The editor changes a field. The mutation propagates through the Content Lake. The `refresh` callback on `<SanityVisualEditing />` fires, triggering `window.location.reload()`. The page re-fetches from the server with the updated draft content.

## Next steps

- **Deploy to production:** Update `stega.studioUrl` in `astro.config.mjs`, the Presentation Tool `initial` URL in `studio/sanity.config.ts`, and your CORS origins to point to your deployed URLs instead of `localhost`. It's common to use environment variables for these values with local fallbacks. Make sure the cookie values meet your security standards.
- **Add more document types to `resolve.ts`:** Any document type that has a corresponding frontend route can get visual editing. Add entries to the `locations` object for each type.

## Troubleshooting

### Overlays appear but clicking does nothing

**Cause:** `stega.studioUrl` is missing from the `@sanity/astro` integration config in `frontend/astro.config.mjs`.

**Fix:** Add `stega: { studioUrl: 'http://localhost:3333' }` to the `sanity()` integration options.

### Presentation Tool shows a blank iframe

**Cause:** `initial` is missing from the Presentation Tool config in `studio/sanity.config.ts`. This only happens when the Studio and frontend run as separate apps.

**Fix:** Add `initial: 'http://localhost:4321'` to `previewUrl` in the `presentationTool()` config.

### Live preview doesn't update, 403 errors in browser console

**Cause:** The frontend's origin is missing from the Sanity project's CORS settings, so the browser can't reach the Content Lake.

**Fix:** Add `http://localhost:4321` (with **Allow credentials** checked) in your project's CORS settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**.

### String comparisons fail in draft mode

**Cause:** Stega encoding adds invisible characters to string values. An equality check like `align === 'center'` returns `false` even when the visible value is `"center"` because the encoded string contains extra characters.

**Fix:** Use `stegaClean()` to strip the encoding before comparing:

```typescript
import { stegaClean } from "@sanity/client/stega";

const cleanAlign = stegaClean(align);
if (cleanAlign === "center") {
  // ...
}
```

### Module resolution errors in development

**Cause:** Vite's dev server fails to pre-bundle certain dependencies used by `@sanity/visual-editing` and its transitive imports.

**Fix:** Add the problematic modules to `vite.optimizeDeps.include` in `astro.config.mjs`:

```javascript
vite: {
  optimizeDeps: {
    include: [
      "react/compiler-runtime",
      "lodash/isObject.js",
      "lodash/groupBy.js",
      "lodash/keyBy.js",
      "lodash/partition.js",
      "lodash/sortedIndex.js",
    ],
  },
},
```

Note that `@sanity/astro` 3.5.0 and later pre-bundles a related set of modules automatically in dev, but it doesn't include the modules listed here, so these entries are still required.

### Draft mode not activating

**Cause:** The browser blocks the cookie because it requires `SameSite=None; Secure`, which in turn requires HTTPS (or localhost).

**Fix:** Ensure you're accessing the frontend via `localhost` (not an IP address or custom domain) during development. For deployed environments, ensure HTTPS is enabled.

### Draft mode works in Chrome but not Safari

**Cause:** Safari blocks third-party cookies that aren't partitioned. The Studio loads your frontend in a cross-site iframe, so the cookie set by `/api/draft-mode/enable` counts as third-party and never gets stored. The Presentation Tool reports "Unable to connect to visual editing" and draft mode never activates. Chrome is more permissive, which is why the same setup works there.

**Fix:** Set the CHIPS `Partitioned` attribute on the cookie when the enable route is hit from a cross-site iframe, and expire both the partitioned and unpartitioned variants in the disable route. Both route examples above do this. The client-side perspective rewrite in `SanityVisualEditing.tsx` needs the same attribute.

### Page titles contain garbled text in draft mode

**Cause:** If you render queried content in `<title>` or `<meta>` tags, stega characters will be embedded in them.

**Fix:** Use `stegaClean()` from `@sanity/client/stega` to strip encoding before inserting into metadata:

```typescript
import { stegaClean } from "@sanity/client/stega";
// In your .astro frontmatter:
const cleanTitle = stegaClean(post.title);
```

Then use `cleanTitle` in the `<title>` tag.

## Reference

### Key packages

- `sanity` (6.x): Sanity Studio
- `astro` (7.x): Astro framework
- `@sanity/astro` (3.5+): Sanity integration for Astro (client, stega config)
- `@astrojs/react` (6.x): React support for client-side components
- `@astrojs/node` (11.x): Node.js server adapter
- `@sanity/visual-editing` (5.x): Visual editing overlays and hooks
- `@sanity/preview-url-secret` (latest): Preview URL validation for draft mode
- `groq`: `defineQuery` for typed GROQ queries
- `@sanity/image-url` (2.1.x): Image URL generation
- `astro-portabletext` (0.x): Portable Text rendering for Astro

### File map

Every file involved in the visual editing integration, what it does, and what it depends on:

- `studio/sanity.config.ts` (Configures the Presentation Tool with the frontend's initial URL and `previewMode.enable` path): `studio/lib/resolve.ts`
- `studio/lib/resolve.ts` (Maps document types to frontend URLs for iframe navigation and location badges): Schema type names, frontend route structure in `src/pages/`
- `frontend/astro.config.mjs` (Astro config: SSR, `@sanity/astro` integration with `stega.studioUrl`, React, Vite optimizeDeps): `PUBLIC_SANITY_PROJECT_ID`, `PUBLIC_SANITY_DATASET`
- `frontend/src/env.d.ts` (Triple-slash references for `astro/client` and `@sanity/astro/module` type definitions): Nothing
- `frontend/src/sanity/lib/draft-mode.ts` (Reads draft mode and perspective cookies from `Astro.cookies`): Nothing
- `frontend/src/sanity/lib/load-query.ts` (Fetches content with perspective/stega switching based on draft mode): `sanity:client`, `SANITY_API_READ_TOKEN`
- `frontend/src/sanity/lib/queries.ts` (Centralized GROQ queries wrapped in `defineQuery`): `groq`
- `frontend/src/components/SanityVisualEditing.tsx` (History adapter, perspective cookie sync, content refresh via page reload): `@sanity/visual-editing/react`
- `frontend/src/components/DisableDraftMode.tsx` (Floating button to exit draft mode, hidden when inside the Presentation Tool): `@sanity/visual-editing/react`
- `frontend/src/components/PortableText.astro` (Renders Portable Text content using `astro-portabletext`): `astro-portabletext`
- `frontend/src/layouts/Layout.astro` (Shared layout: conditional visual editing components in draft mode): `SanityVisualEditing.tsx`, `DisableDraftMode.tsx`
- `frontend/src/pages/api/draft-mode/enable.ts` (Validates Presentation Tool secret, sets the perspective cookie): `sanity:client`, `@sanity/preview-url-secret`, `SANITY_API_READ_TOKEN`
- `frontend/src/pages/api/draft-mode/disable.ts` (Clears cookies, redirects to homepage): Nothing



# Architecture overview

Sanity's visual editing system lets content editors click on elements in a live preview to jump directly to the corresponding field in Sanity Studio. It also supports real-time content updates, so editors see changes reflected in the preview as they type.

This guide explains how the system works at an architectural level, without assuming any specific frontend framework. Whether you're integrating with an existing framework library like `next-sanity` or building a custom integration from scratch, understanding these layers will help you make informed decisions.

## What you'll learn

- The layered architecture of visual editing and how each layer contributes.
- How content flows from the Content Lake to a live preview.
- The role of Content Source Maps and stega encoding in click-to-edit.
- How the Presentation Tool communicates with your frontend.
- What framework libraries abstract away, and what you need to build yourself.

## Architecture layers

Visual editing is built from seven distinct layers. Each layer has a specific responsibility, and they compose together to create the full experience:

```text
┌────────────────────────────────────────────────────────┐
│  7. Framework libraries                                │
│     next-sanity, @nuxtjs/sanity, @sanity/svelte-loader │
├────────────────────────────────────────────────────────┤
│  6. Presentation Tool         (sanity/presentation)    │
│     Studio plugin: iframe preview, document routing    │
├────────────────────────────────────────────────────────┤
│  5. Preview authentication  (preview-url-secret)       │
│     Secure draft mode activation                       │
├────────────────────────────────────────────────────────┤
│  4. Data loading              (@sanity/core-loader)    │
│     Perspective switching, live updates                │
├────────────────────────────────────────────────────────┤
│  3. Overlays          (@sanity/visual-editing)         │
│     DOM scanning, click-to-edit UI                     │
├────────────────────────────────────────────────────────┤
│  2. Communication               (@sanity/comlink)      │
│     postMessage protocol between Studio and iframe     │
├────────────────────────────────────────────────────────┤
│  1. Foundation                  (@sanity/client)       │
│     Stega encoding, Content Source Maps, GROQ          │
└────────────────────────────────────────────────────────┘
```

### Layer 1: foundation (`@sanity/client`)

The Sanity client is the base layer. It handles GROQ queries, Content Source Maps, and stega encoding.

When you enable stega on the client, it requests Content Source Maps from the Content Lake and encodes source metadata into string values as invisible zero-width Unicode characters. This means every rendered string carries information about which document and field it came from, without any visible change to the content.

```typescript
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: 'YOUR_STUDIO_URL',
  },
})
```

The client also supports **perspectives**, which control whether queries return published content, draft content, or content from a specific release. Perspectives can be a single value like `'published'` or `'drafts'`, or a priority-ordered array like `['summer-drop', 'drafts', 'published']` that resolves content from the first matching perspective. This is the mechanism that powers preview mode and content releases.

### Layer 2: communication (`@sanity/comlink`)

Comlink provides a typed, bidirectional messaging protocol over the browser's `postMessage` API. It connects the Sanity Studio (parent window) with your frontend (child iframe) using HTTP-like semantics.

Key features of the protocol:

- **Connection handshaking:** automatic connection establishment with state tracking (idle, handshaking, connected, disconnected).
- **Heartbeat monitoring:** detects when the connection drops.
- **Origin validation:** restricts which origins can communicate, preventing unauthorized access.
- **Named endpoints:** routes messages between specific channels.

You don't interact with Comlink directly in most integrations. The `@sanity/visual-editing` package and the Presentation Tool use it internally to coordinate navigation, content refreshes, and click-to-edit events.

### Layer 3: overlays (`@sanity/visual-editing`)

This package scans the DOM for stega-encoded strings, decodes the embedded Content Source Maps, and draws transparent click-to-edit overlays on top of content elements. When an editor clicks an overlay, it sends a message through Comlink to the Studio, which navigates to the corresponding document and field.

```typescript
import { enableVisualEditing } from '@sanity/visual-editing'

const disable = enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      // Notify the Studio when the frontend URL changes
      const handler = () => navigate({ type: 'pop', url: location.href })
      addEventListener('popstate', handler)
      return () => removeEventListener('popstate', handler)
    },
    update: (update) => {
      // Handle navigation requests from the Studio
      if (update.type === 'push') history.pushState(null, '', update.url)
      if (update.type === 'replace') history.replaceState(null, '', update.url)
    },
  },
  refresh: async (payload) => {
    // Handle content refresh requests.
    // Return Promise<void> for async operations, or false to skip.
    if (payload.source === 'mutation') {
      // A document was edited in the Studio. Re-fetch content.
      // See "Real-time content updates" for full implementation.
    }
  },
})
```

The overlay system also supports manual data attributes for elements where stega encoding isn't available. Since stega only works on strings, you need data attributes for non-string content like images, numbers, and booleans. Use `createDataAttribute()` or set attributes directly:

```typescript
import { createDataAttribute } from '@sanity/visual-editing'

// Using createDataAttribute for structured annotations
const attr = createDataAttribute({
  id: 'post-1',
  type: 'post',
  path: 'mainImage',
  baseUrl: 'YOUR_STUDIO_URL',
})

const img = document.querySelector('.hero-image')
img?.setAttribute('data-sanity', attr.toString())
```

### Layer 4: data loading (`@sanity/core-loader`)

The core loader is a framework-agnostic foundation for data fetching with visual editing support. It handles perspective switching (including stacked perspectives for content releases), stega encoding based on preview state, and live content subscriptions.

```typescript
import { createQueryStore } from '@sanity/core-loader'

const queryStore = createQueryStore({ client })

// Create a reactive store for a specific query
const postStore = queryStore.createFetcherStore(
  '*[_type == "post" && slug.current == $slug][0]',
  { slug: 'my-post' }
)

// Subscribe to updates (the store fetches data when subscribed)
postStore.subscribe((state) => {
  // state.data contains the query result
  // state.sourceMap contains the Content Source Map
})
```

Framework-specific loaders build on top of this:

- `@sanity/react-loader` for React-based frameworks
- `@sanity/svelte-loader` for SvelteKit
- `@nuxtjs/sanity` for Nuxt

If you're building a custom integration, you can use `@sanity/core-loader` directly or work with `@sanity/client` at a lower level.

### Layer 5: preview authentication (`@sanity/preview-url-secret`)

This package handles secure activation of draft mode. When the Presentation Tool opens your frontend in an iframe, it calls an enable endpoint on your server. The `preview-url-secret` package validates that the request is legitimate by checking a shared secret against the Sanity API.

The typical flow:

1. The Presentation Tool sends a request to your `/api/draft-mode/enable` endpoint with a secret token.
2. Your server validates the token using `@sanity/preview-url-secret`.
3. If valid, your server sets a cookie or session flag to enable draft mode.
4. Subsequent requests serve draft content with stega encoding enabled.

### Layer 6: Presentation Tool (`sanity/presentation`)

The Presentation Tool is a Studio plugin that renders your frontend in an iframe. It manages the preview lifecycle: activating draft mode, synchronizing navigation between the Studio and your frontend, and displaying document location information.

It uses two types of resolvers to connect documents with frontend routes:

- **mainDocuments:** maps URL patterns to Sanity documents (for example, `/posts/:slug` resolves to a `post` document).
- **locations:** maps document types to the frontend URLs where they appear (for example, a `post` document appears at `/posts/my-post` and `/posts`).

### Layer 7: framework libraries

Libraries like `next-sanity`, `@nuxtjs/sanity`, and `@sanity/svelte-loader` wrap the lower layers into framework-idiomatic APIs. They handle the framework-specific parts that differ between environments:

- **Preview mode toggling:** Next.js Draft Mode, SvelteKit hooks, Nuxt middleware.
- **Data fetching patterns:** server components, composables, loaders.
- **Caching strategies:** Next.js cache tags, SvelteKit cache control.
- **Reactivity models:** React hooks, Svelte stores, Vue reactivity.

The underlying visual editing primitives are the same across all frameworks. The differences are in how each framework handles server-side rendering, routing, and state management.

#### Framework support matrix

| Framework | Support level | Notes |
| --- | --- | --- |
| Next.js (App Router) | Full | Page building experience via `defineLive`. Use `next-sanity`. |
| Next.js (Pages Router) | Full | Loaders pattern. Use `next-sanity`. |
| Remix | Full | Loaders pattern. |
| Nuxt | Full | Loaders pattern. Use `@nuxtjs/sanity`. |
| SvelteKit | Full | Loaders pattern. Use `@sanity/svelte-loader`. |
| Astro | Basic | Server-side support via SSR/hybrid mode. Use `@sanity/astro`. |
| Vanilla TypeScript or any framework | Basic | Direct use of `@sanity/visual-editing`, `@sanity/core-loader`, and `@sanity/client`. |

Frameworks marked **Full** include built-in helpers for draft mode, server-side fetching, and live updates. Frameworks marked **Basic** require server-side rendering and direct integration with the underlying packages.

## Key concepts

### Content Source Maps

[Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps) are metadata returned by the Content Lake that map every value in a query result back to its source document and field. They follow an open standard and are the foundation for click-to-edit functionality.

Request them by adding `resultSourceMap=true` to your GROQ queries. When stega is enabled, the client requests source maps automatically, using the `withKeyArraySelector` variant:

```json
{
  "result": [
    { "_id": "post-1", "title": "Hello World", "author": { "name": "Jane" } }
  ],
  "resultSourceMap": {
    "documents": [
      { "_id": "post-1", "_type": "post" },
      { "_id": "author-jane", "_type": "author" }
    ],
    "paths": [
      "$['title']",
      "$['author']['name']"
    ],
    "mappings": {
      "$['title']": {
        "type": "value",
        "source": { "type": "documentValue", "document": 0, "path": 0 }
      },
      "$['author']['name']": {
        "type": "value",
        "source": { "type": "documentValue", "document": 1, "path": 1 }
      }
    }
  }
}
```

The `mappings` object connects each value in the result to its source document and field. In this example, `$['title']` maps to document index `0` (`post-1`) at path index `0` (`$['title']`), while `$['author']['name']` maps to a different document (`author-jane`). The keys use JSONPath notation matching the query result structure.

### Stega encoding

Stega encoding embeds Content Source Map data as invisible characters in string values. It uses four zero-width Unicode characters as a base-4 encoding scheme:

| Character | Unicode | Name |
| --- | --- | --- |
| `​` | U+200B | Zero Width Space |
| `‌` | U+200C | Zero Width Non-Joiner |
| `‍` | U+200D | Zero Width Joiner |
| `﻿` | U+FEFF | Byte Order Mark |

A string like `"Oxford Shoes"` looks identical after encoding, but contains an appended sequence of invisible characters that encode the document ID, field path, and Studio URL.

**Automatic exclusions:** stega encoding skips values that would break if modified, including values at paths where the last segment starts with `_` (like `_id` and `_type`), URLs, ISO dates, non-string values, and `slug.current` paths. The client also maintains a 39-name denylist of common non-display field names. See [setting up the Sanity client](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega) for the full filtering rules.

**Cleaning encoded strings:** use `stegaClean()` before using stega-encoded values in non-display contexts like URL construction, string comparisons, or date parsing:

```typescript
import { stegaClean } from '@sanity/client/stega'

const slug = stegaClean(post.slug.current)
const url = `/posts/${slug}`
```

**Automatic clipboard cleanup:** As of `@sanity/visual-editing` 5.5.0, the `<VisualEditing />` component automatically strips stega encoding from text when users copy content from the preview page. This is enabled by default; opt out by passing the `keepStegaOnCopy` prop.

**Studio paste cleanup:** As of Sanity Studio 6.6.0, the Studio strips stega encoding from pasted text in all primitive fields: string, text, URL, slug, number, date, tags, and arrays of primitives. Portable Text already handled this; all primitive field types now do as well.

**Detecting stega in unsafe locations:** Use the opt-in `onSuspiciousStega` callback on `<VisualEditing />` to detect stega encoding in unsafe DOM locations such as element attributes, `<head>`, scripts, and the page URL. This helps identify places where stega-encoded strings are being used in non-display contexts that were not caught by the automatic exclusions.

### Perspectives

[Perspectives](https://www.sanity.io/docs/content-lake/perspectives) control which version of documents your queries return:

- **published** (default): returns only published documents. Results are CDN-cached and suitable for production.
- **drafts**: treats all drafts as if they were published. Results are not cached, ensuring editors always see the latest changes. Requires `useCdn: false`. If you leave the CDN enabled, the client disables it automatically and logs a warning.
- **raw**: returns documents with their actual `_id` (`drafts.`-prefixed documents appear alongside published versions). Drafts and versions are only returned when the request includes an auth token.
- **Stacked perspectives (array):** a priority-ordered list like `['summer-drop', 'drafts', 'published']`. The system resolves content by trying each perspective in order: first the release version, then drafts, then published. This is how content releases work: editors can preview how content will look when a specific release is published. Array perspectives require `useCdn: false`.

Switch perspectives using `client.withConfig()`:

```typescript
// Production: published content, CDN-cached
const publishedClient = client.withConfig({
  perspective: 'published',
  useCdn: true,
})

// Preview: draft content, always fresh
const previewClient = client.withConfig({
  perspective: 'drafts',
  useCdn: false,
})

// Content release: preview a specific release with drafts and published as fallbacks
const releaseClient = client.withConfig({
  perspective: ['summer-drop', 'drafts', 'published'],
  useCdn: false, // Required for array perspectives
})
```

## End-to-end flow

Here's how all the layers work together when an editor uses visual editing:

```text
1. Editor opens the Presentation Tool in Sanity Studio
   │
2. Studio loads your frontend in an iframe
   │
3. Studio calls /api/draft-mode/enable on your server
   │  └─ Your server validates the request (preview-url-secret)
   │  └─ Sets a draft mode cookie
   │
4. Frontend re-renders in draft mode
   │  └─ Queries use the "drafts" perspective
   │  └─ Stega encoding is active
   │
5. Content renders with invisible source metadata
   │  └─ enableVisualEditing() scans the DOM
   │  └─ Transparent overlays appear on content elements
   │
6. Editor clicks on a content element
   │  └─ Overlay decodes the stega data
   │  └─ Sends document ID and field path to Studio via Comlink
   │
7. Studio navigates to the document and focuses the field
   │
8. Editor changes the field value
   │  └─ Mutation saved to the Content Lake
   │  └─ Live Content API emits a sync tag
   │
9. Frontend receives the update
   │  └─ Re-fetches affected content
   │  └─ Page updates with the new value
```

## What framework libraries handle for you

If you use a framework library like `next-sanity`, it handles most of the integration work:

| Concern | Framework library | Custom integration |
| --- | --- | --- |
| Stega encoding | Automatic via client config | Automatic via client config |
| Perspective switching | Automatic based on preview state | Manual via `client.withConfig()` |
| Draft mode toggle | Provided (for example, `defineEnableDraftMode`) | Build your own enable/disable endpoints |
| Overlay rendering | Provided (for example, `<VisualEditing />`) | Call `enableVisualEditing()` directly |
| Live updates | Provided (for example, `<SanityLive />`) | Subscribe to the Live Content API |
| Caching and revalidation | Framework-optimized | Implement your own strategy |
| Router integration | Automatic | Wire up `history` callbacks manually |
| Stega clipboard cleanup | Strips stega from clipboard on copy events via `<VisualEditing />` (default on, opt out with `keepStegaOnCopy`); reports stega in unsafe DOM placements via opt-in `onSuspiciousStega` callback | Same behavior via `enableVisualEditing()` options (`keepStegaOnCopy`, `onSuspiciousStega`). Use `stegaClean()` manually before using encoded values in non-display contexts. |

The core primitives (stega encoding, Content Source Maps, Comlink, overlays) are the same regardless of framework. The differences are in how preview mode is toggled, how data is fetched, and how the UI reacts to changes.

## Packages at a glance

| Package | npm | Role |
| --- | --- | --- |
| `@sanity/client` | `@sanity/client` | GROQ queries, stega encoding, Content Source Maps |
| `@sanity/comlink` | `@sanity/comlink` | postMessage protocol between Studio and iframe |
| `@sanity/visual-editing` | `@sanity/visual-editing` | DOM overlays, click-to-edit, `enableVisualEditing()` |
| `@sanity/core-loader` | `@sanity/core-loader` | Framework-agnostic data loading with live updates |
| `@sanity/preview-url-secret` | `@sanity/preview-url-secret` | Secure draft mode activation and validation |
| `sanity/presentation` | `sanity` | Studio plugin for iframe preview and document routing |

## Next steps

With this architectural understanding, you're ready to start building:

- **Setting up the Sanity client for visual editing:** configure stega encoding, perspectives, and Content Source Maps.
- **Implementing preview/draft mode:** build secure enable/disable endpoints for your framework.
- **Enabling overlays and click-to-edit:** integrate `@sanity/visual-editing` with your frontend.
- **Real-time content updates:** subscribe to the Live Content API for instant preview updates.
- **Configuring the Presentation Tool:** set up the Studio plugin with document resolvers and preview URLs.



# Presentation Tool

The Presentation Tool is a Sanity Studio plugin that renders your frontend application inside an iframe, giving content editors a live preview with click-to-edit functionality. This guide covers how to configure it, set up document resolvers, handle multiple preview origins, and troubleshoot common issues.

All configuration happens in your `sanity.config.ts` file. The Presentation Tool works with any frontend that implements the visual editing protocol, regardless of framework. For an overview of how the Presentation Tool fits into the broader visual editing architecture, see the [architecture overview](https://www.sanity.io/docs/visual-editing/visual-editing-architecture).

## Prerequisites

- A Sanity Studio project with `sanity` v3.85.0 or later installed (Presentation ships in the `sanity` package from v3.20.0, but `allowOrigins` requires v3.85.0).
- A deployed (or locally running) frontend application.
- CORS configured in your Sanity project to allow requests from your frontend origin. Add origins under your project's API settings at [manage.sanity.io](https://manage.sanity.io).

## Basic setup

Install the Presentation Tool (included in the `sanity` package) and add it to your Studio configuration:

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'
import { structureTool } from 'sanity/structure'

export default defineConfig({
  name: 'default',
  title: 'My Studio',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [
    structureTool(),
    presentationTool({
      previewUrl: {
        initial: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
          // Optional. The Presentation Tool doesn't call this endpoint automatically
          disable: '/api/draft-mode/disable',
        },
      },
    }),
  ],
})
```

This configuration tells the Presentation Tool to:

1. Load your frontend at `http://localhost:3000` in an iframe.
2. Call `/api/draft-mode/enable` to activate draft mode when the preview opens.
3. Register `/api/draft-mode/disable` as an optional route editors can visit to exit draft mode. The Presentation Tool doesn't call it automatically.

## Configuration options

The `presentationTool()` function [accepts these options](https://reference.sanity.io/sanity/presentation/PresentationPluginOptions/):

| Option | Required | Description |
| --- | --- | --- |
| `previewUrl` | Yes | Preview URL configuration (see below) |
| `resolve` | No | Document-to-URL mapping with `locations` and `mainDocuments` |
| `allowOrigins` | No | Allowed iframe origins for security |
| `name` | No | Tool name used in the Studio URL. Default: `presentation` |
| `title` | No | Display title in Studio navigation. Default: `Presentation` |
| `icon` | No | Custom icon component for the navigation |
| `components` | No | Customize the preview header or add a navigator sidebar (`unstable_header`, `unstable_navigator`) |
| `devMode` | No | Enable development mode for debugging the Studio-to-preview connection |

### Preview URL configuration

The `previewUrl` option can be a string, an object, or a resolver function:

```typescript
// Simple: just a URL
presentationTool({
  previewUrl: 'http://localhost:3000',
})

// Full: URL with draft mode endpoints
presentationTool({
  previewUrl: {
    initial: 'http://localhost:3000',
    previewMode: {
      enable: '/api/draft-mode/enable',
      disable: '/api/draft-mode/disable',
    },
  },
})
```

When `previewMode` is configured, the Presentation Tool automatically calls the enable endpoint when the preview opens. It never calls the disable endpoint: the disable option exists in the plugin's types but is marked as not yet implemented, so editors exit draft mode by visiting that route directly. Both endpoints are relative to the `initial` URL.

The `previewMode` option also accepts a function, which lets one Studio preview several frontends. See the multiple frontends, one Studio section below.

To generate preview URLs dynamically based on the current document, use `resolve.mainDocuments` (covered in the document location resolvers section below). This maps URL patterns to document types, so the Presentation Tool can navigate the preview to the right page when an editor selects a document.

## Document location resolvers

Document location resolvers connect Sanity documents to frontend routes. They enable two features:

- **Automatic document display:** when an editor navigates to a URL in the preview, the Studio opens the corresponding document
- **"Used on" links:** the Studio shows editors where a document's content appears on the site

### Main documents (`defineDocuments`)

Main documents resolve the primary document for a given URL. When the preview iframe navigates to a new page, the Presentation Tool matches the URL against your route patterns and opens the corresponding document in the editor pane.

```typescript
import { defineConfig } from 'sanity'
import { presentationTool, defineDocuments } from 'sanity/presentation'

const mainDocuments = defineDocuments([
  {
    route: '/posts/:slug',
    filter: `_type == "post" && slug.current == $slug`,
  },
  {
    route: '/products/:slug',
    filter: `_type == "product" && slug.current == $slug`,
  },
  {
    route: '/products',
    type: 'productsListing',
  },
])

export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        initial: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
          disable: '/api/draft-mode/disable',
        },
      },
      resolve: {
        mainDocuments,
      },
    }),
  ],
})
```

Each entry in the array has:

- **route:** a URL pattern with named parameters (for example, `:slug`, `:year`). Parameters are extracted and passed as GROQ query variables.
- **filter:** a GROQ filter expression that identifies the document. Use `$paramName` to reference extracted URL parameters.
- **type:** shorthand for `filter: '_type == "typeName"'` when no parameters are needed.

The Presentation Tool evaluates routes in order and uses the **first match**. Place more specific routes before general ones.

#### Route patterns with multiple parameters

Routes can include multiple parameters:

```typescript
const mainDocuments = defineDocuments([
  {
    route: '/blog/:year/:month/:slug',
    filter: `_type == "post" && slug.current == $slug`,
  },
])
```

All named parameters (`:year`, `:month`, `:slug`) are available as GROQ variables in the filter expression.

### Document locations (`defineLocations`)

Document locations define where a document's content appears across your site. This powers the "Used on" panel in the Studio, showing editors all the pages that reference a given document.

```typescript
import { defineConfig } from 'sanity'
import { presentationTool, defineLocations } from 'sanity/presentation'

const locations = {
  post: defineLocations({
    select: {
      title: 'title',
      slug: 'slug.current',
    },
    resolve: (doc) => ({
      locations: [
        {
          title: doc?.title || 'Untitled',
          href: `/posts/${doc?.slug}`,
        },
        {
          title: 'All posts',
          href: '/posts',
        },
      ],
    }),
  }),

  product: defineLocations({
    select: {
      title: 'title',
      slug: 'slug.current',
      category: 'category->slug.current',
    },
    resolve: (doc) => ({
      locations: [
        {
          title: doc?.title || 'Untitled',
          href: `/products/${doc?.slug}`,
        },
        {
          title: 'Category page',
          href: `/categories/${doc?.category}`,
        },
        {
          title: 'All products',
          href: '/products',
        },
      ],
    }),
  }),

  // For documents used globally (like site settings), use a message instead
  siteSettings: defineLocations({
    message: 'This document is used on all pages',
    tone: 'caution',
  }),
}

export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        initial: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
          disable: '/api/draft-mode/disable',
        },
      },
      resolve: {
        locations,
      },
    }),
  ],
})
```

The `defineLocations` function accepts:

- **select:** a map of field names to GROQ projections. These fields are fetched from the document and passed to the `resolve` function.
- **resolve:** a function that receives the selected fields and returns an object with a `locations` array. Each location has a `title` and `href`.
- **message:** an optional string displayed instead of location links, useful for global documents like site settings.
- **tone:** visual tone for the message. Options: `caution`, `positive`, `critical`.

The `resolve` function is called reactively. As the editor changes document fields, the function re-runs and the location links update in real time. This means editors always see accurate "Used on" links, even for unsaved changes.

### Combining resolvers

Use both `mainDocuments` and `locations` together for the best editing experience:

```typescript
export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        initial: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
          disable: '/api/draft-mode/disable',
        },
      },
      resolve: {
        mainDocuments,
        locations,
      },
    }),
  ],
})
```

## Allowed origins

The `allowOrigins` option controls which frontend origins the Presentation Tool trusts for Comlink (`postMessage`) communication. This is a security measure that prevents unauthorized origins from exchanging messages with your Studio via the iframe.

```typescript
presentationTool({
  previewUrl: {
    initial: 'https://my-site.com',
    previewMode: {
      enable: '/api/draft-mode/enable',
      disable: '/api/draft-mode/disable',
    },
  },
  allowOrigins: [
    'http://localhost:3000',
    'http://localhost:3001',
    'https://my-site.com',
    'https://staging.my-site.com',
  ],
})
```

Origins support wildcard patterns for ports:

```typescript
allowOrigins: [
  'http://localhost:*',       // Any port on localhost
  'https://my-site.com',     // Exact match
  'https://*.my-site.com',   // Subdomains
]
```

If `allowOrigins` is not set, the Presentation Tool allows the origin from `previewUrl.initial` by default. If the origins you list don't match the resolved initial preview URL, that URL's origin is added to the allow list automatically. A wildcard-only hostname (allowing any site) is rejected as insecure.

> [!NOTE]
> **Security note:** only add origins you trust. Any allowed origin can send `postMessage` events to the Studio, run live preview queries, and access draft content.

## Multiple preview environments

For projects with staging and production environments, configure the preview URL dynamically:

```typescript
presentationTool({
  previewUrl: {
    initial: process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000',
    previewMode: {
      enable: '/api/draft-mode/enable',
      disable: '/api/draft-mode/disable',
    },
  },
  allowOrigins: [
    'http://localhost:*',
    'https://staging.my-site.com',
    'https://my-site.com',
  ],
})
```

Set the `SANITY_STUDIO_PREVIEW_URL` environment variable differently for each Studio deployment to point at the corresponding frontend environment.

## Multiple frontends, one Studio

A single `presentationTool()` instance can preview more than one frontend. Pass a function to `previewMode` to resolve the draft mode settings for each origin, and list every frontend in `allowOrigins`:

```typescript
const MARKETING_SITE = 'https://www.my-site.com'
const STOREFRONT = 'https://shop.my-site.com'

presentationTool({
  previewUrl: {
    initial: MARKETING_SITE,
    previewMode: ({targetOrigin}) => {
      if (targetOrigin === STOREFRONT) {
        return {enable: '/preview/enable'}
      }
      return {enable: '/api/draft-mode/enable'}
    },
  },
  allowOrigins: [MARKETING_SITE, STOREFRONT, 'http://localhost:*'],
})
```

The Presentation Tool calls `previewMode` each time it resolves a preview URL, passing the origin it's about to load as `targetOrigin`. Give each frontend its own `enable` route, or return `false` to skip draft mode for an origin entirely. Relative routes resolve against the origin being previewed, so each frontend serves its own draft mode endpoints. The function can also be async, which is useful when the route depends on a lookup.

To show editors where a document appears on each site, return a location per frontend from `resolve.locations`, as described in the document location resolvers section above.

## Draft mode endpoints

Your frontend must implement two HTTP endpoints that the Presentation Tool calls to toggle draft mode. The Presentation Tool navigates the iframe to the enable URL with query parameters. Your endpoint validates the request, sets a cookie, and redirects the iframe to the preview page.

### Enable endpoint

When the Presentation Tool opens, it navigates the iframe to your enable endpoint with a secret token and a redirect path as query parameters:

```text
GET /api/draft-mode/enable?sanity-preview-secret=<token>&sanity-preview-pathname=<path>&sanity-preview-perspective=<perspective>
```

Here's a framework-agnostic implementation using the Web API `Request` and `Response` objects:

```typescript
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { client } from './sanity-client'

export async function handleEnableDraftMode(request: Request): Promise<Response> {
  // validatePreviewUrl checks the secret against the Sanity API
  const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
    client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
    request.url
  )

  if (!isValid) {
    return new Response('Invalid secret', { status: 401 })
  }

  const cleanRedirect = redirectTo
    ? withoutSecretSearchParams(new URL(redirectTo, request.url)).pathname
    : '/'

  // Set the perspective cookie. Serves as both draft mode indicator and perspective value
  const perspective = studioPreviewPerspective || 'drafts'
  const headers = new Headers()
  headers.append(
    'Set-Cookie',
    `${perspectiveCookieName}=${perspective}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=3600`
  )
  headers.set('Location', cleanRedirect)

  return new Response(null, { status: 307, headers })
}
```

The `validatePreviewUrl` function from `@sanity/preview-url-secret` verifies that the secret token was generated by the Presentation Tool. This prevents unauthorized users from activating draft mode.

### Disable endpoint

The Presentation Tool doesn't call this endpoint. Implement it as a route that editors (or your application) can visit directly to clear the perspective cookie and return to published content:

```typescript
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'

export async function handleDisableDraftMode(request: Request): Promise<Response> {
  return new Response(null, {
    status: 307,
    headers: {
      'Set-Cookie': `${perspectiveCookieName}=; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=0`,
      Location: '/',
    },
  })
}
```

### Checking draft mode status

In your application code, check the perspective cookie to determine whether to serve draft or published content. The cookie's presence indicates draft mode is active, and its value specifies the perspective:

```typescript
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'

function isDraftMode(request: Request): boolean {
  const cookies = request.headers.get('Cookie') || ''
  return cookies.includes(`${perspectiveCookieName}=`)
}

// Use the appropriate client configuration based on draft mode
const preview = isDraftMode(request)
const perspective = preview ? 'drafts' : 'published'
const data = await client
  .withConfig({
    perspective,
    useCdn: !preview,
    // Token required server-side to fetch draft/release content
    ...(preview && { token: process.env.SANITY_API_READ_TOKEN }),
  })
  .fetch(query, params)
```

For a complete implementation, see the guide on [implementing preview/draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode).

## Troubleshooting

### The preview iframe shows a blank page

- **Check CORS:** your Sanity project must allow requests from the frontend origin. Verify this in your project's API settings at [manage.sanity.io](https://manage.sanity.io).
- **Check the preview URL:** confirm the `initial` URL is correct and the frontend is running.
- **Check iframe restrictions:** some hosting providers set `X-Frame-Options` or `Content-Security-Policy` headers that prevent embedding. Your frontend must allow being framed by your Studio's origin.

### Click-to-edit overlays don't appear

- **Stega encoding must be active:** stega encodes editing metadata as invisible characters in the text your frontend renders. Verify that your client has `stega: { enabled: true }` and that draft mode is enabled.
- **enableVisualEditing() must be called:** your frontend needs to initialize the overlay system. Check that it runs when draft mode is active.
- **Check allowOrigins:** the frontend origin must be in the allowed list for Comlink messages to flow between the Studio and iframe.

### Copied text and stega encoding

The `<VisualEditing />` component automatically strips stega encoding from clipboard data on copy events by default, so copied text is clean for end users with no additional configuration needed. Sanity Studio also automatically strips stega from text pasted into any primitive field, so stega encoding does not leak into Studio-edited content.

### Document doesn't open when clicking an element

- **Content Source Maps must be present:** Content Source Maps link each value in a query response back to its source document and field. The client must request them (automatic when stega is enabled). Verify by checking the network tab for `resultSourceMap` in GROQ query responses.
- **Check the stega-encoded data:** inspect the rendered HTML for zero-width characters in text content. If they're missing, stega encoding may not be active.

### Navigation doesn't sync between Studio and preview

- **Router integration required:** your `enableVisualEditing()` call must include a `history` option that wires up your router's navigation events. Without this, the Studio can't detect URL changes in the iframe.
- **Check mainDocuments configuration:** if routes don't match, the Studio won't know which document corresponds to the current URL.

### Live updates don't appear

- **Draft mode must be active:** live updates require the `drafts` perspective with `useCdn: false`.
- **Live Content API subscription required:** your frontend must subscribe to content changes and trigger re-fetches. This is handled automatically by framework libraries but must be implemented manually in custom integrations.
- **Check authentication:** the Live Content API requires a valid token for draft content. Verify your token has read access to the dataset.

## Next steps

- **Setting up the Sanity client for visual editing:** configure stega encoding and perspectives.
- **Implementing preview/draft mode:** build the enable/disable endpoints referenced in this guide.
- **Enabling overlays and click-to-edit:** integrate `@sanity/visual-editing` with your frontend.



# Client setup and stega

The Sanity client is the foundation of visual editing. When configured for visual editing, it requests Content Source Maps from the Content Lake and embeds source metadata into string values using stega encoding. This invisible metadata powers click-to-edit overlays and connects rendered content back to its source documents and fields in Sanity Studio.

This guide covers how to configure the client for visual editing, how stega encoding and Content Source Maps work at a technical level, how to handle encoded values in your application, and how to use perspectives to switch between published and draft content.

## Prerequisites

- A Sanity project with content in the Content Lake
- The `@sanity/client` package installed (see [getting started with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started))
- A Studio URL where editors access Sanity Studio

## Install the client

**npm**

```shell
npm install @sanity/client
```

**pnpm**

```shell
pnpm add @sanity/client
```

**yarn**

```shell
yarn add @sanity/client
```

**bun**

```shell
bun add @sanity/client
```

## Basic configuration

Enable stega encoding by setting `stega.enabled` to `true` and providing your Studio URL:

```typescript
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: 'YOUR_STUDIO_URL',
  },
})
```

When `stega.enabled` is `true`, the client automatically:

1. Requests Content Source Maps from the API by adding `resultSourceMap: 'withKeyArraySelector'` to every query. The `withKeyArraySelector` format uses stable `_key`-based selectors for array items instead of numeric indices, which prevents overlays from breaking when array items are reordered.
2. Dynamically imports the stega encoding module (keeping it out of your production bundle when disabled).
3. Encodes source metadata into every string value in the query result as invisible zero-width Unicode characters.
4. Cleans query parameters with `stegaClean()` before sending them to the API, preventing stega-encoded strings from a previous query result from corrupting subsequent queries.

## How Content Source Maps work

Content Source Maps are metadata returned by the Content Lake that map every value in a query result back to its source document and field. They use a compact, index-based structure to minimize payload size.

When you fetch with stega enabled, the API returns both the result and its source map:

```typescript
// The client handles this internally, but here's what the raw response looks like
const response = await client.fetch(query, params, { filterResponse: false })

// response.result contains your query data
// response.resultSourceMap contains the Content Source Map
```

A Content Source Map has three parts:

```json
{
  "documents": [
    { "_id": "post-1", "_type": "post" },
    { "_id": "author-jane", "_type": "author" }
  ],
  "paths": [
    "$['title']",
    "$['name']"
  ],
  "mappings": {
    "$['title']": {
      "type": "value",
      "source": { "type": "documentValue", "document": 0, "path": 0 }
    },
    "$['author']['name']": {
      "type": "value",
      "source": { "type": "documentValue", "document": 1, "path": 1 }
    }
  }
}
```

- **documents:** an array of source documents, each with `_id` and `_type`. For cross-dataset references, documents also include `_projectId` and `_dataset`.
- **paths:** an array of JSON path strings pointing to fields in the source documents.
- **mappings:** a map connecting result paths to their sources. Each mapping's `document` and `path` values are indices into the `documents` and `paths` arrays.

The index-based structure avoids duplication. When multiple values come from the same document, they all reference the same index in the `documents` array.

### Mapping types

Not all values in a query result have a direct document source. Mappings have three source types:

- **documentValue:** the value comes directly from a document field. This is the most common type and the one that enables click-to-edit.
- **literal:** the value is computed or literal (for example, a GROQ projection that concatenates strings). These values can't be traced to a single field.
- **unknown:** the source can't be determined. These are skipped during encoding.

Only `documentValue` sources are stega-encoded. The other types are left unchanged.

## How stega encoding works

Stega encoding embeds Content Source Map data as invisible characters appended to string values. The encoding uses four zero-width Unicode characters as a base-4 alphabet:

| Value | Unicode | Name |
| --- | --- | --- |
| 0 | U+200B | Zero Width Space |
| 1 | U+200C | Zero Width Non-Joiner |
| 2 | U+200D | Zero Width Joiner |
| 3 | U+FEFF | Byte Order Mark |

For each string value with a `documentValue` mapping, the client:

1. Builds a Studio intent URL from the source document ID, type, and field path.
2. Creates a JSON payload: `{"origin":"sanity.io","href":"<studio-intent-url>"}`.
3. UTF-8 encodes the JSON to bytes.
4. Encodes each byte as four invisible characters (two bits per character).
5. Prepends a four-character marker (four U+200B characters) to identify the encoded sequence.
6. Appends the entire invisible sequence to the original string value.

A typical payload is around 200 bytes, resulting in approximately 800 invisible characters per encoded string. These characters are invisible when rendered in browsers but detectable by JavaScript, which is how the overlay system finds and decodes them.

### What gets encoded

The client encodes string values that have a `documentValue` source in the Content Source Map. It skips values that would break if invisible characters were appended.

### What gets skipped

The default filter skips these values automatically (39 field names in the denylist, plus pattern-based rules):

- **Dates:** strings matching a date pattern (for example, `2026-07-01`)
- **URLs:** strings that parse as URLs with recognized protocols (http, https, mailto, tel, and others)
- **Slugs:** values at paths ending with `slug.current`
- **Internal keys:** values at paths where the last segment starts with `_` (for example, `_type`, `_ref`)
- **ID-like fields:** values at paths ending with `Id` (for example, `projectId`)
- **SEO and metadata paths:** values under `meta`, `metadata`, `openGraph`, or `seo` path segments
- **Type-related paths:** values at paths containing "type" (for example, `iconType`, `blockType`)
- **Denylisted field names:** 39 specific field names including `color`, `email`, `hex`, `href`, `icon`, `url`, `path`, `slug`, and others that are commonly used in non-display contexts

The client also optimizes for Portable Text: when walking the result tree, it only traverses `children` for block types and `text` for span types, skipping internal metadata like `markDefs` and `style`.

### Encoding non-string fields

Stega only encodes string values, so number, boolean, and other non-string fields aren't editable by default. To make a number field editable, cast it to a string in your GROQ query:

```groq
*[_type == "property"]{
  name,
  description,
  "beds": string(beds),
  "bathrooms": string(bathrooms)
}
```

Then parse the value back to a number in your renderer:

```typescript
import { stegaClean } from '@sanity/client/stega'

const beds = Number(stegaClean(post.beds))
```

The same pattern works for any non-string field. Use `string()` in GROQ for the editable representation, and convert back in your application code when you need the typed value.

### Custom filtering

Override the default filter to control which values get encoded:

```typescript
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: 'YOUR_STUDIO_URL',
    filter: (props) => {
      // Skip encoding for a specific document type
      if (props.sourceDocument._type === 'icon') return false

      // Skip encoding for a specific field
      if (props.sourcePath.at(-1) === 'cssClass') return false

      // Fall back to the default filter for everything else
      return props.filterDefault(props)
    },
  },
})
```

The filter function receives:

- **value:** the string value being considered for encoding
- **sourcePath:** the path in the source document
- **resultPath:** the path in the query result
- **sourceDocument:** the source document reference (`_id`, `_type`, and optionally `_projectId`, `_dataset`)
- **filterDefault:** a reference to the default filter function, so you can compose custom logic on top of it

Return `true` to encode the value, `false` to skip it.

## Cleaning stega-encoded values

Stega-encoded strings contain invisible characters that can break non-display operations like string comparisons, URL construction, date parsing, and length checks. Use `stegaClean()` to strip the encoding before using values in these contexts:

```typescript
import { stegaClean } from '@sanity/client/stega'

// Clean a single value
const slug = stegaClean(post.slug.current)
const url = `/posts/${slug}`

// Clean a date string before parsing
const date = new Date(stegaClean(post.publishedAt))

// Clean an entire object (deep clean)
const cleanPost = stegaClean(post)
```

`stegaClean()` performs a deep clean: it serializes the value to JSON, strips all invisible character sequences using a regex, and parses it back. This works on strings, objects, and arrays.

The client also cleans query parameters automatically when stega is enabled, so you don't need to manually clean values passed as GROQ query parameters.

## Automatic stega cleanup

As of `@sanity/visual-editing` 5.5.0 and Sanity Studio 6.6.0, stega cleanup now happens automatically in two places, reducing the need for manual `stegaClean()` calls in common scenarios.

### Clipboard cleanup in `<VisualEditing />`

`<VisualEditing />` now strips stega from the clipboard by default when users copy text from a preview page. Both `text/plain` and `text/html` clipboard flavors are cleaned, so rich-text pastes and stega in HTML attribute values (like `img` `alt` text) are also covered. Copies from `input` and `textarea` elements only rewrite the plain-text flavor, since those selections have no HTML representation.

To opt out, use the `keepStegaOnCopy` prop, `<VisualEditing keepStegaOnCopy />`, to disable this behavior if you need to preserve stega in copied content.

### Paste cleanup in Sanity Studio

Sanity Studio strips stega from pasted text in all primitive fields: `string`, `text`, `email`, `url`, `tel`, `number`, `date`, `datetime`, `slug`, `tags`, and arrays of primitive values. Portable Text Editor fields already clean pasted content through the editor's own paste handling. Only pastes that actually contain stega are intercepted; normal paste behavior is unchanged.

## Perspectives

Perspectives control which version of documents your queries return. They're the mechanism that switches between published content (for production), draft content (for preview), and content release versions.

### `published` (default)

Returns only published documents. Results are CDN-cached and suitable for production:

```typescript
const publishedClient = client.withConfig({
  perspective: 'published',
  useCdn: true,
})
```

### `drafts`

Treats all drafts as if they were published. References between draft documents resolve normally. Results are not cached, ensuring editors always see the latest changes:

```typescript
const previewClient = client.withConfig({
  perspective: 'drafts',
  useCdn: false, // Required: CDN only caches published content
})
```

### `raw`

Returns documents with their actual `_id` prefixes intact. Draft documents appear with their `drafts.` prefix alongside published versions. For authenticated requests only:

```typescript
const rawClient = client.withConfig({
  perspective: 'raw',
  useCdn: false,
})
```

### Stacked perspectives (arrays)

For content releases, perspectives can be a priority-ordered array. The system resolves content by trying each perspective in order, returning the first match:

```typescript
const releaseClient = client.withConfig({
  perspective: ['summer-drop', 'drafts', 'published'],
})
```

This tells the client: "Show the `summer-drop` release version of each document if it exists, fall back to the draft version, then fall back to the published version." Release IDs are arbitrary strings assigned when the release is created in the Studio.

CDN caching must be disabled for array perspectives (`useCdn: false`), as with the `drafts` perspective.

The Presentation Tool communicates the active perspective to your frontend (including release perspectives) via the `sanity-preview-perspective` URL search parameter. Framework integrations typically persist it in a cookie of the same name for server-side fetches. See [implementing draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode) for how to parse this value and pass it to the client.

### Switching perspectives based on preview state

In practice, you switch perspectives based on whether draft mode is active. Rather than hardcoding `'drafts'`, use the perspective value communicated by the Presentation Tool, which may be a stacked array for content releases:

```typescript
import { createClient, type ClientPerspective } from '@sanity/client'

const baseClient = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: false,
    studioUrl: 'YOUR_STUDIO_URL',
  },
})

export function getClient(perspective: ClientPerspective = 'published') {
  const isPreview = perspective !== 'published'
  return baseClient.withConfig({
    perspective,
    useCdn: !isPreview,
    stega: { enabled: isPreview },
    // Token required server-side to fetch draft/release content.
    // The API silently returns only published documents without it.
    ...(isPreview && { token: process.env.SANITY_API_READ_TOKEN }),
  })
}
```

> [!NOTE]
> **Security note:** the token is used server-side only. Your server renders HTML with draft content, but the token itself never reaches the browser. Make sure `SANITY_API_READ_TOKEN` is not prefixed with `VITE_`, `NEXT_PUBLIC_`, or any other prefix that exposes environment variables to client-side code.

The `withConfig()` method creates a new client instance with merged configuration. Stega config is merged shallowly, so you can toggle individual properties without repeating the full stega object.

### Per-request stega override

Disable stega encoding for a single query without creating a new client:

```typescript
// Fetch without stega encoding (e.g., for sitemap generation)
const posts = await client.fetch(
  '*[_type == "post"]{ title, "slug": slug.current }',
  {},
  { stega: false }
)
```

This is useful when you need clean values for a specific operation (like generating a sitemap or RSS feed) but want stega enabled for the rest of your application.

## Dynamic Studio URLs

If your Studio URL varies by document type or dataset, pass a function instead of a string:

```typescript
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: (sourceDocument) => {
      // Route cross-dataset references to a different Studio
      if (sourceDocument._projectId && sourceDocument._projectId !== 'YOUR_PROJECT_ID') {
        return `https://other-studio.sanity.studio`
      }
      return 'YOUR_STUDIO_URL'
    },
  },
})
```

The function receives the source document reference (including `_id`, `_type`, and optionally `_projectId` and `_dataset` for cross-dataset references) and returns a Studio URL string or an object with `baseUrl`, `workspace`, and `tool` properties.

## Studio base paths and workspaces

The value you give `studioUrl` has to resolve to the Studio's base path, including any subpath the Studio is served under. A Studio embedded in a Next.js app at `/studio` needs `https://example.com/studio`, not `https://example.com`.

A Studio that defines more than one workspace gives each workspace its own `basePath`, and the intent URL needs that path segment. Pass the object form so the client can build it:

```typescript
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    // The Studio serves this workspace at https://YOUR_STUDIO_URL/production
    studioUrl: {
      baseUrl: 'https://YOUR_STUDIO_URL',
      workspace: 'production',
    },
  },
})
```

`workspace` is the workspace's base path segment, not necessarily its `name`. The two match under the usual convention of pairing `name: 'production'` with `basePath: '/production'`, but the Studio resolves an incoming URL against `basePath`, so use whatever that workspace's `basePath` is. A single-workspace Studio can leave it out: the client skips the segment when `workspace` is unset or `default`.

> [!NOTE]
> An incomplete `studioUrl` still works inside the Presentation Tool, because overlay clicks reach the Studio over its Comlink connection and the Studio already knows its own workspace. The same page opened directly in a browser tab has to build a real URL, which is where a missing base path or workspace shows up as a broken link. Test both.

## The Studio intent URL

When the overlay system decodes stega data from a string, it extracts a Studio intent URL that points directly to the source document and field. The URL follows this format:

```text
{baseUrl}/{workspace}/intent/edit/mode=presentation;id={id};type={type};path={path}[;tool={tool}]
```

- **baseUrl:** your Studio URL
- **workspace:** the workspace's base path segment (omitted when unset or `default`)
- **id:** the published document ID (draft and version prefixes are stripped)
- **type:** the document `_type`
- **path:** the field path in Studio path format (for example, `title`, `body[0].children[0].text`)
- **tool:** the Studio tool (omitted if `default`)

The URL always carries search parameters mirroring the intent (`baseUrl`, `id`, `type`, and `path`, plus `workspace` and `tool` when set). Two more are worth noting:

- **perspective:** included for published IDs (`?perspective=published`) and version IDs (for example, `?perspective=summer-drop`). Draft IDs don't include a perspective parameter.
- **projectId and dataset:** included for cross-dataset references, so the Studio knows which project and dataset the document belongs to.

## Troubleshooting

### Stega characters appear as visible junk in the UI

This usually means the content is being rendered in a context that doesn't support zero-width characters (for example, a plain-text email or a terminal). Use `stegaClean()` to strip encoding before outputting to non-browser contexts.

### Stega encoding breaks string comparisons or URL routing

Use `stegaClean()` before comparing or using values in logic. The default filter skips `slug.current` paths and URLs, but custom fields used in routing may need manual cleaning.

### Content Source Maps are missing from API responses

- **Check the API version:** Content Source Maps require API version `2021-03-25` or later.
- **Check stega configuration:** when `stega.enabled` is `true`, the client automatically requests source maps. If you're requesting them manually, use `resultSourceMap: 'withKeyArraySelector'`.

### Overlays don't appear on some content

- **Check the filter:** the default filter skips dates, URLs, slugs, and 39 denylisted field names. If a field you expect to be clickable is being skipped, use a custom filter to include it.
- **Check the mapping type:** only `documentValue` sources are encoded. Computed values (GROQ projections, coalescing) may have `literal` or `unknown` source types that can't be traced to a single field.

### Stega characters appear in element attributes, the page head, or scripts

Stega in rendered text is intentional and powers click-to-edit, but stega in HTML attributes, `<head>`, `<script>`/`<style>` tags, `textarea` values, or URLs always causes bugs. Use the `onSuspiciousStega` callback on `<VisualEditing />` (in development only) to find these cases. See the "Detecting stega in unsafe DOM locations" section below for details.

### Debugging stega encoding

To see which fields are being encoded and which are being skipped, pass `console` as the `logger` option:

```typescript
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    logger: console,
    studioUrl: '/studio',
  },
})
```

The client logs a table of encoded paths (with values and lengths) and a list of skipped paths to the console on each query. This works in both browser dev tools and server-side logs.

To inspect the raw Content Source Map for a single query, set `filterResponse: false` on the fetch call:

```typescript
const { result, resultSourceMap } = await client.fetch(
  query,
  params,
  { filterResponse: false }
)

console.log(resultSourceMap)
```

Without `filterResponse: false`, the client returns only the `result` field. With it set to `false`, the full API response (including `resultSourceMap`) is returned, which is useful when debugging mapping issues or building custom Content Source Map tooling.

### Detecting stega in unsafe DOM locations

The `onSuspiciousStega` callback on `<VisualEditing />` is an opt-in DOM audit that reports stega found in places where it always causes bugs: wrong element attributes (`class`, `id`, `href`, `src`, `style`, `data-*`, and other URL attributes), inside `<head>` (`title`, `meta[content]`, JSON-LD), in `<script>` or `<style>` text content, in `textarea` form values, and in the page URL itself.

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports)
      console.warn(`Stega found in ${report.kind}`, report)
  }}
/>
```

> [!WARNING]
> This callback runs a full DOM audit using `TreeWalker` plus a `MutationObserver` and has a performance cost. Only use it during development or debugging. Do not enable it in production.

Each report includes the offending element, attribute name (when applicable), the raw value, the cleaned value, and a `sanity` property with decoded edit info when available. The `report.kind` field identifies the category of unsafe location:

- `attribute`: stega found in an element attribute such as `class`, `id`, `href`, `src`, `style`, or `data-*`
- `head`: stega found inside `<head>`, including `title`, `meta[content]`, and JSON-LD script blocks
- `script`: stega found in `<script>` text content
- `style`: stega found in `<style>` text content
- `form-value`: stega found in a `textarea` or other form field value
- `url`: stega found in the page URL itself

### Bundle size concerns

The stega encoding module is dynamically imported and only loaded when `stega.enabled` is `true`. In production (where stega is typically disabled), the encoding code is not included in your bundle.

### Advanced: Content Source Map utilities for framework authors

The client exports lower-level Content Source Map utilities at `@sanity/client/csm`, including `resolveMapping()`, `resolveEditInfo()`, `createEditUrl()`, `walkMap()`, and `applySourceDocuments()`. The `applySourceDocuments()` function is particularly useful for optimistic updates, where you apply local document changes to a query result using the CSM for field tracing. See [live preview content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates) for more on live update patterns.

## Next steps

- **Architecture overview:** understand how the client fits into the broader visual editing system
- **Implementing draft mode:** build the endpoints that toggle between perspectives
- **Enabling overlays and click-to-edit:** use the stega-encoded content to power click-to-edit
- **Live preview content updates:** keep the preview in sync with Studio edits
- **Configuring the Presentation Tool:** set up the Studio plugin that hosts the preview



# Implement draft mode

Draft mode (also called preview mode) lets content editors see unpublished changes in a live frontend preview. When active, your application fetches draft content instead of published content, enables stega encoding for click-to-edit overlays, and disables CDN caching to ensure editors always see the latest changes.

This guide walks through implementing preview mode from scratch using `@sanity/preview-url-secret` for secure activation and standard Web APIs for the endpoints. The approach works with any server-side framework or runtime.

## How draft mode works

The Presentation Tool in Sanity Studio activates preview mode through a secure handshake:

1. The editor opens the Presentation Tool, which renders your frontend in an iframe.
2. The Studio generates a cryptographic secret and stores it as a draft document in your dataset.
3. The Studio navigates the iframe to your enable endpoint, passing the secret as a query parameter.
4. Your endpoint validates the secret against the Sanity API.
5. If valid, your endpoint sets a secure cookie and redirects to the preview page.
6. Subsequent requests check the cookie and serve draft content when present.

```text
Studio                          Frontend
  │                                │
  ├─ Generate secret ──────────►  (stored in dataset)
  │                                │
  ├─ Navigate iframe to ────────► /api/draft-mode/enable
  │   ?sanity-preview-secret=abc    ?sanity-preview-secret=abc
  │   &sanity-preview-pathname=/    &sanity-preview-pathname=/blog
  │   &sanity-preview-perspective=  &sanity-preview-perspective=drafts
  │                                │
  │                                ├─ Validate secret against API
  │                                ├─ Set perspective cookie
  │                                │   (doubles as draft mode flag)
  │                                └─ Redirect to /blog
  │                                │
  │                          ◄──── Page renders with draft content
```

Secrets expire after one hour and are garbage-collected when new secrets are created. Each time the Presentation Tool opens a preview, it generates a fresh secret.

## Prerequisites

- A Sanity project with the Presentation Tool configured (see [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool))
- A server-side runtime that can handle HTTP requests and set cookies
- A [Sanity API token](https://www.sanity.io/docs/content-lake/http-auth) with read access to your dataset (for validating secrets)

## Install dependencies

**npm**

```shell
npm install @sanity/preview-url-secret @sanity/client
```

**pnpm**

```shell
pnpm add @sanity/preview-url-secret @sanity/client
```

**yarn**

```shell
yarn add @sanity/preview-url-secret @sanity/client
```

**bun**

```shell
bun add @sanity/preview-url-secret @sanity/client
```

This guide was tested with `@sanity/preview-url-secret` v4 and `@sanity/client` v7.

## Set up the Sanity client

Create a client configured for secret validation. The client needs a token because preview secrets are stored as draft documents, which require authentication to read.

**lib/sanity-client.ts**

```typescript
import { createClient } from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-07-01',
  useCdn: false,
  token: process.env.SANITY_API_READ_TOKEN,
})
```

> [!WARNING]
> Important
> The token must have read access to draft and version documents. Never expose this token to the browser. It should only be used in server-side code.

## Build the enable endpoint

The enable endpoint validates the preview secret and activates draft mode by setting a cookie.

**api/draft-mode/enable.ts**

```typescript
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { client } from '../../lib/sanity-client'

export async function handleEnableDraftMode(request: Request): Promise<Response> {
  // Validate the secret against the Sanity API.
  // This checks that the secret exists as a draft document
  // and was updated within the last hour.
  // It also checks shared access secrets (no TTL) in the same query,
  // so this endpoint automatically supports both regular preview
  // and shared preview access without additional code.
  const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
    client,
    request.url
  )

  if (!isValid) {
    return new Response('Invalid or expired preview secret', { status: 401 })
  }

  // Build the redirect URL, stripping secret params for clean URLs
  const cleanRedirect = redirectTo
    ? withoutSecretSearchParams(new URL(redirectTo, request.url)).pathname
    : '/'

  // Safari 18.4+ blocks unpartitioned third-party cookies. When the request
  // comes from a cross-site iframe (the Presentation Tool), add the CHIPS
  // Partitioned attribute so the cookie is stored under the Studio's partition.
  const partitioned =
    request.headers.get('sec-fetch-dest') === 'iframe' &&
    request.headers.get('sec-fetch-site') === 'cross-site'

  // Set the perspective cookie. This serves as both the draft mode indicator
  // and the perspective value. If the Studio didn't send a perspective,
  // default to 'drafts'. The value is URL-encoded because stacked
  // perspectives contain commas, which aren't valid in raw cookie values.
  const perspective = studioPreviewPerspective || 'drafts'
  const cookieAttributes = [
    `${perspectiveCookieName}=${encodeURIComponent(perspective)}`,
    'Path=/',
    'HttpOnly',
    'Secure',
    'SameSite=None',
    'Max-Age=3600',
  ]
  if (partitioned) {
    cookieAttributes.push('Partitioned')
  }

  const headers = new Headers()
  headers.append('Set-Cookie', cookieAttributes.join('; '))
  headers.set('Location', cleanRedirect)

  return new Response(null, { status: 307, headers })
}
```

Key details about the enable endpoint:

- **validatePreviewUrl** queries your dataset for a matching secret document. It checks both per-session secrets (one-hour TTL) and shared access secrets (no TTL) in a single GROQ query. Internally, it overrides your client configuration with `perspective: 'raw'`, `useCdn: false`, and a pinned API version, so you don't need to configure the client specially for validation.
- **studioPreviewPerspective** is the perspective the Studio requested. This is usually `drafts`, but may be a comma-separated stacked perspective like `summer-drop,drafts,published` when the editor is previewing a content release. Persisting it as a cookie lets your application use the exact perspective the editor is working in.
- **withoutSecretSearchParams** strips the secret-related query parameters from the redirect URL, keeping URLs clean in the browser's address bar and avoiding accidental secret exposure in logs or analytics.
- **SameSite=None** is required because the frontend runs inside a Studio iframe (cross-origin context). The `Secure` flag is also required when using `SameSite=None`.
- **Partitioned** is added when the request arrives from a cross-site iframe. Safari 18.4 and later blocks unpartitioned third-party cookies, so without the CHIPS attribute the browser silently drops the cookie inside the Presentation Tool.
- **Max-Age=3600** matches the one-hour secret TTL. The cookie expires at the same time the secret would.

## Build the disable endpoint

The disable endpoint clears the perspective cookie:

**api/draft-mode/disable.ts**

```typescript
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'

export async function handleDisableDraftMode(request: Request): Promise<Response> {
  const expired = [
    `${perspectiveCookieName}=`,
    'Path=/',
    'HttpOnly',
    'Secure',
    'SameSite=None',
    'Max-Age=0',
  ]

  const headers = new Headers()
  // Clear both the unpartitioned and partitioned variants of the cookie,
  // since either may have been set depending on the browser and context
  headers.append('Set-Cookie', expired.join('; '))
  headers.append('Set-Cookie', [...expired, 'Partitioned'].join('; '))
  headers.set('Location', '/')

  return new Response(null, { status: 307, headers })
}
```

Setting `Max-Age=0` tells the browser to delete the cookie immediately.

## Check draft mode in your application

Read the cookies on each request to determine whether to serve draft or published content, and which perspective to use. The `sanity-preview-perspective` cookie serves double duty: its presence indicates draft mode is active, and its value specifies which perspective to use:

**lib/draft-mode.ts**

```typescript
import { validateApiPerspective, type ClientPerspective } from '@sanity/client'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'

export function isDraftMode(request: Request): boolean {
  const cookieHeader = request.headers.get('Cookie') || ''
  return cookieHeader.includes(`${perspectiveCookieName}=`)
}

export function getPreviewPerspective(request: Request): ClientPerspective {
  const cookieHeader = request.headers.get('Cookie') || ''
  const regex = new RegExp(`${perspectiveCookieName}=([^;]+)`)
  const match = cookieHeader.match(regex)
  if (!match) return 'drafts'

  // The cookie value is URL-encoded and may be a comma-separated stacked
  // perspective (for example, "summer-drop,drafts,published" for a content release)
  const value = decodeURIComponent(match[1])
  const perspective = value.includes(',') ? value.split(',') : value
  try {
    validateApiPerspective(perspective)
    return perspective === 'raw' ? 'drafts' : perspective
  } catch {
    return 'drafts'
  }
}
```

Then use the results to configure the Sanity client for each request:

**lib/fetch-content.ts**

```typescript
import { client } from './sanity-client'
import { isDraftMode, getPreviewPerspective } from './draft-mode'

export async function fetchContent(
  request: Request,
  query: string,
  params?: Record<string, unknown>
) {
  const preview = isDraftMode(request)
  const perspective = preview ? getPreviewPerspective(request) : 'published'

  const configuredClient = client.withConfig({
    perspective,
    useCdn: !preview,
    stega: { enabled: preview },
    // Token required server-side to fetch draft/release content
    ...(preview && { token: process.env.SANITY_API_READ_TOKEN }),
  })

  return configuredClient.fetch(query, params)
}
```

When draft mode is active, this configuration:

- **Switches to the Studio's requested perspective:** this is usually `drafts`, but may be a stacked perspective array like `['summer-drop', 'drafts', 'published']` when the editor is previewing a content release. Queries resolve content by trying each perspective in priority order.
- **Authenticates with a read token:** draft and release content requires an authenticated client. The token is used server-side only and never sent to the browser.
- **Disables the CDN:** ensures the editor sees the latest changes without caching delays. Both `drafts` and array perspectives require `useCdn: false`.
- **Enables stega encoding:** embeds Content Source Map metadata in string values for click-to-edit overlays.

When `<VisualEditing />` is mounted, it automatically strips stega from clipboard copies by default, so users copying text from the preview page won't get invisible stega characters in their clipboard. Sanity Studio also strips stega from pasted text in all primitive field types (string, text, url, slug, number, date, tags, and others). Both behaviors are automatic and require no extra configuration. To opt out of clipboard cleaning, use the `keepStegaOnCopy` prop on `<VisualEditing />`.

When draft mode is inactive, the client uses the `published` perspective with CDN caching and no stega encoding, which is the standard production configuration.

## Security considerations

### Token handling

The `SANITY_API_READ_TOKEN` is used server-side only to validate preview secrets. It should never be sent to the browser or included in client-side bundles.

If your framework supports environment variable prefixes that expose values to the client (like `NEXT_PUBLIC_` in Next.js or `VITE_` in Vite), make sure the token variable does not use such a prefix.

### Cookie security

The draft mode cookie uses these security attributes:

- **HttpOnly:** prevents JavaScript from reading the cookie, protecting against XSS attacks.
- **Secure:** the cookie is only sent over HTTPS connections.
- **SameSite=None:** required for cross-origin iframe contexts (the Studio and your frontend are on different origins). This is the least restrictive setting, which is why `HttpOnly` and `Secure` are important complementary protections.
- **Partitioned:** opts the cookie into CHIPS (Cookies Having Independent Partitioned State), which Safari 18.4 and later requires for cookies in cross-site iframe contexts. Set it when the request comes from the Presentation Tool iframe, as shown in the enable endpoint.

### Secret lifecycle

Preview secrets have built-in protections:

- **Cryptographic randomness:** each secret is generated from 16 bytes of WebCrypto randomness, making them impractical to guess.
- **One-hour TTL:** secrets expire after 3,600 seconds, limiting the window for replay attacks.
- **Draft document storage:** per-session secrets are stored as draft documents in your dataset, so they're only readable by authenticated clients with draft access, and they never appear in published content or CDN-cached responses. The shared access secret is the exception: it's stored as a singleton document with a regular (non-draft) ID and no TTL, which is what lets it outlive individual preview sessions.
- **Garbage collection:** expired secrets are cleaned up when new secrets are created.

### Shared preview access

The Presentation Tool supports shared preview access, which generates a secret with no TTL. This lets editors share a preview URL with stakeholders who don't have Studio access. Shared access can be toggled on and off in the Presentation Tool UI, and disabling it immediately invalidates the shared secret.

## Adapting for your framework

The examples above use the Web API `Request` and `Response` objects, which work directly in many runtimes (Deno, Bun, Cloudflare Workers, and Node.js 18+ with a web framework). Here's how to adapt the pattern for specific environments:

### Express or Node.js HTTP server

```typescript
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { client } from './lib/sanity-client'

// Express route handler
app.get('/api/draft-mode/enable', async (req, res) => {
  const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
  const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
    client,
    fullUrl
  )

  if (!isValid) {
    return res.status(401).send('Invalid or expired preview secret')
  }

  const perspective = studioPreviewPerspective || 'drafts'
  // res.cookie URL-encodes the value by default
  res.cookie(perspectiveCookieName, perspective, {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
    maxAge: 3600 * 1000,
    path: '/',
  })

  // Strip the secret params from the redirect URL, as in the main example
  const cleanRedirect = redirectTo
    ? withoutSecretSearchParams(new URL(redirectTo, fullUrl)).pathname
    : '/'

  res.redirect(307, cleanRedirect)
})
```

### Edge runtimes

When running in edge runtimes (Cloudflare Workers, Vercel Edge Functions), be aware of two runtime-specific behaviors:

- **Edge Runtime delay:** `validatePreviewUrl` automatically adds a 300ms delay in Edge Runtime environments (detected via `typeof EdgeRuntime !== 'undefined'`) to account for eventual consistency. The secret may have been created moments before the validation request arrives.
- **Cloudflare Workers:** the `cache: 'no-store'` fetch option used internally by `validatePreviewUrl` is automatically disabled when running in Cloudflare Workers (detected via `navigator.userAgent`). No manual configuration is needed.

### Static site generators

Preview mode toggles content per-request, which requires server-side rendering. Pure static site generators (SSG) pre-render pages at build time, so they can't switch between published and draft content for individual requests.

If your production site is statically generated, set up a separate preview deployment that runs in SSR or hybrid mode. Configure the Presentation Tool's `previewUrl` to point at the SSR deployment, and let your SSG production site continue serving published content. Frameworks like Astro and Next.js support this pattern: production builds remain static, while a preview deployment uses the same codebase with SSR enabled.

## Vercel deployment protection

If your frontend uses Vercel's Deployment Protection, the Presentation Tool can bypass it automatically. The preview URL includes `x-vercel-protection-bypass` and `x-vercel-set-bypass-cookie` parameters, which `validatePreviewUrl` forwards to the redirect URL.

No additional configuration is needed in your enable endpoint. The `@sanity/preview-url-secret` package handles the parameter forwarding internally.

## Troubleshooting

### "Invalid or expired preview secret" error

- **Check the token:** your Sanity client must have a valid API token with read access. `validatePreviewUrl` throws a `TypeError` if the client doesn't have a token configured. Without a token, the client can't query draft documents where secrets are stored.
- **Check the clock:** secrets expire after one hour based on the `_updatedAt` timestamp. If your server's clock is significantly skewed, validation may fail.
- **Check the dataset:** the client must be configured with the same dataset that the Studio writes secrets to.
- **Debug with Vision:** you can query secrets directly in the Studio's Vision tool with `*[_type == "sanity.previewUrlSecret"]` to verify they exist. Secrets are stored as draft documents with `drafts.{uuid}` IDs.

### Draft mode activates but content doesn't change

- **Check the perspective:** verify that your client uses the perspective from the `sanity-preview-perspective` cookie when draft mode is active. The `published` perspective never returns draft content. If the cookie contains a comma-separated value (for content releases), make sure you split it into an array before passing it to `withConfig()`.
- **Check useCdn:** the `drafts` perspective and array perspectives both require `useCdn: false`. CDN-cached responses only contain published content.

### Cookie not persisting across requests

- **Check SameSite and Secure:** in iframe contexts, `SameSite=None` and `Secure` are both required. Without them, the browser may silently drop the cookie.
- **Check Safari:** Safari 18.4 and later blocks unpartitioned third-party cookies even with `SameSite=None` and `Secure`. Add the `Partitioned` attribute when the request comes from a cross-site iframe (see the enable endpoint example).
- **Check HTTPS:** the `Secure` flag means the cookie is only sent over HTTPS. If you're developing locally over HTTP, you may need to use `localhost` (which browsers treat as a secure context) or set up a local HTTPS certificate.

### Preview works locally but not in production

- **Check CORS:** your Sanity project must allow requests from your production frontend origin.
- **Check the Presentation Tool's allowOrigins:** your production URL must be in the allowed list.
- **Check environment variables:** verify that `SANITY_API_READ_TOKEN` is set in your production environment.

## Next steps

- **Architecture overview:** understand how preview mode fits into the broader visual editing system.
- **Setting up the Sanity client for visual editing:** configure stega encoding, perspectives, and Content Source Maps.
- **Enabling overlays and click-to-edit:** add click-to-edit functionality to your preview.
- **Configuring the Presentation Tool:** set up the Studio plugin that triggers preview mode.



# Live preview updates

Real-time content updates let editors see their changes reflected in the preview as they type, without manually refreshing the page. This is powered by a combination of the Live Content API, the Comlink messaging protocol, and the core loader's fetcher system.

This guide explains how to implement real-time updates in a custom integration, covering both the high-level approach using `@sanity/core-loader` and the lower-level approach using `@sanity/client` directly.

## How real-time updates work

When an editor changes content in the Studio while the Presentation Tool is open, the update reaches your frontend through one of two paths:

**Path 1: via the Presentation Tool (live mode)**

1. The editor changes a field in the Studio.
2. The Presentation Tool detects the mutation and sends updated query results to your frontend via Comlink (`postMessage`).
3. Your frontend's data layer receives the new data and re-renders.

This path provides the fastest updates because the Presentation Tool already has the query results. It requires your frontend to be running inside the Presentation Tool iframe.

**Path 2: via the Live Content API (direct)**

1. The editor publishes content (or saves a draft).
2. The Content Lake emits sync tags identifying which queries are affected.
3. Your frontend receives the sync tags and re-fetches the affected queries.
4. The page re-renders with fresh data.

This path works both inside and outside the Presentation Tool. It's the mechanism that framework libraries like `next-sanity` use for production real-time updates via `<SanityLive />`.

## Using `@sanity/core-loader`

The core loader provides a framework-agnostic abstraction for data fetching with built-in live mode support. It manages query stores, caching, deduplication, and the live mode connection.

### Install

**npm**

```shell
npm install @sanity/core-loader @sanity/client
```

**pnpm**

```shell
pnpm add @sanity/core-loader @sanity/client
```

**yarn**

```shell
yarn add @sanity/core-loader @sanity/client
```

**bun**

```shell
bun add @sanity/core-loader @sanity/client
```

### Create a query store

**lib/query-store.ts**

```typescript
import { createQueryStore } from '@sanity/core-loader'
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: 'YOUR_STUDIO_URL',
  },
})

export const queryStore = createQueryStore({ client })
```

The query store provides these capabilities:

- **createFetcherStore(query, params?, initial?):** creates a reactive store for a specific query. The `initial` parameter accepts pre-fetched data for SSR hydration. The store fetches data when subscribed to and updates automatically in live mode.
- **enableLiveMode(options):** activates real-time updates via the Presentation Tool's Comlink connection.
- **setServerClient(client):** configures the server-side client (for SSR mode only; throws if called in the browser).

### The Fetcher interface

The core loader's architecture is built around a **Fetcher** interface with two methods:

```typescript
interface Fetcher {
  hydrate(query, params, initial?): QueryStoreState  // Sync: returns initial state
  fetch(query, params, store, controller): void       // Async: triggers data loading
}
```

There are two fetcher implementations:

- **Default fetcher:** fetches from the Sanity API with caching and deduplication (using `async-cache-dedupe` internally).
- **Live mode fetcher:** receives query results from the Studio via Comlink.

The key architectural insight is the **hot-swap mechanism**: when live mode connects, it replaces the default fetcher with the live fetcher. All existing query stores automatically switch to receiving updates from the Studio. When live mode disconnects, the original fetcher is restored and stores resume normal API fetching. This swap is powered by a reactive atom (from `nanostores`) that all stores subscribe to.

### Fetch data with a query store

Each query gets its own reactive store:

```typescript
import { queryStore } from './lib/query-store'

// Create a store for a specific query
const postStore = queryStore.createFetcherStore(
  '*[_type == "post" && slug.current == $slug][0]',
  { slug: 'my-post' }
)

// Subscribe to the store to trigger fetching and receive updates.
// The state includes data, loading, error, sourceMap, perspective, and variant.
const unsubscribe = postStore.subscribe((state) => {
  if (state.loading) {
    // Show loading indicator
    return
  }

  if (state.error) {
    console.error('Query failed:', state.error)
    return
  }

  // Render the data
  renderPost(state.data)

  // state.sourceMap contains the Content Source Map (for data attributes)
  // state.perspective indicates which perspective was used for this result
})

// Unsubscribe when done (stops fetching and live updates for this query)
unsubscribe()
```

The store is lazy: it only fetches data when it has at least one subscriber. When all subscribers unsubscribe, the store stops updating.

### Enable live mode

Live mode connects your query stores to the Presentation Tool for real-time updates.

> [!NOTE]
> Note
> You can use `enableLiveMode()` together with `enableVisualEditing()` from `@sanity/visual-editing` (see [enabling overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays)). This is the standard setup: overlays handle click-to-edit while live mode handles data updates. `enableVisualEditing()` creates its own `visual-editing` Comlink node and only creates a `loaders` node when your app uses its `usePresentationQuery` mechanism. Avoid combining `enableLiveMode()` with `usePresentationQuery`, as both create a `loaders` node with the same name.

```typescript
import { queryStore } from './lib/query-store'

// enableLiveMode() works alongside enableVisualEditing(); see
// "Integrating with the refresh callback" below.
const disableLiveMode = queryStore.enableLiveMode({
  // Called when the Studio changes perspective (for example, when an editor
  // switches to a different content release). The perspective may be a string
  // like 'drafts' or a stacked array like ['summer-drop', 'drafts', 'published'].
  onPerspective: (perspective) => {
    console.log('Perspective changed to:', perspective)
    // Update your client config to match the new perspective.
    // This ensures subsequent server-side fetches use the same perspective.
  },
  // Optional: called when the Comlink connection is established
  onConnect: () => {
    console.log('Connected to Presentation Tool')
  },
  // Optional: called when the connection drops
  onDisconnect: () => {
    console.log('Disconnected from Presentation Tool')
  },
})

// Later, to disable:
disableLiveMode()
```

When live mode activates, it:

1. Lazy-loads the live mode module (including `@sanity/comlink`).
2. Establishes a Comlink connection with the Presentation Tool (node name `loaders`, connecting to `presentation`).
3. Swaps the internal fetcher from the default (API-based) to a live fetcher (Comlink-based).
4. All existing query stores automatically switch to receiving updates from the Studio.
5. Sends a heartbeat every 20 seconds per active query to keep subscriptions alive.
6. Reports which documents are on the current page by sending a `loader/documents` message to the Studio after each update. This enables Studio-side features like showing which documents are visible in the preview.

When live mode is disabled (or the connection drops), the fetcher swaps back to the default API-based fetcher, and query stores resume normal fetching.

### Server-side rendering

For SSR, create the query store with `ssr: true` and set the server client before rendering:

```typescript
// Server-side setup
import { createQueryStore } from '@sanity/core-loader'

// When ssr is true, pass client: false (passing a client throws an error)
const queryStore = createQueryStore({
  client: false,
  ssr: true,
})

// Set the client before handling requests
queryStore.setServerClient(client)
```

The `setServerClient` function can only be called in server environments (it throws if called in the browser). It configures the client used for initial data fetching during SSR. On the client side, `enableLiveMode` takes over for real-time updates.

### Encoding data attributes from query results

The core loader exports `encodeDataAttribute` and `defineEncodeDataAttribute` at `@sanity/core-loader/encode-data-attribute`. These functions bridge data fetching and visual editing overlays by creating `data-sanity` attribute values from Content Source Maps:

```typescript
import { defineEncodeDataAttribute } from '@sanity/core-loader/encode-data-attribute'

// After fetching data with a query store
postStore.subscribe((state) => {
  if (!state.data || !state.sourceMap) return

  // Create an encoder scoped to this query result
  const encode = defineEncodeDataAttribute(
    state.data,
    state.sourceMap,
    'YOUR_STUDIO_URL'
  )

  // Encode a specific field path as a data-sanity attribute value.
  // Returns string | undefined (undefined if the path has no source mapping).
  const titleAttr = encode('title')
  const imageAttr = encode('mainImage')

  // Scope to a nested path
  const bodyEncode = encode.scope('body')
  const firstBlockAttr = bodyEncode([0, 'children', 0, 'text'])

  // Apply to DOM elements (guard against undefined)
  if (titleAttr) {
    document.querySelector('.post-title')?.setAttribute('data-sanity', titleAttr)
  }
  if (imageAttr) {
    document.querySelector('.post-image')?.setAttribute('data-sanity', imageAttr)
  }
})
```

This is particularly useful for non-string content (images, numbers) that can't carry stega encoding. The encoder uses the Content Source Map to resolve the source document and field path for any value in the query result.

`<VisualEditing />` automatically strips stega characters from clipboard copies by default when Visual Editing is active, so users copying text from the live preview page won't get invisible stega characters when pasting into other tools. The Studio also strips stega from pasted text in all primitive fields. Both behaviors are automatic and require no extra configuration. To opt out of clipboard cleaning, use the `keepStegaOnCopy` prop on `<VisualEditing />`.

## Using the Listener API directly

If you don't need the query store abstraction, you can implement real-time updates using the Sanity client's Listener API (`client.listen()`). This is a mutation-based subscription that notifies you when documents change:

```typescript
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: false,
  token: process.env.SANITY_API_READ_TOKEN,
})

// Listen for mutations on specific document types
const subscription = client
  .listen('*[_type in $types]', { types: ['post', 'page'] })
  .subscribe((update) => {
    if (update.type === 'mutation') {
      // A document was created, updated, or deleted.
      // Re-fetch the affected content.
      refetchContent(update.documentId)
    }
  })

// Clean up when done
subscription.unsubscribe()
```

> [!NOTE]
> Note
> `client.listen()` (the Listener API) and the Live Content API are different mechanisms. The Listener API subscribes to individual document mutations and requires an API token for private datasets or to read drafts. The Live Content API (used by framework libraries like`next-sanity`'s `<SanityLive />`) uses sync tags to efficiently invalidate cached queries and works in production without exposing tokens to the client. The core loader's live mode uses neither of these directly; it receives pre-computed query results from the Studio via Comlink.

This approach gives you full control but requires you to manage:

- **Query deduplication:** multiple components may need the same data.
- **Cache invalidation:** deciding which queries to re-fetch when a document changes.
- **Connection lifecycle:** handling reconnections and cleanup.
- **Stega encoding:** applying Content Source Maps to re-fetched data if overlays are active.

The core loader handles all of these concerns automatically.

## Integrating with the refresh callback

The overlay system's `refresh` callback (from `enableVisualEditing()`) complements real-time updates. While live mode pushes new data to your stores, the refresh callback handles cases where the Studio explicitly requests a refresh:

```typescript
import { enableVisualEditing } from '@sanity/visual-editing'
import { queryStore } from './lib/query-store'

// Enable overlays with refresh handling
enableVisualEditing({
  // Return false (synchronous) to use default behavior,
  // or Promise<void> for async refresh operations.
  refresh: (payload) => {
    if (payload.source === 'mutation') {
      // The Studio edited a document. If live mode is active,
      // the query stores update automatically. This callback
      // handles any additional refresh logic your app needs.
      return updateUI() // Returns Promise<void>
    }

    if (payload.source === 'manual') {
      // The editor clicked the refresh button.
      // Force a full re-fetch of all data.
      window.location.reload()
      return new Promise(() => {}) // Never resolves (page is reloading)
    }

    return false // Use default behavior
  },
})

// Enable live mode for automatic query updates
queryStore.enableLiveMode({})
```

When both live mode and the refresh callback are active, live mode handles the data updates while the refresh callback handles UI-level concerns (animations, scroll position, and loading states). Note that the "mutation" refresh source is deprecated and a future Studio major version will stop sending it; rely on live mode for data updates.

## How framework libraries handle this

Framework libraries build on `@sanity/core-loader` to provide framework-idiomatic APIs:

| Framework | Data fetching | Live mode | Real-time hook |
| --- | --- | --- | --- |
| React (`@sanity/react-loader`) | `useQuery()` hook | `useLiveMode()` hook | Wraps `createFetcherStore` with React state |
| Svelte (`@sanity/svelte-loader`) | `useQuery()` function | `useLiveMode()` function | Wraps `createFetcherStore` with Svelte stores |
| Next.js (`next-sanity`) | `sanityFetch()` server function (returned by `defineLive()`) | `<SanityLive />` component | Uses Live Content API with sync tags |

All framework loaders follow the same pattern:

1. Call `createQueryStore()` with a framework-specific tag.
2. Wrap the core store's reactive primitives (nanostores) in framework-native equivalents (React hooks, Svelte stores).
3. Add a `loadQuery()` function for server-side data loading.
4. Export a pre-configured default instance with `ssr: true`.

## Troubleshooting

### Live updates work in the Presentation Tool but not in production

Live mode via `@sanity/core-loader` only works inside the Presentation Tool iframe because it relies on Comlink (`postMessage`) to receive updates from the Studio. For production real-time updates, use `client.listen()` for mutation-based subscriptions, or a framework library's production live component (like `<SanityLive />` in `next-sanity`) for the sync-tag-based Live Content API.

### Updates are delayed or inconsistent

- **Check the heartbeat:** live mode sends a heartbeat every 20 seconds per query. If the Presentation Tool doesn't receive heartbeats, it stops sending updates for that query.
- **Check eventual consistency:** the Content Lake is eventually consistent. After a mutation, there may be a brief delay before queries return the updated data. The overlay system's refresh mechanism accounts for this with an automatic second refresh after 1 second.

### Query stores don't update when live mode activates

- **Check subscription timing:** query stores are lazy. They only fetch (and receive live updates) when they have at least one subscriber. Make sure you call `.subscribe()` before enabling live mode.
- **Check the Comlink connection:** live mode requires a successful Comlink handshake with the Presentation Tool. Check the browser console for connection errors.

### Data appears without stega encoding in live mode

- **Check client configuration:** the core loader applies stega encoding to live mode results only if the client has `stega: { enabled: true }`. Verify your client configuration.

### Live mode breaks when the Studio is embedded in the same app

If your Sanity Studio is embedded as a route inside the same frontend application (for example, `/studio` mounted alongside your content routes), do not call `enableLiveMode()` from your root or layout component. The embedded Studio runs its own Comlink connections, and a top-level `enableLiveMode()` call attaches them to the Studio's own iframe context, conflicting with the Studio's internal handshake.

Use dedicated layouts to keep the Studio route isolated from live mode initialization. Only call `enableLiveMode()` on the content routes that the Presentation Tool previews.

## Next steps

- **Architecture overview:** understand how overlays fit into the broader visual editing system
- **Setting up the Sanity client for visual editing:** configure stega encoding that powers automatic overlay detection
- **Enabling overlays and click-to-edit:** add the refresh callback that complements live updates
- **Configuring the Presentation Tool:** set up the Studio plugin that hosts the preview iframe



# Overlays and click-to-edit

Overlays are the interactive layer that makes visual editing work. When an editor previews your site in the Presentation Tool, overlays appear on content elements, letting the editor click any piece of content to jump directly to the corresponding field in Sanity Studio.

![Screenshot of a Sanity Studio interface displaying a blog post titled "Visual Editing." The post description reads, "Your one stop shop for everything about Visual Editing and the Presentation Tool in Sanity Studio." The editor on the right includes fields for the title and description, along with a warning indicating that the document is used on all pages. The post preview includes an image of a desk with a plant and lamp, and a subheading for "The difficult second post" dated November 18, 2024.](https://cdn.sanity.io/images/3do82whm/next/5b8a4d329a6f44cbff5f884f7c7113f121958019-1459x1110.png)
*The Presentation Tool with the blue overlay frame around the heading*

This guide covers how to integrate `@sanity/visual-editing` into any frontend, how the overlay system detects content elements, how to annotate non-string content with data attributes, and how to wire up router integration for navigation sync.

## How overlays work

The overlay system follows this sequence:

1. Your frontend renders content with stega-encoded strings (invisible metadata embedded by the Sanity client).
2. `enableVisualEditing()` scans the DOM for stega-encoded text nodes and `data-sanity` attributes.
3. For each detected element, a transparent overlay is drawn on top of it.
4. When an editor clicks an overlay, the system decodes the source metadata and sends the document ID and field path to the Studio via the Comlink protocol.
5. The Studio navigates to the document and focuses the field.

The overlay controller is framework-agnostic JavaScript. It uses a `MutationObserver` to watch for DOM changes, an `IntersectionObserver` (with a 0.3 threshold, meaning elements must be at least 30% visible) to track which elements are in the viewport, and a `ResizeObserver` to keep overlay positions accurate. Only visible elements get event handlers attached, which keeps performance stable on content-heavy pages.

## Prerequisites

- A Sanity client configured with stega encoding enabled (see [setting up the Sanity client](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega))
- Draft mode implemented so stega encoding is active during preview (see [implementing draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode))
- The Presentation Tool configured in your Studio (see [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool))

## Install the package

**npm**

```shell
npm install @sanity/visual-editing
```

**pnpm**

```shell
pnpm add @sanity/visual-editing
```

**yarn**

```shell
yarn add @sanity/visual-editing
```

**bun**

```shell
bun add @sanity/visual-editing
```

This package includes the overlay controller, data attribute utilities, and framework-specific integrations. Note that `react` and `react-dom` are required peer dependencies. Install them alongside `@sanity/visual-editing`.

## Basic setup

Call `enableVisualEditing()` when your application enters draft mode:

```typescript
import { enableVisualEditing } from '@sanity/visual-editing'

// Call this when draft mode is active
const disableOverlays = enableVisualEditing()

// Later, to clean up:
disableOverlays()
```

This single function call:

- Dynamically imports the overlay rendering system (which uses React internally)
- Creates a `<sanity-visual-editing>` custom element outside `<body>` to avoid layout interference
- Scans the entire DOM for stega-encoded strings and `data-sanity` attributes
- Draws transparent overlays on detected content elements
- Establishes Comlink connections with the Studio (when running in the Presentation Tool iframe). A separate `loaders` channel is opened only if your app registers query listeners through `usePresentationQuery`.

> [!WARNING]
> Important
> You can call `enableVisualEditing()` alongside `enableLiveMode()` from `@sanity/core-loader` — this is the standard setup, where overlays handle click-to-edit and live mode handles data updates. `enableVisualEditing()` creates its own `visual-editing` Comlink node and only opens a `loaders` node when your app uses its `usePresentationQuery` mechanism. Avoid combining `enableLiveMode()` with `usePresentationQuery`, because both create a `loaders` node with the same name. See [live preview content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates) for when to use `enableLiveMode()` independently.

The returned function cleans up all overlays, observers, and event listeners.

### Conditional initialization

Only initialize overlays when draft mode is active. Your server should conditionally include the visual editing script when it detects the draft mode cookie (or your framework's equivalent). This keeps the overlay code out of production bundles entirely.

For example, in a server-rendered page, only include the `<script>` tag that calls `enableVisualEditing()` when the server confirms draft mode is active. See the [complete integration example](https://www.sanity.io/docs/visual-editing/build-a-visual-editing-integration) for a full pattern.

The overlay setup itself doesn't need a client-side draft mode check. It only runs because the server included it:

```typescript
const disable = enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      const handler = () => navigate({ type: 'pop', url: location.href })
      addEventListener('popstate', handler)
      return () => removeEventListener('popstate', handler)
    },
    update: (update) => {
      if (update.type === 'push') history.pushState(null, '', update.url)
      if (update.type === 'replace') history.replaceState(null, '', update.url)
    },
  },
})
```

## Content detection methods

The overlay system detects content elements using five methods, checked in this priority order:

### 1. The `data-sanity-edit-target` attribute

Add this attribute to any element to make it a click-to-edit target. The overlay system looks for stega-encoded content within the element's subtree and creates a single overlay for the container:

```typescript
const section = document.querySelector('.post-header')
section.dataset.sanityEditTarget = ''
```

This is useful when you want a larger click target than individual text nodes. For example, marking a card component as an edit target creates one overlay for the entire card, even though it contains multiple stega-encoded fields. When the element's subtree contains multiple Sanity nodes, the system computes the common ancestor path across all child fields.

### 2. Stega-encoded text

When stega encoding is active, string values rendered in the DOM contain invisible zero-width Unicode characters that encode Content Source Map metadata. The overlay system scans text nodes using a regex pattern and decodes the metadata to identify the source document and field.

This is automatic. If your Sanity client has `stega: { enabled: true }` and you render the fetched content in the DOM, the overlays detect it without any additional markup.

### 3. The `data-sanity` attribute

For content that can't carry stega encoding (images, numbers, booleans, or values used in non-text contexts), use the `data-sanity` attribute with `createDataAttribute()`:

```typescript
import { createDataAttribute } from '@sanity/visual-editing'

const attr = createDataAttribute({
  id: 'post-123',
  type: 'post',
  path: 'mainImage',
  baseUrl: 'YOUR_STUDIO_URL',
})

// Use as a string value for the data-sanity attribute
const element = document.querySelector('.hero-image')
element.setAttribute('data-sanity', attr.toString())
```

The `createDataAttribute()` function returns a builder with several capabilities:

```typescript
const attr = createDataAttribute({
  id: 'post-123',
  type: 'post',
  path: 'content',
  baseUrl: 'YOUR_STUDIO_URL',
  workspace: 'default', // optional: omitted if "default"
  tool: 'default',      // optional: omitted if "default"
})

// Call with a sub-path to target a nested field
attr('title')     // targets content.title
attr('body')      // targets content.body

// Scope to create a new builder with an extended base path
const bodyAttr = attr.scope('body')
bodyAttr('0.text')  // targets content.body.0.text

// Combine to merge additional properties
const withWorkspace = attr.combine({ workspace: 'staging' })
```

The attribute value is a semicolon-delimited string encoding the document ID, type, field path, Studio base URL, and optional workspace and tool:

```text
id=post-123;type=post;path=mainImage;base=https%3A%2F%2FYOUR_STUDIO_URL
```

`baseUrl` is optional and defaults to `/` when omitted, which resolves against your frontend's own origin rather than the Studio. Set it to the Studio's full URL, including any base path the Studio is served under, and set `workspace` when the Studio serves more than one.

### 4. The `data-sanity-edit-info` attribute (legacy)

This is a legacy attribute format that is still supported by the scanner for backward compatibility. New integrations should use `data-sanity` instead.

### 5. Special element attributes

The system also checks specific element attributes for stega-encoded data:

- `alt` attributes on `<img>` elements
- `dateTime` attributes on `<time>` elements
- `aria-label` attributes on `<svg>` elements

These are checked automatically as part of the DOM scan.

### Making images editable via `altText`

The `alt` attribute fallback enables a useful pattern for images: add an `altText` field to your image schema, then render its value as the `alt` attribute. The stega-encoded text in the `alt` attribute makes the image clickable, and clicking jumps to the image field in the Studio.

```typescript
// Schema: add altText to your image type
defineField({
  name: 'picture',
  type: 'image',
  fields: [defineField({ name: 'altText', type: 'string' })],
})
```

```html
<!-- Render: use the altText value as the alt attribute -->
<img src="${imageUrl}" alt="${image.altText}" />
```

This is the recommended approach for editable images because it also produces accessible alt text. For images without alt text (decorative images), use `createDataAttribute()` with the image field path instead.

### Edit groups

Group related fields under a single overlay using `data-sanity-edit-group`:

```typescript
const card = document.querySelector('.product-card')
card.dataset.sanityEditGroup = ''
```

Child elements with Sanity metadata within a group are collected as targets of the group element. When the editor clicks the group overlay, the system computes the common ancestor path across all child fields. For example, if a group contains fields at `content.0.title` and `content.0.body`, clicking the group targets `content.0`.

## Router integration

The `history` option connects your application's router with the Presentation Tool, enabling bidirectional navigation sync:

- **Preview to Studio:** when the user navigates in the preview, the Studio updates its URL bar and resolves the corresponding document.
- **Studio to preview:** when the editor clicks a document location link in the Studio, the preview navigates to the corresponding page.

```typescript
const disable = enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      // Called once during setup. Register a listener for navigation events
      // in your app and call navigate() when the URL changes.
      const handler = () => {
        navigate({
          type: 'pop',
          url: location.href,
        })
      }
      addEventListener('popstate', handler)

      // Return an unsubscribe function
      return () => removeEventListener('popstate', handler)
    },
    update: (update) => {
      // Called when the Studio wants to navigate the preview.
      // Update your router accordingly.
      switch (update.type) {
        case 'push':
          history.pushState(null, '', update.url)
          break
        case 'replace':
          history.replaceState(null, '', update.url)
          break
        case 'pop':
          history.back()
          break
      }
    },
  },
})
```

If your application uses a client-side router (for example, a single-page application), you need to call `navigate()` whenever the router changes the URL. The `popstate` event only fires for browser back/forward navigation, not for programmatic route changes.

For routers that expose a subscription API, integrate directly:

```typescript
subscribe: (navigate) => {
  // Example: generic router with onChange callback
  const unsubscribe = router.onChange((url) => {
    navigate({ type: 'push', url })
  })
  return unsubscribe
},
```

## Handling content refreshes

The `refresh` option lets you control how the preview updates when content changes in the Studio:

```typescript
const disable = enableVisualEditing({
  refresh: (payload) => {
    if (payload.source === 'mutation') {
      // A document was edited in the Studio.
      // payload.document contains { _id, _type, _rev }
      return refetchContent(payload.document._type)
    }

    if (payload.source === 'manual') {
      // The editor clicked the refresh button in the Studio.
      return refetchAllContent()
    }

    // Return false to use the default behavior (full page reload)
    return false
  },
})

async function refetchContent(documentType: string): Promise<void> {
  // Re-fetch content for the given document type
  // and update the DOM
}

async function refetchAllContent(): Promise<void> {
  // Re-fetch all content and update the DOM
}
```

The refresh callback receives a payload with a `source` field:

- **mutation:** a document was edited in the Studio. The payload includes `document` with `_id`, `_type`, `_rev`, and `slug` fields, plus a `livePreviewEnabled` flag. Both the `mutation` source and `livePreviewEnabled` are deprecated — a future major version of Sanity Studio will stop sending them, in favor of loader APIs such as `loader/query-listen`. Rely on live mode for data updates rather than this event.
- **manual:** the editor clicked the refresh button in the Presentation Tool. The payload includes `livePreviewEnabled`.

Return a `Promise<void>` to signal when the refresh is complete (the Studio shows a loading indicator until the promise resolves). Return `false` to skip custom handling and fall back to the default behavior.

The system automatically performs a second refresh 1 second after a mutation to handle Content Lake eventual consistency.

### Filtering mutations by document

Without filtering, every mutation in the Studio triggers your refresh handler, even edits to documents that aren't visible on the current page. For server-rendered applications without a live streaming data layer, filter mutations by comparing the mutated document to the current route:

```typescript
enableVisualEditing({
  refresh: (payload) => {
    if (payload.source === 'mutation') {
      const currentSlug = window.location.pathname.split('/').pop()
      if (payload.document.slug?.current === currentSlug) {
        window.location.reload()
        return new Promise<void>(() => {})
      }
      return false // Mutation on a different document, ignore
    }
    // ...
  },
})
```

Frameworks with live streaming take a different approach. For example, `next-sanity` returns `false` for mutations when `livePreviewEnabled` is `true`. The Live Content API streams updates to query stores directly, so no reload is needed. Choose the pattern that matches your data layer:

| Data layer | Recommended mutation handling |
| --- | --- |
| Live streaming (`@sanity/core-loader` live mode, Live Content API) | Return `false` and let the stream update the UI |
| Server-rendered without live streaming | Filter by document slug, reload on match |
| Client-side rendered with refetching | Call your refetch function, return the promise |

## Overlay appearance and interaction

### Visual states

Overlays have three visual states:

- **Default:** transparent and invisible. A brief flash animation highlights all overlays when they first appear (1.5 seconds).
- **Hovered:** a colored border appears around the element, and a tab displays the document title. Outside the Presentation Tool the actions bar also shows an "Open in Studio" link; inside the Presentation Tool preview iframe that link is hidden, because you are already in the Studio.
- **Focused:** a thinner border indicates the element is selected. The element scrolls into view if needed.

### Keyboard shortcuts

- **Alt key** (hold): temporarily toggles overlay visibility; releasing the key restores the previous state
- **Cmd+\** (Mac) or **Ctrl+\** (Windows/Linux): toggles overlay visibility
- **Escape:** unfocuses the current element

### Z-index

The overlay root element uses `z-index: 9999999` by default. Override it if this conflicts with your application's stacking context:

```typescript
enableVisualEditing({
  zIndex: 50000,
})
```

### Perspective change callback

Use `onPerspectiveChange` to update your server-side state when the editor switches perspectives in the Studio (for example, switching to a different content release). The `perspective` value may be a string like `'drafts'` or a stacked array like `['summer-drop', 'drafts', 'published']`:

```typescript
enableVisualEditing({
  onPerspectiveChange: async (perspective) => {
    // Serialize the perspective (arrays become comma-separated strings)
    const value = Array.isArray(perspective) ? perspective.join(',') : perspective
    // Update the server-side perspective cookie
    await fetch(`/api/draft-mode/perspective?perspective=${encodeURIComponent(value)}`)
    // Reload to re-render with the new perspective
    window.location.reload()
  },
})
```

This is the framework-agnostic equivalent of `next-sanity`'s server action pattern. Frameworks with partial re-rendering (like React Server Components) can do an in-place refresh instead of a full page reload. See the [complete integration example](https://www.sanity.io/docs/visual-editing/build-a-visual-editing-integration) for the server-side endpoint that handles this request.

## Low-level API: `createOverlayController()`

For advanced use cases where you need direct control over the overlay system, `@sanity/visual-editing` exports `createOverlayController()`. This is the pure JavaScript DOM controller that powers the overlay system, without the React rendering layer.

> [!NOTE]
> Note
> Most integrations should use `enableVisualEditing()` instead. The overlay controller is a low-level API that requires managing additional state (frame detection, popup detection, and optimistic update readiness) that `enableVisualEditing()` handles automatically.

```typescript
import { createOverlayController } from '@sanity/visual-editing'

const controller = createOverlayController({
  handler: (message) => {
    // Handle overlay messages (element/register, element/click, etc.)
    console.log(message.type, message)
  },
  overlayElement: document.createElement('div'),
  // Required: whether the app is running inside an iframe
  inFrame: window.self !== window.top,
  // Required: whether the app is running in a popup window
  inPopUp: !!window.opener,
  // Required: signal when optimistic updates are ready
  optimisticActorReady: Promise.resolve(),
})

// Activate the controller (starts observing the DOM)
controller.activate()

// Later, to clean up:
controller.destroy()
```

Most integrations should use `enableVisualEditing()` instead. The low-level controller is useful if you're building a custom overlay rendering system or need to process overlay events without the built-in React UI.

## Framework-specific integrations

While `enableVisualEditing()` works in any framework, the package also exports framework-specific components for tighter integration:

| Export path | Framework | Usage |
| --- | --- | --- |
| `@sanity/visual-editing/react` | React | `<VisualEditing />` component, `useDocuments()`, `useOptimistic()` hooks |
| `@sanity/visual-editing/react-router` | React Router / Remix | `<VisualEditing />` with automatic router integration |
| `@sanity/visual-editing/next-pages-router` | Next.js Pages Router | `<VisualEditing />` with Next.js Pages Router support |
| `@sanity/visual-editing/svelte` | Svelte | Svelte-native integration |

These components wrap `enableVisualEditing()` with framework-native lifecycle management and router integration. If you're using one of these frameworks, the framework-specific export provides a smoother developer experience. For custom or unsupported frameworks, use `enableVisualEditing()` directly.

## The click-to-edit flow in detail

When an editor clicks a content element in the Presentation Tool:

1. The overlay controller detects the click on the topmost element in its hover stack (nested elements are handled correctly, with the most specific element taking priority).
2. The controller reads the Sanity metadata from the element (decoded from stega or the `data-sanity` attribute).
3. The metadata is sent to the Studio via Comlink as a `visual-editing/focus` message containing the document ID, type, field path, and Studio base URL.
4. The Studio receives the message and navigates to the document, focusing the specified field.
5. The Studio sends back a `presentation/focus` message, which the overlay system uses to visually mark the element as focused.

The "Open in Studio" link in the overlay actions bar constructs a direct URL to the document field:

```text
{studioBaseUrl}[/{workspace}]/intent/edit/mode=presentation;id={id};type={type};path={path}[;tool={tool}]?baseUrl={baseUrl}&id={id}&type={type}&path={path}[&workspace={workspace}][&tool={tool}][&perspective={perspective}]
```

## Overlays outside the Presentation Tool

Overlays render wherever `enableVisualEditing()` runs, whether or not the page is inside the Presentation Tool. The two contexts differ in how a click reaches the Studio, and that difference decides whether `baseUrl` and `workspace` matter.

Inside the Presentation Tool iframe, a click is sent to the parent Studio over Comlink. The Studio already knows which workspace and tool it is showing, so it navigates to the field regardless of what the element's metadata says about the Studio's location.

Outside the iframe, on the same page opened directly in a browser tab, there is no Studio to message. The overlay builds a Studio intent URL from the metadata on the element and opens it in a new tab, so anything missing from that metadata produces a broken link.

A stega configuration or `createDataAttribute()` call with no Studio location therefore works in the Presentation Tool and fails in a standalone tab. Two values decide it:

- **baseUrl:** the Studio's full URL, including any base path it is served under. `createDataAttribute()` defaults it to `/`, which points at your frontend's origin.
- **workspace:** the base path segment of the workspace the document belongs to. Omit it for a single-workspace Studio; the URL builder skips the segment when it is unset or `default`.

Set both where you configure `stega.studioUrl` on the client and where you call `createDataAttribute()`. To verify, open a draft-mode page directly in a browser tab, click an overlay, and check that the Studio opens on the right document and field.

## Troubleshooting

### Overlays don't appear on any elements

- **Check stega encoding:** verify that your Sanity client has `stega: { enabled: true }` and that you're fetching with the `drafts` perspective. Inspect the rendered HTML for zero-width characters in text content.
- **Check initialization:** confirm that `enableVisualEditing()` is called after the DOM has rendered content. If called too early, the initial scan may find nothing (though the `MutationObserver` will catch later additions).
- **Check the iframe context:** overlays are designed to work inside the Presentation Tool iframe. If testing outside the iframe, overlays still render but click-to-edit navigation won't reach the Studio.

### Overlays appear but clicking does nothing

- **Check the Comlink connection:** the overlay system needs a Comlink connection to the Studio. This is established automatically when running in the Presentation Tool iframe. Check the browser console for connection errors.
- **Check allowOrigins:** your frontend origin must be in the Presentation Tool's `allowOrigins` list for Comlink messages to flow.

### Overlays appear on wrong elements or are mispositioned

- **Check for CSS transforms:** the overlay system uses `getBoundingClientRect()` to position overlays. CSS transforms on parent elements can cause misalignment.
- **Check for dynamic content:** if content loads asynchronously after the initial render, the `MutationObserver` detects it automatically. However, if elements change size without a DOM mutation (for example, image loading), the `ResizeObserver` handles repositioning.
- **Check font loading:** overlay positions update automatically after fonts finish loading (`document.fonts.ready`), but custom font loading strategies may cause temporary misalignment.

### Stega encoding breaks non-display values

Use `stegaClean()` to strip invisible characters before using values in non-display contexts:

```typescript
import { stegaClean } from '@sanity/client/stega'

// Clean values before using in URLs, comparisons, or logic
const slug = stegaClean(post.slug.current)
const url = `/posts/${slug}`

// Clean values before date parsing
const date = new Date(stegaClean(post.publishedAt))
```

### Overlays don't appear when frontend is embedded in the Studio

If your Sanity Studio is embedded as a route inside the same frontend application (for example, `/studio` mounted alongside your content routes), do not call `enableVisualEditing()` from your root or layout component. The Studio runs its own Comlink connections, and a top-level `enableVisualEditing()` call attaches them to the Studio's own iframe context, breaking the connection between the Studio and your content pages.

Use dedicated layouts to keep the Studio route isolated from visual editing initialization. Only mount `enableVisualEditing()` on the content routes that the Presentation Tool previews.

## Next steps

- **Architecture overview:** understand how overlays fit into the broader visual editing system
- **Setting up the Sanity client for visual editing:** configure stega encoding that powers automatic overlay detection
- **Real-time content updates:** keep the preview in sync with Studio edits
- **Configuring the Presentation Tool:** set up the Studio plugin that hosts the preview iframe



# End to end example

This guide walks through building a complete visual editing integration from scratch using vanilla TypeScript and Vite. By the end, you'll have a working setup where content editors can preview draft content, click on elements to edit them in Sanity Studio, and see changes reflected in real time.

The example uses standard Web APIs and no frontend framework, so you can adapt the patterns to any server-side runtime or framework.

> [!NOTE]
> This isn’t a drop-in solution, but should help you (and your agents) build custom implementations.

## What you'll build

A minimal web application with:

- A Sanity client configured for visual editing with stega encoding.
- Server-side draft mode with secure enable/disable endpoints.
- Click-to-edit overlays powered by `@sanity/visual-editing`.
- Real-time content updates via the Presentation Tool's Comlink connection.
- A Presentation Tool configuration in Sanity Studio.

Each step builds on the previous one and produces a testable result.

## Prerequisites

- A Sanity project with at least one document type (this example uses a `post` type with `title`, `slug`, and `body` fields). Follow the [Studio quick start](https://www.sanity.io/docs/sanity-studio-quickstart) to get up and running.
- Node.js 20 or later.
- A Sanity Studio deployed or running locally.

## Step 1: create the project and fetch content

Start by scaffolding a Vite project and configuring the Sanity client. We’ll use Vite to bundle the client-side visual editing components to make this example easier to follow.

### Create the project

**npm**

```shell
npm create vite@latest -- visual-editing-demo --template vanilla-ts
cd visual-editing-demo
npm install @sanity/client @portabletext/to-html
```

**pnpm**

```shell
pnpm create vite@latest visual-editing-demo --template vanilla-ts
cd visual-editing-demo
pnpm add @sanity/client @portabletext/to-html
```

**yarn**

```shell
yarn create vite@latest visual-editing-demo --template vanilla-ts
cd visual-editing-demo
yarn add @sanity/client @portabletext/to-html
```

**bun**

```shell
bun create vite@latest visual-editing-demo --template vanilla-ts
cd visual-editing-demo
bun add @sanity/client @portabletext/to-html
```

The `@portabletext/to-html` package renders Portable Text (the array format Sanity uses for rich text) as HTML. It preserves all text content, including stega-encoded zero-width characters, so overlays work within body content with no extra configuration.

### Configure the client

Create a client with stega encoding enabled. The configuration below creates a base client, then exposes a client configured for visual editing. The `studioUrl` tells the overlay system where your Studio lives:

**src/lib/sanity.ts**

```typescript
import { createClient, type ClientPerspective } from '@sanity/client'

const baseClient = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2025-12-01',
  useCdn: true,
  stega: {
    enabled: false,
    studioUrl: 'YOUR_STUDIO_URL',
  },
})

// Returns a client configured for the current perspective.
// In preview mode, the perspective comes from the Studio (via cookie)
// and may be a stacked array for content releases.
export function getClient(perspective: ClientPerspective = 'published') {
  const isPreview = perspective !== 'published'
  return baseClient.withConfig({
    perspective,
    useCdn: !isPreview,
    stega: { enabled: isPreview },
    // Token required server-side to fetch draft/release content.
    // Without it, the API silently returns only published documents.
    ...(isPreview && { token: process.env.SANITY_API_READ_TOKEN }),
  })
}
```

> [!NOTE]
> **Security:** the token is used server-side only. Your server renders HTML with draft content, but the token itself never reaches the browser. Make sure `SANITY_API_READ_TOKEN` is not prefixed with `VITE_`, `NEXT_PUBLIC_`, or any other prefix that exposes environment variables to client-side code. For more detail, see [setting up the Sanity client for visual editing](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

### Create a server for rendering

This example uses a basic Node.js HTTP server to avoid confusion. In a real project, you'd use your framework's server (Express, Hono, Fastify, or similar):

**server.ts**

```typescript
import { createServer } from 'node:http'
import { toHTML } from '@portabletext/to-html'
import { getClient } from './src/lib/sanity'

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{
  _id,
  title,
  slug,
  body // Portable Text array
}`

const server = createServer(async (req, res) => {
  const url = new URL(req.url || '/', `http://${req.headers.host}`)
  const slug = url.pathname.split('/posts/')[1]

  if (!slug) {
    res.writeHead(404)
    res.end('Not found')
    return
  }

  const client = getClient() // Defaults to 'published' perspective
  const post = await client.fetch(POST_QUERY, { slug })

  if (!post) {
    res.writeHead(404)
    res.end('Post not found')
    return
  }

  // Render the Portable Text body as HTML
  const bodyHtml = toHTML(post.body ?? [])

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  res.end(`<!DOCTYPE html>
<html>
<head>
  <title>${post.title}</title>
</head>
<body>
  <article>
    <h1>${post.title}</h1>
    <div class="body">${bodyHtml}</div>
  </article>
</body>
</html>`)
})

server.listen(3000, () => console.log('Server running at http://localhost:3000'))
```

> [!NOTE]
> **Note on Portable Text and overlays:** `@portabletext/to-html` preserves all text content in spans, including the invisible stega-encoded characters. This means overlays work within body content, and each block element (paragraph, heading, list item) becomes an edit target. For custom block types (images, code blocks, custom embeds), pass a `components` option. [See the documentation for details](https://github.com/portabletext/to-html).

**Test it:** run `npx tsx server.ts` and visit `http://localhost:3000/posts/your-post-slug`. You should see the published content rendered on the page.

> [!WARNING]
> Important
> The `charset=utf-8` declaration in the `Content-Type` header is required. Stega encoding uses zero-width Unicode characters (U+200B–U+200D and U+FEFF) that browsers will misinterpret without explicit UTF-8 encoding, causing invisible stega data to render as visible characters and breaking overlay detection. Most frameworks set UTF-8 by default, but vanilla Node.js `http` does not.

For more on client configuration, see [setting up the Sanity client for visual editing](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

## Step 2: add draft mode

Add secure endpoints that the Presentation Tool calls to toggle draft mode.

### Install dependencies

Still in the `visual-editing-demo` directory:

**npm**

```shell
npm install @sanity/preview-url-secret
```

**pnpm**

```shell
pnpm add @sanity/preview-url-secret
```

**yarn**

```shell
yarn add @sanity/preview-url-secret
```

**bun**

```shell
bun add @sanity/preview-url-secret
```

### Create the draft mode endpoints

**src/lib/draft-mode.ts**

```typescript
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { validateApiPerspective, type ClientPerspective } from '@sanity/client'
import { getClient } from './sanity'

export function isDraftMode(cookieHeader: string): boolean {
  return cookieHeader.includes(`${perspectiveCookieName}=`)
}

export function getPreviewPerspective(cookieHeader: string): ClientPerspective {
  const regex = new RegExp(`${perspectiveCookieName}=([^;]+)`)
  const match = cookieHeader.match(regex)
  if (!match) return 'drafts'

  // The cookie value may be a comma-separated stacked perspective
  // for content releases (for example, "summer-drop,drafts,published")
  const value = match[1]
  const perspective = value.includes(',') ? value.split(',') : value
  try {
    validateApiPerspective(perspective)
    return perspective === 'raw' ? 'drafts' : perspective
  } catch {
    return 'drafts'
  }
}

export async function handleEnableDraftMode(requestUrl: string): Promise<{
  status: number
  headers: Record<string, string | string[]>
}> {
  // The token is required (validatePreviewUrl throws a TypeError without it)
  if (!process.env.SANITY_API_READ_TOKEN) {
    return { status: 500, headers: {} }
  }

  const client = getClient().withConfig({
    token: process.env.SANITY_API_READ_TOKEN,
  })

  const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
    client,
    requestUrl
  )

  if (!isValid) {
    return { status: 401, headers: {} }
  }

  const cleanRedirect = redirectTo
    ? withoutSecretSearchParams(new URL(redirectTo, requestUrl)).pathname
    : '/'

  const cookies: string[] = [
    `${perspectiveCookieName}=${studioPreviewPerspective || 'drafts'}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=3600`,
  ]

  return {
    status: 307,
    headers: {
      'Set-Cookie': cookies,
      Location: cleanRedirect,
    },
  }
}

export function handleDisableDraftMode(): {
  status: number
  headers: Record<string, string | string[]>
} {
  return {
    status: 307,
    headers: {
      'Set-Cookie': [
        `${perspectiveCookieName}=; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=0`,
      ],
      Location: '/',
    },
  }
}
```

### Update the server to handle draft mode

**server.ts**

```typescript
// server.ts, updated with draft mode support
import { createServer } from 'node:http'
import { toHTML } from '@portabletext/to-html'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { getClient } from './src/lib/sanity'
import {
  isDraftMode,
  getPreviewPerspective,
  handleEnableDraftMode,
  handleDisableDraftMode,
} from './src/lib/draft-mode'

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{
  _id,
  title,
  slug,
  body
}`

const server = createServer(async (req, res) => {
  const url = new URL(req.url || '/', `http://${req.headers.host}`)
  const cookieHeader = req.headers.cookie || ''

  // Route to draft mode endpoints
  if (url.pathname === '/api/draft-mode/enable') {
    const result = await handleEnableDraftMode(url.toString())
    const setCookie = result.headers['Set-Cookie']
    if (Array.isArray(setCookie)) {
      setCookie.forEach((c) => res.appendHeader('Set-Cookie', c))
    }
    if (result.headers.Location) {
      res.writeHead(result.status, { Location: result.headers.Location })
    } else {
      res.writeHead(result.status)
    }
    res.end()
    return
  }

  if (url.pathname === '/api/draft-mode/disable') {
    const result = handleDisableDraftMode()
    const setCookie = result.headers['Set-Cookie']
    if (Array.isArray(setCookie)) {
      setCookie.forEach((c) => res.appendHeader('Set-Cookie', c))
    }
    res.writeHead(result.status, { Location: result.headers.Location as string })
    res.end()
    return
  }

  // Update the perspective cookie when the editor switches releases in the Studio.
  // The Studio sends the perspective on EVERY page load, not just on change,
  // so we compare against the current value and return 204 if unchanged to
  // avoid an infinite reload loop.
  if (url.pathname === '/api/draft-mode/perspective') {
    const newPerspective = url.searchParams.get('perspective')
    if (!newPerspective || !isDraftMode(cookieHeader)) {
      res.writeHead(400)
      res.end()
      return
    }
    const currentPerspective = getPreviewPerspective(cookieHeader)
    const currentValue = Array.isArray(currentPerspective)
      ? currentPerspective.join(',')
      : currentPerspective
    if (currentValue === newPerspective) {
      // Perspective hasn't changed: no cookie update, no reload needed
      res.writeHead(204)
      res.end()
      return
    }
    res.writeHead(200, {
      'Set-Cookie': `${perspectiveCookieName}=${newPerspective}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=3600`,
    })
    res.end()
    return
  }

  const slug = url.pathname.split('/posts/')[1]
  if (!slug) {
    res.writeHead(404)
    res.end('Not found')
    return
  }

  const preview = isDraftMode(cookieHeader)
  const perspective = preview ? getPreviewPerspective(cookieHeader) : 'published'
  const client = getClient(perspective)
  const post = await client.fetch(POST_QUERY, { slug })

  if (!post) {
    res.writeHead(404)
    res.end('Post not found')
    return
  }

  const bodyHtml = toHTML(post.body ?? [])

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  res.end(`<!DOCTYPE html>
<html>
<head>
  <title>${post.title}</title>
</head>
<body>
  <article>
    <h1>${post.title}</h1>
    <div class="body">${bodyHtml}</div>
  </article>
</body>
</html>`)
})

server.listen(3000, () => console.log('Server running at http://localhost:3000'))
```

This step isn't independently testable yet because the Presentation Tool (configured in Step 4) triggers the enable endpoint automatically. For now, verify the code compiles and move to Step 3. Once the full integration is wired up, you can confirm the `sanity-preview-perspective` cookie is set and that draft content renders correctly.

For the details on how this preview mode implementation works, see [implementing preview/draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode).

## Step 3: add click-to-edit overlays and live updates

Add the overlay system so editors can click on content elements to jump to the corresponding field in the Studio. The `enableVisualEditing()` function handles both overlays and real-time content updates through the Presentation Tool's Comlink connection.

### Install dependencies

Still in the `visual-editing-demo` directory:

**npm**

```shell
npm install @sanity/visual-editing
```

**pnpm**

```shell
pnpm add @sanity/visual-editing
```

**yarn**

```shell
yarn add @sanity/visual-editing
```

**bun**

```shell
bun add @sanity/visual-editing
```

Note that `react`, `react-dom`, and `styled-components` are required peer dependencies of `@sanity/visual-editing`:

**npm**

```shell
npm install react react-dom styled-components
```

**pnpm**

```shell
pnpm add react react-dom styled-components
```

**yarn**

```shell
yarn add react react-dom styled-components
```

**bun**

```shell
bun add react react-dom styled-components
```

### Create the preview module

Create a module that initializes visual editing. This file is a client component, and it is only loaded when draft mode is active (the server conditionally includes it):

**src/preview.ts**

```typescript
import { enableVisualEditing } from '@sanity/visual-editing'

// enableVisualEditing() handles both overlays AND real-time updates.
// It creates Comlink connections for overlay interactions and live
// content sync. Do NOT also call enableLiveMode() from @sanity/core-loader.
// That would create duplicate Comlink nodes and break the handshake.
enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      const handler = () => navigate({
        type: 'pop',
        url: location.href,
      })
      addEventListener('popstate', handler)
      return () => removeEventListener('popstate', handler)
    },
    update: (update) => {
      if (update.type === 'push') history.pushState(null, '', update.url)
      if (update.type === 'replace') history.replaceState(null, '', update.url)
    },
  },
  refresh: (payload) => {
    // Called when the Studio signals a content change.
    // `source: 'manual'` is the editor clicking the refresh button.
    // `source: 'mutation'` is a document mutation in the Studio.
    if (payload.source === 'manual') {
      window.location.reload()
      return new Promise<void>(() => {}) // Never resolves, keeps loading indicator visible during reload
    }
    if (payload.source === 'mutation') {
      // Only reload if the mutation affects the current page.
      // Without this filter, every edit in the Studio would reload every
      // open preview, even unrelated ones. Frameworks with live streaming
      // (for example, next-sanity with the Live Content API) return `false`
      // here and let their data layer update incrementally instead.
      const currentSlug = window.location.pathname.split('/').pop()
      if (payload.document.slug?.current === currentSlug) {
        window.location.reload()
        return new Promise<void>(() => {})
      }
    }
    return false
  },
  // Called when the editor switches perspectives in the Studio
  // (for example, switching to a different content release).
  // The Studio sends the perspective on every page load, not just on change,
  // so the endpoint returns 204 when unchanged. We only reload on 200
  // (actual change) to avoid an infinite reload loop. This is the
  // framework-agnostic equivalent of next-sanity's server action +
  // router.refresh() pattern.
  onPerspectiveChange: async (perspective) => {
    const value = Array.isArray(perspective) ? perspective.join(',') : perspective
    const response = await fetch(
      `/api/draft-mode/perspective?perspective=${encodeURIComponent(value)}`
    )
    // 204 = perspective unchanged (normal page-load sync from Studio)
    // 200 = perspective actually changed, reload to re-render
    if (response.status === 200) {
      window.location.reload()
    }
  },
})
```

The `mutation` refresh source is deprecated in `@sanity/presentation-comlink`, and a future major version of Sanity Studio will stop sending it in favor of loader-based APIs. Treat the `mutation` branch as a compatibility fallback rather than building new logic on it.

### Additional options: keepStegaOnCopy and onSuspiciousStega

@sanity/visual-editing 5.5.0 introduces two new options you can pass to `enableVisualEditing()`.

#### `keepStegaOnCopy` (boolean, optional, default: `false`)

By default, `enableVisualEditing()` intercepts copy events and removes stega encoding from both `text/plain` and `text/html` clipboard payloads, so users copying text from the preview page don't get invisible characters in their clipboard. Pass `keepStegaOnCopy: true` to opt out of this behavior.

#### `onSuspiciousStega` (callback, optional)

An opt-in callback that reports stega found in unsafe DOM placements: element attributes (`class`, `id`, `href`, `src`, `style`, `data-*`, and similar), inside `<head>` (`title`, `meta[content]`, JSON-LD), in `<script>` or `<style>` text content, in `textarea` form values, or in the page URL. Each report includes the `kind`, `element`, `attribute` (if applicable), `value`, and `cleaned`.

**src/preview.ts**

```typescript
enableVisualEditing({
  // ... other options
  onSuspiciousStega: (reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  },
})
```

> [!WARNING]
> **Performance warning:** `onSuspiciousStega` performs a full DOM audit on load using TreeWalker, then uses a MutationObserver to re-check only what changes. The work is deferred to idle time, and reports are deduped and batched, but it still adds overhead on large or frequently changing pages. It's intended for development and debugging rather than as a permanent fixture in production.

### Update the server to include the preview script

Add the conditional script tag to your server's HTML template. This loads the preview module only when draft mode is active:

**server.ts**

```typescript
// In server.ts, update the HTML template:
  res.end(`<!DOCTYPE html>
<html>
<head>
  <title>${post.title}</title>
</head>
<body>
  <article>
    <h1>${post.title}</h1>
    <div class="body">${bodyHtml}</div>
  </article>
  ${preview ? `<script type="module" src="http://localhost:5173/src/preview.ts"></script>` : ''}
</body>
</html>`)
```

The server conditionally includes the preview script only when draft mode is active. The client-side code never needs to detect draft mode because it only runs when the server includes it.

### Start both development servers

You need two processes running: the Node server that renders HTML, and the Vite dev server that serves the preview module. **Both must be running for visual editing to work.**

Open two terminal windows:

**npm**

```shell
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints)
npx tsx server.ts

# Terminal 2: Vite dev server (serves preview module on port 5173)
npx vite
```

**pnpm**

```shell
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints)
pnpm dlx tsx server.ts

# Terminal 2: Vite dev server (serves preview module on port 5173)
pnpm dlx vite
```

**yarn**

```shell
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints)
yarn dlx tsx server.ts

# Terminal 2: Vite dev server (serves preview module on port 5173)
yarn dlx vite
```

**bun**

```shell
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints)
bunx tsx server.ts

# Terminal 2: Vite dev server (serves preview module on port 5173)
bunx vite
```

When draft mode is active, the Node server includes `<script type="module" src="http://localhost:5173/src/preview.ts">` in the HTML. Vite's dev server compiles and serves this module (and its dependencies like React and `@sanity/visual-editing`) on the fly. If Vite isn't running, the script tag silently fails and overlays won't appear.

### Add data attributes for non-string content

For content that can't carry stega encoding (like images), use `createDataAttribute()`:

**src/lib/data-attributes.ts**

```typescript
import { createDataAttribute } from '@sanity/visual-editing'

export function getImageAttribute(documentId: string, path: string) {
  return createDataAttribute({
    id: documentId,
    type: 'post',
    path,
    baseUrl: 'YOUR_STUDIO_URL',
  })
}

// In your server-rendered HTML:
// <img data-sanity="${getImageAttribute(post._id, 'mainImage')}" src="..." alt="..." />
```

Because stega encoding is active in draft mode, the rendered text already contains invisible metadata. The overlay system detects this automatically and draws transparent overlays on content elements. Data attributes are only needed for non-string content.

For the full overlay API, see [enabling overlays and click-to-edit](https://www.sanity.io/docs/visual-editing/visual-editing-overlays). For more on real-time update patterns, see [real-time content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates).

## Step 4: configure the Presentation Tool

Set up the Studio plugin that ties everything together.

### Update your Studio configuration

Navigate to your Studio directory and update the config.

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { presentationTool, defineDocuments, defineLocations } from 'sanity/presentation'
import { structureTool } from 'sanity/structure'

const mainDocuments = defineDocuments([
  {
    route: '/posts/:slug',
    filter: `_type == "post" && slug.current == $slug`,
  },
])

const locations = {
  post: defineLocations({
    select: {
      title: 'title',
      slug: 'slug.current',
    },
    resolve: (doc) => ({
      locations: [
        {
          title: doc?.title || 'Untitled',
          href: `/posts/${doc?.slug}`,
        },
      ],
    }),
  }),
}

export default defineConfig({
  name: 'default',
  title: 'My Studio',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [
    structureTool(),
    presentationTool({
      previewUrl: {
        initial: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
          disable: '/api/draft-mode/disable',
        },
      },
      resolve: {
        mainDocuments,
        locations,
      },
    }),
  ],
})
```

This configuration:

- **previewUrl:** tells the Presentation Tool where your frontend is running and which endpoints toggle draft mode.
- **mainDocuments:** maps URL patterns to documents, so navigating to `/posts/my-post` in the preview automatically opens the corresponding `post` document in the editor.
- **locations:** maps document types to frontend URLs, so the Studio shows editors where each document appears on the site.

For the full Presentation Tool configuration, see [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool).

## Test the full system

Run each part of the system:

**In your Studio:**

**npm**

```shell
npx sanity dev
```

**pnpm**

```shell
pnpm dlx sanity dev
```

**yarn**

```shell
yarn dlx sanity dev
```

**bun**

```shell
bunx sanity dev
```

**In the visual-editing-demo directory:**

**npm**

```shell
npx tsx server.ts
```

**pnpm**

```shell
pnpm dlx tsx server.ts
```

**yarn**

```shell
yarn dlx tsx server.ts
```

**bun**

```shell
bunx tsx server.ts
```

**Open another terminal window in visual-editing-demo, and run**:

**npm**

```shell
npx vite
```

**pnpm**

```shell
pnpm dlx vite
```

**yarn**

```shell
yarn dlx vite
```

**bun**

```shell
bunx vite
```

Open the Presentation Tool in your Studio. The preview should load your frontend in an iframe, activate draft mode automatically, and show click-to-edit overlays. Clicking an overlay should open the document in the editor pane. Editing a field should update the preview in real time. Navigating in the preview should sync with the Studio's document panel.

## What you've built

Your integration now supports the complete visual editing workflow:

1. **Draft mode:** the Presentation Tool activates draft mode via a secure handshake, switching your frontend to the Studio's requested perspective (which may be `drafts` or a stacked perspective for content releases) with stega encoding active.
2. **Click-to-edit:** editors click on any content element in the preview to jump directly to the corresponding field in the Studio.
3. **Real-time updates:** content changes in the Studio are reflected in the preview instantly via the Comlink connection managed by `enableVisualEditing()`.
4. **Navigation sync:** navigating in the preview updates the Studio's document panel, and clicking document locations in the Studio navigates the preview.

## Production considerations

This example uses Vite's dev server to serve client-side modules during development. For production:

- **Build the client-side code:** run `npx vite build` to produce optimized bundles. Serve the built assets from your production server.
- **Conditional script loading:** the server already conditionally includes the preview script only when draft mode is active. In production, point the `src` attribute to your built asset path instead of the Vite dev server.
- **Environment variables:** store `SANITY_API_READ_TOKEN` as a server-side environment variable. Never expose it to the client.
- **Escape HTML interpolation:** the `server.ts` examples interpolate values like `${post.title}` into HTML without escaping to keep the example minimal. In production, escape any content you interpolate into HTML to prevent cross-site scripting (XSS).

## Next steps

This example covers the core integration. Here are some areas to explore further:

- **Custom filtering:** control which values get stega-encoded using the client's `stega.filter` option. See [setting up the Sanity client](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).
- **Shared preview access:** let editors share preview URLs with stakeholders who don't have Studio access. See [implementing preview/draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode).
- **Edit groups and data attributes:** group related fields under a single overlay or annotate non-string content. See [enabling overlays and click-to-edit](https://www.sanity.io/docs/visual-editing/visual-editing-overlays).
- **Production real-time updates:** use the Listener API or a framework library's live component for real-time updates outside the Presentation Tool. See [real-time content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates).
- **Multiple preview environments:** configure different preview URLs for staging and production. See [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool).



# Overlay and control components

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be fully complete.

Custom overlay components let you extend the functionality of [visual editing overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) with custom React components. Such components can greatly enhance the editing experience by enabling direct, in-app, content editing and displaying rich metadata or controls to content editors.

With custom overlays, you can:

- Add interactive controls such as color pickers or sliders to configure complex objects, for example 3D models.
- Display additional context, such as related product data from external systems.

You can also [customize the Presentation Tool's preview header](https://www.sanity.io/docs/visual-editing/customizing-preview-header-and-navigation), giving you the flexibility to toggle custom overlays, or add controls, status indicators, or other UI elements that enhance the editor experience.

[Visual Editing – Introduction](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
Get started with Visual Editing

[The Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool)
Live preview of your web application within Sanity Studio.

[Fetching content for Visual Editing](https://www.sanity.io/docs/visual-editing/visual-editing-architecture)
How to reason about fetching content for Visual Editing.

[Visual Editing - Overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays)

## Prerequisites

Before getting started, ensure the following:

- [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing) enabled with up-to-date dependencies in your front end
- `@sanity/visual-editing` 2.15.0 or later, which covers every API this article uses
- Sanity Studio v3.65.0 or later (`npm install sanity@latest`)

> [!WARNING]
> Gotcha
> Custom overlay components currently only support React.

## Custom overlay plugins

Overlay plugins will let you mount custom React components in the visual editing overlay, enabling complex views and controls for interacting with your content:

**my-first-plugin.tsx**

```tsx
'use client'

import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'

export const MyFirstPlugin = defineOverlayPlugin(() => ({
  type: 'hud',
  name: 'my-first-plugin',
  title: 'My First Plugin!',
  component: function MyFirstPluginComponent() {
    return <div>Hello World</div>
  },
}))

```

For all available configuration properties, see [OverlayPluginDefinition in the generated reference](https://reference.sanity.io/_sanity/visual-editing/index/OverlayPluginDefinition/).

> [!TIP]
> Gotcha
> Custom overlay plugins, components, and resolvers should be rendered client-side, commonly done with [the "use client" directive for React](https://react.dev/reference/rsc/use-client).

### Use custom overlay plugins

Depending on your framework and implementation, pass an array of instantiated plugins through the `plugins` property of the object passed to the [enableVisualEditing](https://reference.sanity.io/_sanity/visual-editing/index/enableVisualEditing/) function, or the `plugins` prop of the `<VisualEditing>` component. For example:

**app/(website)/layout.tsx**

```tsx
import {VisualEditing} from 'next-sanity/visual-editing'
import {draftMode} from 'next/headers'
import {plugins} from './overlay-plugins'

// minimal Next.js-like example
export default async function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html>
      <body>
        <main>{children}</main>
        {(await draftMode()).isEnabled && (
          <VisualEditing plugins={plugins} />
        )}
      </body>
    </html>
  )
}

```

**overlay-plugins.ts**

```ts
'use client'

import type {OverlayPluginDefinition} from '@sanity/visual-editing/react'
import {MyFirstPlugin} from './my-first-plugin'

export const plugins: OverlayPluginDefinition[] = [
  MyFirstPlugin()
]

```

### Overlay plugin types

There are two different types of overlay plugins which define how the plugin behaves in the visual editing overlay:

#### HUD

Plugins with the `hud` type can be used to mount a component under the overlay element when hovered.

#### Exclusive

Plugins with the `exclusive` type can be used to take exclusive control of the overlay experience. The type of plugin is listed in an overlay element menu. When selected by the user, the component is rendered inside the overlay element and all other overlay UI is hidden until closed.

### User-configurable options

Plugins support user-configurable options enabling them to be used multiple times per project with different configurations or shared between multiple projects:

**my-configurable-plugin.tsx**

```tsx
'use client'

import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'

type MyConfigurablePluginOptions = {
  displayText?: string
}

export const MyConfigurablePlugin = defineOverlayPlugin<MyConfigurablePluginOptions>(
  ({displayText = 'Default Text'}) => ({
    type: 'hud',
    name: 'my-configurable-plugin',
    title: 'My Configurable Plugin!',
    component: function MyConfigurablePluginComponent() {
      return <div>{displayText}</div>
    },
  }),
)

```

**overlay-plugins.ts**

```ts
'use client'

import type {OverlayPluginDefinition} from '@sanity/visual-editing/react'
import {MyConfigurablePlugin} from './my-configurable-plugin'

export const plugins: OverlayPluginDefinition[] = [
  MyConfigurablePlugin({
    options: {
      displayText: 'Hello World!'
    }
  })
]

```

### Guarded overlay plugins

The `guard` conditional function can be used to filter which overlay element the plugin should be displayed on.

When using `defineOverlayPlugin`, guards can be configured at both the plugin level and the user level. Both must return `true` for the plugin to be shown, and if no guard function is provided it will pass (as if `true`):

**my-guarded-plugin.tsx**

```tsx
'use client'

import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'

export const MyGuardedPlugin = defineOverlayPlugin(() => ({
  type: 'hud',
  name: 'my-guarded-plugin',
  title: 'My Guarded Plugin!',
  component: function MyGuardedPluginComponent() {
    return <div>Hello World</div>
  },
  guard: ({type}) => {
    // This plugin only supports string fields.
    return type === 'string'
  },
}))

```

**overlay-plugins.ts**

```ts
'use client'

import type {OverlayPluginDefinition} from '@sanity/visual-editing/react'
import {MyGuardedPlugin} from './my-guarded-plugin'

export const plugins: OverlayPluginDefinition[] = [
  MyGuardedPlugin({
    // This plugin instance should only show on title nodes
    guard: ({node}) => node.path.endsWith('title'),
  })
]

```

### Update document data

Custom overlay plugins enable powerful editing capabilities directly in your application, from basic string manipulation to advanced controls for complex content types.

> [!TIP]
> Protip
> Custom overlay plugins will automatically use the logged-in user's authentication to update content. This means that any permissions that the user has will still be respected.

Install the `@sanity/mutate` package in your front-end project to create the necessary patches for updating data. Refer to that package’s [documentation](https://github.com/sanity-io/mutate) for available methods. The example below also uses `@sanity/util/paths` to read the current value from a document snapshot, which comes from the `@sanity/util` package.

**npm**

```shell
npm install @sanity/mutate @sanity/util
```

**pnpm**

```shell
pnpm add @sanity/mutate @sanity/util
```

**yarn**

```shell
yarn add @sanity/mutate @sanity/util
```

**bun**

```shell
bun add @sanity/mutate @sanity/util
```

This example mounts a button in an overlay that appends an exclamation mark to the end of a `string` value when clicked:

**exciting-string-control-plugin.tsx**

```tsx
'use client'

import {at, set} from '@sanity/mutate'
import {get} from '@sanity/util/paths'
import {useDocuments} from '@sanity/visual-editing/react'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'

export const ExcitingStringControlPlugin = defineOverlayPlugin(() => ({
  type: 'hud',
  name: 'exciting-string-control',
  title: 'Exciting String Control',
  component: function ExcitingStringControlComponent(props) {
    const {node} = props

    // Get the document ID and field path from the Sanity node.
    const {id, path} = node

    const {getDocument} = useDocuments()
    // Get the optimistic document using the document ID.
    const doc = getDocument(id)

    const onChange = () => {
      doc.patch(async ({getSnapshot}) => {
        const snapshot = await getSnapshot()
        // Get the current value using the document snapshot and the field path.
        const currentValue = get<string>(snapshot, path)
        // Append "!" to the string.
        const newValue = `${currentValue}!`
        // Use `@sanity/mutate` functions to create the document patches.
        return [at(path, set(newValue))]
      })
    }

    return (
      <button
        // Tailwind CSS classes
        className="rounded bg-blue-500 px-2 py-1 text-sm text-white"
        onClick={onChange}
      >
        Click Me 🎉
      </button>
    )
  },
  guard: (context) => {
    // This plugin only supports string fields.
    return context.type === 'string'
  },
}))

```

### Custom preview header state

The visual editing package exports a named [useSharedState](https://reference.sanity.io/_sanity/visual-editing/index/useSharedState/) hook. Given the unique key defined in [a custom preview header](https://www.sanity.io/docs/visual-editing/customizing-preview-header-and-navigation), it returns the value shared by the corresponding `useSharedState` Presentation Tool hook.

This HUD plugin displays only if the `showMyPlugin` shared state value is set to `true` from a custom preview header:

**my-toggle-plugin.tsx**

```tsx
'use client'

import {useSharedState} from '@sanity/visual-editing'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'

export const MyTogglePlugin = defineOverlayPlugin(() => ({
  type: 'hud',
  name: 'my-toggle-plugin',
  title: 'My Toggle Plugin!',
  component: function MyTogglePluginComponent() {
    const showMyPlugin = useSharedState<boolean>('showMyPlugin')

    if (!showMyPlugin) {
      return null
    }

    return <div>Hello World</div>
  },
}))

```

### Close exclusive plugins

By default, exclusive plugins can be closed by clicking outside of the overlay element area. However, the `closeExclusiveView` function can be used to programmatically close the plugin:

**closeable-exclusive-plugin.tsx**

```tsx
'use client'

import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'

export const CloseableExclusivePlugin = defineOverlayPlugin(() => ({
  type: 'exclusive',
  name: 'closeable-exclusive',
  title: 'Closeable Exclusive',
  component: function CloseableExclusiveComponent({closeExclusiveView}) {
    return (
      <button
        // Tailwind CSS classes
        className="absolute right-0 top-0 rounded bg-blue-500 px-2 py-1 text-sm text-white"
        onClick={() => closeExclusiveView()}
      >
        Close Me
      </button>
    )
  },
}))

```

## Custom component resolver

While the plugin system is helpful at managing the position and visibility of components, the custom component resolver can be used for cases where more control over rendering is required.

Resolvers determine which custom components to mount for specific overlays. Use the `defineOverlayComponents` helper to conditionally resolve components based on overlay context.

This function runs each time an overlay renders, and the context object it receives can be used to determine which components to return.

Resolver functions can return:

- JSX elements.
- React component(s), single or array.
- Object(s) with `component` and `props` values. Use the `defineOverlayComponent` for convenience and type safety, single or array.
- `undefined` or `void` when no custom components should be mounted.

When using the custom component resolver, the `PointerEvents` component must be used if interaction is required, this is available through the overlay component props:

**overlay-components.tsx**

```tsx
'use client'

import {type OverlayComponent} from '@sanity/visual-editing'

export const FieldNameOverlay: OverlayComponent = ({field, PointerEvents}) => (
  <PointerEvents>
    <div
      // Tailwind CSS classes
      className="absolute bottom-0 left-0 m-1 rounded bg-black bg-opacity-50 px-2 py-1 text-xs text-white"
    >
      {field?.name}
    </div>
  </PointerEvents>
)

```

This example resolves different custom overlay components conditionally:

**component-resolver.tsx**

```tsx
'use client'

import {
  defineOverlayComponent,
  defineOverlayComponents,
} from '@sanity/visual-editing/unstable_overlay-components'
import {
  HighlightOverlay,
  TitleControl,
  UnionControl,
  UnionTypeMarker,
} from './overlay-components'

export const components = defineOverlayComponents((context) => {
  const {document, element, field, type, parent} = context

  // Mount a component in overlays attached to string
  // fields named 'title'
  if (type === 'string' && field?.name === 'title') {
    return TitleControl
  }

  // Return JSX directly
  if (type === 'string' && field?.name === 'subtitle') {
    return <div>Subtitle</div>
  }

  // Mount a component in overlays attached to any element
  // corresponding to a 'product' document
  if (document.name === 'product') {
    const color = element.dataset.highlightColor || 'red'
    return defineOverlayComponent(HighlightOverlay, {color})
  }

  // Mount multiple components in overlays attached to any
  // member element of a union type
  if (parent?.type === 'union') {
    return [UnionTypeMarker, defineOverlayComponent(UnionControl, {direction: 'vertical'})]
  }

  return undefined
})

```

Depending on your framework and implementation, pass the resolver function through the `components` property of the object passed to the `enableVisualEditing` function, or the `components` prop of the `<VisualEditing>` component. For example:

**app/(website)/layout.tsx**

```tsx
import {VisualEditing} from 'next-sanity/visual-editing'
import {draftMode} from 'next/headers'
import {components} from './component-resolver'

// minimal Next.js-like example
export default async function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html>
      <body>
        <main>{children}</main>
        {(await draftMode()).isEnabled && (
          <VisualEditing components={components} />
        )}
      </body>
    </html>
  )
}

```

## Reference

Full type signatures for `@sanity/visual-editing` live in [the generated reference](https://reference.sanity.io/_sanity/visual-editing/), including `defineOverlayPlugin`, `defineOverlayComponents`, `OverlayPluginDefinition`, `OverlayComponentResolverContext`, and `useSharedState`. This section covers only what the generated reference doesn't express.

### useDocuments

The generated reference types `getDocument` and `mutateDocument` through internal aliases that it doesn't publish, so their parameters and the methods on the document `getDocument` returns are listed here.

**getDocument(documentId): { id, get, getSnapshot, patch, commit }**

Returns an optimistic document interface with the following methods:

id: string: The document ID.

get: (path?: string): SanityDocument | PathValue: Returns the document snapshot or the specific value at the given path. Deprecated — use getSnapshot instead.

getSnapshot: () => Promise<SanityDocument | null>: Resolves with the current document snapshot, or null when no snapshot is available. Use it instead of the deprecated get.

patch: (patches: OptimisticDocumentPatches, options?: {commit?: boolean | {debounce: number}}) => void: Applies patches to the document. Commits patches by default.

commit: () => void: Commits pending changes.

Parameters:
- **documentId** (string): The ID of the document to get.

**mutateDocument(documentId, mutations, options): void**

Parameters:
- **documentId** (string): The ID of the document to mutate.
- **mutations** (Mutation[]): The mutations to apply to the document.
- **options** ({commit?: boolean | {debounce: number}}): Optional commit options.

## Troubleshooting

The `useDocuments` hook throws rather than returning an error state, so a failure surfaces as an uncaught error in your component. The hook body never throws. The errors come from `getDocument` and `mutateDocument`, and from the methods `getDocument` returns.

### Hook used outside a Presentation preview

`getDocument` and `mutateDocument` throw `The `useDocuments` hook cannot be used in this context` when the page isn't running inside a Presentation preview iframe or pop-up window. The same error fires before Presentation's optimistic actor is set. Rendering an overlay component in a plain browser tab always triggers it.

To avoid it, guard on `useIsPresentationTool` and render a fallback until it returns `true`. It returns `null` until `VisualEditing` renders, and can return `false` before eventually returning `true`, so treat any other value as not ready.

### Document not tracked by the overlay

`getDocument` throws `Document "DOCUMENT_ID" not found` when the requested ID isn't among the documents the overlay currently tracks. The overlay tracks the IDs of overlay elements it detects on the page, so an ID with no rendered element is not found. A document existing in your dataset isn't enough.

Pass an ID that belongs to an element rendered on the current page. This error also fires before the overlay's tracking effect has run, so don't call `getDocument` during the first render.

### Snapshot not available yet

Two paths throw `Snapshot for document "DOCUMENT_ID" not found`, and they mean different things. The deprecated `snapshot` property throws on access when the document data hasn't synced yet. `patch` rejects with the same message when the document doesn't exist at all.

Use `getSnapshot` instead of `snapshot`. It waits for the document to sync and resolves `null` rather than throwing, which turns a timing problem into a null check. Catch the rejection from `patch` for the case where the document is genuinely absent.

## Resources

- [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
- [Overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays)
- [@sanity/mutate](https://github.com/sanity-io/mutate)
- [Sanity UI](https://www.sanity.io/ui)



# Preview header and navigation

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

The Presentation tool also allows you to customize the preview header, giving you the flexibility to add controls, status indicators, or other UI elements that enhance the editor experience. 

This is particularly useful for enabling users to:

- [Interact with specific overlays](https://www.sanity.io/docs/visual-editing/custom-overlay-components)
- Toggle features
- Access contextual tools directly from the preview interface.

## Prerequisites

Before getting started, ensure the following:

- [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing) enabled with up-to-date dependencies in your front end
- Sanity Studio v3.65.0 or later (`npm install sanity@latest`)

## Define a custom preview header component

First, create a custom header component to mount in the Presentation tool. In this case, you are creating a dropdown with a single toggle for enabling and disabling highlighting.

![Interface view of a web application featuring a toggle switch labeled ‘Edit,’ a URL input field displaying ‘http://localhost:3005/,’ and a dropdown menu option to ‘Enable Highlighting.’](https://cdn.sanity.io/images/3do82whm/next/db8d45afe1574cbfe53567cf40d85132334861bc-1706x350.png)
*A simple menu appended to the preview header.*

You can use the `renderDefault` prop to keep existing functionality whilst appending a custom control. You use `@sanity/ui` to render the new elements.

> [!TIP]
> Protip
> Remember to install Sanity UI as a dependency, if you haven't already:
> `npm install @sanity/ui`

## Share state between overlays and the preview header

If you have multiple [custom overlays](https://www.sanity.io/docs/visual-editing/custom-overlay-components), you may need to allow content editors the ability to toggle specific overlays on or off, or provide more fine grained control over which overlay UI elements to render.

To do this, you need to share state between your Presentation tool in the Studio and your front end in preview mode that is rendered in the tool's iframe.

Both the Presentation tool and `@sanity/visual-editing` package provide `useSharedState` hooks. These hooks allow you to share state defined in the Presentation tool with custom overlay components in your front end. Both accepts two parameters: a unique string identifier as the `key`, and the state `value` itself.

> [!WARNING]
> Gotcha
> Only JSON serializable state can be passed to `useSharedState`, this is data types like `string`, `number`, `boolean`, `null`, `arrays`, or plain `objects`.

Below is an example of a custom preview header that renders the out-of-box header UI (`props.renderDefault(props)`), with an additional new menu with a toggle for highlight components using Sanity UI:

```tsx
// ./CustomPreviewHeader.tsx

import {CheckmarkIcon} from '@sanity/icons/Checkmark'
import {CloseIcon} from '@sanity/icons/Close'
import {EllipsisVerticalIcon} from '@sanity/icons/EllipsisVertical'
import {useSharedState, type PreviewHeaderProps} from '@sanity/presentation'
import {Button} from '@sanity/ui'
import {Menu, MenuButton, MenuItem} from '@sanity/ui/menu'
import {useState, type FunctionComponent} from 'react'

export const CustomPreviewHeader: FunctionComponent<PreviewHeaderProps> = (props) => {
  const [enabled, setEnabled] = useState(false)
  useSharedState('highlighting', enabled)

  // Render the default header component, and append a new control
  return (
    <>
      {props.renderDefault(props)}
      <MenuButton
        button={
          <Button fontSize={1} icon={EllipsisVerticalIcon} mode="bleed" padding={2} gap={2} />
        }
        id="custom-menu"
        menu={
          <Menu style={{maxWidth: 240}}>
            <MenuItem
              fontSize={1}
              icon={enabled ? CloseIcon : CheckmarkIcon}
              onClick={() => setEnabled((enabled) => !enabled)}
              padding={3}
              tone={enabled ? 'caution' : 'positive'}
              text={enabled ? 'Disable Highlighting' : 'Enable Highlighting'}
            />
          </Menu>
        }
        popover={{
          animate: true,
          constrainSize: true,
          placement: 'bottom',
          portal: true,
        }}
      />
    </>
  )
}

```

Now, you can access this state in a custom overlay component. Find instructions for how to do this in [the custom overlay component documentation](https://www.sanity.io/docs/visual-editing/custom-overlay-components).

## Mount a custom preview header component

> [!NOTE]
> Unstable feature
> `unstable_navigator` has the `unstable` prefix because the API is likely to change. Don’t use it in a production environment unless you are ready to change it when the API stabilizes.

Pass custom preview header component via `components.unstable_header` in the Presentation tool configuration object, as below:

```tsx
// sanity.config.ts
import {defineConfig} from "sanity"
import {presentationTool} from "sanity/presentation"
import {CustomHeader} from "./CustomHeader"

export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      // ...
      components: {
        unstable_header: {
          component: CustomHeader,
        },
      },
    }),
  ],
});


```

## Mount a custom navigator component

Optionally, you can enhance the Presentation tool with a custom document navigator component to help users select different documents or views in the front-end UI.

Example:

```typescript
// Import your custom navigator component
import {NavigatorComponent} from './presentation/NavigatorComponent'

export default defineConfig({
  // Your configuration for the project
  // ...

  plugins: [
    presentationTool({
      // ...
      component: {
  			// Pass the custom component to the plugin
        unstable_navigator: NavigatorComponent
      },
    })
  ],
})
```

### Navigator properties

#### Properties

**component** (React component, required)

Specifies the navigator component to use with the Presentation tool. The component specified will be rendered as the content of the navigator panel.

**minWidth** (number)

Sets the minimum width of the navigator component when it’s rendered in the UI. For the component to render, its value must be > 0 and other than null.

**maxWidth** (number)

Sets the maximum width of the navigator component when it’s rendered in the UI. For the component to render, its value must be > 0 and other than null.

## Hooks reference

### useSharedState

**useSharedState(key, value): Your serializeable state**

The useSharedState enables you to share state between the Presentation tool and your custom overlay components in your front end’s preview.

Parameters:
- **key** (string): Acts as a unique identifier for the shared state within the context. This key is used to associate a specific state value with a logical “slot” in the shared state object. Best practice: Use descriptive and unique keys to avoid conflicts between multiple shared states. Keys should be stable (i.e., not dynamically generated) to ensure predictable behavior. Example: useSharedState('highlighting', true);
- **value** (A serializeable state): Represents the state value associated with the given key. This value will be shared with other components that query the state using the same key. Requirements: Must be JSON serializable (string, number, boolean, null, arrays, or plain objects) to ensure compatibility with mechanisms like serialization, storage, or sharing across contexts. Best practices: Ensure the value is minimal and only includes the necessary data. Avoid passing complex or deeply nested structures to keep the shared state manageable.

## Resources

- [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
- [Overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays)
- [@sanity/mutate](https://github.com/sanity-io/mutate)
- [Sanity UI](https://www.sanity.io/ui)



# Drag and drop

Visual Editing offers page building capabilities that let content editors add, move, remove, and reorder content sections directly within their website's preview. Drag and drop lets content editors visually rearrange content in the context of their application or website. They can reorder array items with immediate visual feedback and dynamic zoomed-out overviews.

![Graphic illustrating a draggable user interface. It features transparent rectangular overlays labeled ‘Draggable Overlay’ with a thin blue outline, positioned over numbered placeholders (‘1,’ ‘2,’ ‘3,’ ‘4’) in a grid layout. The design is on a light blue background, emphasizing interaction and movement.](https://cdn.sanity.io/images/3do82whm/next/0980577d20dbf094133f894c8662844250d273a1-1274x824.png)

## Prerequisites

To implement page building features, you need:

- Visual Editing configured and enabled, with up-to-date dependencies.
- Content structured using arrays for reorderable sections.
- Some understanding of [Stega/Content Source Maps](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega) and how to enable [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) manually.
- Studio on version `3.65.0` or above (`npm install sanity@latest`). Also requires `@sanity/visual-editing` `5.7.3` or later, which needs React `19.2` or later and `@sanity/client` `7.24.0` or later.

### Browser/device support

Drag and drop is supported in the following browsers/versions:

- Chrome ≥ 108
- Safari ≥ 15.6
- Firefox ≥ 115
- Edge ≥ 126

> [!WARNING]
> Gotcha
> Drag and drop is currently not compatible with touch-based devices.

[Visual Editing – Introduction](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)

[Overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays)

[Fetching content for Visual Editing](https://www.sanity.io/docs/visual-editing/visual-editing-architecture)

[Custom overlay components](https://www.sanity.io/docs/visual-editing/custom-overlay-components)

## Drag and drop building blocks

The Presentation Tool's drag-and-drop functionality is framework-agnostic and can be implemented without significant changes to your codebase. It uses Overlays for visual representation, and updates your structured content directly. It does not mutate or reorder the DOM.

In a Presentation Tool drag-and-drop sequence:

1. An [Overlay](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) element is dragged to a new position on the page.
2. The array order in the Presentation Tool is updated, reflecting the item’s new position.
3. Your front end receives the updated Sanity data and re-renders as normal.

## Content modeling for page building

Drag and drop for page building, and similar layout systems, works with array-based content. Your schema (content model) should:

- Use arrays to represent reorderable sections
- Define content blocks as object types

```javascript
// Example schema
defineField({
  name: 'sections',
  type: 'array',
  of: [
    defineArrayMember({ type: 'hero' }),
    defineArrayMember({ type: 'features' }),
    defineArrayMember({ type: 'callToAction' })
  ]
})
```

> [!TIP]
> Protip
> You can nest `array` type fields, but it is required that you wrap the nested array in an `object` type.

## Enable drag and drop in your front-end application

To enable drag-and-drop functionality in your front end, you must:

- Implement [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
- Apply data attributes to the array items, and optionally the array parent if you want to enable click-to-edit for it
- Make sure the array is rendering as a client-side component (`'use client'` with React Server Components-based frameworks)

### Add data attributes to elements

> [!TIP]
> Protip
> There are a few different concepts of "paths" in Sanity. The data attributes in this section use **form paths**, which you can learn more about in [How form paths work](https://www.sanity.io/docs/studio/how-form-paths-work).

To enable drag-and-drop functionality:

1. Add `data-sanity` attributes to the array elements
2. Include required information:- Document ID (`_id`)
- Document type (`_type`)
- Array item key (`_key`)
- Path to array schema type (`arrayName[_key=="SECTION_KEY"]`)



These attributes connect your UI elements to the underlying content structure.

You can use the [createDataAttribute](https://reference.sanity.io/_sanity/visual-editing/index/createDataAttribute-1/) helper function to achieve this:

**components/SectionParent.tsx**

```tsx
import {createDataAttribute} from '@sanity/visual-editing'
import {Sections} from '@/components/Sections'

// Your Sanity configuration
const config = {
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  baseUrl: 'https://your-studio-url.sanity.studio',
}

export function SectionParent({documentId, documentType, sections}) {
  return (
    <div
      data-sanity={createDataAttribute({
        ...config,
        id: documentId,
        type: documentType,
        path: 'sections',
      }).toString()}
    >
      <Sections
        documentId={documentId}
        documentType={documentType}
        sections={sections}
      />
    </div>
  )
}
```

### Implement optimistic updates

Load the array item data through the [useOptimistic](https://www.sanity.io/docs/visual-editing/useoptimistic-reference) hook from the Visual Editing package (or framework-specific toolkit) to ensure that the user experience is fast and not slowed down by network latency.

The `useOptimistic` hook exposes ways of controlling the state and when to update the UI, which you typically want only when the array data has changed:

```typescript
const sections = useOptimistic<PageSection[] | undefined, SanityDocument<PageData>>(
  initialSections,
  (currentSections, action) => {
    // The action contains updated document data from Sanity
    // when someone makes an edit in the Studio

    // If the edit was to a different document, ignore it
    if (action.id !== documentId) {
      return currentSections
    }

    // If there are sections in the updated document, use them
    if (action.document.sections) {
      return action.document.sections
    }

    // Otherwise keep the current sections
    return currentSections
  }
)
```

> [!TIP]
> Protip
> The `useOptimistic` hook is supplementary to data fetching and works independently.

### How the useOptimistic hook works

Typically, mutations created in your application need to be committed to Content Lake through the Presentation Tool, and content refetched before the UI can be updated.

![Diagram illustrating a sequence of interactions between three components: ‘Application,’ ‘Presentation Tool,’ and ‘Content Lake.’ 	•	The ‘Application’ sends a mutation to the ‘Presentation Tool.’ 	•	The ‘Presentation Tool’ forwards the mutation to the ‘Content Lake.’ 	•	The ‘Content Lake’ commits the mutation and returns the content to the ‘Presentation Tool.’ 	•	The ‘Presentation Tool’ sends the content back to the ‘Application.’  Each interaction is depicted with labeled arrows connecting the components, providing a clear flow of data.](https://cdn.sanity.io/images/3do82whm/next/51476ebe49fecd191631aafbb3ab437ebbc0aaad-3600x2000.png)
*Mutation flow without useOptimistic*

The `useOptimistic` hook uses a local document store to enable developers to opt-in to instant updates for specific content. UI can be updated with the anticipated result of a mutation, avoiding the delay required when submitting and refetching data from Content Lake.

`useOptimistic` detects when up-to-date content does eventually arrive and resets its internal state, ready to handle the next mutation.

![Diagram illustrating a more detailed sequence of interactions between ‘Application,’ ‘Document Store,’ ‘Presentation Tool,’ and ‘Content Lake.’ 	•	The ‘Application’ sends a mutation to the ‘Document Store.’ 	•	The ‘Document Store’ commits the mutation and returns content, with an additional step labeled ‘useOptimistic,’ which allows the ‘Application’ to return content while the server commits mutations. 	•	After the mutation is committed, the ‘Document Store’ forwards the mutation to the ‘Presentation Tool.’ 	•	The ‘Presentation Tool’ sends the mutation to the ‘Content Lake.’ 	•	The ‘Content Lake’ commits the mutation and returns the updated content. 	•	The final content is returned back to the ‘Application.’  Each step is represented by arrows connecting the components, highlighting the flow of data and the use of optimistic updates.](https://cdn.sanity.io/images/3do82whm/next/83a7a8b7908551fdc6a344f5fda5bcf19796d48e-3600x2000.png)
*Mutation flow with useOptimistic*

### Reconcile references

Array reordering is an ideal use case for `useOptimistic`. However, when composing pages with reusable blocks, array items may contain references to other documents.

`useOptimistic` actions only provide an up-to-date snapshot of the mutated document, so you need to ensure that any references within the array item itself point to the correct documents in your original query result.

Typically, the optimistic ordering of an updated array can be used, with each item's content set using the data from the passthrough `state` value, if it exists.

```typescript
const sections = useOptimistic(page.sections, (state, action) => {
  if (action.id === page._id && action.document.sections) {
    return action.document.sections.map(
      (section) => state?.find((s) => s._key === section?._key) || section
    );
  }
  return state;
});
```

You can find [the useOptimistic reference documentation here](https://www.sanity.io/docs/visual-editing/useoptimistic-reference).

### Minimal example

The following example implements drag and drop in React:

**components/Sections.tsx**

```tsx
'use client'
import {createDataAttribute, useOptimistic} from '@sanity/visual-editing/react'
import type {SanityDocument} from '@sanity/client'

// Minimal type definitions
type PageSection = {
  _key: string
  _type: string
}

type PageData = {
  _id: string
  _type: string
  sections?: PageSection[]
}

type SectionsProps = {
  documentId: string
  documentType: string
  sections?: PageSection[]
}

// Your Sanity configuration
const config = {
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  baseUrl: 'https://your-studio-url.sanity.studio',
}

export function Sections({documentId, documentType, sections: initialSections}: SectionsProps) {
  const sections = useOptimistic<PageSection[] | undefined, SanityDocument<PageData>>(
    initialSections,
    (currentSections, action) => {
      if (action.id === documentId && action.document.sections) {
        return action.document.sections
      }
      return currentSections
    },
  )

  if (!sections?.length) {
    return null
  }

  return (
    <div
      data-sanity={createDataAttribute({
        ...config,
        id: documentId,
        type: documentType,
        path: 'sections',
      }).toString()}
    >
      {sections.map((section) => (
        <div
          key={section._key}
          data-sanity={createDataAttribute({
            ...config,
            id: documentId,
            type: documentType,
            path: `sections[_key=="${section._key}"]`,
          }).toString()}
        >
          {/* Render your section content here */}
          {section._type}
        </div>
      ))}
    </div>
  )
}
```

> [!TIP]
> Protip
> **On the 'use client' requirement**
> The component that holds the array needs to be rendered on the client for `useOptimistic` to work. While it's generally a good rule of thumb to avoid client-side JavaScript, the footprint of this hook is minimal, and it's only conditionally rendered when Visual Editing is enabled in preview.
> It's important to remember that sometimes you hurt performance if you render **too much** on the server. If the JSON data you need, and the amount of JS required to render it, is less than the HTML you produce and send down the wire with React Server Components, then you should make it a client component.
> With page building scenarios that can very often be the case.

The drag-and-drop-enabled sections can now be imported into a page route component:

**[slug]/page.tsx**

```tsx
import {notFound} from 'next/navigation'
import {sanityFetch} from '@/sanity/fetch'
import {PAGE_QUERY} from '@/sanity/queries'
import {Sections} from '@/components/Sections'

export default async function Page({params}) {
  const {data} = await sanityFetch({query: PAGE_QUERY, params})
  if (!data) {
    notFound()
  }

  return (
    <main>
      <Sections
        documentId={data._id}
        documentType={data._type}
        sections={data.sections}
      />
    </main>
  )
}
```

## Understand drag-and-drop behavior

Once an array child has a `data-sanity` attribute, drag and drop is enabled by default. This is reflected in the element’s Overlay label:

![UI component featuring a blue rectangular button with rounded corners. Inside, there are a dotted grid icon, a document icon, and the text ‘Element Label’ in white. The background is light blue, giving it a clean and minimalistic design.](https://cdn.sanity.io/images/3do82whm/next/b377dea74ada88cffe6b30cf7993e5dffb76ea87-1200x600.png)

Drag and drop is designed for a straightforward user experience and low-touch integration. To achieve this, it makes some assumptions:

1. The web page is using a left-to-right, top-to-bottom format with a logical content flow.
2. Drag groups can be broken into two categories: horizontal and vertical.

The Presentation Tool calculates the direction of a drag group based on the alignment of its children.

A drag group with children that share a y-axis is `horizontal`:

![Graphic showing a simple grid layout with four rectangular blocks labeled ‘1,’ ‘2,’ ‘3,’ and ‘4.’ The blocks are outlined and filled with a light blue background. An arrow below the layout indicates a directional flow or sequence, moving left to right.](https://cdn.sanity.io/images/3do82whm/next/592890036d79adce90ecf1ef3fc8af4c7485e3c0-1200x600.png)
*Horizontal layout of array items that share a y-axis*

A drag group with children that do not share a y-axis is `vertical`:

![Graphic displaying two horizontal rectangular blocks labeled ‘1’ and ‘2’ in a stacked arrangement. A vertical arrow on the left points downward, indicating a flow from the top block to the bottom block. The background is light blue with a minimalist design.](https://cdn.sanity.io/images/3do82whm/next/7342a0231405c4451a1cc98cf6d342e55c1cc9ae-1200x600.png)
*Drag group of array items that do not share a y-axis*

### Minimap

When dragging an item that belongs to a group that is larger than the screen height, press the `shift` key while scrolling or dragging to enter minimap mode. This applies a three-dimensional transform to the page, focusing the group within the viewport. This makes it easier to move sections to slots outside of the immediate viewport:

![Video](https://stream.mux.com/z5d02wk23LIhYv300IhA0201f8vuWwjaz00bf)

## Customize drag and drop

You can customize drag-and-drop behavior in the following ways:

### Data attributes

Drag and drop’s default behavior can be customized using HTML data-attributes:

- `data-sanity-drag-disable`: Disable drag and drop.
- `data-sanity-drag-flow=(horizontal|vertical)`: Override the default drag direction.
- `data-sanity-drag-group`: Manually assign an element to a drag group. Useful when there are multiple elements representing the same data on a page.
- `data-sanity-drag-prevent-default`: Prevent data from updating after drag sequences. Useful for defining custom insert behavior (see the "Custom events" section).
- `data-sanity-drag-minimap-disable`: Disable the minimap for a specific element.

### Custom events

Drag and drop emits a custom `sanity/dragEnd` event when an element is dropped.

`sanity/dragEnd` events can be used alongside the Presentation Tool's [useDocuments](https://reference.sanity.io/_sanity/visual-editing/react/useDocuments/) functionality to override the default drag-and-drop mutation logic. This is useful for defining custom behavior for non left-to-right/top-to-bottom languages, or other bespoke use cases.

This example requires two additional packages: `npm install @sanity/mutate @sanity/util`. The following code provides a boilerplate for adding custom patching logic to drag-and-drop events:

**components/DnDCustomBehaviour.tsx**

```tsx
'use client'

import {at, createIfNotExists, insert, patch, remove} from '@sanity/mutate'
import {get as getFromPath} from '@sanity/util/paths'
import {getArrayItemKeyAndParentPath} from '@sanity/visual-editing'
import {useDocuments} from '@sanity/visual-editing/react'
import {useEffect} from 'react'

function getReferenceNodeAndInsertPosition(position: any) {
  if (position) {
    const {top, right, bottom, left} = position
    if (left || top) {
      return {node: (left ?? top)!.sanity, position: 'after' as const}
    } else if (right || bottom) {
      return {node: (right ?? bottom)!.sanity, position: 'before' as const}
    }
  }
  return undefined
}

export function DnDCustomBehaviour() {
  const {getDocument} = useDocuments()

  useEffect(() => {
    const handler = (e: CustomEvent) => {
      const {insertPosition, target, preventInsertDefault} = e.detail

      if (!preventInsertDefault) return

      const reference = getReferenceNodeAndInsertPosition(insertPosition)
      if (reference) {
        const doc = getDocument(target.id)
        // We must have access to the document actor in order to perform the
        // necessary mutations. If this is undefined, something went wrong when
        // resolving the currently in use documents
        const {node, position} = reference
        // Get the key of the element that was dragged
        const {key: targetKey} = getArrayItemKeyAndParentPath(target)
        // Get the key of the reference element, and path to the parent array
        const {path: arrayPath, key: referenceItemKey} = getArrayItemKeyAndParentPath(node)
        // Don't patch if the keys match, as this means the item was only
        // dragged to its existing position, i.e. not moved
        if (arrayPath && referenceItemKey && referenceItemKey !== targetKey) {
          doc.patch(async ({getSnapshot}) => {
            const snapshot = await getSnapshot()
            // Get the current value of the element we dragged, as we will need
            // to clone this into the new position
            const elementValue = getFromPath(snapshot, target.path)
            return [
              // Remove the original dragged item
              at(arrayPath, remove({_key: targetKey})),
              // Insert the cloned dragged item into its new position
              at(arrayPath, insert(elementValue, position, {_key: referenceItemKey})),
            ]
          })
        }
      }
    }

    window.addEventListener('sanity/dragEnd', handler as EventListener)

    return () => {
      window.removeEventListener('sanity/dragEnd', handler as EventListener)
    }
  }, [getDocument])

  return <></>
}
```

> [!WARNING]
> Gotcha
> `useDocuments` is currently only available as a React hook.

## Troubleshooting

### Prevent Stega children from overriding array paths

Occasionally, a Stega-encoded string can override drag and drop on a parent array item. Here, the `title` string occupies the entire `<button>` element. The `title` automatically has an Overlay created for it, which prevents interaction with the parent Overlay:

```jsx
// Your Sanity configuration
const config = {
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  baseUrl: 'https://your-studio-url.sanity.studio',
}

<button
  data-sanity={createDataAttribute({
    ...config,
    id: parentDocument._id,
    type: parentDocument._type,
    path: `arrayItems[_key=="${arrayItem._key}"]`,
  }).toString()}
>
  {arrayItem.title}
</button>
```

To prevent this, use [stegaClean](https://reference.sanity.io/_sanity/client/stega/stegaClean/):

```jsx
import {stegaClean} from '@sanity/client/stega'

<button
  ...
>
  {stegaClean(arrayItem.title)}
</button>
```

Or add some visual padding to the array child to create space for the “draggable” area:

```jsx
<button
  ...
  style={{padding: '1rem'}}
>
  {arrayItem.title}
</button>
```



# Create Studio edit intent links

Edit intent links are URLs that open specific documents and fields in Sanity Studio. They are useful for building custom editorial interfaces or adding edit buttons to your preview environments.

When combined with [Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps) or [steganography](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega), you can automatically generate these URLs based on source map data for fully automated visual editing experiences. This article covers the manual approach for cases where you need direct control over the links. The `resolveEditUrl` helper covers the automatic approach, for cases where you have a Content Source Map but not the document ID.

## Edit intent URL format

The basic format for an edit intent URL is:

**URL format**

```text
YOUR_STUDIO_URL/intent/edit/id=DOCUMENT_ID;type=DOCUMENT_TYPE;path=FIELD_PATH
```

## Constructing edit URLs programmatically

You can construct edit intent URLs programmatically. The following helper function builds URLs for documents, specific fields, and nested field paths:

**edit-intent.js**

```javascript
// Helper function to create edit intent URLs
function createEditUrl({
  studioUrl,
  documentId,
  documentType,
  path = '',
}) {
  // Intent parameters go in a single path segment, separated by semicolons
  const params = [`id=${documentId}`, `type=${documentType}`]

  if (path) {
    params.push(`path=${encodeURIComponent(path)}`)
  }

  return `${studioUrl}/intent/edit/${params.join(';')}`
}

// Create link to edit a document
const editPostUrl = createEditUrl({
  studioUrl: 'YOUR_STUDIO_URL',
  documentId: 'post-123',
  documentType: 'post',
})
// YOUR_STUDIO_URL/intent/edit/id=post-123;type=post

// Create link to edit a specific field
const editTitleUrl = createEditUrl({
  studioUrl: 'YOUR_STUDIO_URL',
  documentId: 'post-123',
  documentType: 'post',
  path: 'title',
})
// YOUR_STUDIO_URL/intent/edit/id=post-123;type=post;path=title

// Create link to edit nested field
const editAuthorNameUrl = createEditUrl({
  studioUrl: 'YOUR_STUDIO_URL',
  documentId: 'post-123',
  documentType: 'post',
  path: 'author.name',
})
// YOUR_STUDIO_URL/intent/edit/id=post-123;type=post;path=author.name
```

## Adding edit buttons to a preview interface

You can use edit intent URLs to add edit buttons to your preview interface. Here are examples for document-level and field-level edit links:

**PostPreview.jsx**

```jsx
// React component with edit button
function PostPreview({post}) {
  const editUrl = createEditUrl({
    studioUrl: process.env.NEXT_PUBLIC_STUDIO_URL,
    documentId: post._id,
    documentType: post._type,
  })
  
  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <a
          href={editUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="edit-button"
        >
          Edit in Studio
        </a>
      </header>
      <div>{post.body}</div>
    </article>
  )
}

// Field-level edit links
function EditableField({value, documentId, documentType, fieldPath}) {
  const editUrl = createEditUrl({
    studioUrl: process.env.NEXT_PUBLIC_STUDIO_URL,
    documentId,
    documentType,
    path: fieldPath,
  })
  
  return (
    <div className="editable-field">
      <span>{value}</span>
      <a href={editUrl} className="edit-icon" title="Edit this field">
        ✏️
      </a>
    </div>
  )
}
```

## Resolve edit URLs from a Content Source Map

When you render query results, you don't always have the document ID and type at hand. A slug used to build a URL, for example, never appears on the page. `resolveEditUrl` from `@sanity/client/csm` takes a path into a query result and resolves the source document, type, and field path from the query's Content Source Map.

> [!WARNING]
> Alpha API
> `resolveEditUrl` is marked `@alpha` in `@sanity/client`, and the `createEditUrl` function it calls is marked `@internal`. Both work today, but the signature and the URL they produce can change in a minor release.

Two settings are required before you can resolve a URL:

- Set `resultSourceMap: 'withKeyArraySelector'` in the client config. Plain `true` also returns a source map, but links to array items break when the array is reordered.
- Pass `filterResponse: false` to `client.fetch()`. The default response contains the result only, without the source map.

**resolve-edit-url.ts**

```typescript
import { createClient } from '@sanity/client'
import { resolveEditUrl } from '@sanity/client/csm'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-07-01',
  useCdn: false,
  // 'withKeyArraySelector' keeps links to array items stable when the array is reordered
  resultSourceMap: 'withKeyArraySelector',
})

const { result, resultSourceMap } = await client.fetch(
  '*[_type == "author" && slug.current == $slug][0]{name, slug, pictures}',
  { slug: 'john-doe' },
  // Without this, the response contains the result only
  { filterResponse: false }
)

// resultSourceMap is undefined unless the client asks for it
const editAltTextUrl = resultSourceMap
  ? resolveEditUrl({
      studioUrl: 'YOUR_STUDIO_URL',
      resultSourceMap,
      // A path into the query result, as a string or an array of segments
      resultPath: 'pictures[0].alt',
    })
  : undefined
```

The resolved URL carries the intent parameters as a path segment and repeats them as a query string:

**Resolved URL**

```text
YOUR_STUDIO_URL/intent/edit/mode=presentation;id=462efcc6-3c8b-47c6-8474-5544e1a4acde;type=author;path=pictures%5B_key%3D%3D%22cee5fbb69da2%22%5D.alt?baseUrl=YOUR_STUDIO_URL&id=462efcc6-3c8b-47c6-8474-5544e1a4acde&type=author&path=pictures%5B_key%3D%3D%22cee5fbb69da2%22%5D.alt&perspective=published
```

### Differences from a manually built link

Links from `resolveEditUrl` differ from the ones you build yourself:

- The URL always sets `mode=presentation`, so it opens the document in the Presentation tool rather than the default document editor.
- A `resultPath` is required. The underlying `createEditUrl` throws `path is required`, so you can't build a document-only link this way.
- The field path is percent-encoded, and `perspective=published` is appended when the source document is published.
- Unresolvable paths return `undefined`. A value computed in the query maps to no document field, for example. Check the return value before you render a link.

## Related resources

[Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps)
Automatically map query results back to their source documents and fields.

[Stega-encoding](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega)
Embed source map metadata directly into strings for automatic click-to-edit overlays.

[Visual Editing with Sanity](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
Get an overview of all Visual Editing features and framework-specific guides.



# Keep Presentation fast on large pages

On a page-builder page, a single edit in the Presentation Tool can rebuild the whole route on the server: header, footer, navigation, and every sibling section. On a page with a dozen sections, that turns a one-word change into several seconds of waiting.

This page collects four patterns that narrow what an edit re-renders. The patterns are framework-neutral, but how much each one buys you depends on how your front end receives live updates, so each section names the frameworks it applies to.

## How your framework updates the preview

There are two update models, and they behave differently under editing load. Loader-based front ends keep a store per query on the client and let live mode patch each store independently. The Next.js App Router with the Live Content API refreshes the route instead.

| Framework | What an edit triggers in preview | Do per-section queries isolate it? |
| --- | --- | --- |
| React Router and Hydrogen (`@sanity/react-loader`, `hydrogen-sanity`) | Live mode patches each query store over the Comlink channel. Mutations skip route revalidation while the Studio reports live preview as connected. | Yes |
| SvelteKit (`@sanity/svelte-loader`) | Live mode patches each query store. A manual refresh calls `invalidateAll()` and re-runs every `load` and remote `query` function. | Yes |
| Next.js App Router (`next-sanity`) | `<SanityLive />` calls `router.refresh()`. In draft mode, tag revalidation is skipped and the whole route re-renders. | No |

Patterns 1 and 2 depend on that difference. Patterns 3 and 4 help in every framework, and they are the main levers available in the Next.js App Router.

## Prerequisites

- A front end already set up for visual editing. See [Visual Editing with React Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-react-router), [Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router), or [Visual Editing with SvelteKit](https://www.sanity.io/docs/visual-editing/visual-editing-with-sveltekit).
- One of `hydrogen-sanity` 5.0.0, `@sanity/svelte-loader` 3.0.0, or `next-sanity` 13.0.2, or later. On React Router, `@sanity/react-loader` at any current version.
- A page whose content is modeled as an array of section objects.

## Give each section its own query

**Applies to:** loader-based front ends, which includes React Router, Hydrogen, SvelteKit, and custom integrations built on the core loader.

Use this when a page is assembled from an array of sections and editors spend most of their time changing one section at a time.

One query for the whole page means one result. Any edit that touches the page produces a new result, and every section re-renders. Splitting the page into one query per section gives each section its own store, and a store only re-renders when its own result actually changes, so editing the hero leaves the sections below it untouched.

The larger effect is on the server. Once loader queries are mounted on the client, live mode delivers mutations over the Comlink channel and skips route revalidation, so an edit no longer costs a server round trip. Manual refreshes still revalidate. The skip is conditional: the adapter bypasses revalidation only while the Studio reports live preview as connected, and that signal is deprecated, so expect mutations to revalidate again in a future Studio major.

Give each section component its own query, seeded with the data your loader already fetched:

**@sanity/react-loader**

```tsx
import {useLiveMode, useQuery} from '@sanity/react-loader'
import type {QueryResponseInitial} from '@sanity/react-loader'
import {client} from '~/sanity/client'

const HERO_QUERY = `*[_type == "page" && slug.current == $slug][0].hero`

type Hero = {heading: string; subheading?: string}

// Mount once, in preview mode only. Without it the sections render their
// initial data forever and encodeDataAttribute returns undefined.
export function LiveMode() {
  useLiveMode({client})
  return null
}

export function Hero({slug, initial}: {slug: string; initial: QueryResponseInitial<Hero>}) {
  const {data, encodeDataAttribute} = useQuery<Hero>(HERO_QUERY, {slug}, {initial})

  return (
    <section data-sanity={encodeDataAttribute('heading')}>
      <h1>{data.heading}</h1>
      {data.subheading ? <p>{data.subheading}</p> : null}
    </section>
  )
}

```

**hydrogen-sanity**

```tsx
import {Query} from 'hydrogen-sanity'

const HERO_QUERY = `*[_type == "page" && slug.current == $slug][0].hero`
const PRODUCT_GRID_QUERY = `*[_type == "page" && slug.current == $slug][0].productGrid`

type Hero = {heading: string}
type ProductGrid = {title: string}
type LoaderData = {slug: string; hero: Hero; productGrid: ProductGrid}

export default function Page({loaderData}: {loaderData: LoaderData}) {
  const {slug, hero, productGrid} = loaderData

  return (
    <>
      <Query<Hero> query={HERO_QUERY} params={{slug}} options={{initial: hero}}>
        {(data, encodeDataAttribute) => (
          <h1 data-sanity={encodeDataAttribute('heading')}>{data.heading}</h1>
        )}
      </Query>

      <Query<ProductGrid>
        query={PRODUCT_GRID_QUERY}
        params={{slug}}
        options={{initial: productGrid}}
      >
        {(data) => <h2>{data.title}</h2>}
      </Query>
    </>
  )
}

```

**SvelteKit:** the `useQuery` returned by `createQueryStore` in `@sanity/svelte-loader` wraps the same per-query store, so one call per section gives you the same isolation. See [Visual Editing with SvelteKit](https://www.sanity.io/docs/visual-editing/visual-editing-with-sveltekit) for the setup. One caveat before you start: `@sanity/svelte-loader` 3.x ships no type declarations even though its `package.json` points at them, and because the SvelteKit template enables `skipLibCheck` the import resolves to `any` with no error, so nothing in your load functions is type-checked.

**src/lib/queries.ts**

```typescript
// loadQuery and useQuery must receive the identical query string, so the
// queries live in one module rather than being inlined at both call sites.
// Project off the document: `[0].hero` returns an inline object, which has
// no _id, so anything downstream needing the document id gets undefined.

export const heroQuery = `*[_type == "page" && slug.current == $slug][0]{
  _id,
  hero{heading, subheading}
}`

export const featuresQuery = `*[_type == "page" && slug.current == $slug][0]{
  _id,
  features[]{_key, title, body}
}`

export const reviewsQuery = `*[_type == "review" && page->slug.current == $slug]
  | order(_createdAt desc)[0...20]{_id, author, rating, body}`

export const navQuery = `*[_type == "navigation" && _id == "navigation"][0]{
  _id,
  links[]{_key, title, "href": url}
}`

export type HeroResult = {
  _id: string
  hero: {heading: string | null; subheading: string | null} | null
} | null

export type FeaturesResult = {
  _id: string
  features: Array<{_key: string; title: string | null; body: string | null}> | null
} | null

export type ReviewsResult = Array<{
  _id: string
  author: string | null
  rating: number | null
  body: string | null
}>

export type NavResult = {
  _id: string
  links: Array<{_key: string; title: string | null; href: string | null}> | null
} | null

```

**src/routes/[slug]/+page.server.ts**

```typescript
import {featuresQuery, heroQuery, type FeaturesResult, type HeroResult} from '$lib/queries'
import type {PageServerLoad} from './$types'

export const load: PageServerLoad = async ({depends, params, locals: {loadQuery}}) => {
  // The key the layout's scoped refresh handler invalidates.
  depends('sanity:page')

  const queryParams = {slug: params.slug}

  // One loadQuery per section, so each section gets its own resolved
  // QueryResponseInitial and its own useQuery store on the client.
  const [hero, features] = await Promise.all([
    loadQuery<HeroResult>(heroQuery, queryParams, {stega: true}),
    loadQuery<FeaturesResult>(featuresQuery, queryParams, {stega: true}),
  ])

  return {queryParams, hero, features}
}

```

**src/lib/sections/Hero.svelte**

```svelte
<script lang="ts">
  import {useQuery, type QueryResponseInitial} from '@sanity/svelte-loader'
  import {heroQuery, type HeroResult} from '$lib/queries'

  let {
    initial,
    params,
  }: {initial: QueryResponseInitial<HeroResult>; params: {slug: string}} = $props()

  // useQuery calls onMount internally, so it has to run during component
  // init. `initial` must be the resolved QueryResponseInitial, not a promise.
  // svelte-ignore state_referenced_locally
  const hero = useQuery<HeroResult>(heroQuery, params, {initial})
</script>

{#if $hero.data?.hero}
  <section>
    <h1 data-sanity={$hero.encodeDataAttribute(['hero', 'heading'])}>
      {$hero.data.hero.heading}
    </h1>
    <p data-sanity={$hero.encodeDataAttribute(['hero', 'subheading'])}>
      {$hero.data.hero.subheading}
    </p>
  </section>
{/if}

```

**Next.js App Router:** this pattern does not isolate updates, and splitting can cost you. In draft mode, `<SanityLive />` refreshes the whole route instead of revalidating sync tags, and Next.js routes draft-mode requests past its Data Cache, so every `sanityFetch` on the page re-executes on every edit, as two uncached requests each, since `sanityFetch` looks up sync tags before fetching the result. One query per section means two round trips per section. Keep the page's queries few and narrow instead. Outside draft mode the route still re-renders, but sync tags scope which cache entries are invalidated, so untouched sections come from the Data Cache and splitting still pays off in production. Under `cacheComponents: true`, `<SanityLive />` can no longer read draft mode itself and needs an explicit `includeDrafts` prop. See [next-sanity: <SanityLive> strict mode](https://www.sanity.io/docs/help/next-sanity-live-strict) and [Sanity Live with Next.js Cache Components](https://www.sanity.io/docs/nextjs/cache-components).

> [!WARNING]
> Gotcha
> `encodeDataAttribute` returns `undefined` until `useLiveMode` runs with a client that has `stega.studioUrl` set: that hook is the only thing that injects the Studio URL into the query store. It also returns `undefined` when the result carries no Content Source Map. Either way it fails silently, and click-to-edit stops working on that section.

## Keep shared data out of the revalidation path

**Applies to:** React Router, Hydrogen, and SvelteKit, where a manual refresh re-runs data functions across the whole route tree.

Use this when your navigation, header, or footer is fetched in a root loader that runs on every route change.

Live mode covers mutations once loaders are mounted, but a manual refresh still triggers a revalidation, and that refetches every loader in the route hierarchy. Scoping `shouldRevalidate` keeps nav and footer data out of that path.

In your root route, opt out of revalidation when the URL has not changed and nothing was submitted:

**app/root.tsx**

```tsx
import type {ShouldRevalidateFunction} from 'react-router'

// Nav and footer come from the root loader. Skip refetching them when the
// URL hasn't changed and nothing was submitted, so a refresh in Presentation
// doesn't rebuild them.
export const shouldRevalidate: ShouldRevalidateFunction = ({
  currentUrl,
  nextUrl,
  formMethod,
  defaultShouldRevalidate,
}) => {
  const sameUrl =
    currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search

  if (sameUrl && !formMethod) {
    return false
  }

  return defaultShouldRevalidate
}

```

The `formMethod` and `search` guards matter. React Router revalidates after every submission and on any search-param change by design, and a bare pathname comparison swallows both, leaving nav and footer stale after a same-path mutation. Nothing in these arguments identifies Presentation either, so this also opts the root loader out of any other same-URL revalidation, including one you trigger yourself with `useRevalidator`.

**SvelteKit:** the same hazard exists, but it comes from the overlay rather than the loader. `<VisualEditing />` in `@sanity/visual-editing/svelte` calls `invalidateAll()` when the reader clicks refresh, which re-runs every `load` and remote `query` function including the root layout. It skips only mutations, and only while live preview is connected. Marking loads with `depends()` is not enough on its own, because `invalidateAll()` forces every load to re-run whatever it depends on. Pass a `refresh` prop that calls `invalidate('sanity:page')` instead of the default handler, and mark the loads you want it to reach with `depends('sanity:page')`.

**src/routes/+layout.svelte**

```svelte
<script lang="ts">
  import {useLiveMode} from '@sanity/svelte-loader'
  import {VisualEditing, type VisualEditingProps} from '@sanity/visual-editing/svelte'
  import {invalidate} from '$app/navigation'
  import {client} from '$lib/sanity'
  import Nav from '$lib/sections/Nav.svelte'
  import {onMount} from 'svelte'
  import type {LayoutData} from './$types'

  let {data, children}: {data: LayoutData; children: import('svelte').Snippet} = $props()

  // onMount is required, not optional: useLiveMode throws "Live mode is not
  // supported in server environments" if it runs during SSR, so unlike
  // useQuery it must NOT be called at component init. Returning its disable
  // function gives Svelte the teardown.
  //
  // `client` is required. Passing {studioUrl} alone, as the package README
  // suggests, throws "The `client` option in `enableLiveMode` is required".
  onMount(() => {
    if (!data.preview) return
    return useLiveMode({client: client.withConfig({stega: true})})
  })

  const refresh: VisualEditingProps['refresh'] = (payload) => {
    // Live mode already streams mutations into the individual stores, so
    // there is nothing to re-fetch. Returning false skips the refresh and
    // leaves Presentation's refresh button out of its loading state. Keep the
    // livePreviewEnabled gate: without live mode the page would go stale.
    // This branch fires twice per mutation (1s debounce), so keep it
    // idempotent.
    if (payload.source === 'mutation' && payload.livePreviewEnabled) return false

    // Manual refresh, or a mutation with no live mode. The default handler
    // calls invalidateAll(), which re-runs every load on the page including
    // this layout's nav query. Scope it to the page instead.
    return invalidate('sanity:page')
  }
</script>

<Nav initial={data.nav} />

{@render children()}

{#if data.preview}
  <VisualEditing {refresh} />
{/if}

```

**src/routes/+layout.server.ts**

```typescript
import {navQuery, type NavResult} from '$lib/queries'
import type {LayoutServerLoad} from './$types'

export const load: LayoutServerLoad = async ({depends, locals: {loadQuery, preview}}) => {
  // Its own invalidation key, so a page-level refresh skips the nav and you
  // can still refresh it deliberately with invalidate('sanity:nav').
  depends('sanity:nav')

  const nav = await loadQuery<NavResult>(navQuery, {}, {stega: true})

  return {nav, preview}
}

```

**Next.js App Router:** there is no draft-mode equivalent. `router.refresh()` re-renders the route and its layouts together. A custom `action` prop is honored in draft mode but cannot help, because the tags it receives address the Next.js cache and draft mode has already bypassed it.

## Narrow the projections you use in preview

**Applies to:** every framework, and it matters most in the Next.js App Router, where the whole route re-renders on each update.

Use this when your page-builder queries dereference other documents with `->`, which most do once sections reference products, authors, or categories.

Server-side preview queries bypass the CDN whenever the perspective is draft-shaped, and in draft mode Next.js routes them past its own Data Cache as well, so editors never see stale content. Every dereference is paid in full on the initial render and on every manual refresh. A projection that costs nothing in production because it is cached can dominate preview latency. Once live mode is connected, the Studio runs the query and sends results over the Comlink channel instead, so a narrow projection also keeps those messages small.

Ask for the fields the section renders instead of the whole referenced document:

**Wide projection**

```groq
*[_type == "page" && slug.current == $slug][0]{
  ...,
  sections[]{
    ...,
    products[]->
  }
}

```

**Narrow projection**

```groq
*[_type == "page" && slug.current == $slug][0]{
  _id,
  title,
  sections[]{
    _key,
    _type,
    heading,
    products[]->{
      _id,
      title,
      "slug": slug.current,
      // Ask for a sized, format-negotiated image. The bare asset URL is the
      // full-size original, which is the largest thing on the page in preview.
      "imageUrl": image.asset->url + "?w=400&auto=format"
    }
  }
}

```

Narrowing the projection shrinks the response and the work needed to build it. It also reduces the number of documents the query touches, which narrows what a later edit can invalidate.

## Defer sections that are not visible on load

**Applies to:** every framework that supports streaming.

Use this when a page has sections below the fold whose queries are slower than the ones above it.

A loader that awaits every section query blocks the whole response on the slowest one. Returning the slow queries as promises lets the preview stream, so the top of the page renders while the rest resolves.

In React Router and Hydrogen, return the promise from the loader without awaiting it, then resolve it in the component:

**app/routes/page.tsx**

```typescript
import type {Route} from './+types/page'

const PAGE_QUERY = `*[_type == "page" && slug.current == $slug][0]{_id, hero}`
const REVIEWS_QUERY = `*[_type == "review" && references($pageId)]{quote, author}`

type Page = {_id: string; hero: {heading: string}}

export async function loader({context, params}: Route.LoaderArgs) {
  // Awaited, and read here, so use `fetch` for the unwrapped result.
  // `query` returns a union and can't be dereferenced without narrowing.
  const page = await context.sanity.fetch<Page>(PAGE_QUERY, {slug: params.slug}, {tag: 'page'})

  if (!page) {
    throw new Response('Not found', {status: 404})
  }

  // Not awaited: streams in after the response starts. `query` returns the
  // shape <Query> accepts as `initial`, so leave it wrapped.
  const reviews = context.sanity.query(REVIEWS_QUERY, {pageId: page._id}, {tag: 'page.reviews'})

  return {slug: params.slug, page, reviews}
}

```

**app/sections/reviews.tsx**

```tsx
import {Suspense} from 'react'
import {Await} from 'react-router'
import {Query} from 'hydrogen-sanity'

const REVIEWS_QUERY = `*[_type == "review" && references($pageId)]{quote, author}`

type Reviews = {quote: string; author: string}[]

export function Reviews({pageId, reviews}: {pageId: string; reviews: Promise<Reviews>}) {
  return (
    <Suspense fallback={<p>Loading reviews</p>}>
      <Await resolve={reviews}>
        {(initial) => (
          <Query<Reviews> query={REVIEWS_QUERY} params={{pageId}} options={{initial}}>
            {(data) => (
              <ul>
                {data.map((review) => (
                  <li key={review.quote}>
                    {review.quote} <cite>{review.author}</cite>
                  </li>
                ))}
              </ul>
            )}
          </Query>
        )}
      </Await>
    </Suspense>
  )
}

```

The outer `Await` is what resolves the deferred promise, so keep it: `Query` renders its own Suspense boundary only in preview mode, and that boundary covers its lazy client import rather than your loader data. Give each `Suspense` its own `fallback` instead of one spinner for the whole page. Watch the parameters you thread between sections: a null parameter makes `references()` match nothing, and a missing one fails the query outright, so a deferred section that renders empty is usually a bad `pageId` rather than missing content.

**Next.js App Router:** give the slow section its own async server component and wrap it in `<Suspense>`. The route still re-renders on every update, but the fast sections stream first. The result types come from TypeGen, so run `npx sanity@latest typegen generate` after changing a query, and render `<SanityLive />` in the layout or nothing updates at all:

**src/app/[slug]/page.tsx**

```tsx
import {Suspense} from 'react'
import {notFound} from 'next/navigation'
import {defineQuery} from 'next-sanity'
import {sanityFetch} from '@/sanity/lib/live'

const PAGE_QUERY = defineQuery(`*[_type == "page" && slug.current == $slug][0]{_id, hero}`)
const REVIEWS_QUERY = defineQuery(`*[_type == "review" && references($pageId)]{quote, author}`)

// Its own server component, so the Suspense boundary can stream it in.
async function Reviews({pageId}: {pageId: string}) {
  const {data} = await sanityFetch({query: REVIEWS_QUERY, params: {pageId}})

  return (
    <ul>
      {data.map((review, index) => (
        <li key={index}>{review.quote}</li>
      ))}
    </ul>
  )
}

export default async function Page({params}: {params: Promise<{slug: string}>}) {
  const {slug} = await params
  // Project _id off the document. `[0].hero` returns the field's value,
  // which has no _id, and a missing param fails the reviews query.
  const {data: page} = await sanityFetch({query: PAGE_QUERY, params: {slug}})

  if (!page) {
    notFound()
  }

  return (
    <>
      <h1>{page.hero?.heading}</h1>
      <Suspense fallback={<p>Loading reviews</p>}>
        <Reviews pageId={page._id} />
      </Suspense>
    </>
  )
}

```

**src/sanity/lib/live.ts**

```typescript
import {createClient} from '@sanity/client'
import {defineLive} from 'next-sanity/live'

const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
  apiVersion: '2026-09-01',
  useCdn: false,
})

export const {sanityFetch, SanityLive} = defineLive({
  client,
  serverToken: process.env.SANITY_API_READ_TOKEN,
  browserToken: process.env.SANITY_API_READ_TOKEN,
})

```

**src/app/layout.tsx**

```tsx
import {SanityLive} from '@/sanity/lib/live'

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
      </body>
    </html>
  )
}

```

**SvelteKit:** return the un-awaited `loadQuery()` promise from `load` and resolve it in an `{#await}` block. Call `useQuery` inside a child component rendered in the `then` branch rather than in the callback itself: it calls `onMount`, so it has to run at component init. The query store also needs an already-resolved initial value, so pass the resolved result in.

**src/routes/[slug]/+page.server.ts**

```typescript
import {heroQuery, reviewsQuery, type HeroResult, type ReviewsResult} from '$lib/queries'
import type {PageServerLoad} from './$types'

export const load: PageServerLoad = async ({depends, params, locals: {loadQuery}}) => {
  depends('sanity:page')

  const queryParams = {slug: params.slug}

  return {
    queryParams,
    // Awaited: blocks the response, ships in the initial HTML.
    hero: await loadQuery<HeroResult>(heroQuery, queryParams, {stega: true}),
    // Not awaited: streamed to the browser after the shell renders.
    reviews: loadQuery<ReviewsResult>(reviewsQuery, queryParams, {stega: true}),
  }
}

```

**src/routes/[slug]/+page.svelte**

```svelte
<script lang="ts">
  import Hero from '$lib/sections/Hero.svelte'
  import Reviews from '$lib/sections/Reviews.svelte'
  import type {PageData} from './$types'

  let {data}: {data: PageData} = $props()
</script>

<!--
  useQuery reads its params once, at component init, so a client-side
  navigation to a different slug has to remount the sections. Without {#key}
  every section store stays pinned to the previous slug.
-->
{#key data.queryParams.slug}
  <Hero initial={data.hero} params={data.queryParams} />

  <!--
    The deferred section. The useQuery call lives in the child component
    because it calls onMount and so must run at component init; it cannot be
    called inside the body of the then branch.
  -->
  {#await data.reviews}
    <p>Loading reviews…</p>
  {:then initial}
    <Reviews {initial} params={data.queryParams} />
  {:catch}
    <p>Reviews are unavailable.</p>
  {/await}
{/key}

```

**src/lib/sections/Reviews.svelte**

```svelte
<script lang="ts">
  import {useQuery, type QueryResponseInitial} from '@sanity/svelte-loader'
  import {reviewsQuery, type ReviewsResult} from '$lib/queries'

  // `initial` is the resolved value handed down by the {#await ... then}
  // block. useQuery cannot take a promise, and cannot be called inside the
  // then branch's body either, which is why this is its own component.
  let {
    initial,
    params,
  }: {initial: QueryResponseInitial<ReviewsResult>; params: {slug: string}} = $props()

  // svelte-ignore state_referenced_locally
  const reviews = useQuery<ReviewsResult>(reviewsQuery, params, {initial})
</script>

<ul>
  {#each $reviews.data as review, i (review._id)}
    <li data-sanity={$reviews.encodeDataAttribute([i, 'body'])}>
      <strong>{review.author}</strong>
      {review.body}
    </li>
  {/each}
</ul>

```

## Next steps

- [Live preview content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates): how live mode, the Comlink channel, and the query stores fit together, framework by framework.
- [Visual editing architecture overview](https://www.sanity.io/docs/visual-editing/visual-editing-architecture): where loaders sit in the wider visual editing system.
- [Configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool): resolvers, allowed origins, and preview environments.



# Resolver API

The Presentation Tool lets you add shortcuts for quickly opening a document and its preview. This is done by adding configuration to `resolve.mainDocuments` and `resolve.locations` where you define what content goes into a specific route.

Main Document Resolvers lets you express the most important document for any given route. Moreover, the Locations Resolvers lets you define patterns for other routes that content from any given document might be used.

[Configuring the Presentation tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool)

## Main document resolvers

The Main Document Resolver API provides a method of resolving a main document from a given route or route pattern.

Often, a route in an application will be closely tied to a document in Content Lake. For instance, a blog post will likely draw most of its content from a single post document. We could describe this as the "main" document for that post route.

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {defineDocuments, presentationTool} from 'sanity/presentation'

export default defineConfig({
  /* ... */
  plugins: [
    presentationTool({
      /* ... */
      resolve: {
        mainDocuments: defineDocuments([
          {
            route: '/posts/:slug',
            filter: `_type == "post" && slug.current == $slug`,
          },
        ]),
      },
    }),
    structureTool(),
  ],
})

```

When a main document is defined, Presentation will automatically display it when navigating to the matching route in our application.

The `resolve.mainDocuments` property of the Presentation Tool's configuration accepts an array of objects, each with a route pattern and method for resolving a document. This can be a document `type`, an object with a GROQ `filter` and optional `params`, or a `resolve` function that returns one.

Internally, when Presentation detects a navigation event, it will compare the current URL against one of these resolver methods. The first matching document will be considered the main document, and be automatically displayed as the navigation occurs.

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {defineDocuments, presentationTool} from 'sanity/presentation'

export default defineConfig({
  /* ... */
  plugins: [
    presentationTool({
      /* ... */
      resolve: {
        mainDocuments: defineDocuments([
          // Document type, useful shorthand for singleton documents.
          {
            route: '/products',
            type: 'productsListing',
          },
          // GROQ filter with static parameters.
          {
            route: '/products/a-unique-product',
            filter: `_type == "product" && slug.current == $slug`,
            params: {slug: 'has-a-different-slug'},
          },
          // GROQ filter, infer the parameters from the route definition.
          {
            route: '/products/:slug',
            filter: `_type == "product" && slug.current == $slug`,
          },
          // Resolve function for more complex cases, for example modifying the slug.
          {
            route: '/pages/:type/:slug',
            resolve(ctx) {
              const {params} = ctx
              return {
                filter: `_type == $type && slug.current == $slug`,
                params: {
                  type: params.type,
                  slug: params.slug.replaceAll('_', '-'),
                },
              }
            },
          },
          // Supports both array and absolute URL route definitions
          {
            route: ['https://sanity.io/:slug', 'https://sanity.studio/:slug'],
            filter: `_type == "page" && slug.current == $slug`,
          },
        ]),
      },
    }),
    structureTool(),
  ],
})

```

> [!TIP]
> Pro tip
> `defineDocuments()` is an optional helper function for TypeScript users. You can provide an array directly if you don't need type safety. 

The paths of each `route` value are parsed using [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp/tree/v8.4.2) to extract the parameters. Studio v6.7.0 and later use path-to-regexp v8. Earlier versions, including Studio v5 and Studio v6.0.0 through v6.6.0, use path-to-regexp v6, which parses wildcards differently.

If an origin is provided, it will **not** be parsed for parameters, i.e. this will be matched literally: `https://:subdomain.sanity.io`

### Wildcard and catch-all routes

To match an unknown number of path segments, name a wildcard parameter with `/*name`. The `/:name*` syntax from path-to-regexp v6 is not valid in v8, and a route that uses it throws `"/:name*" is not a valid route pattern` when Presentation matches a navigation event. A bare `*` is invalid too: every wildcard must be named.

A wildcard parameter resolves to an **array** of path segments rather than a string. TypeScript does not catch this, because `DocumentResolverContext` types every route parameter as a string, so handle both shapes in `resolve`:

```typescript
// mainDocuments.ts
import {defineDocuments} from 'sanity/presentation'

export const mainDocuments = defineDocuments([
  // List specific routes before the catch-all: the first match wins.
  {
    route: '/posts/:slug',
    filter: `_type == "post" && slug.current == $slug`,
  },
  {
    route: '/*pathSegments',
    resolve(ctx) {
      // A wildcard returns an array of path segments, even though
      // `DocumentResolverContext` types every parameter as a string.
      const raw = ctx.params.pathSegments
      const segments: string[] = Array.isArray(raw)
        ? raw
        : typeof raw === 'string'
          ? raw.split('/').filter(Boolean)
          : []

      // Nothing to match on, so this route has no main document.
      if (segments.length === 0) return undefined

      const slug = segments[segments.length - 1]
      const section = segments.length === 1 ? 'top' : segments.slice(0, -1).join('|')

      return {
        filter: `_type == "page" && section == $section && slug.current == $slug`,
        params: {slug, section},
      }
    },
  },
])
```

`/*pathSegments` matches one or more segments: `/about` resolves to `['about']`, and `/docs/guides/install` resolves to `['docs', 'guides', 'install']`. It does not match the site root, `/`. To match the root as well, wrap the wildcard in braces to make it zero or more: `{/*pathSegments}`. At the root the parameter is then undefined, which the example above handles by returning no main document.

Presentation uses the first route that matches, so list a catch-all last. A catch-all matches every path, and any route defined after it never runs.

## Document location resolvers

The Document Locations Resolver API allows you to define *where* data is being used in your application(s).

It gives content editors visibility of the pages that display the content they are editing, and the potential impact of the changes they make.

The `resolve.locations` property of the Presentation Tool's configuration accepts an object whose keys each correspond to a document type in your schema. The corresponding value provides a method for resolving document location state.

If you're used to [configuring preview options](https://www.sanity.io/docs/studio/previews-list-views) in your schema, the API will feel familiar.

The `select` object defines the fields that should be returned in the document object passed to the `resolve` function. The `resolve` function itself accepts a document and should return some `DocumentLocationsState`.

Alternatively, you can directly pass `DocumentLocationsState`.

> [!TIP]
> Pro tip
> `defineLocations()` is an optional helper function for TypeScript users. You can provide the object directly if you don't need type safety. 

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {defineLocations, presentationTool} from 'sanity/presentation'
import {EnvelopeIcon} from '@sanity/icons/Envelope'

export default defineConfig({
  /* ... */
  plugins: [
    presentationTool({
      /* ... */
      resolve: {
        locations: {
          // Resolve locations using values from the matched document
          product: defineLocations({
            select: {
              title: 'title',
              slug: 'slug.current',
            },
            resolve: (doc) => ({
              locations: [
                {
                  title: doc?.title || 'Untitled',
                  href: `/products/${doc?.slug}`,
                  icon: EnvelopeIcon,  // Custom icon (optional)
                  showHref: false,     // Show/hide URL (default: true)
                },
                {
                  title: 'Products',
                  href: '/products',
                },
              ],
            }),
          }),
          // Define static locations
          productListing: defineLocations({
            select: {title: 'title'},
            resolve: () => ({
              locations: [
                { title: 'Products', href: '/products'},
              ]
            })
          }),
          // Provide a notice when a document is used across all pages
          siteSettings: defineLocations({
            message: 'This document is used on all pages',
            tone: 'caution',
          }),
        },
      },
    }),
    structureTool(),
  ],
})

```

### Advanced location resolvers

The above pattern should cover the vast majority of use cases. However, if you need more fine-grained control, `resolve.locations` also accepts a `DocumentLocationResolver` function.

In most cases, you will call `context.documentStore.listenQuery` with a GROQ query that returns the queried documents as an Observable.

We recommend installing `rxjs` as a dependency in your Studio project to make it easier to interact with the Observable object returned from `listenQuery`. You can also add `@sanity/id-utils` to make capturing the draft and/or version IDs easier.

**npm**

```shell
npm install rxjs @sanity/id-utils
```

**pnpm**

```shell
pnpm add rxjs @sanity/id-utils
```

**yarn**

```shell
yarn add rxjs @sanity/id-utils
```

**bun**

```shell
bun add rxjs @sanity/id-utils
```

```typescript
// locations.ts
import {DocumentLocationResolver} from 'sanity/presentation'
import {DocumentId, getDraftId} from '@sanity/id-utils'
import {map} from 'rxjs'

// Pass 'context' as the second argument
export const locations: DocumentLocationResolver = (params, context) => {
  // Set up locations for post documents
  if (params.type === 'post') {
    const query = {
      fetch: `*[_id==$id][0]{slug,title}`,
      listen: `*[_id in [$id,$draftId]]`,
    }
    const queryParams = {id: params.id, draftId: getDraftId(DocumentId(params.id))}
    // Subscribe to the latest slug and title
    const doc$ = context.documentStore.listenQuery(
      query,
      queryParams,
      {perspective: 'drafts'}, // returns a draft article if it exists
    )
    // Return a streaming list of locations
    return doc$.pipe(
      map((doc) => {
        // If the document doesn't exist or have a slug, return null
        if (!doc || !doc.slug?.current) {
          return null
        }
        return {
          locations: [
            {
              title: doc.title || 'Untitled',
              href: `/post/${doc.slug.current}`,
            },
            {
              title: 'Posts',
              href: '/',
            },
          ],
        }
      }),
    )
  }
  return null
}


```

![A screenshot from the Studio's content form showing a list of locations where a product document is being used.](https://cdn.sanity.io/images/3do82whm/next/d0b2ee5c27abfcd1f3837386110c2439df2c15e7-560x406.png)
*The Locations UI showing where a product has been used*

The Document Location Resolver is a function that receives an object with the document's `id` and `type` parameters, and returns a state object that contains the locations that can be previewed for a given document.

We recommend containing the Document Location Resolver in a dedicated file to reduce clutter in your Studio configuration file. In the example above, it's exported as a named JavaScript variable so it can be imported to the Studio configuration file like this:

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'
import {presentationTool} from 'sanity/presentation'
import {locations} from './locations'
// ...other imports


export default defineConfig({
  // ...configuration for the project

  plugins: [
    presentationTool({
      previewUrl: process.env.SANITY_STUDIO_PREVIEW_URL,
      resolve: {
        locations
      }
    }),
    // ...other plugins
  ],
})
```

### Show all locations where a document is being used

Typically, with structured content, content from a document might be used in multiple locations by [means of references](https://www.sanity.io/docs/studio/connected-content). With GROQ, you can query for all documents that refer to a specific document ([or use other join logic](https://www.sanity.io/docs/specifications/groq-joins)) and use that to build a list of locations for where a document is being used. 

Below is an example showing how to build a list of locations for a "person" document that is being referred to by a "post" document as its author:

```typescript
// locations.ts
import {
  DocumentLocationResolver,
  DocumentLocationsState,
} from 'sanity/presentation'
import { map, Observable } from 'rxjs'

export const locations: DocumentLocationResolver = (params, context) => {
  if (params.type === 'post' || params.type === 'person') {
    /* 
      Listen to all changes in the selected document 
      and all documents that reference it
    */
    const doc$ = context.documentStore.listenQuery(
      `*[_id==$id || references($id)]{_type,slug,title, name}`,
      params,
      { perspective: 'drafts' },
    ) as Observable<
      | {
          _type: string
          slug?: { current: string }
          title?: string | null
          name?: string | null
        }[]
      | null
    >
    // pipe the real-time results to RXJS's map function
    return doc$.pipe(
      map((docs) => {
        if (!docs) {
          return {
            message: 'Unable to map document type to locations',
            tone: 'critical',
          } satisfies DocumentLocationsState
        }
        // Generate all the locations for person documents
        const personLocations = docs
          .filter(({ _type, slug }) => _type === 'person' && slug?.current)
          .map(({ name, slug }) => ({
            title: name || 'Name missing',
            href: `/authors/${slug.current}`,
          }))

        // Generate all the locations for post documents
        const postLocations: Array<any> = docs
          .filter(({ _type, slug }) => _type === 'post' && slug?.current)
          .map(({ title, slug }) => ({
            title: title || 'Name missing',
            href: `/posts/${slug.current}`,
          }))

        return {
          locations: [
            ...personLocations,
            ...postLocations,
            // Add a link to the "All posts" page when there are post documents
            postLocations.length > 0 && {
              title: 'All posts',
              href: '/posts',
            },
            // Add a link to the "All authors" page when there are person documents
            personLocations.length > 0 && {
              title: 'All authors',
              href: '/authors',
            },
          ].filter(Boolean),
        } satisfies DocumentLocationsState
      }),
    )
  }

  return null
}
```

## Document location state properties

In addition to the example above, the Document Location Resolver can return customized top-level messages and visual cues:

#### Properties

**message** (string)

Override the top-level text in the document locations UI. Useful if you want to customize the string (replace "pages") or display warnings.

Default value: Used on ${number} pages

Examples:

Unable to resolve locations for this document

Used in ${docs.length} email campaign${docs.length === 1 ? '' : 's'}

**tone** ('positive' | 'caution' | 'critical')

Gives the document locations UI a background color. It can be used to signal criticality.

Optional. When omitted, the locations banner renders with no tone (no background color) and no tone icon.

**locations** (DocumentLocation[])

An array of document locations objects with title and href properties. The href can be absolute or relative and will open in the Presentation Tool.



# useOptimistic hook

The `useOptimistic` hook lets you opt in to instant updates for specific content. It renders the anticipated result of a mutation right away, instead of waiting for the updated document to come back from Content Lake. Import it from `@sanity/visual-editing/react`. It is not React's built-in `useOptimistic`.

For the full signature and type parameters, see [useOptimistic in the generated reference](https://reference.sanity.io/_sanity/visual-editing/react/useOptimistic/). This page covers what the generated reference doesn't express: the reducer contract and the shape of the action a reducer receives.

The Svelte hook exported from `@sanity/visual-editing/svelte` has a different shape: it takes `initial` and returns `{value, update}`, where `value` is a readable store. The generated reference doesn't cover the Svelte entry point.

It's primarily used for enabling drag-and-drop functionality for Visual Editing. You can learn more about how to use it in [the Drag and drop documentation](https://www.sanity.io/docs/visual-editing/enabling-drag-and-drop).

## Behavior

When no mutations are pending, the hook returns the passthrough value unchanged. When a mutation is pending, it returns the optimistically updated state your reducers produce.

Outside the Presentation Tool, `useOptimistic` is a no-op and always returns the passthrough value. Optimistic state applies only when your application renders in Presentation's preview iframe or window and the connected Studio supports optimistic updates.

## Reducers

**Reducers(state, action): void**

A reducer receives the current state and an action and returns the next state. Signature: (state: T, action: OptimisticReducerAction<U>) => T. Return state unchanged to ignore an action. When an array of reducers is supplied, they run in order, each receiving the previous reducer's return value.

Parameters:
- **state** (T): The current state, equal to the passthrough value when no reducers have been applied.
- **action** (OptimisticReducerAction<U>): The action received by the reducer, used to optimistically update the state.

This reducer replaces a list of products with the version carried on the mutated category document, and ignores actions for any other document:

**ProductList.tsx**

```tsx
'use client'

import type {SanityDocument} from '@sanity/client'
import {useOptimistic} from '@sanity/visual-editing/react'

type Product = {_id: string; title: string}

export function ProductList(props: {categoryId: string; products: Product[]}) {
  const products = useOptimistic<Product[], SanityDocument<{products?: Product[]}>>(
    props.products,
    (state, action) => {
      if (action.type !== 'mutate' || action.id !== props.categoryId) {
        return state
      }
      return action.document.products ?? state
    },
  )

  return (
    <ul>
      {products.map((product) => (
        <li key={product._id}>{product.title}</li>
      ))}
    </ul>
  )
}

```

## Reducer actions

Every reducer receives an `action` object with the following properties:

#### Properties

**document** (U, required)

The document that was updated.

**id** (string, required)

The published ID of the mutated document, with any drafts. prefix removed. Compare this against a published document ID, not against action.document._id.

**originalId** (string, required)

The ID of the document that was mutated, including any drafts. prefix. Matches action.document._id.

**type** ('appear' | 'mutate' | 'disappear', required)

The type of action occurring (only mutate is currently supported).



# Content Source Maps

The Content Lake can enrich all queries with metadata describing the **source** (the document and attribute) of every content fragment retrieved in the query. This metadata is useful for tooling that runs on the frontend and interprets this information to provide additional ways of interacting with the content for users: for example, displaying content provenance details for compliance reviewers, providing direct links to where to edit the content for editors, and even allowing in-line editing directly in the frontend experience.

This metadata is called Content Source Maps, and it follows the Content Source Maps specification. You can find details about the Content Source Maps specification in the [official specification GitHub repository](https://github.com/sanity-io/content-source-maps).

To request a Content Source Map on a GROQ query, you need to pass the parameter `resultSourceMap=true` when you send your query to the Content Lake HTTP API. When you provide this parameter, the result of your query will include an additional element with the corresponding content source map.

> [!WARNING]
> Gotcha
> Content Source Maps are supported only in version `2021-03-25` or later of the Content Lake API.

For example, imagine these three documents exist in your dataset:

- A document representing the author George Orwell

```json
{
  "_id": "author-george-orwell-4c9f",
  "_type": "author",
  "died": "1950-01-21",
  "dob": "1903-05-25",
  "firstName": "George",
  "lastName": "Orwell"
}
```

- Another document representing the book “Animal Farm” by author George Orwell (a reference to the document for this author above)

```json
{
  "_id": "book-animal-farm-3856",
  "_type": "book",
  "description": "It tells the story of a group of farm animals who rebel against their human farmer",
  "title": "Animal Farm",
  "author": {
    "_ref": "author-george-orwell-4c9f"
  }
}
```

- And another document representing the book “Nineteen Eighty-Four” by author George Orwell as well (as before, a reference to the first document above)

```json
{
  "_id": "book-1984-12eb",
  "_type": "book",
  "description": "Nineteen Eighty-Four (also published as 1984) is a dystopian social science fiction novel and cautionary tale by English writer George Orwell.",
  "title": "Nineteen Eighty-Four",
  "author": {
    "_ref": "author-george-orwell-4c9f"
  }
}
```

You can run the following GROQ query to obtain a consolidated view that provides the author's last name and a set with the names of the books they have written:

```groq
*[_type == 'author'] {
  "authorName": lastName,
  "booksWritten": *[_type == 'book' && references(^._id)].title
}
```

If you run this query passing the `resultSourceMap` parameter (i.e. `https://<project-id>.api.sanity.io/v2026-07-01/data/query/<dataset>?query=<GROQ-query>&resultSourceMap=true`), the response will look like this:

```json
{
  "result": [
    {
      "authorName": "Orwell",
      "booksWritten": ["Nineteen Eighty-Four", "Animal Farm"]
    }
  ],
  "resultSourceMap": {
    "documents": [
      {
        "_id": "author-george-orwell-4c9f"
      },
      {
        "_id": "book-1984-12eb"
      },
      {
        "_id": "book-animal-farm-3856"
      }
    ],
    "paths": ["$['lastName']", "$['title']"],
    "mappings": {
      "$[0]['authorName']": {
        "source": {
          "document": 0,
          "path": 0,
          "type": "documentValue"
        },
        "type": "value"
      },
      "$[0]['booksWritten'][0]": {
        "source": {
          "document": 1,
          "path": 1,
          "type": "documentValue"
        },
        "type": "value"
      },
      "$[0]['booksWritten'][1]": {
        "source": {
          "document": 2,
          "path": 1,
          "type": "documentValue"
        },
        "type": "value"
      }
    }
  }
}

```

Observe that the `resultSourceMap` entry includes:

- Under `documents`, a list of documents from where the content in the query results comes from, i.e.: - The author document with ID `author-george-orwell-4c9f`,
- The book document with ID `book-1984-12eb`
- The book document with ID `book-animal-farm-3856`


- Under `paths`, a list of the attribute names from where the content in the query results comes from, i.e.:- The attribute name `lastName` (specified as `"$['lastName']"`)
- The attribute name `title` (specified as `"$['title']"`)


- Under `mappings`, a map where each entry indicates, for each element of the response, what document and path (attribute) from the lists above it comes from. 

In this example:

```json
"$[0]['authorName']": {
    "source": {
      "document": 0,
      "path": 0,
      "type": "documentValue"
    },
    "type": "value"
}
```

The first map entry describes that in the first element in the response (`$[0]`), the attribute "authorName" (`['authorName']`) comes from (`source:`) the document in the first position of the “documents” set (`"document": 0`, which is `author-george-orwell-4c9f`), and the attribute in the first position in the “paths” set (`"path": 0`, which is `lastName`); i.e. the value `"authorName": "Orwell"` in the response, comes from the document `author-george-orwell-4c9f`, and attribute `lastName`.

```json
"$[0]['booksWritten'][0]": {
  "source": {
    "document": 1,
    "path": 1,
    "type": "documentValue"
  },
  "type": "value"
}
```

The second map entry describes that in the first element in the response (`$[0]`), the attribute “booksWritten” (`['booksWritten']`), its first element (`[0]`) comes from (`source:`) the document in the second position of the “documents” set (`"document": 1`, which is `book-1984-12eb`), and the attribute in the second position in the “paths” set (`"path": 1`, which is `title`); i.e. the value in the first element of `booksWritten` in the response (the string `"Nineteen Eighty-Four"`), comes from the document `book-1984-12eb`, and attribute `title`.

```json
"$[0]['booksWritten'][1]": {
  "source": {
    "document": 2,
    "path": 1,
    "type": "documentValue"
  },
  "type": "value"
}
```

The third map entry describes that in the first element in the response (`$[0]`), the attribute “booksWritten” (`['booksWritten']`), its second element (`[1]`) comes from (`source:`) the document in the third position of the “documents” set (`"document": 2`, which is `book-animal-farm-3856`), and the attribute in the second position in the “paths” set (`"path": 1`, which is `title`); i.e. the value in the second element of `booksWritten` in the response (the string `"Animal Farm"`), comes from the document `book-animal-farm-3856`, and attribute `title`.

## GROQ compatibility

> [!WARNING]
> Gotcha
> In the current version of the Content Source Maps capability, only the GROQ features listed below will provide content source metadata in a query. Also note that the feature is currently only supported in the Content Lake (i.e. implementations such as groq-js are not yet supported).

Types:

- [Object](https://www.sanity.io/docs/studio/object-type) (includes Portable Text)
- [Array](https://www.sanity.io/docs/studio/array-type)
- [Number](https://www.sanity.io/docs/studio/number-type)
- [String](https://www.sanity.io/docs/studio/string-type)

Traversal:

- Attribute access traversal
- Element access traversal
- Slice traversal
- Filter traversal
- Array postfix traversal
- Projection traversal
- Dereference traversal

Conditionals:

- `select()` (partially supported: result only, for string and number type)
- Conditional projection traversal (partially supported: result only, for string and number type)

Operators:

- Dereference

Functions:

- `string()` (coercing) (partially supported: string(number) only)
- `lower()`
- `upper()`
- `pt::text()`

## GraphQL

Content Source Maps are supported in the Sanity [GraphQL API v2023-08-01](https://www.sanity.io/changelog?platforms=GraphQL#change-9ec89318-a340-4e23-91d9-3154da5b6244) and later. Read more about this in the [GraphQL docs](https://www.sanity.io/docs/content-lake/graphql).



# Vercel protection bypass

For users of [Vercel's Deployment Protection](https://vercel.com/docs/deployment-protection/methods-to-protect-deployments), you may experience issues that prevent your application from loading in the Presentation Tool preview frame. [Vercel's Protection Bypass](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) is the solution, but to make the setup process easier you can use [the @sanity/vercel-protection-bypass plugin](https://github.com/sanity-io/plugins/tree/main/plugins/%40sanity/vercel-protection-bypass) as part of your studio's configuration.

> [!NOTE]
> This protection is commonly enabled in enterprise environments. If you're not receiving errors related to deployment protection, you likely don't need to set up this plugin.

## Enable protection bypass

### Install the plugin

In your studio directory, add the package.

**npm**

```shell
npm install @sanity/vercel-protection-bypass
```

**pnpm**

```shell
pnpm add @sanity/vercel-protection-bypass
```

**yarn**

```shell
yarn add @sanity/vercel-protection-bypass
```

**bun**

```shell
bun add @sanity/vercel-protection-bypass
```

### Add the plugin to your studio config

Import and add the plugin to the `plugins` array. This adds the new tool to your studio.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'
import {presentationTool} from 'sanity/presentation'
import {vercelProtectionBypassTool} from '@sanity/vercel-protection-bypass'

export default defineConfig({
  //... rest of config
  plugins: [
    presentationTool({
      // ... presentation config
    }),
    vercelProtectionBypassTool()
  ]
})
```

### Launch your studio and complete the instructions

Run your studio and navigate to the new "**Vercel Bypass Protection**" tab. Depending on where the plugin is within the `plugins` array, it may be in a different location in the top toolbar.

![A modal window titled "Vercel Protection Bypass" with instructions on setting up a secret environment variable for automation.](https://cdn.sanity.io/images/3do82whm/next/4396a95c7ab9a67f634f157d855e786d266bb5fe-2272x1356.png)

Once the steps are complete and you've added the secret, you're all set. If you need to remove or change the secret in the future, you can return to this page.

## Limitations and considerations

- Setting a bypass secret in your dataset enables protection bypass automation for *all instances* of `presentationTool` on `sanity@3.70.0` or later. You cannot enable it for only some instances.
- You can't have different secrets for different URLs when using `@sanity/vercel-protection-bypass`. They must all use the same secret. If different secrets are required then this won't work for your needs.



# Troubleshooting Visual Editing

## Overlay and highlighting problems

### Text breaks out of its container, or the container is too wide

If the text on the page breaks out of its container, or the container is much wider than normal, split the encoded text out from the original text.

> [!NOTE]
> Note
> This is not due to the encoded characters themselves. This problem occurs only when the element also uses negative letter-spacing in its CSS, or sits inside a `<Balancer>` component from `react-wrap-balancer`.

Identify where the problematic element is rendered in your code, for example:

**components/MyComponent.tsx**

```tsx
export function MyComponent({ text }: { text: string }) {
  return <h1>{text}</h1>;
}

```

Rewrite using `@vercel/stega` to avoid any styling issues:

**components/MyComponent.tsx**

```tsx
import { vercelStegaSplit } from "@vercel/stega";

export function MyComponent({ text }: { text: string }) {
  const { cleaned, encoded } = vercelStegaSplit(text);

  return (
    <h1>
      {cleaned}
      <span style={{ display: "none" }}>{encoded}</span>
    </h1>);
}

```

If you need this more than once, extract the logic into a reusable component:

**components/Clean.tsx**

```tsx
import { vercelStegaSplit } from "@vercel/stega";

export default function Clean({ value }: { value: string }) {
  const { cleaned, encoded } = vercelStegaSplit(value);

  return encoded ? (
    <>
      {cleaned}
      <span style={{ display: "none" }}>{encoded}</span>
    </>) : (
    cleaned
  );
}

export function MyComponent({ text }: { text: string }) {
  return (
    <h1>
      <Clean value={text} />
    </h1>);
}

```

### Overlay displays over the wrong element

If the wrong element is highlighted when you hover, add an attribute to a containing element.

For example, if this component highlights the `<h1>` and you want it to highlight the `<section>` element:

```html
<section>
  <h1>{dynamicTitle}</h1>
  <div>Hardcoded Tagline</div>
</section>

```

Add a data attribute to highlight the correct item:

- For Visual Editing with `@sanity/visual-editing`, add `data-sanity-edit-target`.
- For Vercel Visual Editing (Vercel's Edit Mode), add `data-vercel-edit-target`.

```html
<section data-sanity-edit-target>
  <h1>{dynamicTitle}</h1>
  <div>Hardcoded Tagline</div>
</section>

```

### Overlay can’t resolve a field for an element

`@sanity/visual-editing` logs `[@sanity/visual-editing] No field could be resolved at path: "FIELD_PATH"` to the browser console when the path encoded in an element doesn’t match any field in the schema your Studio reported. Visual Editing catches the error and continues without field information for that element.

The encoded path and the schema have drifted apart. Check that:

- The schema deployed to your Studio still contains the field the path names.
- The element renders a value from the document the path belongs to, and not a stega-carrying value copied in from another field.

## Stega encoding problems

As of `@sanity/visual-editing` 5.5.0 and Sanity Studio 6.6.0, two common sources of stega contamination are handled automatically. First, clipboard copies are cleaned. When Visual Editing is active, `<VisualEditing />` strips stega from clipboard data as you copy text from the preview page. Pasting into external tools such as Notion, Slack, or spreadsheets no longer produces invisible characters. You can opt out of this behavior with the `keepStegaOnCopy` prop if needed.

Second, Sanity Studio cleans field pastes automatically: pasting stega-contaminated text into any Sanity Studio primitive field (`string`, `text`, `email`, `url`, `slug`, `tags`, `number`, and arrays of primitives, plus any custom input that spreads `elementProps` onto a native input or textarea) now strips stega before storing the value. Portable Text already handled this; as of 6.6.0, all primitive fields do as well.

### There are weird characters in your DOM

These are most likely stega-encoded strings. They are a subset of [HTML entities](https://developer.mozilla.org/en-US/docs/Glossary/Entity) that, when rendered, produce invisible output. This is the string value of “Oxford Shoes” when it contains a stega-encoded Content Source Map:

```html
Oxford Shoes&ZeroWidthSpace;&ZeroWidthSpace;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwj;&#xFEFF;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&zwnj;&#xFEFF;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&#xFEFF;&zwj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&#xFEFF;&zwj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwj;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&#xFEFF;&zwnj;&zwnj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&#xFEFF;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&zwj;&ZeroWidthSpace;&#xFEFF;&zwj;&#xFEFF;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&zwj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&zwj;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwj;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwj;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&zwj;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&#xFEFF;&zwnj;&zwnj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&zwj;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&zwj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&zwnj;&#xFEFF;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwj;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwnj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&zwj;&ZeroWidthSpace;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&zwj;&zwnj;&zwnj;&zwnj;&zwj;&#xFEFF;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&zwj;&#xFEFF;&#xFEFF;&zwnj;&zwj;&#xFEFF;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&zwnj;&zwj;&zwj;&zwnj;&zwnj;&#xFEFF;&ZeroWidthSpace;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwnj;&ZeroWidthSpace;&zwnj;&#xFEFF;&ZeroWidthSpace;&zwj;&zwnj;&zwj;&ZeroWidthSpace;&zwnj;&zwnj;&zwj;&zwnj;&zwj;&zwnj;&#xFEFF;&zwnj;&ZeroWidthSpace;&ZeroWidthSpace;&#xFEFF;&#xFEFF;&zwnj;&ZeroWidthSpace;&zwj;&ZeroWidthSpace;&zwj;&zwnj;&#xFEFF;&#xFEFF;&zwnj;
```

When rendered in an HTML document, this string still displays as “Oxford Shoes.” You can use [the HTML entity decoder](https://mothereff.in/html-entities) to test the string by pasting the encoded value into the “Encoded” input field.

### Comparing field values doesn’t work in preview mode

Your application likely evaluates values from the Content Lake to perform specific logic. If these values contain invisible encoded metadata, they may no longer work.

For example, imagine a function that determines that a Sanity document's market value is the same as the current market:

**lib/showDocument.ts**

```ts
function showDocument(document: SanityDocument, currentMarket: string) {
  return document.market === currentMarket
}

```

Without stega enabled, this function works as expected. However, if `document.market` contains encoded metadata, this comparison fails.

If `document.market` is never shown on the page and does not benefit from Visual Editing, filter it out of the stega encoding process. You can pass a [filtering function](https://reference.sanity.io/_sanity/client/index/FilterDefault/) to `stega.filter` when configuring the client.

Alternatively, [clean the value](https://reference.sanity.io/_sanity/client/stega/stegaClean/) before comparing it:

**lib/showDocument.ts**

```ts
import {stegaClean} from "@sanity/client/stega"
import type {SanityDocument} from "@sanity/client"

function showDocument(document: SanityDocument, currentMarket: string) {
  return stegaClean(document.market) === currentMarket
}

```

If you need this more than once, extract it into a helper function.

Studio 6.6.0 and later strips stega when content is pasted into studio fields, so contamination in stored data is handled for you. `stegaClean()` is still required in your frontend code for string comparisons and URL construction.

### Stega appears outside visible text

Stega in rendered visible text is intentional — it powers click-to-edit. Stega anywhere else always causes bugs and must be avoided:

- HTML element attributes such as `class`, `id`, `href`, `src`, `style`, and `data-*`.
- Inside `<head>` — page title, `meta[content]`, and JSON-LD.
- `<script>` and `<style>` text content.
- `<textarea>` form values.
- The page URL.

The `onSuspiciousStega` callback on `<VisualEditing />` is an opt-in tool to detect these cases during development. It receives an array of reports, each with a `report.kind` property indicating where the stega was found:

**app/layout.tsx**

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>

```

> [!WARNING]
> Performance cost
> The `onSuspiciousStega` callback runs a full DOM audit using `TreeWalker` and `MutationObserver` and has a performance cost. It is most useful during development and debugging.

Possible `report.kind` values are: `attribute`, `head`, `script`, `style`, `form-value`, and `url`.

## Version mismatch warnings

`@sanity/visual-editing` asks the Presentation tool in your Studio which features it supports. When your Studio is older than the frontend package expects, the request fails, Visual Editing skips the affected feature, and it logs a warning to the browser console. Update the `sanity` package in your Studio to clear these warnings.

`@sanity/visual-editing` logs `[@sanity/visual-editing] Package version mismatch detected: Please update your Sanity studio to prevent potential compatibility issues.` when the feature negotiation request fails. Optimistic updates stay disabled.

`@sanity/visual-editing` logs `[@sanity/visual-editing]: Failed to fetch shared state. Check your version of `sanity` is up-to-date` when the shared state request fails. The underlying reason is logged separately with `console.debug`, so turn on verbose console output to see it.

## Embedded studio problems

When using Visual Editing with embedded studios (studios that render on a route in your frontend app), do not include the Visual Editing or `SanityLive` components in the root or layout component for your studio.

Create dedicated content and studio layouts so you can keep your studio separate from the Visual Editing components.



# Integrate Sanity with your Astro app

#### The basics

[Start here](https://www.sanity.io/docs/astro/introduction)
Discover the different ways to integrate Astro and Sanity

[Astro quickstart](https://www.sanity.io/docs/astro-quickstart)
New to Sanity and Astro? Follow these step-by-step instructions to set up your site and Studio.

[Visual Editing with Astro](https://www.sanity.io/docs/visual-editing/astro-visual-editing)
Configure Sanity’s Presentation Tool, draft mode, and visual editing overlays to work with an Astro 7 server-rendered frontend.

#### Configuration

[Configure @sanity/astro](https://www.sanity.io/docs/astro/configure-sanity-astro)
Integration options, the sanity:client virtual module, environment variables, and client customization.

[Embedding Studio in Astro](https://www.sanity.io/docs/astro/embedding-studio-in-astro)
Mount Sanity Studio as a route using studioBasePath.

#### Content delivery

[Query content](https://www.sanity.io/docs/astro/query-content-astro)
Fetch Sanity content with GROQ queries in Astro components.

[Images and Portable Text](https://www.sanity.io/docs/astro/images-and-portable-text-astro)
Render images with CDN transforms and rich text with Portable Text.

[Static and server rendering](https://www.sanity.io/docs/astro/static-and-server-rendering)
Choose the right rendering mode for your Astro + Sanity project.



# Introduction

Sanity gives Astro developers a structured content backend with real-time collaboration, a customizable editing environment, and APIs designed for both static and server-rendered sites. Sanity provides the content infrastructure while Astro handles the frontend.

## Get started

- [Quickstart](https://www.sanity.io/docs/astro-quickstart): get a working Astro + Sanity project running in minutes.
- [Starter template](https://www.sanity.io/templates/astro-sanity-clean): clone a preconfigured project with Sanity Studio and content fetching already wired up.
- [Blog guide](https://www.sanity.io/docs/developer-guides/sanity-astro-blog): build a complete blog with Astro and Sanity from scratch, with more details and explanation than the quick start.

## The @sanity/astro integration

`@sanity/astro` is the official Astro integration for Sanity. It configures the Sanity Client as a virtual module in your Astro project and optionally embeds Sanity Studio on a route.

Install it inside an existing Astro project:

**npm**

```shell
npx astro add @sanity/astro @astrojs/react
```

**pnpm**

```shell
pnpm dlx astro add @sanity/astro @astrojs/react
```

**yarn**

```shell
yarn dlx astro add @sanity/astro @astrojs/react
```

**bun**

```shell
bunx astro add @sanity/astro @astrojs/react
```

This adds `@sanity/astro` to your `astro.config.mjs` and makes the Sanity client available through the `sanity:client` virtual module. For full configuration options, see [Configuring @sanity/astro](https://www.sanity.io/docs/astro/configure-sanity-astro).

The integration provides:

- **Sanity Client** (`sanityClient`): a pre-configured client available via `import { sanityClient } from "sanity:client"`.
- **Embedded Studio**: mount Sanity Studio as a route in your Astro app using `studioBasePath`.
- **Visual Editing**: click-to-edit overlays for draft content in the Presentation Tool (requires SSR).
- **Stega encoding**: invisible source mapping that connects rendered content back to its source documents.

> [!NOTE]
> `@astrojs/react` is only needed if you plan to embed Sanity Studio or use the Visual Editing component in your project.

## How Astro differs from Next.js

If you're coming from `next-sanity`, there are some differences worth understanding.

Astro defaults to static site generation. Pages are pre-rendered at build time, which means content is fetched once during the build rather than on every request. This is great for performance but means content updates require a rebuild unless you switch to [server-side rendering](https://www.sanity.io/docs/astro/static-and-server-rendering).

The `@sanity/astro` integration is lighter than `next-sanity`. It focuses on client configuration, Studio embedding, and basic Visual Editing. Some features available in `next-sanity` don't have equivalents here:

- **No Live Content API:** `next-sanity` provides `defineLive` and `SanityLive` for automatic real-time content updates and cache revalidation. Astro doesn't have an equivalent. Content updates require a rebuild (static mode) or a page refresh (SSR mode) unless you [implement the live content integration](https://www.sanity.io/docs/developer-guides/live-content-guide) yourself.
- **No built-in cache revalidation:** `next-sanity` integrates with the Next.js data cache for time-based, tag-based, and path-based revalidation. In Astro, caching behavior depends on your rendering mode and hosting adapter.
- **No webhook validation helper:** `next-sanity` exports `parseBody` for secure webhook signature validation. For Astro, you'd implement webhook handling through your hosting platform or a custom API endpoint.

## Key features

### Visual Editing

Visual Editing lets content editors click directly on rendered content in the Presentation Tool to open the corresponding field in the Studio. It uses stega encoding to invisibly map rendered text back to its source document and field.

Visual Editing in Astro requires server-side rendering. Static pages can't encode stega strings at request time. Set `output: 'server'` in your Astro config for project-wide SSR, or keep the default `output: 'static'` and add `export const prerender = false` to individual pages that need server rendering. See [Visual Editing for Astro](https://www.sanity.io/docs/visual-editing/astro-visual-editing) for setup instructions.

### Embedded Studio

You can mount Sanity Studio as a route inside your Astro application using the `studioBasePath` option. This means your content editing environment lives at a path like `/admin` in the same deployment as your frontend. See [Embedding Studio in Astro](https://www.sanity.io/docs/astro/embedding-studio-in-astro) for configuration details.

### Portable Text and images

Sanity stores rich text as Portable Text, a JSON-based format that's framework-agnostic and queryable. The community-maintained `astro-portabletext` library renders Portable Text in Astro components with support for custom block types and annotations. For images, `@sanity/image-url` generates CDN URLs with on-demand transforms, automatic optimization, and responsive sizing. See [Images and Portable Text in Astro](https://www.sanity.io/docs/astro/images-and-portable-text-astro) for usage patterns.

## Next steps

- [Astro quickstart](https://www.sanity.io/docs/astro-quickstart): set up a new project from scratch.
- [Configure @sanity/astro](https://www.sanity.io/docs/astro/configure-sanity-astro): client setup, environment variables, and integration options.
- [Query content in Astro](https://www.sanity.io/docs/astro/query-content-astro): fetching data with GROQ in `.astro` components.
- [Static and server rendering](https://www.sanity.io/docs/astro/static-and-server-rendering): choose the right rendering mode for your project.



# Configure @sanity/astro

Configure the integration in your `astro.config.mjs` file. The integration accepts the same options as `@sanity/client`, plus additional integration-specific options:

**astro.config.mjs**

```typescript
import sanity from '@sanity/astro'
import { defineConfig } from 'astro/config'

export default defineConfig({
  integrations: [
    sanity({
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'production',
      apiVersion: '2026-03-01',
      useCdn: false,
    }),
  ],
})
```

## Configuration options

The integration accepts these options:

- `projectId` (string): your Sanity project ID. Find it at sanity.io/manage.
- `dataset` (string): the dataset to query. Typically "production".
- `apiVersion` (string): API version date string (e.g., '2026-03-01'). Required for predictable query behavior.
- `useCdn` (boolean): whether to use the Sanity CDN. Set to false for static builds, true for server-rendered pages.
- `token` (string): API token for authenticated requests. Required for draft content and Visual Editing. Never hardcode tokens; use environment variables.
- `studioBasePath` (string): route path for embedded Studio (e.g., '/admin'). Requires `@astrojs/react` (also needed for the Visual Editing component).
- `stega` (object): stega encoding configuration for Visual Editing.

## The sanity:client virtual module

Once configured, the integration exposes a pre-configured Sanity client through a virtual module:

**src/pages/index.astro**

```typescript
---
import { sanityClient } from 'sanity:client'

const posts = await sanityClient.fetch(
  `*[_type == "post" && defined(slug)] | order(publishedAt desc)`
)
---
```

This client uses the options you provided in `astro.config.mjs`. You can use it in any `.astro` component's frontmatter, in API routes, or in server-side scripts.

### Adding type declarations

To get TypeScript support for the `sanity:client` module, add a reference to your `env.d.ts` file (typically in your `src` directory):

**src/env.d.ts**

```typescript
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
```

After updating this file, restart your TypeScript language server (or restart your editor) for the types to resolve.

## Environment variables

Store sensitive values like project IDs and tokens in environment variables rather than hardcoding them in your config. Create a `.env` file in your project root:

**.env**

```sh
PUBLIC_SANITY_PROJECT_ID="YOUR_PROJECT_ID"
PUBLIC_SANITY_DATASET="production"
SANITY_API_READ_TOKEN="your-read-token"
```

Astro uses the `PUBLIC_` prefix convention to distinguish client-side and server-side variables. Variables with the prefix are safe to expose to the browser. Never prefix tokens with `PUBLIC_`, as this would expose them in client-side JavaScript.

## Creating additional client instances

The virtual module client works well for most use cases. If you need a second client with different settings (for example, one configured for draft content), create it from `@sanity/client` directly:

**src/lib/sanity.ts**

```typescript
import { createClient } from '@sanity/client'

export const previewClient = createClient({
  projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
  dataset: import.meta.env.PUBLIC_SANITY_DATASET,
  apiVersion: '2026-03-01',
  useCdn: false,
  token: import.meta.env.SANITY_API_READ_TOKEN,
  perspective: 'drafts',
})
```

This is useful when you need to query draft content alongside published content, or when different parts of your application require different API versions or perspectives.

## When to set useCdn

The right `useCdn` value depends on your rendering mode:

- **Static builds** (`output: 'static'`): set `useCdn: false`. Content is fetched once at build time, so CDN caching adds no benefit. You want the freshest data at build time.
- **Server-rendered** (`output: 'server'`): set `useCdn: true` for published content. The CDN reduces latency for repeated queries. Set it to `false` when fetching draft content with a token.
- **Mixed static and server rendering**: Astro 5 removed the `output: 'hybrid'` mode. Use `output: 'static'` or `output: 'server'` and opt individual pages in or out with `export const prerender = true` or `export const prerender = false`. Set `useCdn` to match the rendering mode of each page: `false` for prerendered pages, `true` for server-rendered pages fetching published content. 

## Minimal working configuration

Here's the smallest setup that works for a statically generated Astro site:

**astro.config.mjs**

```typescript
import sanity from '@sanity/astro'
import { defineConfig } from 'astro/config'

export default defineConfig({
  integrations: [
    sanity({
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'production',
      apiVersion: '2026-03-01',
      useCdn: false,
    }),
  ],
})
```

**src/env.d.ts**

```typescript
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
```

From here, you can import `sanityClient` in any `.astro` file and start querying content.



# Embedding Studio in Astro

The recommended approach is to deploy your Studio to Sanity, but you can also embed it inside your Astro app so your content editing environment lives alongside your frontend at a path like `/admin`.

## Configure Studio embedding

### 1. Add studioBasePath to your integration config

**astro.config.mjs**

```typescript
import sanity from '@sanity/astro'
import react from '@astrojs/react'
import { defineConfig } from 'astro/config'

export default defineConfig({
  integrations: [
    sanity({
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'production',
      apiVersion: '2026-03-01',
      useCdn: false,
      studioBasePath: '/admin',
    }),
    react(),
  ],
})
```

### 2. Create a Studio configuration file

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'

export default defineConfig({
  name: 'my-project',
  title: 'My Project',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [structureTool()],
  schema: {
    types: [
      // Your content types go here
    ],
  },
})
```

### 3. Configure CORS

The Studio makes authenticated API requests from the browser, so you need to allow your domain in your project's CORS settings. Go to sanity.io/manage, select your project, navigate to the API tab, and under CORS origins, add your development URL (e.g., `http://localhost:4321`) and your production URL. Check "Allow credentials" for each origin.

**Caution:** Only enable CORS with credentials on domains you control. Allowing credentials on a third-party domain could expose your project to unauthorized access.

## Known issues

### Page refresh returns 404

When the Studio navigates to a sub-route like `/admin/structure`, that path only exists within the Studio's client-side router. Refreshing the page at that URL can return a 404. For server-rendered apps (`output: 'server'`), the integration handles catch-all routing automatically. For static builds, configure your hosting platform's rewrite rules to direct all requests under `/admin/*` to the `/admin` page.

## Multiple workspaces

If your Studio configuration defines multiple workspaces, the integration mounts them as sub-routes under your `studioBasePath`. A single workspace mounts at `/admin`. Multiple workspaces mount at `/admin/workspace-one`, `/admin/workspace-two`, and so on.

## Router modes

By default, the embedded Studio uses browser history routing. The Studio's internal navigation (opening documents, switching tools) updates the URL path, so a URL like `/admin/structure/post;abc123` represents a specific document in the structure tool.

If you prefer hash-based routing (where Studio navigation stays within a single URL like `/admin#/structure/post;abc123`), you can configure this in your integration options. Hash routing can be useful if your hosting platform has difficulty with the catch-all route pattern.

**astro.config.mjs**

```javascript
// astro.config.mjs
import { defineConfig } from "astro/config";
import sanity from "@sanity/astro";
import react from "@astrojs/react";

export default defineConfig({
  integrations: [
    sanity({
      projectId: PUBLIC_SANITY_PROJECT_ID,
      dataset: PUBLIC_SANITY_DATASET,
      useCdn: false,
      apiVersion: "2025-02-19",
      stega: {
        studioUrl: "http://localhost:3333",
      },
      studioRouterHistory: "hash",
    }),
    react(),
  ],
  // ... rest of config
})
```

### Hot module replacement disruptions

During development, file changes that trigger HMR can momentarily disrupt the Studio. If the Studio becomes unresponsive after an HMR update, a manual page refresh resolves it.

## Separating Studio and frontend concerns

The embedded Studio shares Astro's development server (built on Vite), which means your Astro plugins and configuration affect the Studio's build process. This is usually fine, but can cause issues if an Astro plugin transforms files in ways that conflict with Studio dependencies, or if your project has strict Content Security Policy headers that block Studio scripts.

If you run into conflicts, consider deploying the Studio separately using `sanity deploy` or hosting it as its own application.



# Query content

## Basic queries

Import the pre-configured client from the `sanity:client` virtual module and call `fetch()` in your component's frontmatter:

**src/pages/index.astro**

```typescript
---
import { sanityClient } from 'sanity:client'

const posts = await sanityClient.fetch(
  `*[_type == "post" && defined(slug)] | order(publishedAt desc){
    _id,
    title,
    slug,
    publishedAt
  }`
)
---

<h1>Blog</h1>
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.slug.current}`}>{post.title}</a>
    </li>
  ))}
</ul>
```

The client is available in any `.astro` file's frontmatter, in API routes, and in server-side scripts. Queries use GROQ, Sanity's query language.

## Dynamic routes with getStaticPaths

For statically generated pages, use Astro's `getStaticPaths` to create a page for each document:

**src/pages/blog/[slug].astro**

```typescript
---
import { sanityClient } from 'sanity:client'

export async function getStaticPaths() {
  const posts = await sanityClient.fetch(
    `*[_type == "post" && defined(slug)]{
      "slug": slug.current,
      title,
      body
    }`
  )

  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }))
}

const { post } = Astro.props
---

<h1>{post.title}</h1>
```

Astro calls `getStaticPaths` at build time and generates one HTML file per route. Content changes require a rebuild to appear on the site.

## Parameterized queries

Use GROQ parameters to avoid string interpolation in queries. Pass parameters as the second argument to `fetch()`:

**Example**

```typescript
---
import { sanityClient } from 'sanity:client'

const slug = Astro.params.slug

const post = await sanityClient.fetch(
  `*[_type == "post" && slug.current == $slug][0]{
    _id,
    title,
    body,
    "author": author->{name, image}
  }`,
  { slug }
)
---
```

Parameters are sanitized by the client, preventing GROQ injection. Always prefer parameters over template literal interpolation.

## Using Sanity TypeGen

For generated types that stay in sync with your schema, use Sanity TypeGen. Define queries with `defineQuery` in separate `.ts` files and import them into your Astro components:

**npm**

```shell
npm install groq
```

**pnpm**

```shell
pnpm add groq
```

**yarn**

```shell
yarn add groq
```

**bun**

```shell
bun add groq
```

**src/lib/queries.ts**

```typescript
import { defineQuery } from 'groq'

export const postsQuery = defineQuery(
  `*[_type == "post" && defined(slug)] | order(publishedAt desc){
    _id,
    title,
    slug,
    publishedAt
  }`
)
```

> [!WARNING]
> **Known limitation:** Sanity TypeGen doesn't fully support `.astro` file syntax. The type generator's parser can't handle Astro's frontmatter fences and may produce errors. Define queries in separate `.ts` files as a workaround.

## Querying references and joins

GROQ supports following references inline with the `->` operator:

**GROQ query**

```groq
*[_type == "post" && slug.current == $slug][0]{
  title,
  body,
  "author": author->{
    name,
    "imageUrl": image.asset->url
  },
  "categories": categories[]->{
    title,
    slug
  }
}
```

This fetches the post along with its referenced author and category documents in a single query. For more on GROQ query patterns, see the GROQ documentation.

## Typing query results

For basic type safety, use a generic on the `fetch()` call:

**Example**

```typescript
---
import { sanityClient } from 'sanity:client'

interface Post {
  _id: string
  title: string
  slug: { current: string }
  publishedAt: string
}

const posts = await sanityClient.fetch<Post[]>(
  `*[_type == "post" && defined(slug)] | order(publishedAt desc)`
)
---
```

For generated types that stay in sync with your schema, use Sanity TypeGen with `defineQuery` as shown in the section above.

## The loadQuery helper for Visual Editing

When using Visual Editing, queries need additional configuration: a viewer token, the perspective, source maps, and stega encoding. Rather than adding these options to every `fetch()` call, create a shared `loadQuery` helper:

**src/lib/load-query.ts**

```typescript
import type { QueryParams } from 'sanity'
import { sanityClient } from 'sanity:client'

const visualEditingEnabled =
  import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === 'true'
const token = import.meta.env.SANITY_API_READ_TOKEN

export async function loadQuery<T>({
  query,
  params,
}: {
  query: string
  params?: QueryParams
}) {
  if (visualEditingEnabled && !token) {
    throw new Error(
      'The `SANITY_API_READ_TOKEN` environment variable is required during Visual Editing.'
    )
  }

  const perspective = visualEditingEnabled ? 'drafts' : 'published'

  const { result, resultSourceMap } = await sanityClient.fetch<T>(
    query,
    params ?? {},
    {
      filterResponse: false,
      perspective,
      resultSourceMap: visualEditingEnabled
        ? 'withKeyArraySelector'
        : false,
      stega: visualEditingEnabled,
      ...(visualEditingEnabled ? { token } : {}),
      useCdn: !visualEditingEnabled,
    }
  )

  return {
    data: result,
    sourceMap: resultSourceMap,
    perspective,
  }
}
```

Then use it in your components:

**src/pages/blog/[slug].astro**

```typescript
---
import { loadQuery } from '../../lib/load-query'

const { data: post } = await loadQuery<Post>({
  query: `*[_type == "post" && slug.current == $slug][0]{
    _id,
    title,
    body
  }`,
  params: { slug: Astro.params.slug },
})
---

<h1>{post.title}</h1>
```

The helper transparently switches between published and draft content based on the `PUBLIC_SANITY_VISUAL_EDITING_ENABLED` environment variable. [See Visual Editing for Astro for a full setup](https://www.sanity.io/docs/visual-editing/astro-visual-editing).



# Images and Portable Text

## Rendering images

Sanity's asset pipeline serves images from a global CDN with on-demand transforms, automatic format optimization, and metadata like dimensions, color palettes, and blur hashes. Use `@sanity/image-url` to generate URLs with the transforms you need.

### Create an image URL builder

**src/lib/image.ts**

```typescript
import { createImageUrlBuilder } from '@sanity/image-url'
import { sanityClient } from 'sanity:client'

const builder = createImageUrlBuilder(sanityClient)

export function urlFor(source: any) {
  return builder.image(source)
}
```

### Use it in components

**src/components/SanityImage.astro**

```typescript
---
import { urlFor } from '../lib/image'

interface Props {
  image: any
  alt: string
  width?: number
}

const { image, alt, width = 800 } = Astro.props
const url = urlFor(image).width(width).auto('format').url()
---

<img src={url} alt={alt} width={width} loading="lazy" />
```

The `auto('format')` option serves modern formats like AVIF or WebP based on browser support, and falls back to the original format for others.

## Rendering Portable Text

Sanity stores rich text as Portable Text, a JSON-based format. The community-maintained `astro-portabletext` library handles rendering in Astro.

### Basic rendering

**src/components/PortableText.astro**

```typescript
---
import { PortableText } from 'astro-portabletext'

interface Props {
  value: any[]
}

const { value } = Astro.props
---

<PortableText value={value} />
```

### Custom block types and annotations

If your schema includes custom block types (like code blocks, images, or YouTube embeds), map them to Astro components:

**src/components/PortableText.astro**

```typescript
---
import { PortableText as PortableTextInternal } from 'astro-portabletext'
import CodeBlock from './CodeBlock.astro'
import SanityImage from './SanityImage.astro'
import YouTubeEmbed from './YouTubeEmbed.astro'
import InternalLink from './InternalLink.astro'

interface Props {
  value: any[]
}

const { value } = Astro.props

const components = {
  type: {
    code: CodeBlock,
    image: SanityImage,
    youtube: YouTubeEmbed,
  },
  mark: {
    internalLink: InternalLink,
  },
}
---

<PortableTextInternal value={value} components={components} />
```

Each component receives the block data as props. For the full API, see the astro-portabletext documentation.

## Using Astro's Image component

Astro's built-in `<Image>` component is designed for local assets and third-party image URLs that are not part of the Sanity pipeline. For images stored in Sanity, use `@sanity/image-url` instead: it handles format negotiation (AVIF/WebP), resizing, and cropping at the CDN level, with no build-time processing required. If you do use `<Image>` with Sanity CDN URLs, add the Sanity CDN to your allowed image domains:

**astro.config.mjs**

```typescript
// astro.config.mjs
export default defineConfig({
  image: {
    domains: ['cdn.sanity.io'],
  },
  // ...
})
```

Note that passing a Sanity CDN URL to Astro's `<Image>` component results in double-processing: Sanity's CDN applies its transforms first, then Astro's Sharp pipeline processes the result again at build time. This wastes build time and can degrade image quality.

### Example: code block component

**src/components/CodeBlock.astro**

```typescript
---
interface Props {
  node: {
    code: string
    language?: string
    filename?: string
  }
}

const { node } = Astro.props
---

{node.filename && <div class="code-filename">{node.filename}</div>}
<pre><code class={`language-${node.language || 'text'}`}>{node.code}</code></pre>
```

### Example: internal link annotation

**src/components/InternalLink.astro**

```typescript
---
interface Props {
  node: {
    slug?: { current: string }
    _type?: string
  }
}

const { node } = Astro.props
const href = node.slug ? `/${node.slug.current}` : '#'
---

<a href={href}><slot /></a>
```

## Querying images and rich text together

A typical query for a content page includes both image references and Portable Text body content:

**GROQ query**

```groq
*[_type == "post" && slug.current == $slug][0]{
  title,
  "mainImage": mainImage{
    asset->,
    alt,
    caption
  },
  body[]{
    ...,
    _type == "image" => {
      asset->,
      alt,
      caption
    }
  },
  "author": author->{
    name,
    "imageUrl": image.asset->url
  }
}
```

The `asset->` dereference on images gives you the full asset document, including dimensions and metadata. The `body[]{ ... }` projection with the conditional image expansion ensures inline images in Portable Text also include their asset data.



# Static and server rendering

Astro supports two output modes, and your choice affects how (and when) content from Sanity reaches your site.

## Static generation (default)

Astro renders all pages to HTML at build time. Every GROQ query runs once during the build, and the resulting HTML files are served as static assets.

**astro.config.mjs**

```typescript
import sanity from '@sanity/astro'
import { defineConfig } from 'astro/config'

export default defineConfig({
  integrations: [
    sanity({
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'production',
      apiVersion: '2026-03-01',
      useCdn: false, // Fetch fresh data at build time
    }),
  ],
})
```

Static generation is the right choice when content doesn't change frequently and you can afford a short delay between publishing and the site updating. This includes most blogs, marketing sites, portfolios, and documentation.

### Triggering rebuilds on content changes

Static sites need a rebuild to reflect content changes. The most common approach is a webhook that triggers your hosting platform's build process. Go to your project at sanity.io/manage, navigate to API > Webhooks, and create a webhook pointing to your hosting platform's build hook URL.

### Setting useCdn for static builds

Set `useCdn: false` for static builds. Since queries only run at build time, you want the freshest data from the Content Lake, not a potentially stale CDN cache.

## Server-side rendering

In server mode, pages render on every request. GROQ queries run at request time, so content changes appear on the next page load without a rebuild.

**astro.config.mjs**

```typescript
import sanity from '@sanity/astro'
import vercel from '@astrojs/vercel'
import { defineConfig } from 'astro/config'

export default defineConfig({
  output: 'server',
  adapter: vercel(),
  integrations: [
    sanity({
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'production',
      apiVersion: '2026-03-01',
      useCdn: false, // Required for draft content; set to true for published-only
    }),
  ],
})
```

Server rendering requires a deployment adapter that matches your hosting platform. Server rendering is necessary when you need Visual Editing, draft previews, personalized content, or frequent content updates. Set `useCdn: false` when using the `drafts` perspective for visual editing or previews. For pages that only serve published content, `useCdn: true` provides faster responses from the Sanity CDN.

## Mixing static and server-rendered pages

Both output modes support per-page overrides. In `output: "static"` (the default), all pages are prerendered at build time. To opt individual pages into server rendering, add `export const prerender = false` to the page's frontmatter. In `output: "server"`, all pages are server-rendered by default. To opt individual pages into static generation, add `export const prerender = true`. An adapter is required for any server-rendered pages, even in static mode.

**src/pages/preview/[slug].astro**

```typescript
---
export const prerender = false

import { loadQuery } from '../../lib/load-query'

const { data: post } = await loadQuery({
  query: `*[_type == "post" && slug.current == $slug][0]`,
  params: { slug: Astro.params.slug },
})
---
```

A common pattern for Astro + Sanity projects is static pages for blog posts and landing pages (content changes infrequently), with server-rendered pages for Visual Editing preview routes and API endpoints. This gives you the performance of static generation for the majority of your site, with server rendering only where you need it. Prior to Astro 5, this was handled by a separate `output: "hybrid"` mode, which has been removed.

## Adapters for server rendering

Server rendering requires a deployment adapter that matches your hosting platform:

- `@astrojs/vercel` for Vercel
- `@astrojs/netlify` for Netlify
- `@astrojs/cloudflare` for Cloudflare Workers/Pages
- `@astrojs/node` for self-hosted Node.js

See [Astro’s server adapter documentation](https://docs.astro.build/en/guides/on-demand-rendering/) for more options.

## Content freshness at a glance

In static mode, content is fetched at build time. There is no runtime dependency on the Sanity API, Visual Editing is not supported, any static host works, and performance is the fastest since pages are pre-rendered. In server mode, content is fetched at request time. Every request depends on the Sanity API, Visual Editing is supported, you need a platform with SSR support, and performance depends on CDN caching. Per-page overrides let you mix both approaches within a single output mode.

## Next steps

- If you're building a static site, set up build [functions](https://www.sanity.io/docs/functions/functions-introduction) or webhooks to keep content fresh.
- If you need Visual Editing, configure server rendering and follow the [Visual Editing guide](https://www.sanity.io/docs/visual-editing/astro-visual-editing).
- For most projects, static mode with per-page server rendering for preview routes offers the best balance of performance and flexibility. If most pages need server rendering (for example, a site heavily using Visual Editing), `output: "server"` is the simpler choice since it flips the default.



# Visual Editing with Astro

This guide walks through the specific wiring that makes Sanity's visual editing work with an Astro application. It allows for automatic content refresh on edit, perspective switching, and more flexibility at the expense of more complexity.

> [!TIP]
> If you’re looking for a more drop-in, but less featured visual editing implementation, check out the [Building a blog with Sanity and Astro guide](https://www.sanity.io/docs/developer-guides/sanity-astro-blog).

By the end, editors will be able to open the Presentation Tool in the Studio, see the frontend in a live preview, click on any text element to jump to the corresponding field, and see changes reflected after each edit.

**What you'll set up:**

- The `@sanity/astro` integration, which provides a pre-configured Sanity client with Content Source Map encoding.
- A custom `loadQuery` function that switches between published and draft content based on cookies.
- Cookie-based draft mode routes to toggle between published and draft content.
- The Presentation Tool with document-to-URL mapping.
- A custom `<SanityVisualEditing />` React component that powers click-to-edit overlays, browser history sync, and content refresh.

The guide assumes you already have document types defined in your Studio and pages that render them. The focus is purely on the integration layer: the files and configuration that connect the two apps.

> [!NOTE]
> Astro and the Live Content API
> Next.js integrations use the Live Content API (`defineLive` / `<SanityLive />`) for real-time re-rendering without page reloads. Astro does not have an equivalent. Instead, when an editor changes a field, the `<SanityVisualEditing />` component triggers a full page reload to fetch fresh content from the server. This is the standard approach for Astro and works well in practice.

## Prerequisites

- Node.js 20+.
- Astro 7 with `output: "server"`. Visual editing requires server-side rendering because draft mode depends on per-request cookie checking. Static output mode will not work.
- `@sanity/astro` v3.5.0 or later, `@astrojs/react` v6+, and `@astrojs/node` v11+.
- A Sanity project with a dataset. [Create one](https://www.sanity.io/manage) if you don't have one.
- [An API token](https://www.sanity.io/docs/content-lake/http-auth) with **Viewer** permissions for that project. Create one under **API** → **Tokens** in your project settings.
- `http://localhost:4321` added as a [CORS origin](https://www.sanity.io/docs/content-lake/browser-security-and-cors) with **Allow credentials** checked.

You can create a basic Astro app by following the [Astro quickstart](https://docs.astro.build/en/install-and-setup/). Then, navigate to the Astro project’s frontend and make sure you have the latest packages by running the following command:

**npm**

```shell
npm install @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

**pnpm**

```shell
pnpm add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

**yarn**

```shell
yarn add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

**bun**

```shell
bun add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
```

In this example, we’re separating the Studio from the Astro app. You can create a new Studio by running the following command in your project root:

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio
cd studio
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

## How the pieces fit together

Before diving into the code, here's what happens at runtime when an editor opens the Presentation Tool:

1. The Studio loads the Astro frontend inside an iframe. The URL it loads comes from the `initial` field in the Presentation Tool configuration.
2. The Studio hits the draft mode enable route on the frontend (`/api/draft-mode/enable`). This sets a cookie that activates draft mode in the iframe session.
3. With draft mode active, `loadQuery` returns strings with invisible characters embedded in them. These characters are Content Source Maps (called "stega") that encode which document and field each string came from, along with the Studio URL.
4. The `<SanityVisualEditing />` component (which only renders during draft mode) reads those encoded strings from the DOM and draws click-to-edit overlays on every text element.
5. When an editor clicks an overlay, the Studio navigates to that document and field.
6. When an editor changes a field, the `<SanityVisualEditing />` component's `refresh` callback triggers a full page reload. The page re-fetches from the server with the updated draft content.

> [!NOTE]
> Contracts between the two apps.
> If you change one side, check the other.
> - The Studio's `previewMode.enable` path (`/api/draft-mode/enable`) must match an actual API route in the Astro app.
> - The URLs returned by `resolve.ts` (e.g., `/post/${slug}`) must match actual page routes in `frontend/src/pages/`.
> - The `stega.studioUrl` in the `@sanity/astro` integration config must point to the running Studio.
> - The Sanity project must have the frontend's origin in its CORS settings with **Allow credentials** enabled.

## Environment variables

Set up environment variables for your Astro app (`frontend`) and Studio (`studio`).

**frontend/.env**

```sh
PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID
PUBLIC_SANITY_DATASET=YOUR_DATASET
SANITY_API_READ_TOKEN=YOUR_VIEWER_TOKEN
```

**studio/.env**

```text
SANITY_STUDIO_PROJECT_ID=YOUR_PROJECT_ID
SANITY_STUDIO_DATASET=YOUR_DATASET
SANITY_STUDIO_PREVIEW_URL=http://localhost:4321
```

`PUBLIC_SANITY_PROJECT_ID` and `PUBLIC_SANITY_DATASET` are public because the `@sanity/astro` integration needs them in `astro.config.mjs` (loaded via Vite's `loadEnv`).

`SANITY_API_READ_TOKEN` is server-only and never exposed to the client bundle. It's passed to `loadQuery` only when draft mode is active, to authenticate requests for draft content.

Note that [Studio environment variables](https://www.sanity.io/docs/studio/environment-variables) should always start with `SANITY_STUDIO`. However, it’s safe to hard-code the projectId, dataset, and preview URL in `sanity.config.ts` if you prefer.

## Studio setup

These files live in `studio/`. If you're setting up a new Studio from scratch, these examples use a blog schema with `post`, `author`, and `category` document types.

### Presentation Tool configuration

The Presentation Tool is a Studio plugin that renders your frontend inside an iframe and enables the visual editing workflow. Configure it in `sanity.config.ts`:

**studio/sanity.config.ts**

```typescript
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { presentationTool } from "sanity/presentation";
import { schema } from "./schemaTypes";
import { resolve } from "./lib/resolve";

export default defineConfig({
  projectId: process.env.SANITY_STUDIO_PROJECT_ID || 'YOUR_PROJECT_ID',
  dataset: process.env.SANITY_STUDIO_DATASET || 'production',
  plugins: [
    structureTool(),
    presentationTool({
      resolve,
      previewUrl: {
        initial:
          process.env.SANITY_STUDIO_PREVIEW_URL || "http://localhost:4321",
        previewMode: {
          enable: "/api/draft-mode/enable",
        },
      },
    }),
  ],
  schema,
});
```

The important fields here:

- **resolve**: This defines the document location resolver. You'll set this up in the next section.
- **previewUrl.initial**: The full URL of the Astro app. The Presentation Tool loads this in the iframe. When the Studio and frontend are separate apps (as they are here), this is required.
- **previewUrl.previewMode.enable**: The path (relative to `initial`) that the Studio calls to activate draft mode. The Studio makes a GET request to `http://localhost:4321/api/draft-mode/enable` with authentication parameters. This is what activates draft mode so the frontend returns draft content with stega encoding.

### Document locations

Document locations tell the Presentation Tool which frontend URLs correspond to which document types. This powers two things: when you select a document in the Studio, the iframe navigates to the right page; and documents show location badges linking to their frontend URLs.

**studio/lib/resolve.ts**

```typescript
import { defineLocations } from "sanity/presentation";
import type { PresentationPluginOptions } from "sanity/presentation";

export const resolve: PresentationPluginOptions["resolve"] = {
  locations: {
    // The key is the document type name from your schema
    post: defineLocations({
      select: {
        title: "title",
        slug: "slug.current",
      },
      resolve: (doc) => ({
        locations: [
          {
            title: doc?.title || "Untitled",
            href: `/post/${doc?.slug}`,
          },
          {
            title: "Home",
            href: "/",
          },
        ],
      }),
    }),
  },
};
```

`select` uses GROQ-like field paths to pull data from the document. `resolve` receives that data and returns an array of `{title, href}` objects. The first location is treated as the primary one. You can add multiple locations if a document appears on several pages (for example, a post appears on its own page and on the posts index).

### CORS

The Sanity project needs `http://localhost:4321` added as a CORS origin with **Allow credentials** enabled. If you already added this in the prerequisites, you're set. If not, add it in your project settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**, or add it with the CLI:

**npm**

```shell
npx sanity cors add http://localhost:4321 --credentials
```

**pnpm**

```shell
pnpm dlx sanity cors add http://localhost:4321 --credentials
```

**yarn**

```shell
yarn dlx sanity cors add http://localhost:4321 --credentials
```

**bun**

```shell
bunx sanity cors add http://localhost:4321 --credentials
```

For production, you'd add your deployed frontend URL as well.

## Astro setup

These files live in `frontend/`. The structure follows a standard Astro project with server-side rendering enabled.

### Astro configuration

**frontend/astro.config.mjs**

```typescript
import { defineConfig } from "astro/config";

import sanity from "@sanity/astro";
import react from "@astrojs/react";
import node from "@astrojs/node";

import { loadEnv } from "vite";
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
  process.env.NODE_ENV,
  process.cwd(),
  "",
);

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  integrations: [
    sanity({
      projectId: PUBLIC_SANITY_PROJECT_ID,
      dataset: PUBLIC_SANITY_DATASET,
      useCdn: false,
      apiVersion: "2026-03-01",
      stega: {
        studioUrl: "http://localhost:3333",
      },
    }),
    react(),
  ],
  vite: {
    optimizeDeps: {
      include: [
        "react/compiler-runtime",
        "lodash/isObject.js",
        "lodash/groupBy.js",
        "lodash/keyBy.js",
        "lodash/partition.js",
        "lodash/sortedIndex.js",
      ],
    },
  },
});
```

There's a lot here, so let's break it down:

- **output: "server"**: Enables server-side rendering. This is required because draft mode depends on reading cookies from each incoming request to decide whether to return published or draft content. Static builds can't do this.
- **adapter: node({ mode: "standalone" })**: The Node.js adapter runs the Astro app as a standalone server. You could also use other adapters (Vercel, Cloudflare, etc.) for deployment.
- **sanity({ ... })**: The `@sanity/astro` integration configures a Sanity client that's available throughout your app via the `sanity:client` virtual module. No manual `createClient` call needed.
- **stega.studioUrl**: When draft mode is active and stega encoding is enabled, this URL is embedded in the invisible characters so the overlay knows where to send the editor when they click. For production, point this to your deployed Studio URL.
- **useCdn: false**: Disabled because we need fresh data for draft content. In a production setup, you might conditionally enable it for published content.
- **react()**: Required because the visual editing overlay components (`SanityVisualEditing`, `DisableDraftMode`) are React components that run in the browser.
- **vite.optimizeDeps.include**: Pre-bundles certain dependencies that Vite's dev server would otherwise fail to optimize on the fly. Without these entries, you may see module resolution errors in development. `@sanity/astro` 3.5.0 and later pre-bundles a related set of modules automatically for its embedded Studio setup, but that set doesn't cover the modules listed here, so keep these entries.

### The Sanity client

Unlike Next.js where you create the client manually with `createClient`, the `@sanity/astro` integration provides a pre-configured client via a virtual module. To use it, add the type references in your env file:

**frontend/src/env.d.ts**

```typescript
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
```

The second line tells TypeScript about the `sanity:client` virtual module, which you can then import anywhere:

```typescript
import { sanityClient } from "sanity:client";
```

The client is automatically configured with the `projectId`, `dataset`, `apiVersion`, and `stega` settings from `astro.config.mjs`.

### Draft mode helper

Astro doesn't have a dedicated draftMode like Next.js, so we implement draft mode with cookies. This small helper reads the draft mode state from `Astro.cookies`:

**frontend/src/sanity/lib/draft-mode.ts**

```typescript
import type { AstroCookies } from "astro";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
export function getDraftModeProps(cookies: AstroCookies) {
  return {
    perspectiveCookie: cookies.get(perspectiveCookieName)?.value ?? undefined,
  };
}
```

This reads a client-writable cookie that is set by the draft mode enable route and kept up to date by the `<SanityVisualEditing />` component. It stores the editor's current perspective preference (e.g., a specific content release). The Presentation Tool then interacts with this when the editor switches perspectives in the Studio.

### Fetching data

This is the central piece that replaces Next.js's `defineLive` / `sanityFetch`. It's a custom `loadQuery` function that handles perspective switching, stega encoding, and source maps based on whether draft mode is active:

**frontend/src/sanity/lib/load-query.ts**

```typescript
import type { ClientPerspective, QueryParams } from "@sanity/client";
import { sanityClient } from "sanity:client";

const token = import.meta.env.SANITY_API_READ_TOKEN;

function parsePerspective(
  raw: string | undefined,
): ClientPerspective | undefined {
  if (!raw) return undefined;
  const decoded = decodeURIComponent(raw);
  if (decoded.startsWith("[")) {
    try {
      return JSON.parse(decoded) as ClientPerspective;
    } catch {
      return undefined;
    }
  }
  return decoded as ClientPerspective;
}

export async function loadQuery<QueryResponse>({
  query,
  params,
  perspectiveCookie = undefined,
}: {
  query: string;
  params?: QueryParams;
  perspectiveCookie?: string | undefined;
}) {
  const draftMode = perspectiveCookie ? true : false;
  if (draftMode && !token) {
    throw new Error(
      "The `SANITY_API_READ_TOKEN` environment variable is required during Visual Editing.",
    );
  }

  const perspective: ClientPerspective = draftMode
    ? (parsePerspective(perspectiveCookie) ?? "drafts")
    : "published";

  const { result, resultSourceMap } = await sanityClient.fetch<QueryResponse>(
    query,
    params ?? {},
    {
      filterResponse: false,
      perspective,
      resultSourceMap: draftMode ? "withKeyArraySelector" : false,
      stega: draftMode,
      ...(draftMode ? { token } : {}),
    },
  );

  return {
    data: result,
    sourceMap: resultSourceMap,
    perspective,
  };
}
```

The function handles two modes:

- **Published mode** (default): Uses the `"published"` perspective, no stega encoding, no source maps, no token. This is what visitors see.
- **Draft mode**: Uses the `"drafts"` perspective (or a custom perspective from the cookie for Content Releases), enables stega encoding and source maps with `withKeyArraySelector`, and authenticates with the API token.

The `parsePerspective` helper deserializes the perspective cookie, which can be either a simple string like `"drafts"` or a JSON-encoded array for Content Release stacks. This exact implementation isn’t required, but works with the rest of the code.

The `filterResponse: false` option tells the client to return both the query result and the source map, rather than just the result.

### GROQ queries

Queries are defined using `defineQuery` from the `groq` package, which enables TypeGen to generate result types. If you don’t have it already, add `groq` to your project dependencies:

**frontend/src/sanity/lib/queries.ts**

```typescript
import { defineQuery } from "groq";

export const POSTS_QUERY = defineQuery(
  `*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
    _id,
    title,
    "slug": slug.current,
    publishedAt
  }`,
);

export const POST_QUERY = defineQuery(
  `*[_type == "post" && slug.current == $slug][0]{
    _id,
    _type,
    title,
    "slug": slug.current,
    publishedAt,
    mainImage {
      asset->{ _id, url, metadata { lqip, dimensions } },
      alt,
      hotspot,
      crop
    },
    body[]{
      ...,
      _type == "image" => {
        ...,
        asset->{ _id, url, metadata { lqip, dimensions } },
        alt
      }
    },
    author->{ _id, name, "slug": slug.current },
    categories[]->{ _id, title }
  }`,
);
```

With [TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) configured in the Studio's `sanity.cli.ts`, running `sanity typegen generate` produces typed result types (`POSTS_QUERY_RESULT`, `POST_QUERY_RESULT`) in `frontend/sanity.types.ts`. These are used as generics with `loadQuery<POST_QUERY_RESULT>()` for type-safe data access.

### The layout

The shared layout conditionally renders visual editing components when draft mode is active:

**frontend/src/layouts/Layout.astro**

```html
---
import SanityVisualEditing from "../components/SanityVisualEditing";
import DisableDraftMode from "../components/DisableDraftMode";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";

const draftMode = Astro.cookies.has(perspectiveCookieName);
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>Astro Basics</title>
  </head>
  <body>
    <slot />
    {draftMode && <>
      <SanityVisualEditing client:only="react" />
      <DisableDraftMode client:only="react" />
    </>}
  </body>
</html>
```

Two components are doing the visual editing work here:

- **<SanityVisualEditing />:** scans the DOM for stega-encoded strings, decodes the Content Source Map data embedded in them (document ID, field path, Studio URL), and draws transparent overlays on top of each element. It also handles browser history synchronization with the Studio and triggers page reloads when content changes.
- **<DisableDraftMode />:** renders a floating button to exit draft mode, but only when the user is viewing the frontend directly (not inside the Presentation Tool's iframe).

The `client:only="react"` directive is critical. It tells Astro to render these components exclusively on the client side using React, with no server-side rendering attempt. This is necessary because both components use browser-only APIs (`window`, `document.cookie`, `postMessage`).

The `Astro.cookies.has(perspectiveCookieName)` check is the gate. Outside of draft mode, the page renders clean published content with no overlays and no invisible characters.

### The `SanityVisualEditing` component

This is the most complex Astro-specific piece. In Next.js, `next-sanity` provides a `<VisualEditing />` component that handles everything. In Astro, we need a custom component because Astro doesn't have a client-side router, and the built-in `@sanity/astro` visual editing component doesn't expose perspective change handling.

The component has three responsibilities: browser history synchronization, perspective cookie management, and content refresh.

**History synchronization:** The Presentation Tool needs to keep its URL bar in sync with the iframe. In a Next.js or React SPA, the router provides navigation events. Astro uses full page loads, so we monkey-patch `pushState` and `replaceState` to detect navigation, and listen for `popstate` and `hashchange` events.

When the Studio navigates (e.g., the editor selects a different document, and `resolve.ts` maps it to a new URL), the `update` callback calls `window.location.assign()` to trigger a full navigation. This is the key difference from SPA frameworks, where navigation would happen client-side without a page reload.

**Perspective cookie management:** When an editor switches perspectives in the Studio (e.g., viewing a Content Release), the component writes the new perspective to a cookie so the server can use it in `loadQuery`.

The `<VisualEditing />` component from `@sanity/visual-editing/react` does the heavy lifting of reading stega-encoded strings and drawing overlays. Our wrapper provides the Astro-specific adapters:

- **history**: Tells the Studio what URL the iframe is showing, and handles navigation requests from the Studio.
- **portal={true}**: Renders the overlay outside the normal DOM tree so it doesn't interfere with page layout.
- **onPerspectiveChange**: Writes the new perspective to a cookie and reloads the page so the server can fetch content with the new perspective.
- **refresh**: Called when the Studio detects a content change. Triggers a full page reload to get fresh server-rendered content.
- **keepStegaOnCopy**: Optional boolean prop, default false. When omitted, <VisualEditing /> intercepts copy events and strips stega encoding from both text/plain and text/html clipboard payloads, so users copying text from the preview page don't get invisible characters in their clipboard. Pass keepStegaOnCopy to disable this behavior and preserve stega in clipboard content.
- **onSuspiciousStega**: Optional callback prop (opt-in). Reports stega found in unsafe DOM placements: element attributes (class, id, href, src, style, data-*, etc.), inside <head> (title, meta[content], JSON-LD), in <script> or <style> text content, in textarea form values, or in the page URL. Each report includes the kind, element, attribute (if applicable), raw value, and cleaned value.

Pass a callback to onSuspiciousStega to audit your page for stega in unsafe positions. The callback receives an array of report objects, each describing where the stega was found and what it contained.

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

> [!WARNING]
> The onSuspiciousStega callback runs a full DOM audit using TreeWalker and MutationObserver and has a performance cost. Use it in development and debugging only. Do not enable it in production.

Here’s the full component. This isn’t the only way to approach this, but it allows the component to react to perspective changes passed to it by Studio’s Presentation Tool.

**frontend/src/components/SanityVisualEditing.tsx**

```tsx
import { useEffect, useMemo, useRef } from "react";
import {
  VisualEditing,
  type HistoryAdapter,
  type HistoryUpdate,
} from "@sanity/visual-editing/react";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
import type { ClientPerspective } from "@sanity/client";

function serializePerspective(perspective: ClientPerspective): string {
  return typeof perspective === "string"
    ? perspective
    : JSON.stringify(perspective);
}

function getCookie(name: string): string | undefined {
  const match = document.cookie.match(
    new RegExp(`(?:^|; )${name}=([^;]*)`),
  );
  return match ? decodeURIComponent(match[1]) : undefined;
}

function setPerspectiveCookie(perspective: ClientPerspective): boolean {
  const next = serializePerspective(perspective);
  const current = getCookie(perspectiveCookieName);
  if (current === next) return false;
  // Match the attributes the enable route set. Inside the Presentation Tool this
  // page runs in a cross-site iframe, so the cookie has to carry Partitioned or
  // Safari drops the rewrite. In this guide's architecture the Studio and the
  // frontend are on different domains, so being framed implies cross-site.
  const partitioned = window.self !== window.top ? "; Partitioned" : "";
  document.cookie = `${perspectiveCookieName}=${encodeURIComponent(next)}; path=/; SameSite=None; Secure${partitioned}`;
  return true;
}

function currentUrl() {
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}

function applyHistoryUpdate(
  update: Pick<HistoryUpdate, "type" | "url">,
  currentHref: string,
) {
  switch (update.type) {
    case "push":
      if (currentHref !== update.url) window.location.assign(update.url);
      return;
    case "replace":
      if (currentHref !== update.url) window.location.replace(update.url);
      return;
    case "pop":
      window.history.back();
      return;
  }
}

export default function SanityVisualEditing() {
  type Navigate = Parameters<HistoryAdapter["subscribe"]>[0];
  const navigateRef = useRef<Navigate | undefined>(undefined);
  const lastUrlRef = useRef("");

  useEffect(() => {
    const sync = () => {
      const url = currentUrl();
      if (url !== lastUrlRef.current) {
        lastUrlRef.current = url;
        navigateRef.current?.({ type: "push", title: document.title, url });
      }
    };

    sync();
    window.addEventListener("popstate", sync);
    window.addEventListener("hashchange", sync);

    const origPush = window.history.pushState;
    const origReplace = window.history.replaceState;
    window.history.pushState = function (...args) {
      origPush.apply(window.history, args);
      sync();
    };
    window.history.replaceState = function (...args) {
      origReplace.apply(window.history, args);
      sync();
    };

    return () => {
      window.removeEventListener("popstate", sync);
      window.removeEventListener("hashchange", sync);
      window.history.pushState = origPush;
      window.history.replaceState = origReplace;
    };
  }, []);

  const history = useMemo<HistoryAdapter>(
    () => ({
      subscribe: (navigate) => {
        navigateRef.current = navigate;
        const url = currentUrl();
        lastUrlRef.current = url;
        navigate({ type: "push", title: document.title, url });
        return () => {
          if (navigateRef.current === navigate) {
            navigateRef.current = undefined;
          }
        };
      },
      update: (update) => {
        applyHistoryUpdate(update, window.location.href);
      },
    }),
    [],
  );

  return (
    <VisualEditing
      history={history}
      portal={true}
      onPerspectiveChange={(perspective) => {
        if (setPerspectiveCookie(perspective)) {
          window.location.reload();
        }
      }}
      refresh={() => {
        return new Promise((resolve) => {
          window.location.reload();
          resolve();
        });
      }}
    />
  );
}

```

### Draft mode routes

These two routes are the bridge between the Studio and the frontend.

**Enable route:**

**frontend/src/pages/api/draft-mode/enable.ts**

```typescript
import type { APIRoute } from "astro";
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { perspectiveCookieName } from "@sanity/preview-url-secret/constants";
import { sanityClient } from "sanity:client";

export const GET: APIRoute = async ({ request, cookies, redirect }) => {
  const token = import.meta.env.SANITY_API_READ_TOKEN;

  if (!token) {
    return new Response("Server misconfigured: missing read token", {
      status: 500,
    });
  }

  const clientWithToken = sanityClient.withConfig({ token });
  const { isValid, redirectTo = "/", studioPreviewPerspective } = await validatePreviewUrl(
    clientWithToken,
    request.url,
  );

  if (!isValid) {
    return new Response("Invalid secret", { status: 401 });
  }

  // Safari blocks third-party cookies that aren't partitioned. When the
  // Presentation Tool loads this route inside a cross-site iframe, add the
  // CHIPS Partitioned attribute so Safari stores the cookie under the Studio's
  // partition. Top-level requests stay unpartitioned, so the disable route can
  // still clear them.
  const partitioned =
    request.headers.get("sec-fetch-dest") === "iframe" &&
    request.headers.get("sec-fetch-site") === "cross-site";

  cookies.set(perspectiveCookieName, studioPreviewPerspective ?? "drafts", {
    httpOnly: false,
    sameSite: "none",
    secure: true,
    path: "/",
    partitioned,
  });

  return redirect(redirectTo, 307);
};

```

When an editor opens the Presentation Tool, the Studio makes a GET request to this route with authentication parameters. `validatePreviewUrl` (from `@sanity/preview-url-secret`) handles the handshake: it verifies the request came from a legitimate Studio session by checking a shared secret stored in the dataset. If valid, we set the cookie to the perspective value and redirect to the requested page.

The cookie settings are important:

- **httpOnly**: `false` allows client-side JavaScript to read and modify the cookie. Confirm this is what you want in your implementation. For this guide, it enables the perspective-switching mechanism to function.
- **sameSite: "none"**: Required because the request comes from the Studio (a different origin) loading the frontend in an iframe.
- **secure:** `true` required when `sameSite` is `"none"`.
- **partitioned**: Adds the CHIPS `Partitioned` attribute when the request comes from a cross-site iframe. Safari blocks third-party cookies that aren't partitioned, so without it the cookie is dropped and draft mode never activates. Top-level requests stay unpartitioned, because a partitioned cookie can't be cleared by a same-site request.

In Next.js, `defineEnableDraftMode` from `next-sanity/draft-mode` wraps this logic. In Astro, we use `validatePreviewUrl` directly.

**Disable route:**

**frontend/src/pages/api/draft-mode/disable.ts**

```typescript
import type { APIRoute } from "astro";
import { perspectiveCookieName } from "@sanity/preview-url-secret/constants";

export const GET: APIRoute = async () => {
  // A partitioned cookie is only cleared by an expiring cookie that carries the
  // same Partitioned attribute, and cookies.delete() emits a single Set-Cookie
  // header per cookie name. Expire both variants directly instead, since either
  // may have been set depending on the browser and context.
  const expired = [
    `${perspectiveCookieName}=`,
    "Path=/",
    "Secure",
    "SameSite=None",
    "Max-Age=0",
  ];

  const headers = new Headers();
  headers.append("Set-Cookie", expired.join("; "));
  headers.append("Set-Cookie", [...expired, "Partitioned"].join("; "));
  headers.set("Location", "/");

  return new Response(null, { status: 307, headers });
};
```

This clears the cookie and redirects to the homepage. It's called by the "Disable Draft Mode" button.

### The "Disable Draft Mode" button

**frontend/src/components/DisableDraftMode.tsx**

```tsx
import { useIsPresentationTool } from "@sanity/visual-editing/react";

export default function DisableDraftMode() {
  const isPresentationTool = useIsPresentationTool();

  // null = still detecting, true = inside Presentation tool
  if (isPresentationTool !== false) return null;

  return (
    <a
      href="/api/draft-mode/disable"
      style={{
        position: "fixed",
        bottom: "1rem",
        right: "1rem",
        zIndex: 50,
        padding: "0.5rem 1rem",
        borderRadius: "9999px",
        backgroundColor: "#101112",
        color: "#fff",
        fontSize: "0.875rem",
        textDecoration: "none",
      }}
    >
      Disable Draft Mode
    </a>
  );
}
```

This component renders a floating button to exit draft mode, but only when the user is viewing the frontend directly in a browser tab (not inside the Presentation Tool's iframe). Inside the Presentation Tool, the Studio controls draft mode, so the button would be redundant.

`useIsPresentationTool` returns `true` when the frontend is loaded inside a Presentation Tool iframe and `false` when it's loaded directly in a browser tab. It returns `null` while detection is in progress, and may briefly return `false` before the Studio connection is established.

### Fetching data in pages

With all the infrastructure in place, fetching data in page components is straightforward. The pattern is the same on every page: call `loadQuery` with the query and spread `getDraftModeProps(Astro.cookies)`.

**frontend/src/pages/index.astro**

```html
---
import type { POSTS_QUERY_RESULT } from "../../sanity.types";
import { POSTS_QUERY } from "../sanity/lib/queries";
import { loadQuery } from "../sanity/lib/load-query";
import { getDraftModeProps } from "../sanity/lib/draft-mode";
import Layout from "../layouts/Layout.astro";

const { data: posts } = await loadQuery<POSTS_QUERY_RESULT>({
  query: POSTS_QUERY,
  ...getDraftModeProps(Astro.cookies),
});
---

<Layout>
  <h1>Posts</h1>
  <ul>
    {posts.map((post) => (
      <li>
        <a href={`/post/${post.slug}`}>{post.title}</a>
      </li>
    ))}
  </ul>
</Layout>
```

**frontend/src/pages/post/[slug].astro**

```html
---
import type { POST_QUERY_RESULT } from "../../../sanity.types";
import { POST_QUERY } from "../../sanity/lib/queries";
import { loadQuery } from "../../sanity/lib/load-query";
import { getDraftModeProps } from "../../sanity/lib/draft-mode";
import Layout from "../../layouts/Layout.astro";
import PortableText from "../../components/PortableText.astro";

const { params } = Astro;

const { data: post } = await loadQuery<POST_QUERY_RESULT>({
  query: POST_QUERY,
  params,
  ...getDraftModeProps(Astro.cookies),
});

if (!post) {
  return new Response(null, { status: 404 });
}
---

<Layout>
  <h1>A post about {post.title}</h1>
  <PortableText portableText={post.body} />
</Layout>
```

The post page renders its body with a small `PortableText.astro` wrapper component built on `astro-portabletext`, which you installed in the prerequisites:

**frontend/src/components/PortableText.astro**

```html
---
import { PortableText as PortableTextRenderer } from "astro-portabletext";

const { portableText } = Astro.props;
---

<PortableTextRenderer value={portableText} />
```

> [!NOTE]
> **Note:** If you render queried content in `<title>` tags or `<meta>` descriptions, stega characters will be present during draft mode. This is harmless for editors (the characters are invisible), but if you want clean metadata even in draft mode, use `stegaClean()` from `@sanity/client/stega`.

## Run both apps

With everything set up, run both apps to test. In separate terminal windows:

**npm**

```shell
# Terminal 1: Start the Studio
cd studio
npm run dev
# Runs on http://localhost:3333
```

**pnpm**

```shell
# Terminal 1: Start the Studio
cd studio
pnpm run dev
# Runs on http://localhost:3333
```

**yarn**

```shell
# Terminal 1: Start the Studio
cd studio
yarn run dev
# Runs on http://localhost:3333
```

**bun**

```shell
# Terminal 1: Start the Studio
cd studio
bun run dev
# Runs on http://localhost:3333
```

**npm**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
npm run dev
# Runs on http://localhost:4321
```

**pnpm**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
pnpm run dev
# Runs on http://localhost:4321
```

**yarn**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
yarn run dev
# Runs on http://localhost:4321
```

**bun**

```shell
# Terminal 2: Start the Astro frontend
cd frontend
bun run dev
# Runs on http://localhost:4321
```

Open `http://localhost:3333` in your browser and navigate to the Presentation Tool. You should see the Astro frontend loaded in the iframe with click-to-edit overlays on text elements.

## The full flow

Now that you've seen every file, here's the complete sequence when an editor uses visual editing. This is the same flow described in "How the pieces fit together," but now you can trace each step back to the specific file that handles it:

1. The editor opens the **Presentation Tool** in the Studio (`studio/sanity.config.ts`).
2. The Studio loads `http://localhost:4321` (the `initial` URL) in an iframe and uses `studio/lib/resolve.ts` to map the current document to a frontend URL.
3. The Studio hits `http://localhost:4321/api/draft-mode/enable` with authentication parameters (`frontend/src/pages/api/draft-mode/enable.ts`).
4. The enable route validates the secret via `validatePreviewUrl`, sets the cookie, and redirects to the requested page.
5. The page re-renders. `getDraftModeProps` (`frontend/src/sanity/lib/draft-mode.ts`) reads the cookie and passes the value to `loadQuery` (`frontend/src/sanity/lib/load-query.ts`). `loadQuery` fetches draft content with **stega-encoded strings**: each string value has invisible characters that encode the document ID, field path, and Studio URL (configured in `frontend/astro.config.mjs`).
6. `<SanityVisualEditing />` (`frontend/src/components/SanityVisualEditing.tsx`, mounted via `frontend/src/layouts/Layout.astro` only during draft mode) reads the DOM, finds the stega-encoded strings, and renders transparent **click-to-edit overlays** on each text element.
7. The editor clicks an overlay. The overlay sends a `postMessage` to the parent Studio window with the document ID and field path. The Studio navigates to that field.
8. The editor changes a field. The mutation propagates through the Content Lake. The `refresh` callback on `<SanityVisualEditing />` fires, triggering `window.location.reload()`. The page re-fetches from the server with the updated draft content.

## Next steps

- **Deploy to production:** Update `stega.studioUrl` in `astro.config.mjs`, the Presentation Tool `initial` URL in `studio/sanity.config.ts`, and your CORS origins to point to your deployed URLs instead of `localhost`. It's common to use environment variables for these values with local fallbacks. Make sure the cookie values meet your security standards.
- **Add more document types to `resolve.ts`:** Any document type that has a corresponding frontend route can get visual editing. Add entries to the `locations` object for each type.

## Troubleshooting

### Overlays appear but clicking does nothing

**Cause:** `stega.studioUrl` is missing from the `@sanity/astro` integration config in `frontend/astro.config.mjs`.

**Fix:** Add `stega: { studioUrl: 'http://localhost:3333' }` to the `sanity()` integration options.

### Presentation Tool shows a blank iframe

**Cause:** `initial` is missing from the Presentation Tool config in `studio/sanity.config.ts`. This only happens when the Studio and frontend run as separate apps.

**Fix:** Add `initial: 'http://localhost:4321'` to `previewUrl` in the `presentationTool()` config.

### Live preview doesn't update, 403 errors in browser console

**Cause:** The frontend's origin is missing from the Sanity project's CORS settings, so the browser can't reach the Content Lake.

**Fix:** Add `http://localhost:4321` (with **Allow credentials** checked) in your project's CORS settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**.

### String comparisons fail in draft mode

**Cause:** Stega encoding adds invisible characters to string values. An equality check like `align === 'center'` returns `false` even when the visible value is `"center"` because the encoded string contains extra characters.

**Fix:** Use `stegaClean()` to strip the encoding before comparing:

```typescript
import { stegaClean } from "@sanity/client/stega";

const cleanAlign = stegaClean(align);
if (cleanAlign === "center") {
  // ...
}
```

### Module resolution errors in development

**Cause:** Vite's dev server fails to pre-bundle certain dependencies used by `@sanity/visual-editing` and its transitive imports.

**Fix:** Add the problematic modules to `vite.optimizeDeps.include` in `astro.config.mjs`:

```javascript
vite: {
  optimizeDeps: {
    include: [
      "react/compiler-runtime",
      "lodash/isObject.js",
      "lodash/groupBy.js",
      "lodash/keyBy.js",
      "lodash/partition.js",
      "lodash/sortedIndex.js",
    ],
  },
},
```

Note that `@sanity/astro` 3.5.0 and later pre-bundles a related set of modules automatically in dev, but it doesn't include the modules listed here, so these entries are still required.

### Draft mode not activating

**Cause:** The browser blocks the cookie because it requires `SameSite=None; Secure`, which in turn requires HTTPS (or localhost).

**Fix:** Ensure you're accessing the frontend via `localhost` (not an IP address or custom domain) during development. For deployed environments, ensure HTTPS is enabled.

### Draft mode works in Chrome but not Safari

**Cause:** Safari blocks third-party cookies that aren't partitioned. The Studio loads your frontend in a cross-site iframe, so the cookie set by `/api/draft-mode/enable` counts as third-party and never gets stored. The Presentation Tool reports "Unable to connect to visual editing" and draft mode never activates. Chrome is more permissive, which is why the same setup works there.

**Fix:** Set the CHIPS `Partitioned` attribute on the cookie when the enable route is hit from a cross-site iframe, and expire both the partitioned and unpartitioned variants in the disable route. Both route examples above do this. The client-side perspective rewrite in `SanityVisualEditing.tsx` needs the same attribute.

### Page titles contain garbled text in draft mode

**Cause:** If you render queried content in `<title>` or `<meta>` tags, stega characters will be embedded in them.

**Fix:** Use `stegaClean()` from `@sanity/client/stega` to strip encoding before inserting into metadata:

```typescript
import { stegaClean } from "@sanity/client/stega";
// In your .astro frontmatter:
const cleanTitle = stegaClean(post.title);
```

Then use `cleanTitle` in the `<title>` tag.

## Reference

### Key packages

- `sanity` (6.x): Sanity Studio
- `astro` (7.x): Astro framework
- `@sanity/astro` (3.5+): Sanity integration for Astro (client, stega config)
- `@astrojs/react` (6.x): React support for client-side components
- `@astrojs/node` (11.x): Node.js server adapter
- `@sanity/visual-editing` (5.x): Visual editing overlays and hooks
- `@sanity/preview-url-secret` (latest): Preview URL validation for draft mode
- `groq`: `defineQuery` for typed GROQ queries
- `@sanity/image-url` (2.1.x): Image URL generation
- `astro-portabletext` (0.x): Portable Text rendering for Astro

### File map

Every file involved in the visual editing integration, what it does, and what it depends on:

- `studio/sanity.config.ts` (Configures the Presentation Tool with the frontend's initial URL and `previewMode.enable` path): `studio/lib/resolve.ts`
- `studio/lib/resolve.ts` (Maps document types to frontend URLs for iframe navigation and location badges): Schema type names, frontend route structure in `src/pages/`
- `frontend/astro.config.mjs` (Astro config: SSR, `@sanity/astro` integration with `stega.studioUrl`, React, Vite optimizeDeps): `PUBLIC_SANITY_PROJECT_ID`, `PUBLIC_SANITY_DATASET`
- `frontend/src/env.d.ts` (Triple-slash references for `astro/client` and `@sanity/astro/module` type definitions): Nothing
- `frontend/src/sanity/lib/draft-mode.ts` (Reads draft mode and perspective cookies from `Astro.cookies`): Nothing
- `frontend/src/sanity/lib/load-query.ts` (Fetches content with perspective/stega switching based on draft mode): `sanity:client`, `SANITY_API_READ_TOKEN`
- `frontend/src/sanity/lib/queries.ts` (Centralized GROQ queries wrapped in `defineQuery`): `groq`
- `frontend/src/components/SanityVisualEditing.tsx` (History adapter, perspective cookie sync, content refresh via page reload): `@sanity/visual-editing/react`
- `frontend/src/components/DisableDraftMode.tsx` (Floating button to exit draft mode, hidden when inside the Presentation Tool): `@sanity/visual-editing/react`
- `frontend/src/components/PortableText.astro` (Renders Portable Text content using `astro-portabletext`): `astro-portabletext`
- `frontend/src/layouts/Layout.astro` (Shared layout: conditional visual editing components in draft mode): `SanityVisualEditing.tsx`, `DisableDraftMode.tsx`
- `frontend/src/pages/api/draft-mode/enable.ts` (Validates Presentation Tool secret, sets the perspective cookie): `sanity:client`, `@sanity/preview-url-secret`, `SANITY_API_READ_TOKEN`
- `frontend/src/pages/api/draft-mode/disable.ts` (Clears cookies, redirects to homepage): Nothing



# Sanity CLI

#### Common commands

[Init CLI command reference](https://www.sanity.io/docs/cli-reference/init)
Initialize a new Sanity project or plugin

[Dev CLI command reference](https://www.sanity.io/docs/cli-reference/dev)
Starts a development server for the Sanity Studio

[Docs CLI command reference](https://www.sanity.io/docs/cli-reference/docs)
Browse, read, and search the Sanity documentation from the CLI.

[Deploy CLI command reference](https://www.sanity.io/docs/cli-reference/deploy)
Deploys a statically built Sanity studio

[TypeGen CLI command reference](https://www.sanity.io/docs/cli-reference/cli-typegen)
Generate TypeScript type definition from a Studio schema and GROQ queries

[Schemas CLI command reference](https://www.sanity.io/docs/cli-reference/cli-schemas)
List, validate, extract, and deploy schema.



# Configuration

The `sanity` Command Line Interface (CLI) is a handy tool for managing your Sanity projects in your terminal. Note that there are some commands that can only be run in a project folder and global ones.

[Learn more about the Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli)
Learn how to set up and configure the Sanity CLI

## Configuration file

The Sanity CLI can read configuration from a `sanity.cli.js` (`.ts`) file in the same folder that the command is run in. It will fall back on the configuration in the `sanity.config.ts` file.

Use `defineCliConfig` from `sanity/cli` to configure the CLI with TypeScript type-checking:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: '<YOUR_PROJECT_ID>',
    dataset: '<YOUR_DATASET>',
  },
  server: {
    hostname: 'localhost',
    port: 3333,
  },
})
```

See the properties table below for all available options.

#### Properties

**api** (CliApiConfig)

Defines the projectId and dataset that the CLI should connect to and run its commands on.

**deployment** ({ appId?: string, autoUpdates?: boolean})

appId: The ID of your studio or app. Generated when deploying your studio or app for the first time.

autoUpdates: Enable auto-updates for studios.

**graphql** (GraphQLAPIConfig[])

Defines the GraphQL APIs that the CLI can deploy and interact with.

**mediaLibrary** ({ aspectsPath?: string })

aspectsPath: The path to the Media Library aspects directory. When using the CLI to manage aspects, this is the directory they will be read from and written to.

**project** ({ basePath?: string })

Contains the property basePath which lets you change the top-level slug for the Studio. You typically need to set this if you embed the Studio in another application where it is one of many routes. Defaults to an empty string.

**reactCompiler** (boolean | ReactCompilerConfig)

Allows customization of the underlying React compiler config.

**reactStrictMode** (boolean)

Wraps the Studio in <React.StrictMode> root to aid in flagging potential problems related to concurrent features (startTransition, useTransition, useDeferredValue, Suspense). Defaults to true in development. To opt out, set reactStrictMode: false. Can also be controlled by setting SANITY_STUDIO_REACT_STRICT_MODE="true"|"false".

**server** ({ hostname?: string, port?: number })

Defines the hostname and port that the development server should run on. hostname defaults to localhost, and port to 3333.

**vite** (any)

Exposes the default Vite configuration for the Studio so it can be changed and extended.

**typegen** (TypeGenConfig)

Configures automatic TypeScript type generation during sanity dev and sanity build. Properties include enabled, path, generates, and overloadClientMethods. See Sanity TypeGen for details.

**schemaExtraction** (Object)

Configures automatic schema extraction during sanity dev and sanity build. Properties: enabled, path, enforceRequiredFields, watchPatterns, and workspace.

**app** (AppConfig)

Configuration for App SDK applications. Properties: organizationId (required), entry (default: './src/App.tsx'), and visibility (Dashboard visibility; default or unlisted; defaults to default).

> [!WARNING]
> Gotcha
> If you run `sanity --help` outside a folder with a project configuration file and without a specified `projectId` flag, you will only see the subset of commands that aren't project-specific.

## GraphQLAPIConfig

#### Properties

**id** (string)

ID of GraphQL API. Only (currently) required when using the --api flag for sanity graphql deploy, in order to only deploy a specific API.

**workspace** (string)

Name of workspace containing the schema to deploy

Optional, defaults to default (e.g., the one used if no name is defined).

**source** (string)

Name of source containing the schema to deploy, within the configured workspace

Optional, defaults to default (e.g., the one used if no name is defined).

**tag** (string)

API tag for this API. Allows deploying multiple different APIs to a single dataset.

Optional, defaults to default

**playground** (boolean)

Whether or not to deploy a "GraphQL Playground" to the API URL. This is an HTML interface that allows running queries and introspecting the schema from the browser. Note that this interface is not secured in any way, but as the schema definition and API route is generally open, this does not expose any more information than is otherwise available. It only makes it more discoverable.
Optional, defaults to true.

**generation** ('gen3' | 'gen2' | 'gen1')

Generation of API to auto-generate from schema. New APIs should use the latest (gen3).

Optional, defaults to gen3

**nonNullDocumentFields** (boolean)

Define document interface fields (_id, _type, etc.) as non-nullable. If you never use a document type as an object (within other documents) in your schemas, you can (and probably should) set this to true. Because a document type could be used inside other documents, it is by default set to false, as in these cases these fields can be null.

Optional, defaults to false

**filterSuffix** (string)

Suffix to use for generated filter types.

Optional, defaults to Filter.

## Commands

```text
USAGE
  $ npx sanity [COMMAND]

TOPICS
  api            Make an authenticated HTTP request to a Sanity API
  backups        Manage dataset backups
  blueprints     Local Blueprint and remote Stack management commands
  cors           Manage CORS origins for your project
  datasets       Manage datasets in your project
  docs           Browse and search Sanity documentation
  documents      Manage documents in a dataset
  functions      Sanity Function development and management commands
  graphql        Manage GraphQL APIs for your project
  hooks          Manage webhooks for your project
  manifest       Extract studio configuration as JSON manifests
  mcp            Configure Sanity MCP server for AI agents
  media          Manage media assets and aspect definitions
  migrations     Run and manage content migrations
  openapi        Manage OpenAPI specifications
  organizations  Manage your organizations
  projects       Manage Sanity projects
  schemas        Manage and validate schemas
  skills         Install Sanity agent skills for AI agents
  telemetry      Manage telemetry consent
  tokens         Manage API tokens for your project
  typegen        Generate TypeScript types for schema and GROQ
  users          Manage project users and invitations

COMMANDS
  api       Make an authenticated HTTP request to a Sanity API
  build     Build Sanity Studio into a static bundle
  codemod   Updates Sanity Studio codebase with a code modification script
  debug     Print diagnostic info for troubleshooting
  deploy    Builds and deploys Sanity Studio or application to Sanity hosting
  dev       Start a local development server with live reloading
  doctor    Run diagnostics on your Sanity project
  exec      Executes a script within the Sanity Studio context
  help      Display help for sanity.
  init      Initialize a new Sanity Studio, project and/or app
  install   Install dependencies for the Sanity Studio project
  learn     Open Sanity Learn in your browser
  login     Log in to your Sanity account
  logout    Log out of the current session
  manage    Open project settings in your browser
  preview   Start a local server to preview a production build
  undeploy  Removes the deployed Sanity Studio/App from Sanity hosting
  versions  Show installed package versions

```

> [!NOTE]
> CLI option flag order
> For commands with option flags, add the option flag to the end after any arguments. When adding option flags to both commands and subcommands, make sure the command flags are before the subcommand. For example: 
> `sanity COMMAND [args] [--command-flags] SUBCOMMAND [args] --[subcommand-flags]`
> You can always run `sanity COMMAND --help` for usage tips and examples.

## Changing <hostname>.sanity.studio

To change the host name of your Sanity-hosted Studio (e.g., `https://<oldHostName>.sanity.studio` to `https://<newHostName>.sanity.studio`), please see [Undeploying the Studio](https://www.sanity.io/docs/studio/deployment).

## Debugging `sanity` commands

Not to be confused with [sanity debug](https://www.sanity.io/docs/cli-reference/debug), which returns information about your Sanity environment, you can use the `DEBUG` environment variable with your `sanity` commands to get more verbose results and troubleshoot potential issues.

For full debugger results, use a wildcard on its own (`DEBUG=* sanity <command>`). For more targeted results, you can specify a namespace followed by a wildcard (`DEBUG=sanity* sanity <command>` or `DEBUG=sanity:cli* sanity <command>`).

> [!NOTE]
> Example
> Least verbose
> `sanity dataset import production.tar.gz dev`
> More verbose, returning all debuggers in the `sanity` namespace
> `DEBUG=sanity* sanity dataset import production.tar.gz dev`
> Most verbose, returning **all** debuggers
> `DEBUG=* sanity dataset import production.tar.gz dev`

Results can also be excluded by using a `-` prefix. `DEBUG=sanity*,-sanity:export* sanity dataset export production production.tar.gz` would return all debuggers in the `sanity` namespace except for `sanity:export` debuggers (e.g., `sanity:cli` and `sanity:client`) during export of the `production` dataset.

## Authorizing the CLI

In most cases, you'll use `sanity login` to authenticate with the Sanity API. When you need to run the CLI unattended, like in a CI/CD environment, set the `SANITY_AUTH_TOKEN` environment variable to a token. You can generate tokens in the [project management dashboard](https://sanity.io/manage).



# API

**CLI output**

```sh
USAGE
  $ sanity api ENDPOINT [-d <name>] [-f <key=value>] [-F <key=value>] [-H <key:value>] [--include] [-p <id>] [-t <token>] [-X <method>] [--anonymous] [--api-version <version>] [--global] [--input <file>] [--pretty] [--project-hosted]

ARGUMENTS
  ENDPOINT  API path (eg "projects" or "data/query/{dataset}"), optionally with placeholders, or a full https://*.api.sanity.io URL

FLAGS
  -f, --raw-field=<key=value>  Add a string parameter (key=value)
  -F, --field=<key=value>      Add a typed parameter (key=value): true/false/null and numbers are converted, @file reads the value from a file, @- from stdin
  -H, --header=<key:value>     Add an HTTP request header (key: value)
  -i, --include                Include the HTTP response status and headers in the output
  -t, --token=<token>          API token to authenticate with, instead of the logged-in user token
  -X, --method=<method>        HTTP method to use (default GET, or POST when fields or --input are provided)
      --anonymous              Send the request without an authorization token
      --api-version=<version>  API version to use (eg v2025-02-19). Defaults to a version embedded in the endpoint path, or the version from the matching OpenAPI spec
      --global                 Force the request to the global API host (api.sanity.io)
      --input=<file>           Read the raw request body from a file (use "-" for stdin). Sent without a default Content-Type - provide one with -H when the API requires it
      --pretty                 Colorize JSON output
      --project-hosted         Force the request to the project API host (<projectId>.api.sanity.io)

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset for {dataset} placeholders (overrides CLI configuration)
  -p, --project-id=<id>  Project ID for {projectId} placeholders and project-hosted APIs (overrides CLI configuration)

DESCRIPTION
  Make an authenticated HTTP request to a Sanity API
  
  The endpoint argument is an API path as documented in the published OpenAPI
  specifications - list them with "sanity openapi list" and inspect one with
  "sanity openapi get <slug>". Paths can be copied verbatim from the specs:
  {projectId} and {dataset} placeholders are filled in from flags or the CLI
  configuration, and the API host (api.sanity.io or <projectId>.api.sanity.io)
  is chosen based on the specs' routing information.
  
  The default request method is GET, or POST when fields or --input are
  provided. For GET/HEAD requests, fields are sent as query parameters;
  otherwise they are combined into a JSON request body sent with
  "Content-Type: application/json". Raw --input bodies are sent without a
  default Content-Type - provide one with -H when the API requires it. The
  response body is written to stdout.
  
  Requests are authenticated with the token from "sanity login". To use a
  specific token instead - for example in CI or when the CLI is not logged in
  - pass --token or set the SANITY_AUTH_TOKEN environment variable. Pass
  --anonymous to send no token at all.

EXAMPLES
  Get the current user

    $ sanity api users/me

  Get the current project (placeholder filled from CLI config)

    $ sanity api projects/{projectId}

  Run a GROQ query against the project host

    $ sanity api 'data/query/{dataset}' -f query='*[_type == "movie"][0..2]'

  Send a JSON body built from typed fields

    $ sanity api projects/{projectId} -X PATCH -F displayName="My project"

  Send a raw request body from stdin

    $ echo '{"mutations": []}' | sanity api 'data/mutate/{dataset}' --input - -H 'Content-Type: application/json'

  Include the response status and headers, pinning the API version

    $ sanity api jobs/123 --include --api-version v2025-02-19

  Authenticate with a specific token instead of the logged-in session

    $ SANITY_AUTH_TOKEN=<token> sanity api users/me
```



# Assets

**npm**

```shell
npx sanity assets --help
```

**pnpm**

```shell
pnpm dlx sanity assets --help
```

**yarn**

```shell
yarn dlx sanity assets --help
```

**bun**

```shell
bunx sanity assets --help
```

## Commands

### `upload`

**CLI output**

```sh
USAGE
  $ sanity assets upload [-d <name>] [-p <id>] [--content-type <mime-type>] --file <path> [--filename <filename>] [--type <value>]

FLAGS
      --content-type=<mime-type>  MIME type of the asset, such as image/png or application/pdf
      --file=<path>               Path to the local file to upload
      --filename=<filename>       Original filename stored on the asset document. Defaults to the local filename
      --type=<value>              Asset type to create

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset to upload the asset to (overrides CLI configuration)
  -p, --project-id=<id>  Project ID to upload the asset to (overrides CLI configuration)

DESCRIPTION
  Upload one local image or file to a Sanity dataset and print the asset document as JSON

EXAMPLES
  Upload an image using the configured project

    $ sanity assets upload --file ./hero.png --type image --dataset production

  Upload a file with explicit project, dataset, and MIME type

    $ sanity assets upload --file ./brief.pdf --type file --content-type application/pdf --project-id abc123 --dataset production
```



# Backups

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

This is reference documentation for the CLI's backups command. If you're new to the backup feature, check out our getting started guide.
[Get started](https://www.sanity.io/docs/content-lake/backups)

**npm**

```shell
npx sanity backups --help
```

**pnpm**

```shell
pnpm dlx sanity backups --help
```

**yarn**

```shell
yarn dlx sanity backups --help
```

**bun**

```shell
bunx sanity backups --help
```

## Commands

### `disable`

**CLI output**

```sh
USAGE
  $ sanity backups disable [DATASET] [-p <id>]

ARGUMENTS
  [DATASET]  Dataset name to disable backup for

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to disable backups for (overrides CLI configuration)

DESCRIPTION
  Disable backup for a dataset

EXAMPLES
  Interactively disable backup for a dataset

    $ sanity backups disable

  Disable backup for the production dataset

    $ sanity backups disable production
```

### `download`

**CLI output**

```sh
USAGE
  $ sanity backups download [DATASET] [-p <id>] [--backup-id <value>] [--concurrency <value>] [--out <value>] [--overwrite]

ARGUMENTS
  [DATASET]  Dataset name to download backup from

FLAGS
      --backup-id=<value>    The backup ID to download
      --concurrency=<value>  Concurrent number of backup item downloads (max: 24)
      --out=<value>          The file or directory path the backup should download to
      --overwrite            Allows overwriting of existing backup file

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to download backup from (overrides CLI configuration)

DESCRIPTION
  Download a dataset backup to a local file

EXAMPLES
  Interactively download a backup

    $ sanity backups download

  Download a specific backup for the production dataset

    $ sanity backups download production --backup-id 2024-01-01-backup-1

  Download backup to a specific file

    $ sanity backups download production --backup-id 2024-01-01-backup-2 --out /path/to/file

  Download backup and overwrite existing file

    $ sanity backups download production --backup-id 2024-01-01-backup-3 --out /path/to/file --overwrite
```

### `enable`

**CLI output**

```sh
USAGE
  $ sanity backups enable [DATASET] [-p <id>]

ARGUMENTS
  [DATASET]  Dataset name to enable backup for

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to enable backups for (overrides CLI configuration)

DESCRIPTION
  Enable backup for a dataset

EXAMPLES
  Interactively enable backup for a dataset

    $ sanity backups enable

  Enable backup for the production dataset

    $ sanity backups enable production
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity backups list [DATASET] [-l <value>] [-p <id>] [--after <value>] [--before <value>]

ARGUMENTS
  [DATASET]  Dataset name to list backups for

FLAGS
  -l, --limit=<value>   Maximum number of backups returned
      --after=<value>   Only return backups after this date (inclusive, YYYY-MM-DD format)
      --before=<value>  Only return backups before this date (exclusive, YYYY-MM-DD format)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list backups for (overrides CLI configuration)

DESCRIPTION
  List available backups for a dataset

EXAMPLES
  List backups for a dataset interactively

    $ sanity backups list

  List backups for the production dataset

    $ sanity backups list production

  List up to 50 backups for the production dataset

    $ sanity backups list production --limit 50

  List up to 10 backups created after 2024-01-31

    $ sanity backups list production --after 2024-01-31 --limit 10
```



# Blueprints

The `blueprints` CLI command enables initializing, managing, and deploying Blueprints and resources like Functions.

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

[Functions introduction](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

**npm**

```shell
npx sanity blueprints --help
```

**pnpm**

```shell
pnpm dlx sanity blueprints --help
```

**yarn**

```shell
yarn dlx sanity blueprints --help
```

**bun**

```shell
bunx sanity blueprints --help
```

## Commands

### `add`

**CLI output**

```sh
USAGE
  $ sanity blueprints add TYPE [--install] [-n <value>] [--example <value>] [--fn-helpers] [--fn-installer <value>] [--fn-type <value>] [--javascript] [--json] [--language <value>]

ARGUMENTS
  TYPE  Type of resource to add (only "function" is supported)

FLAGS
  -i, --install               Shortcut for --fn-installer npm
  -n, --name=<value>          Name of the resource to add
      --example=<value>       Example to use for the function resource. Discover examples at https://www.sanity.io/exchange/type=recipes/by=sanity
      --fn-helpers            Add helpers to the new function
      --fn-installer=<value>  Which package manager to use when installing the @sanity/functions helpers
      --fn-type=<value>       Document change event(s) that should trigger the function; you can specify multiple events by specifying this flag multiple times
      --javascript            Use JavaScript instead of TypeScript
      --json                  Format output as json
      --language=<value>      Language of the new function

DESCRIPTION
  This command is deprecated. Use "functions add" instead.
  
  Equivalent usage:
    $ <%= config.bin %> functions add
    $ <%= config.bin %> functions add --name my-function --type document-create

EXAMPLES
    $ sanity blueprints add function

    $ sanity blueprints add function --helpers

    $ sanity blueprints add function --name my-function

    $ sanity blueprints add function --name my-function --fn-type document-create

    $ sanity blueprints add function --name my-function --fn-type document-create --fn-type document-update --lang js
```

### `config`

**CLI output**

```sh
USAGE
  $ sanity blueprints config [--edit] [--json] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -e, --edit                     Modify the configuration interactively, or directly when combined with ID flags.
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to set in the configuration. Requires --edit flag

DESCRIPTION
  Manages the local Blueprint configuration, which links your Blueprint to a Sanity project and Stack.
  
  Without flags, displays the current configuration. Use --edit to interactively modify settings, or combine --edit with ID flags to update values directly (useful for scripting and automation).
  
  If you need to switch your Blueprint to a different Stack, use --edit --stack.

EXAMPLES
    $ sanity blueprints config

    $ sanity blueprints config --edit

    $ sanity blueprints config --edit --project-id <projectId>

    $ sanity blueprints config --edit --project-id <projectId> --stack <name-or-id>
```

### `deploy`

**CLI output**

```sh
USAGE
  $ sanity blueprints deploy [-m <value>] [--json] [--new-stack-name <value>] [--no-wait] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -m, --message=<value>          Message describing the deployment (e.g. reason for change)
      --json                     Format output as json
      --new-stack-name=<value>   Set a new name for the Stack
      --no-wait                  Do not wait for Stack deployment to complete
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Applies your local Blueprint to the remote Stack, creating, updating, or removing resources as needed. This is the primary command for applying infrastructure changes.
  
  Before deploying, run 'blueprints plan' to preview changes. After deployment, use 'blueprints info' to verify Stack status or 'blueprints logs' to monitor activity.
  
  Use --no-wait to queue the deployment and return immediately without waiting for completion.
  
  Use --fn-installer to force which package manager to use when deploying functions.
  
  Set SANITY_ASSET_TIMEOUT (seconds) to override the 180-second timeout for processing resource assets.
  
  Set SANITY_ASSET_CONCURRENCY to override how many resource assets are processed at once (default 4).
  
  Exit codes: 0 deployed, 2 deployment failed, 75 deployment accepted but completion could not be confirmed (rerun 'blueprints info' to check).

EXAMPLES
    $ sanity blueprints deploy

    $ sanity blueprints deploy --message "Enable staging dataset"

    $ sanity blueprints deploy --no-wait

    $ sanity blueprints deploy --fn-installer npm

    $ sanity blueprints deploy --stack <name-or-id>

    $ sanity blueprints deploy --organization-id <orgId> --stack <name-or-id>

    $ sanity blueprints deploy --new-stack-name <new-name>
```

### `destroy`

**CLI output**

```sh
USAGE
  $ sanity blueprints destroy [--force] [--json] [--no-wait] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -f, --force                    Force Stack destruction (skip confirmation)
      --json                     Format output as json
      --no-wait                  Do not wait for Stack destruction to complete
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to destroy (defaults to the locally configured Stack)

DESCRIPTION
  Permanently removes the remote Stack and all its provisioned resources. Your Blueprint manifest and resource files remain intact; "stackId" is unset in your local config.
  
  This is a destructive operation. You will be prompted to confirm unless --force is specified.
  
  Use this to clean up test environments or decommission a Stack you no longer need.
  
  Exit codes: 0 destroyed, 2 destruction failed, 75 destruction accepted but completion could not be confirmed (rerun 'blueprints info' to check).

EXAMPLES
    $ sanity blueprints destroy

    $ sanity blueprints destroy --stack <name-or-id> --project-id <projectId> --force --no-wait
```

### `doctor`

**CLI output**

```sh
USAGE
  $ sanity blueprints doctor [-p <value>] [--fix] [--json]

FLAGS
  -p, --path=<value>  Path to a Blueprint file or directory containing one
      --fix           Interactively fix configuration issues
      --json          Format output as json

DESCRIPTION
  Analyzes your local Blueprint and remote Stack configuration for common issues, such as missing authentication, invalid project references, or misconfigured resources.
  
  Run this command when encountering errors with other Blueprint commands. Use --fix to interactively resolve detected issues.
  
  Supports --json for programmatic consumption of diagnostic results.

EXAMPLES
    $ sanity blueprints doctor

    $ sanity blueprints doctor --fix
```

### `info`

**CLI output**

```sh
USAGE
  $ sanity blueprints info [--verbose] [--json] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
  -v, --verbose                  Show resource and external IDs
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID

DESCRIPTION
  Displays the current state and metadata of your remote Stack deployment, including deployed resources, status, and configuration.
  
  Use this command to verify a deployment succeeded, check what resources are live, or confirm which Stack your local Blueprint is connected to.
  
  Run 'blueprints stacks' to see all available Stacks in your project or organization.

EXAMPLES
    $ sanity blueprints info

    $ sanity blueprints info --stack <name-or-id>

    $ sanity blueprints info --project-id <id> --stack <name-or-id>

    $ sanity blueprints info --organization-id <orgId> --stack <name-or-id>
```

### `init`

**CLI output**

```sh
USAGE
  $ sanity blueprints init [DIR] [--blueprint-type <value>] [--dir <value>] [--example <value>] [--json] [--organization-id <value>] [--project-id <value>] [--stack-id <value>] [--stack-name <value>]

ARGUMENTS
  [DIR]  Directory to create the local Blueprint in (defaults to the current directory)

FLAGS
      --blueprint-type=<value>   Blueprint manifest type to use for the local Blueprint
      --dir=<value>              Directory to create the local Blueprint in
      --example=<value>          Example to use for the local Blueprint
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack-id=<value>         Existing Stack ID used to scope local Blueprint
      --stack-name=<value>       Name to use for a new Stack provisioned during initialization

DESCRIPTION
  A Blueprint is your local infrastructure-as-code configuration that defines Sanity resources (datasets, functions, etc.). A Stack is the remote deployment target where your Blueprint is applied.
  
  This is typically the first command you run in a new project. It creates a local Blueprint manifest file (sanity.blueprint.ts, .js, or .json) and provisions a new remote Stack.
  Additionally, a Blueprint configuration file is created in .sanity/ containing the scope and Stack IDs. A .gitignore covering node_modules, .env, and Function build output is created or updated; the .sanity/ config itself is not ignored.
  
  After initialization, use 'blueprints plan' to preview changes, then 'blueprints deploy' to apply them.
  
  Running without a directory prompts to confirm the current directory. Run 'blueprints init .' to initialize in the current directory without a prompt.

EXAMPLES
    $ sanity blueprints init

    $ sanity blueprints init .

    $ sanity blueprints init [directory]

    $ sanity blueprints init --blueprint-type <json|js|ts>

    $ sanity blueprints init --organization-id <organizationId>

    $ sanity blueprints init --project-id <projectId>

    $ sanity blueprints init --stack-name <newStackName>

    $ sanity blueprints init --stack-id <existingStackId>

    $ sanity blueprints init new-stack --type <json|js|ts> --org <organizationId> --name <newStackName>

    $ sanity blueprints init old-stack --type <json|js|ts> --project-id <projectId> --stack-id <existingStackId>
```

### `logs`

**CLI output**

```sh
USAGE
  $ sanity blueprints logs [-l <value>] [--watch] [--before <value>] [--json] [--organization-id <value>] [--project-id <value>] [--since <value>] [--stack <value>]

FLAGS
  -l, --limit=<value>            Maximum number of log entries to retrieve (1-500)
  -w, --watch                    Watch for new Stack logs
      --before=<value>           Only show logs before this ISO 8601 timestamp
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --since=<value>            Only show logs after this ISO 8601 timestamp
      --stack=<value>            Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Retrieves Stack deployment logs, useful for debugging and monitoring deployment activity.
  
  Use --watch (-w) to tail logs in real-time.
  
  Use --limit, --since, or --before to narrow the result set when not watching.
  
  If you're not seeing expected logs, verify your Stack is deployed with 'blueprints info'.

EXAMPLES
    $ sanity blueprints logs

    $ sanity blueprints logs --watch

    $ sanity blueprints logs --stack <name-or-id>

    $ sanity blueprints logs --limit 500

    $ sanity blueprints logs --since 2026-05-01T00:00:00Z

    $ sanity blueprints logs --before 2026-05-01T00:00:00Z
```

### `mint-deploy-token`

**CLI output**

```sh
USAGE
  $ sanity blueprints mint-deploy-token [--print] [--json] [--label <value>] [--organization-id <value>] [--project-id <value>]

FLAGS
  -P, --print                    Print only the raw token to stdout (suitable for shell substitution)
      --json                     Format output as json
      --label=<value>            Human-readable label for the robot. Defaults to a generated value.
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack

DESCRIPTION
  Mints a long-lived robot token with the role required to plan, deploy, and destroy Blueprints in this project or organization.
  
  By default the command runs interactively and asks how you want to receive the token (clipboard, print, or exit). Use --print to emit only the raw token for shell pipelines, or --json for full API output.
  
  The minted token is also visible in your Sanity Manage UI under Robots, where it can be revoked.

EXAMPLES
    $ sanity blueprints mint-deploy-token

    $ sanity blueprints mint-deploy-token --label "ci-deploy"

    $ sanity blueprints mint-deploy-token --print

    $ export SANITY_AUTH_TOKEN=$(sanity blueprints mint-deploy-token --print)

    $ sanity blueprints mint-deploy-token --json

    $ sanity blueprints mint-deploy-token --project-id <projectId>

    $ sanity blueprints mint-deploy-token --organization-id <orgId>
```

### `plan`

**CLI output**

```sh
USAGE
  $ sanity blueprints plan [--json] [--organization-id <value>] [--project-id <value>] [--stack <value>]

FLAGS
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack
      --stack=<value>            Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Use this command to preview what changes will be applied to your remote Stack before deploying. This is a safe, read-only operation—no resources are created, modified, or deleted.
  
  Run 'blueprints plan' after making local changes to your Blueprint manifest to verify the expected diff. When ready, run 'blueprints deploy' to apply changes.

EXAMPLES
    $ sanity blueprints plan

    $ sanity blueprints plan --stack <name-or-id>

    $ sanity blueprints plan --organization-id <orgId> --stack <name-or-id>
```

### `promote`

**CLI output**

```sh
USAGE
  $ sanity blueprints promote [--force] [--json] [--new-stack-name <value>] [--project-id <value>] [--stack <value>]

FLAGS
      --force                   Skip confirmation prompt
      --json                    Format output as json
      --new-stack-name=<value>  Set a new name for the Stack while promoting
      --project-id=<value>      Sanity project ID used to scope Blueprint and Stack
      --stack=<value>           Stack name or ID to promote

DESCRIPTION
  Promotes a deployed Stack to organization scope, enabling management of org-level resources. Promotion cannot be reversed.
  
  Your local Blueprint configuration will be updated to reflect the new scope.

EXAMPLES
    $ sanity blueprints promote

    $ sanity blueprints promote --stack <name-or-id>

    $ sanity blueprints promote --project-id <projectId> --stack <name-or-id>

    $ sanity blueprints promote --new-stack-name <new-name>
```

### `stacks`

**CLI output**

```sh
USAGE
  $ sanity blueprints stacks [--all] [--include-projects] [--json] [--organization-id <value>] [--project-id <value>]

FLAGS
      --all                      List Stacks from every organization and project you have access to
      --include-projects         Include Stacks from all projects within the organization. Requires --organization-id.
      --json                     Format output as json
      --organization-id=<value>  Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>       Sanity project ID used to scope Blueprint and Stack

DESCRIPTION
  Shows all Stacks associated with a project or organization. By default, lists Stacks scoped to the local Blueprint.
  
  Use this to discover existing Stacks you can scope a local Blueprint to (using 'blueprints config --edit'), or to audit what's deployed across your project.
  
  Without a scope, prompts for an organization or project. Use --all to list Stacks across every organization and project you can access, or --include-projects with --organization-id for one organization and its projects.

EXAMPLES
    $ sanity blueprints stacks

    $ sanity blueprints stacks --all

    $ sanity blueprints stacks --project-id <projectId>

    $ sanity blueprints stacks --organization-id <organizationId>

    $ sanity blueprints stacks --organization-id <organizationId> --include-projects
```



# Build

**CLI output**

```sh
USAGE
  $ sanity build [OUTPUTDIR] [--yes] [--auto-updates] [--minify] [--source-maps] [--stats]

ARGUMENTS
  [OUTPUTDIR]  Output directory

FLAGS
  -y, --yes           Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults
      --auto-updates  Enable/disable auto updates of studio versions
      --minify        Enable/disable minifying of built bundles
      --source-maps   Enable source maps for built bundles (increases size of bundle)
      --stats         Show stats about the built bundles

DESCRIPTION
  Build Sanity Studio into a static bundle

EXAMPLES
    $ sanity build

    $ sanity build --no-minify --source-maps
```



# Codemod

**CLI output**

```sh
USAGE
  $ sanity codemod [CODEMODNAME] [--dry] [--extensions <value>] [--no-verify]

ARGUMENTS
  [CODEMODNAME]  Name of the codemod to run

FLAGS
      --dry                 Dry run (no changes are made to files)
      --extensions=<value>  Transform files with these file extensions (comma separated)
      --no-verify           Skip verification steps before running codemod

DESCRIPTION
  Updates Sanity Studio codebase with a code modification script

EXAMPLES
  Show available code mods

    $ sanity codemod

  Run codemod to transform react-icons imports (dry run)

    $ sanity codemod reactIconsV3 --dry
```





# CORS

**npm**

```shell
npx sanity cors --help
```

**pnpm**

```shell
pnpm dlx sanity cors --help
```

**yarn**

```shell
yarn dlx sanity cors --help
```

**bun**

```shell
bunx sanity cors --help
```

## Commands

### `add`

**CLI output**

```sh
USAGE
  $ sanity cors add ORIGIN [-p <id>] [--yes] [--credentials]

ARGUMENTS
  ORIGIN  Origin to allow (e.g., https://example.com)

FLAGS
  -y, --yes          Confirm risky wildcard origins without prompting
      --credentials  Allow credentials (token/cookie) to be sent from this origin

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to add CORS origin to (overrides CLI configuration)

DESCRIPTION
  Add a CORS origin to the project

EXAMPLES
  Interactively add a CORS origin

    $ sanity cors add

  Add a localhost origin without credentials

    $ sanity cors add http://localhost:3000 --no-credentials

  Add a production origin with credentials allowed

    $ sanity cors add https://myapp.com --credentials

  Add a CORS origin for a specific project

    $ sanity cors add https://myapp.com --project-id abc123
```

### `delete`

**CLI output**

```sh
USAGE
  $ sanity cors delete [ORIGIN] [-p <id>]

ARGUMENTS
  [ORIGIN]  Origin to delete (will prompt if not provided)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete CORS origin from (overrides CLI configuration)

DESCRIPTION
  Delete a CORS origin from the project

EXAMPLES
  Interactively select and delete a CORS origin

    $ sanity cors delete

  Delete a specific CORS origin

    $ sanity cors delete https://example.com

  Delete a CORS origin from a specific project

    $ sanity cors delete --project-id abc123
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity cors list [-p <id>]

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list CORS origins for (overrides CLI configuration)

DESCRIPTION
  List CORS origins for the project

EXAMPLES
  List CORS origins for the project

    $ sanity cors list

  List CORS origins for a specific project

    $ sanity cors list --project-id abc123
```



# Datasets

**npm**

```shell
npx sanity datasets --help
```

**pnpm**

```shell
pnpm dlx sanity datasets --help
```

**yarn**

```shell
yarn dlx sanity datasets --help
```

**bun**

```shell
bunx sanity datasets --help
```

## Commands

### `alias`

#### `create`

**CLI output**

```sh
USAGE
  $ sanity datasets alias create [ALIASNAME] [TARGETDATASET] [-p <id>]

ARGUMENTS
  [ALIASNAME]      Dataset alias name to create
  [TARGETDATASET]  Target dataset name to link the alias to

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to create dataset alias in (overrides CLI configuration)

DESCRIPTION
  Create a dataset alias for the project

EXAMPLES
  Create alias in a specific project

    $ sanity datasets alias create --project-id abc123 conference conf-2025

  Create an alias with interactive prompts

    $ sanity datasets alias create

  Create alias named "conference" with interactive dataset selection

    $ sanity datasets alias create conference

  Create alias "conference" linked to "conf-2025" dataset

    $ sanity datasets alias create conference conf-2025
```

#### `delete`

**CLI output**

```sh
USAGE
  $ sanity datasets alias delete ALIASNAME [-p <id>] [--force]

ARGUMENTS
  ALIASNAME  Dataset alias name to delete

FLAGS
      --force  Skip confirmation prompt and delete immediately

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete dataset alias from (overrides CLI configuration)

DESCRIPTION
  Delete a dataset alias from the project

EXAMPLES
  Delete alias named "conference" with confirmation prompt

    $ sanity datasets alias delete conference

  Delete alias named "conference" without confirmation prompt

    $ sanity datasets alias delete conference --force
```

#### `link`

**CLI output**

```sh
USAGE
  $ sanity datasets alias link [ALIASNAME] [TARGETDATASET] [-p <id>] [--force]

ARGUMENTS
  [ALIASNAME]      Dataset alias name to link
  [TARGETDATASET]  Target dataset name to link the alias to

FLAGS
      --force  Skip confirmation prompt when relinking existing alias

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to link dataset alias in (overrides CLI configuration)

DESCRIPTION
  Link a dataset alias to a dataset in the project

EXAMPLES
  Link an alias with interactive prompts

    $ sanity datasets alias link

  Link alias named "conference" with interactive dataset selection

    $ sanity datasets alias link conference

  Link alias "conference" to "conf-2025" dataset

    $ sanity datasets alias link conference conf-2025

  Force link without confirmation (skip relink prompt)

    $ sanity datasets alias link conference conf-2025 --force
```

#### `unlink`

**CLI output**

```sh
USAGE
  $ sanity datasets alias unlink [ALIASNAME] [-p <id>] [--force]

ARGUMENTS
  [ALIASNAME]  Dataset alias name to unlink

FLAGS
      --force  Skip confirmation prompt and unlink immediately

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to unlink dataset alias in (overrides CLI configuration)

DESCRIPTION
  Unlink a dataset alias from its dataset in the project

EXAMPLES
  Unlink an alias with interactive selection

    $ sanity datasets alias unlink

  Unlink alias "conference" with confirmation prompt

    $ sanity datasets alias unlink conference

  Unlink alias "conference" without confirmation prompt

    $ sanity datasets alias unlink conference --force
```

### `copy`

**CLI output**

```sh
USAGE
  $ sanity datasets copy [SOURCE] [TARGET] [-p <id>] [--attach <value>] [--detach] [--limit <value>] [--list] [--offset <value>] [--skip-content-releases] [--skip-history]

ARGUMENTS
  [SOURCE]  Name of the dataset to copy from
  [TARGET]  Name of the dataset to copy to

FLAGS
      --attach=<value>         Attach to the running copy process to show progress
      --detach                 Start the copy without waiting for it to finish
      --limit=<value>          Maximum number of jobs returned (default 10, max 1000)
      --list                   Lists all dataset copy jobs
      --offset=<value>         Start position in the list of jobs (default 0)
      --skip-content-releases  Don't copy content release documents to the target dataset
      --skip-history           Don't preserve document history on copy

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to copy dataset in (overrides CLI configuration)

DESCRIPTION
  Copy a dataset or manage copy jobs

EXAMPLES
  Interactively copy a dataset

    $ sanity datasets copy

  Copy from source-dataset (prompts for target)

    $ sanity datasets copy source-dataset

  Copy from source-dataset to target-dataset

    $ sanity datasets copy source-dataset target-dataset

  Copy without preserving document history (faster for large datasets)

    $ sanity datasets copy --skip-history source target

  Copy without content release documents

    $ sanity datasets copy --skip-content-releases source target

  Start copy job without waiting for completion

    $ sanity datasets copy --detach source target

  Attach to a running copy job to follow progress

    $ sanity datasets copy --attach <job-id>

  List all dataset copy jobs

    $ sanity datasets copy --list

  List copy jobs with pagination

    $ sanity datasets copy --list --offset 2 --limit 10
```

### `create`

**CLI output**

```sh
USAGE
  $ sanity datasets create [NAME] [-p <id>] [--embeddings] [--embeddings-projection <value>] [--visibility <value>]

ARGUMENTS
  [NAME]  Name of the dataset to create

FLAGS
      --embeddings                     Enable embeddings for this dataset
      --embeddings-projection=<value>  GROQ projection for embeddings indexing (e.g. "{ title, body }")
      --visibility=<value>             Set visibility for this dataset (custom/private/public)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to create dataset in (overrides CLI configuration)

DESCRIPTION
  Create a new dataset for the project

EXAMPLES
  Interactively create a dataset

    $ sanity datasets create

  Create a dataset named "my-dataset"

    $ sanity datasets create my-dataset

  Create a private dataset named "my-dataset"

    $ sanity datasets create my-dataset --visibility private
```

### `delete`

**CLI output**

```sh
USAGE
  $ sanity datasets delete DATASETNAME [-p <id>] [--force]

ARGUMENTS
  DATASETNAME  Dataset name to delete

FLAGS
      --force  Do not prompt for delete confirmation - forcefully delete

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete dataset from (overrides CLI configuration)

DESCRIPTION
  Delete a dataset from the project

EXAMPLES
  Delete a specific dataset

    $ sanity datasets delete my-dataset

  Delete a specific dataset without confirmation

    $ sanity datasets delete my-dataset --force
```

### `embeddings`

#### `disable`

**CLI output**

```sh
USAGE
  $ sanity datasets embeddings disable [DATASET] [-p <id>]

ARGUMENTS
  [DATASET]  Dataset name to disable embeddings for

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to disable embeddings for (overrides CLI configuration)

DESCRIPTION
  Disable embeddings for a dataset

EXAMPLES
  Disable embeddings for the production dataset

    $ sanity datasets embeddings disable production
```

#### `enable`

**CLI output**

```sh
USAGE
  $ sanity datasets embeddings enable [DATASET] [-p <id>] [--projection <value>] [--wait]

ARGUMENTS
  [DATASET]  Dataset name to enable embeddings for

FLAGS
      --projection=<value>  GROQ projection defining which fields to embed (e.g. "{ title, body }")
      --wait                Wait for embeddings processing to complete before returning

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to enable embeddings for (overrides CLI configuration)

DESCRIPTION
  Enable embeddings for a dataset

EXAMPLES
  Enable embeddings for the production dataset

    $ sanity datasets embeddings enable production

  Enable embeddings with a specific projection

    $ sanity datasets embeddings enable production --projection "{ title, body }"

  Enable embeddings and wait for processing to complete

    $ sanity datasets embeddings enable production --wait
```

#### `status`

**CLI output**

```sh
USAGE
  $ sanity datasets embeddings status [DATASET] [-p <id>]

ARGUMENTS
  [DATASET]  The name of the dataset to check embeddings status for

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to check embeddings status for (overrides CLI configuration)

DESCRIPTION
  Show embeddings settings and status for a dataset

EXAMPLES
  Show embeddings status for the production dataset

    $ sanity datasets embeddings status production
```

### `export`

**CLI output**

```sh
USAGE
  $ sanity datasets export [NAME] [DESTINATION] [-p <id>] [--asset-concurrency <value>] [--mode <value>] [--no-assets] [--no-compress] [--no-drafts] [--no-strict-asset-verification] [--overwrite] [--raw] [--types <value>]

ARGUMENTS
  [NAME]         Name of the dataset to export
  [DESTINATION]  Output destination file path

FLAGS
      --asset-concurrency=<value>     Concurrent number of asset downloads
      --mode=<value>                  Export mode ('cursor' is faster for large datasets but may miss concurrent changes)
      --no-assets                     Export only non-asset documents and remove references to image assets
      --no-compress                   Skips compressing tarball entries (still generates a gzip file)
      --no-drafts                     Export only published versions of documents
      --no-strict-asset-verification  Do not abort the export when an asset fails hash or content-length verification
      --overwrite                     Overwrite any file with the same name
      --raw                           Extract only documents, without rewriting asset references
      --types=<value>                 Defines which document types to export (comma-separated)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to export dataset from (overrides CLI configuration)

DESCRIPTION
  Export a dataset to a local gzipped tarball. Assets returning 401, 403, or 404 are excluded from the export.

EXAMPLES
  Export dataset "moviedb" to localPath.tar.gz

    $ sanity datasets export moviedb localPath.tar.gz

  Export dataset without assets

    $ sanity datasets export moviedb assetless.tar.gz --no-assets

  Export raw documents without asset reference rewriting

    $ sanity datasets export staging staging.tar.gz --raw

  Export specific document types

    $ sanity datasets export staging staging.tar.gz --types products,shops

  Export dataset without aborting on asset verification failures

    $ sanity datasets export moviedb moviedb.tar.gz --no-strict-asset-verification
```

### `import`

**CLI output**

```sh
USAGE
  $ sanity datasets import SOURCE [TARGETDATASET] [-d <name>] [-p <id>] [-t <value>] [--allow-assets-in-different-dataset] [--allow-failing-assets] [--allow-replacement-characters] [--allow-system-documents] [--asset-concurrency <value>] [--missing] [--replace] [--replace-assets] [--skip-cross-dataset-references]

ARGUMENTS
  SOURCE           Source file (use "-" for stdin)
  [TARGETDATASET]  Target dataset (prefer --dataset flag instead)

FLAGS
  -d, --dataset=<name>                     Dataset to import to
  -t, --token=<value>                      Token to authenticate with
      --allow-assets-in-different-dataset  Allow asset documents to reference different project/dataset
      --allow-failing-assets               Skip assets that cannot be fetched/uploaded
      --allow-replacement-characters       Allow unicode replacement characters in imported documents
      --allow-system-documents             Imports system documents
      --asset-concurrency=<value>          Number of parallel asset imports
      --missing                            Skip documents that already exist
      --replace                            Replace documents with the same IDs
      --replace-assets                     Skip reuse of existing assets
      --skip-cross-dataset-references      Skips references to other datasets

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to import to (overrides CLI configuration)

DESCRIPTION
  Import documents to a Sanity dataset

EXAMPLES
  Import "./my-dataset.ndjson" into dataset "staging"

    $ sanity datasets import -d staging my-dataset.ndjson

  Import into dataset "test" from stdin

    $ cat my-dataset.ndjson | sanity datasets import -d test -

  Import with explicit project ID (overrides CLI configuration)

    $ sanity datasets import -p projectId -d staging my-dataset.ndjson

  Import with an explicit token (e.g. for CI/CD)

    $ sanity datasets import -d staging -t someSecretToken my-dataset.ndjson
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity datasets list [-p <id>]

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list datasets for (overrides CLI configuration)

DESCRIPTION
  List datasets for the project

EXAMPLES
  List datasets for the project

    $ sanity datasets list

  List datasets for a specific project

    $ sanity datasets list --project-id abc123
```

### `visibility`

#### `get`

**CLI output**

```sh
USAGE
  $ sanity datasets visibility get DATASET [-p <id>]

ARGUMENTS
  DATASET  The name of the dataset to get visibility for

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to get dataset visibility for (overrides CLI configuration)

DESCRIPTION
  Get the visibility of a dataset

EXAMPLES
  Check the visibility of a dataset

    $ sanity datasets visibility get my-dataset
```

#### `set`

**CLI output**

```sh
USAGE
  $ sanity datasets visibility set DATASET MODE [-p <id>]

ARGUMENTS
  DATASET  The name of the dataset to set visibility for
  MODE     The visibility mode to set

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to set dataset visibility for (overrides CLI configuration)

DESCRIPTION
  Set the visibility of a dataset

EXAMPLES
  Make a dataset private

    $ sanity datasets visibility set my-dataset private

  Make a dataset public

    $ sanity datasets visibility set my-dataset public
```



# Debug

**CLI output**

```sh
USAGE
  $ sanity debug [--secrets] [--verbose]

FLAGS
      --secrets  Include API keys in output
      --verbose  Show full error details including stack traces

DESCRIPTION
  Print diagnostic info for troubleshooting

EXAMPLES
    $ sanity debug

    $ sanity debug --secrets
```



# Deploy

**CLI output**

```sh
USAGE
  $ sanity deploy [SOURCEDIR] [--json] [--yes] [--auto-updates] [--build] [--dry-run] [--external] [--minify] [--schema-required] [--source-maps] [--title <value>] [--url <value>] [--verbose]

ARGUMENTS
  [SOURCEDIR]  Source directory

FLAGS
  -j, --json             Output the result as JSON
  -y, --yes              Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults
      --auto-updates     Automatically update the studio to the latest version
      --build            Build the studio before deploying (use --no-build to deploy existing `dist/` output)
      --dry-run          Report what would be deployed without uploading or creating anything
      --external         Register an externally hosted studio
      --minify           Minify built JavaScript (use --no-minify to skip for faster builds)
      --schema-required  Fail if schema deployment fails
      --source-maps      Enable source maps for built bundles (increases size of bundle)
      --title=<value>    Title for a newly created application or studio. For apps it also skips the interactive title prompt, enabling unattended creation
      --url=<value>      Studio URL for deployment. For external studios, the full URL. For hosted studios, the hostname (e.g. "my-studio" or "my-studio.sanity.studio")
      --verbose          Enable verbose logging

DESCRIPTION
  Builds and deploys Sanity Studio or application to Sanity hosting

EXAMPLES
  Build and deploy the studio to Sanity hosting

    $ sanity deploy

  Deploys non-minified build with source maps

    $ sanity deploy --no-minify --source-maps

  Fail fast on schema store fails - for when other services rely on the stored schema

    $ sanity deploy --schema-required

  Register an externally hosted studio (studioHost contains full URL)

    $ sanity deploy --external
```

> [!NOTE]
> Deployment size limit
> A single deployment is limited to 2 GB. The limit applies to the total size of the built files in the deployment, and deploys that exceed it are rejected with an error. The same limit applies to Studio deployments and App SDK app deployments.
> Most deployments are a few megabytes, so typical projects stay well below this limit.



## What --no-build skips

By default, sanity deploy builds the Studio through Vite, extracts your schema and manifest, packages everything, and uploads it to Sanity hosting. Passing --no-build skips the build step but still runs schema extraction and upload. The dist/ directory must already exist. To make schema extraction fail instead of warn, pass --schema-required.



# Dev

**CLI output**

```sh
USAGE
  $ sanity dev [--auto-updates] [--host <value>] [--load-in-dashboard] [--port <value>]

FLAGS
      --auto-updates       Automatically update Sanity Studio dependencies
      --host=<value>       Local network interface to listen on (default: localhost)
      --load-in-dashboard  Load the app/studio in the Sanity dashboard
      --port=<value>       TCP port to start server on (default: 3333)

DESCRIPTION
  Start a local development server with live reloading

EXAMPLES
    $ sanity dev --host=0.0.0.0

    $ sanity dev --port=1942

    $ sanity dev --load-in-dashboard
```



# Docs

**npm**

```shell
npx sanity docs --help
```

**pnpm**

```shell
pnpm dlx sanity docs --help
```

**yarn**

```shell
yarn dlx sanity docs --help
```

**bun**

```shell
bunx sanity docs --help
```

## Commands

### `browse`

**CLI output**

```sh
USAGE
  $ sanity docs browse

DESCRIPTION
  Open Sanity docs in your browser
```

### `read`

**CLI output**

```sh
USAGE
  $ sanity docs read PATH [--web]

ARGUMENTS
  PATH  Path or URL to article, found in search results and docs content as links

FLAGS
      --web  Open in a web browser

DESCRIPTION
  Read an article in terminal

EXAMPLES
  Read as markdown in terminal

    $ sanity docs read /docs/studio/installation

  Read using full URL

    $ sanity docs read https://www.sanity.io/docs/studio/installation

  Open in web browser

    $ sanity docs read /docs/studio/installation --web

  Open using full URL in web browser

    $ sanity docs read https://www.sanity.io/docs/studio/installation -w
```

### `search`

**CLI output**

```sh
USAGE
  $ sanity docs search QUERY [--limit <value>]

ARGUMENTS
  QUERY  Search query for documentation

FLAGS
      --limit=<value>  Maximum number of results to return

DESCRIPTION
  Search Sanity docs

EXAMPLES
  Search for documentation about schemas

    $ sanity docs search schema

  Search with phrase

    $ sanity docs search "groq functions"

  Limit search results

    $ sanity docs search "deployment" --limit=5
```



# Documents



**npm**

```shell
npx sanity documents --help
```

**pnpm**

```shell
pnpm dlx sanity documents --help
```

**yarn**

```shell
yarn dlx sanity documents --help
```

**bun**

```shell
bunx sanity documents --help
```

## Commands

### `create`

**CLI output**

```sh
USAGE
  $ sanity documents create [FILE] [-d <name>] [-p <id>] [--id <value>] [--json5] [--missing] [--replace] [--watch]

ARGUMENTS
  [FILE]  JSON file to create document(s) from

FLAGS
      --id=<value>  Specify a document ID to use. Will fetch remote document ID and populate editor.
      --json5       Use JSON5 file type to allow a "simplified" version of JSON
      --missing     On duplicate document IDs, don't modify the target document(s)
      --replace     On duplicate document IDs, replace existing document with specified document(s)
      --watch       Write the documents whenever the target file or buffer changes

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset to create document(s) in (overrides CLI configuration)
  -p, --project-id=<id>  Project ID to create document(s) in (overrides CLI configuration)

DESCRIPTION
  Create one or more documents

EXAMPLES
  Create the document specified in "myDocument.json"

    $ sanity documents create myDocument.json

  Open configured $EDITOR and create the specified document(s)

    $ sanity documents create

  Fetch document with the ID "myDocId" and open configured $EDITOR with the current document content (if any). Replace document with the edited version when the editor closes

    $ sanity documents create --id myDocId --replace

  Open configured $EDITOR and replace the document with the given content on each save. Use JSON5 file extension and parser for simplified syntax.

    $ sanity documents create --id myDocId --watch --replace --json5

  Create documents in a specific project

    $ sanity documents create myDocument.json --project-id abc123
```

### `delete`

**CLI output**

```sh
USAGE
  $ sanity documents delete ID [IDS] [-d <name>] [-p <id>]

ARGUMENTS
  ID     Document ID to delete
  [IDS]  Additional document IDs to delete

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset to delete from (overrides CLI configuration)
  -p, --project-id=<id>  Project ID to delete from (overrides CLI configuration)

DESCRIPTION
  Delete one or more documents from the project's configured dataset

EXAMPLES
  Delete the document with the ID "myDocId"

    $ sanity documents delete myDocId

  ID wrapped in double or single quote works equally well

    $ sanity documents delete 'myDocId'

  Delete document with ID "someDocId" from dataset "blog"

    $ sanity documents delete --dataset=blog someDocId

  Delete the document with ID "doc1" and "doc2"

    $ sanity documents delete doc1 doc2

  Delete a document from a specific project

    $ sanity documents delete myDocId --project-id abc123
```

### `get`

**CLI output**

```sh
USAGE
  $ sanity documents get DOCUMENTID [-d <name>] [-p <id>] [--pretty]

ARGUMENTS
  DOCUMENTID  Document ID to retrieve

FLAGS
      --pretty  Colorize JSON output

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset to get document from (overrides CLI configuration)
  -p, --project-id=<id>  Project ID to get document from (overrides CLI configuration)

DESCRIPTION
  Get and print a document by ID

EXAMPLES
  Get the document with ID "myDocId"

    $ sanity documents get myDocId

  Get document with colorized JSON output

    $ sanity documents get myDocId --pretty

  Get document from a specific dataset

    $ sanity documents get myDocId --dataset production

  Get a document from a specific project

    $ sanity documents get myDocId --project-id abc123
```

### `query`

**CLI output**

```sh
USAGE
  $ sanity documents query QUERY [-d <name>] [-p <id>] [--anonymous] [--api-version <value>] [--pretty]

ARGUMENTS
  QUERY  GROQ query to run against the dataset

FLAGS
      --anonymous            Send the query without any authorization token
      --api-version=<value>  API version to use (defaults to 2025-08-15)
      --pretty               Colorize JSON output

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset to query (overrides CLI configuration)
  -p, --project-id=<id>  Project ID to query (overrides CLI configuration)

DESCRIPTION
  Query for documents

EXAMPLES
  Fetch 5 documents of type "movie"

    $ sanity documents query '*[_type == "movie"][0..4]'

  Fetch title of the oldest movie in the dataset named "staging"

    $ sanity documents query '*[_type == "movie"]|order(releaseDate asc)[0]{title}' --dataset staging

  Use API version v2021-06-07 and do a query

    $ sanity documents query '*[_id == "header"] { "headerText": pt::text(body) }' --api-version v2021-06-07

  Query documents in a specific project and dataset

    $ sanity documents query '*[_type == "post"]' --project-id abc123 --dataset production
```

### `validate`

**CLI output**

```sh
USAGE
  $ sanity documents validate [-d <name>] [-p <id>] [--yes] [--file <value>] [--format <value>] [--level <value>] [--max-custom-validation-concurrency <value>] [--max-fetch-concurrency <value>] [--workspace <value>]

FLAGS
  -d, --dataset=<name>                             Override the dataset used. By default, this is derived from the given workspace
  -p, --project-id=<id>                            Override the project ID used. By default, this is derived from the given workspace
  -y, --yes                                        Skips the first confirmation prompt
      --file=<value>                               Path to an NDJSON file or tar archive containing an NDJSON file (optionally gzip-compressed)
      --format=<value>                             The output format used to print the found validation markers and report progress
      --level=<value>                              The minimum level reported. Defaults to warning
      --max-custom-validation-concurrency=<value>  Specify how many custom validators can run concurrently
      --max-fetch-concurrency=<value>              Specify how many `client.fetch` requests are allowed to run concurrently
      --workspace=<value>                          The name of the workspace to use when downloading and validating all documents

DESCRIPTION
  Validate documents in a dataset against the studio schema

EXAMPLES
  Validates all documents in a Sanity project with more than one workspace

    $ sanity documents validate --workspace default

  Override the dataset specified in the workspace

    $ sanity documents validate --workspace default --dataset staging

  Save the results of the report into a file

    $ sanity documents validate --yes > report.txt

  Report out info level validation markers too

    $ sanity documents validate --level info

  Validate documents in a specific project and dataset

    $ sanity documents validate --project-id abc123 --dataset production
```



# Exec

**CLI output**

```sh
USAGE
  $ sanity exec SCRIPT [--mock-browser-env] [--with-user-token]

ARGUMENTS
  SCRIPT  Path to the script to execute

FLAGS
      --mock-browser-env  Mock a browser environment with jsdom
      --with-user-token   Include your auth token in getCliClient()

DESCRIPTION
  Executes a script within the Sanity Studio context

EXAMPLES
  Run the script at some/script.js in Sanity context

    $ sanity exec some/script.js

  Run the script at migrations/fullname.ts and configure `getCliClient()` from `sanity/cli` to include the current user's token

    $ sanity exec migrations/fullname.ts --with-user-token

  Run the script at scripts/browserScript.js in a mock browser environment

    $ sanity exec scripts/browserScript.js --mock-browser-env

  Pass arbitrary arguments to scripts by separating them with a `--`. Arguments are available in `process.argv` as they would in regular node scripts (eg the following command would yield a `process.argv` of: `['/path/to/node', '/path/to/myscript.js', '--dry-run', 'positional-argument']`)

    $ sanity exec --mock-browser-env myscript.js -- --dry-run positional-argument
```





# Functions

The `functions` CLI command enables managing and testing functions. It's used alongside the `blueprints` command to create and deploy functions.

[Functions introduction](https://www.sanity.io/docs/functions/functions-introduction)
Learn how to take advantage of Functions in your Sanity projects.

[Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)
Learn what Blueprints are, how they work, and how to get started.

**npm**

```shell
npx sanity functions --help
```

**pnpm**

```shell
pnpm dlx sanity functions --help
```

**yarn**

```shell
yarn dlx sanity functions --help
```

**bun**

```shell
bunx sanity functions --help
```

## Commands

### `add`

**CLI output**

```sh
USAGE
  $ sanity functions add [--install] [-n <value>] [--example <value>] [--helpers] [--installer <value>] [--javascript] [--json] [--language <value>] [--type <value>]

FLAGS
  -i, --install            Shortcut for --installer npm
  -n, --name=<value>       Name of the Function to add
      --example=<value>    Example to use for the Function
      --helpers            Add helpers to the new Function
      --installer=<value>  How to install the @sanity/functions helpers
      --javascript         Use JavaScript instead of TypeScript
      --json               Format output as json
      --language=<value>   Language of the new Function
      --type=<value>       Document change event(s) that should trigger the function; you can specify multiple events by specifying this flag multiple times

DESCRIPTION
  Scaffolds a new Function in the functions/ folder and templates a resource for your Blueprint manifest.
  
  Functions are serverless handlers triggered by document, live content or media-library events (create, update, delete, publish).
  
  After adding, use 'functions dev' to test locally, then 'blueprints deploy' to publish.

EXAMPLES
    $ sanity functions add

    $ sanity functions add --helpers

    $ sanity functions add --name my-function

    $ sanity functions add --name my-function --type document-create

    $ sanity functions add --name my-function --type document-create --type document-update --lang js
```

### `dev`

**CLI output**

```sh
USAGE
  $ sanity functions dev [-h <value>] [-p <value>] [-t <value>] [--json]

FLAGS
  -h, --host=<value>     The local network interface at which to listen. [default: "localhost"]
  -p, --port=<value>     TCP port to start emulator on. [default: 8080]
  -t, --timeout=<value>  Maximum execution time for all functions, in seconds. Takes precedence over function-specific `timeout`
      --json             Format output as json

DESCRIPTION
  Runs a local, web-based development server to test your functions before deploying.
  
  Open the emulator in your browser to interactively test your functions with the payload editor.
  
  Optionally, set the host and port with the --host and --port flags. Port 8974 is reserved for the emulator's live-reload WebSocket server. Function timeout can be configured with the --timeout flag.
  
  To invoke a function with the CLI, use 'functions test'.

EXAMPLES
    $ sanity functions dev --host 127.0.0.1 --port 3333

    $ sanity functions dev --timeout 60
```

### `env`

#### `add`

**CLI output**

```sh
USAGE
  $ sanity functions env add NAME KEY VALUE [--json] [--stack <value>]

ARGUMENTS
  NAME   The name of the Sanity Function
  KEY    The name of the environment variable
  VALUE  The value of the environment variable

FLAGS
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Sets an environment variable in a deployed Sanity Function. If the variable already exists, its value is updated.
  
  Environment variables are useful for API keys, configuration values, and other secrets that shouldn't be hardcoded. Changes take effect on the next function invocation.

EXAMPLES
    $ sanity functions env add MyFunction API_URL https://api.example.com/

    $ sanity functions env add --stack <name-or-id> MyFunction API_URL https://api.example.com/
```

#### `list`

**CLI output**

```sh
USAGE
  $ sanity functions env list NAME [--json] [--stack <value>]

ARGUMENTS
  NAME  The name of the Sanity Function

FLAGS
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Displays all environment variables (keys only) configured in a deployed Sanity Function.
  
  Use 'functions env add' to set variables or 'functions env remove' to delete them.

EXAMPLES
    $ sanity functions env list MyFunction

    $ sanity functions env list --stack <name-or-id> MyFunction
```

#### `remove`

**CLI output**

```sh
USAGE
  $ sanity functions env remove NAME KEY [--json] [--stack <value>]

ARGUMENTS
  NAME  The name of the Sanity Function
  KEY   The name of the environment variable

FLAGS
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Deletes an environment variable from a deployed Sanity Function. The change takes effect on the next function invocation.
  
  Use 'functions env list' to see current variables before removing.

EXAMPLES
    $ sanity functions env remove MyFunction API_URL

    $ sanity functions env remove --stack <name-or-id> MyFunction API_URL
```

### `logs`

**CLI output**

```sh
USAGE
  $ sanity functions logs [NAME] [--delete] [--force] [-l <value>] [--utc] [--watch] [--json] [--stack <value>]

ARGUMENTS
  [NAME]  The name of the Sanity Function

FLAGS
  -d, --delete         Delete all logs for the function
  -f, --force          Skip confirmation for deleting logs
  -l, --limit=<value>  Total number of log entries to retrieve
  -u, --utc            Show dates in UTC time zone
  -w, --watch          Watch for new logs (streaming mode)
      --json           Format output as json
      --stack=<value>  Stack name or ID to use instead of the locally configured Stack

DESCRIPTION
  Fetches execution logs from a deployed function, useful for debugging production issues or monitoring activity.
  
  Use --watch (-w) to stream logs in real-time. Use --delete to clear all logs for a function (requires confirmation unless --force is specified).

EXAMPLES
    $ sanity functions logs <name>

    $ sanity functions logs <name> --json

    $ sanity functions logs <name> --limit 100

    $ sanity functions logs <name> --delete
```

### `test`

**CLI output**

```sh
USAGE
  $ sanity functions test [NAME] [-a <value>] [-d <value>] [-e <value>] [-f <value>] [-t <value>] [--data-after <value>] [--data-before <value>] [--dataset <value>] [--document-id <value>] [--document-id-after <value>] [--document-id-before <value>] [--file-after <value>] [--file-before <value>] [--json] [--media-library-id <value>] [--no-wait] [--organization-id <value>] [--project-id <value>] [--with-user-token]

ARGUMENTS
  [NAME]  The name of the Sanity Function

FLAGS
  -a, --api=<value>                 Sanity API Version to use
  -d, --data=<value>                Data to send to the function
  -e, --event=<value>               Type of event (create, update, delete)
  -f, --file=<value>                Read data from file and send to the function
  -t, --timeout=<value>             Execution timeout value in seconds
      --data-after=<value>          Current document
      --data-before=<value>         Original document
      --dataset=<value>             The Sanity dataset to use
      --document-id=<value>         Document to fetch and send to function
      --document-id-after=<value>   Current document
      --document-id-before=<value>  Original document
      --file-after=<value>          Current document
      --file-before=<value>         Original document
      --json                        Format output as json
      --media-library-id=<value>    Sanity Media Library ID to use
      --no-wait                     Skip durable wait delays instead of sleeping
      --organization-id=<value>     Sanity organization ID used to scope Blueprint and Stack
      --project-id=<value>          Sanity project ID used to scope Blueprint and Stack
      --with-user-token             Prime access token from CLI config

DESCRIPTION
  Executes a function locally with the provided payload, simulating how it would run when deployed. Use this to test your function logic before deploying.
  
  Provide test data via --data (inline JSON), --file (JSON file), or --document-id (fetch from Sanity). For update events, use the before/after flag pairs to simulate document changes.

EXAMPLES
    $ sanity functions test <name> --data '{ "id": 1 }'

    $ sanity functions test <name> --file 'payload.json'

    $ sanity functions test <name> --data '{ "id": 1 }' --timeout 60

    $ sanity functions test <name> --event update --data-before '{ "title": "before" }' --data-after '{ "title": "after" }'
```



# GraphQL

**npm**

```shell
npx sanity graphql --help
```

**pnpm**

```shell
pnpm dlx sanity graphql --help
```

**yarn**

```shell
yarn dlx sanity graphql --help
```

**bun**

```shell
bunx sanity graphql --help
```

## Commands

### `deploy`

**CLI output**

```sh
USAGE
  $ sanity graphql deploy [-d <name>] [--api <value>] [--dry-run] [--force] [--generation <value>] [--non-null-document-fields] [--playground] [--tag <value>] [--with-union-cache]

FLAGS
  -d, --dataset=<name>            Deploy API for the given dataset
      --api=<value>               Only deploy API with this ID (can be specified multiple times)
      --dry-run                   Validate defined GraphQL APIs, check for breaking changes, skip deploy
      --force                     Deploy API without confirming breaking changes
      --generation=<value>        API generation to deploy (defaults to "gen3")
      --non-null-document-fields  Use non-null document fields (_id, _type etc)
      --playground                Enable GraphQL playground for easier debugging
      --tag=<value>               Deploy API(s) to given tag (defaults to "default")
      --with-union-cache          Cache union types (faster for schemas with many self-references)

DESCRIPTION
  Deploy a GraphQL API from the current Sanity schema

EXAMPLES
  Deploy all defined GraphQL APIs

    $ sanity graphql deploy

  Validate defined GraphQL APIs, check for breaking changes, skip deploy

    $ sanity graphql deploy --dry-run

  Deploy only the GraphQL APIs with the IDs "staging" and "ios"

    $ sanity graphql deploy --api staging --api ios

  Deploy all defined GraphQL APIs, overriding any playground setting

    $ sanity graphql deploy --playground
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity graphql list [-p <id>]

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list GraphQL endpoints for (overrides CLI configuration)

DESCRIPTION
  List deployed GraphQL endpoints for the project

EXAMPLES
  List GraphQL endpoints for the project

    $ sanity graphql list

  List GraphQL endpoints for a specific project

    $ sanity graphql list --project-id abc123
```

### `undeploy`

**CLI output**

```sh
USAGE
  $ sanity graphql undeploy [-d <name>] [-p <id>] [--api <value>] [--force] [--tag <value>]

FLAGS
      --api=<value>  Undeploy API with this ID
      --force        Skip confirmation prompt
      --tag=<value>  Tag to undeploy GraphQL API from

OVERRIDE FLAGS
  -d, --dataset=<name>   Dataset to undeploy GraphQL API from (overrides CLI configuration)
  -p, --project-id=<id>  Project ID to undeploy GraphQL API from (overrides CLI configuration)

DESCRIPTION
  Remove a deployed GraphQL API

EXAMPLES
  Undeploy GraphQL API for current project and dataset

    $ sanity graphql undeploy

  Undeploy API with ID "ios"

    $ sanity graphql undeploy --api ios

  Undeploy GraphQL API for staging dataset

    $ sanity graphql undeploy --dataset staging

  Undeploy GraphQL API for staging dataset with "next" tag

    $ sanity graphql undeploy --dataset staging --tag next

  Undeploy GraphQL API without confirmation prompt

    $ sanity graphql undeploy --force

  Undeploy GraphQL API for a specific project and dataset

    $ sanity graphql undeploy --project-id abc123 --dataset production
```



# Help

**CLI output**

```sh
USAGE
  $ sanity help [COMMAND] [--nested-commands]

ARGUMENTS
  [COMMAND]  Command to show help for.

FLAGS
  -n, --nested-commands  Include all nested commands in the output.

DESCRIPTION
  Display help for <%= config.bin %>.
```



# Hooks

**npm**

```shell
npx sanity hooks --help
```

**pnpm**

```shell
pnpm dlx sanity hooks --help
```

**yarn**

```shell
yarn dlx sanity hooks --help
```

**bun**

```shell
bunx sanity hooks --help
```

## Commands

### `attempt`

**CLI output**

```sh
USAGE
  $ sanity hooks attempt ATTEMPTID [-p <id>]

ARGUMENTS
  ATTEMPTID  The delivery attempt ID to get details for

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to view webhook attempt for (overrides CLI configuration)

DESCRIPTION
  Print details of a given webhook delivery attempt

EXAMPLES
  Print details of webhook delivery attempt with ID abc123

    $ sanity hooks attempt abc123

  Get attempt details for a specific project

    $ sanity hooks attempt abc123 --project-id projectId
```

### `create`

**CLI output**

```sh
USAGE
  $ sanity hooks create [-p <id>]

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to create webhook for (overrides CLI configuration)

DESCRIPTION
  Create a new webhook for the project

EXAMPLES
  Create a new webhook for the project

    $ sanity hooks create

  Create a webhook for a specific project

    $ sanity hooks create --project-id abc123
```

### `delete`

**CLI output**

```sh
USAGE
  $ sanity hooks delete [NAME] [-p <id>]

ARGUMENTS
  [NAME]  Name of webhook to delete (will prompt if not provided)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete webhook from (overrides CLI configuration)

DESCRIPTION
  Delete a webhook from the project

EXAMPLES
  Interactively select and delete a webhook

    $ sanity hooks delete

  Delete a specific webhook by name

    $ sanity hooks delete my-hook

  Delete a webhook from a specific project

    $ sanity hooks delete --project-id abc123
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity hooks list [-p <id>]

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list webhooks for (overrides CLI configuration)

DESCRIPTION
  List webhooks for the project

EXAMPLES
  List webhooks for the project

    $ sanity hooks list

  List webhooks for a specific project

    $ sanity hooks list --project-id abc123
```

### `logs`

**CLI output**

```sh
USAGE
  $ sanity hooks logs [NAME] [-p <id>] [--detailed]

ARGUMENTS
  [NAME]  Name of the webhook to show logs for

FLAGS
      --detailed  Include detailed payload and attempts

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to view webhook logs for (overrides CLI configuration)

DESCRIPTION
  Show log entries for project webhooks

EXAMPLES
  Show log entries for project webhooks

    $ sanity hooks logs

  Show log entries for a specific webhook by name

    $ sanity hooks logs [NAME]

  Show log entries for a specific project

    $ sanity hooks logs --project-id abc123
```



# Init

**CLI output**

```sh
USAGE
  $ sanity init [--yes] [--auto-updates] [--bare] [--coupon <code>] [--dataset <name>] [--dataset-default] [--env <filename>] [--git <message>] [--import-dataset] [--json] [--mcp] [--nextjs-add-config-files] [--nextjs-append-env] [--nextjs-embed-studio] [--organization <id>] [--output-path <path>] [--overwrite-files] [--package-manager <manager>] [--project <id>] [--project-name <name>] [--project-plan <name>] [--provider <provider>] [--skills] [--template <template>] [--typescript] [--visibility <mode>]

FLAGS
  -y, --yes                        Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults
      --auto-updates               Enable auto updates of studio versions
      --bare                       Skip the Studio initialization and only print the selected project ID and dataset name to stdout
      --coupon=<code>              Optionally select a coupon for a new project (cannot be used with --project-plan)
      --dataset=<name>             Dataset name for the studio
      --dataset-default            Set up a project with a public dataset named "production"
      --env=<filename>             Write environment variables to file
      --git=<message>              Specify a commit message for initial commit, or disable git init
      --import-dataset             Import template sample dataset
      --mcp                        Enable AI editor integration (MCP) setup
      --organization=<id>          Organization ID to use for the project (required for unattended project creation)
      --output-path=<path>         Path to write studio project to
      --overwrite-files            Overwrite existing files
      --package-manager=<manager>  Specify which package manager to use [allowed: npm, yarn, pnpm]
      --project=<id>               Project ID to use for the studio
      --project-name=<name>        Create a new project with the given name
      --project-plan=<name>        Optionally select a plan for a new project
      --provider=<provider>        Login provider to use
      --skills                     Install Sanity agent skills globally for detected AI editors
      --template=<template>        Project template to use [default: "clean"]
      --typescript                 Enable TypeScript support
      --visibility=<mode>          Visibility mode for dataset

GLOBAL FLAGS
      --json  Format output as json.

Next.js FLAGS
      --nextjs-add-config-files  Add config files to Next.js project
      --nextjs-append-env        Append project ID and dataset to .env file
      --nextjs-embed-studio      Embed the Studio in Next.js application

DESCRIPTION
  Initialize a new Sanity Studio, project and/or app

EXAMPLES
    $ sanity init

  Initialize a new project with a public dataset named "production"

    $ sanity init --dataset-default

  Initialize a project with the given project ID and dataset to the given path

    $ sanity init -y --project abc123 --dataset production --output-path ~/myproj

  Initialize a project with the given project ID and dataset using the moviedb template to the given path

    $ sanity init -y --project abc123 --dataset staging --template moviedb --output-path .

  Create a brand new project with name "Movies Unlimited"

    $ sanity init -y --project-name "Movies Unlimited" --dataset moviedb --visibility private --template moviedb --output-path /Users/espenh/movies-unlimited
```

## Available templates

Pass a template slug to the `--template` flag when running `sanity init`. The following named templates are available: `clean` (default, minimal Studio setup), `moviedb` (movies dataset with sample schema and data), and `page-builder` (page builder setup using the @sanity/presets library).

**npm**

```shell
npm create sanity@latest -- --template page-builder
```

**pnpm**

```shell
pnpm create sanity@latest --template page-builder
```

**yarn**

```shell
yarn create sanity@latest --template page-builder
```

**bun**

```shell
bun create sanity@latest --template page-builder
```



# Install

**CLI output**

```sh
USAGE
  $ sanity install [PACKAGES]

ARGUMENTS
  [PACKAGES]  Packages to install

DESCRIPTION
  Install dependencies for the Sanity Studio project

EXAMPLES
    $ sanity install

    $ sanity install @sanity/vision

    $ sanity install some-package another-package
```





# Learn

**CLI output**

```sh
USAGE
  $ sanity learn

DESCRIPTION
  Open Sanity Learn in your browser
```



# Login

**CLI output**

```sh
USAGE
  $ sanity login [--open] [--provider <providerId>] [--sso <slug>] [--sso-provider <name>] [--with-token]

FLAGS
      --open                   Open a browser window to log in (`--no-open` only prints URL)
      --provider=<providerId>  Log in using a provider ID (google, github, sanity, vercel)
      --sso=<slug>             Log in using Single Sign-On, using the given organization slug
      --sso-provider=<name>    Select a specific SSO provider by name (use with --sso)
      --with-token             Read token from standard input

DESCRIPTION
  Log in to your Sanity account

EXAMPLES
  Log in using default settings

    $ sanity login

  Log in using a token from standard input

    $ sanity login --with-token < token.txt

  Login with GitHub provider, but do not open a browser window automatically

    $ sanity login --provider github --no-open

  Log in using Single Sign-On with the "my-organization" slug

    $ sanity login --sso my-organization

  Log in using a specific SSO provider within an organization

    $ sanity login --sso my-organization --sso-provider "Okta SSO"
```

The `sanity login` process requires a browser. To run a command that requires authentication but where a browser is not available, such as on a server, you can login locally, run `sanity debug --secrets` to get a personal auth token, and then precede the command requiring authentication with `SANITY_AUTH_TOKEN=<token>`.

```markdown
SANITY_AUTH_TOKEN=ab97ae7...0f9ff sanity init -y \
  --create-project "Movies Unlimited" \
  --dataset moviedb \
  --visibility private \
  --template moviedb \
  --output-path /path/to/folder
```

## Login with SAML SSO

> [!NOTE]
> SAML SSO Prerequisites
> SAML SSO requires an Enterprise plan, or a Growth plan with the SAML SSO add-on, and an external identity provider that supports SAML authentication, such as Okta, Azure AD, or Google.

Users configured with [SAML SSO](https://www.sanity.io/docs/developer-guides/sso-saml) can use the `--sso` flag when logging in to pass their slug and log into a project using their third-party identity provider. The slug is set via the [Sanity Management Console](https://www.sanity.io/manage) and is configured under the Settings tab for the Organization.

```markdown
usage: sanity login --sso <slug>

   Authenticates against a third-party identity provider

```

To sign in as a different user, run `sanity login` again. The CLI invalidates the previous session and writes the new token in one step. See [CLI authentication](https://www.sanity.io/docs/cli-reference/authentication) for the full auth surface, including SSO, robot tokens, and token storage.



# Logout

**CLI output**

```sh
USAGE
  $ sanity logout

DESCRIPTION
  Log out of the current session
```



## Robot token error

If `sanity logout` returns *Cannot delete session for robot user - use delete token endpoint*, your CLI is configured with a robot token rather than a user session. Use `sanity tokens delete <token-id>` to revoke it. See [CLI authentication](https://www.sanity.io/docs/cli-reference/authentication) for details.



# Manage

**CLI output**

```sh
USAGE
  $ sanity manage

DESCRIPTION
  Open project settings in your browser
```



# Manifest

**npm**

```shell
npx sanity manifest --help
```

**pnpm**

```shell
pnpm dlx sanity manifest --help
```

**yarn**

```shell
yarn dlx sanity manifest --help
```

**bun**

```shell
bunx sanity manifest --help
```

## Commands

### `extract`

**CLI output**

```sh
USAGE
  $ sanity manifest extract [--path <value>]

FLAGS
      --path=<value>  Optional path to specify destination directory of the manifest files

DESCRIPTION
  Extract studio configuration as JSON manifest files.
  
  Note: This command is experimental and subject to change. It is currently intended for use with Create only.

EXAMPLES
  Extracts manifests

    $ sanity manifest extract

  Extracts manifests into /public/static

    $ sanity manifest extract --path /public/static
```



# MCP

#### New to the Sanity MCP server?
This is reference documentation for the CLI's MCP command. If you're new to the Sanity MCP server, check out our getting started guide.
[Get started](https://www.sanity.io/docs/ai/mcp-server)

**npm**

```shell
npx sanity mcp --help
```

**pnpm**

```shell
pnpm dlx sanity mcp --help
```

**yarn**

```shell
yarn dlx sanity mcp --help
```

**bun**

```shell
bunx sanity mcp --help
```

## Commands

### `configure`

**CLI output**

```sh
USAGE
  $ sanity mcp configure

DESCRIPTION
  Configure Sanity MCP server for AI editors (Antigravity, Claude Code, Cline, Cline CLI, Codex CLI, Cursor, Gemini CLI, GitHub Copilot CLI, MCPorter, OpenCode, VS Code, VS Code Insiders, Zed)

EXAMPLES
  Configure Sanity MCP server for detected AI editors

    $ sanity mcp configure
```



# Media

Interact with Media Library with the `npx sanity media` command.

**npm**

```shell
npx sanity media --help
```

**pnpm**

```shell
pnpm dlx sanity media --help
```

**yarn**

```shell
yarn dlx sanity media --help
```

**bun**

```shell
bunx sanity media --help
```

The `media` command must be run from within a directory that contains a valid `santy.cli.ts` configuration file. We recommend running it from within an existing Sanity project. [Learn more about configuring Media Library](https://www.sanity.io/docs/media-library/configure-library).

## Commands

### `create-aspect`

**CLI output**

```sh
USAGE
  $ sanity media create-aspect [--name <value>] [--title <value>]

FLAGS
      --name=<value>   Aspect name. Defaults to the title in camel case
      --title=<value>  Aspect title

DESCRIPTION
  Create a new aspect definition file

EXAMPLES
  Create a new aspect definition file

    $ sanity media create-aspect
```

### `delete-aspect`

**CLI output**

```sh
USAGE
  $ sanity media delete-aspect ASPECTNAME [-p <id>] [--yes] [--media-library-id <value>]

ARGUMENTS
  ASPECTNAME  Name of the aspect to delete

FLAGS
  -y, --yes                       Run without prompts and confirm deletion
      --media-library-id=<value>  The id of the target media library

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete media aspect from (overrides CLI configuration)

DESCRIPTION
  Delete an aspect definition

EXAMPLES
  Delete the aspect named "someAspect"

    $ sanity media delete-aspect someAspect
```

### `deploy-aspect`

**CLI output**

```sh
USAGE
  $ sanity media deploy-aspect [ASPECTNAME] [-p <id>] [--all] [--media-library-id <value>]

ARGUMENTS
  [ASPECTNAME]  Name of the aspect to deploy

FLAGS
      --all                       Deploy all aspects
      --media-library-id=<value>  The id of the target media library

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to deploy media aspect to (overrides CLI configuration)

DESCRIPTION
  Deploy an aspect

EXAMPLES
  Deploy the aspect named "someAspect"

    $ sanity media deploy-aspect someAspect

  Deploy all aspects

    $ sanity media deploy-aspect --all
```

### `export`

**CLI output**

```sh
USAGE
  $ sanity media export [DESTINATION] [-p <id>] [--asset-concurrency <value>] [--media-library-id <value>] [--no-compress] [--overwrite]

ARGUMENTS
  [DESTINATION]  Output destination file path

FLAGS
      --asset-concurrency=<value>  Concurrent number of asset downloads
      --media-library-id=<value>   The id of the target media library
      --no-compress                Skips compressing tarball entries (still generates a gzip file)
      --overwrite                  Overwrite any file with the same name

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to export media from (overrides CLI configuration)

DESCRIPTION
  Export file and image assets from a media library (excludes video)

EXAMPLES
  Export media library interactively

    $ sanity media export

  Export media library to output.tar.gz

    $ sanity media export output.tar.gz

  Export specific media library

    $ sanity media export --media-library-id my-library-id
```

### `import`

**CLI output**

```sh
USAGE
  $ sanity media import SOURCE [-p <id>] [--media-library-id <value>] [--replace-aspects]

ARGUMENTS
  SOURCE  Image file or folder to import from

FLAGS
      --media-library-id=<value>  The id of the target media library
      --replace-aspects           Replace existing aspect data. All versions will be replaced (e.g. published and draft aspect data)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to import media to (overrides CLI configuration)

DESCRIPTION
  Import a set of assets to the target media library.

EXAMPLES
  Import all assets from the "products" directory

    $ sanity media import products

  Import all assets from "gallery" archive

    $ sanity media import gallery.tar.gz

  Import all assets from the "products" directory and replace aspects

    $ sanity media import products --replace-aspects
```



# Migrations

**npm**

```shell
npx sanity migrations --help
```

**pnpm**

```shell
pnpm dlx sanity migrations --help
```

**yarn**

```shell
yarn dlx sanity migrations --help
```

**bun**

```shell
bunx sanity migrations --help
```

## Commands

### `create`

**CLI output**

```sh
USAGE
  $ sanity migrations create [TITLE]

ARGUMENTS
  [TITLE]  Title of migration

DESCRIPTION
  Create a new migration within your project

EXAMPLES
  Create a new migration, prompting for title and options

    $ sanity migrations create

  Create a new migration with the provided title, prompting for options

    $ sanity migrations create "Rename field from location to address"
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity migrations list

DESCRIPTION
  List available migrations

EXAMPLES
  List all available migrations in the project

    $ sanity migrations list
```

### `run`

**CLI output**

```sh
USAGE
  $ sanity migrations run [ID] [--api-version <value>] [--concurrency <value>] [--confirm] [--dataset <value>] [--dry-run] [--from-export <value>] [--progress] [--project <value>]

ARGUMENTS
  [ID]  ID

FLAGS
      --api-version=<value>  API version to use when migrating. Defaults to v2024-01-29.
      --concurrency=<value>  How many mutation requests to run in parallel. Must be between 1 and 10. Default: 6.
      --confirm              Prompt for confirmation before running the migration (default: true). Use --no-confirm to skip.
      --dataset=<value>      Dataset to migrate. Defaults to the dataset configured in your Sanity CLI config.
      --dry-run              By default the migration runs in dry mode. Use --no-dry-run to migrate dataset.
      --from-export=<value>  Use a local dataset export as source for migration instead of calling the Sanity API. Note: this is only supported for dry runs.
      --progress             Display progress during migration (default: true). Use --no-progress to hide output.
      --project=<value>      Project ID of the dataset to migrate. Defaults to the projectId configured in your Sanity CLI config.

DESCRIPTION
  Run a migration against a dataset

EXAMPLES
  dry run the migration

    $ sanity migrations run <id>

  execute the migration against a dataset

    $ sanity migrations run <id> --no-dry-run --project xyz --dataset staging

  execute the migration using a dataset export as the source

    $ sanity migrations run <id> --from-export=production.tar.gz --no-dry-run --project xyz --dataset staging
```



# New

**CLI output**

```sh
USAGE
  $ sanity new [PROJECTNAME] [--yes] [--instructions] [--json] [--scaffold]

ARGUMENTS
  [PROJECTNAME]  Display name for the new project

FLAGS
  -y, --yes           Skip prompts and use defaults (project: "My Sanity project")
      --instructions  Print the full setup guide from https://sanity.new and exit, creating nothing
      --scaffold      Set up a Studio in ./sanity and a Next.js website in ./web (on by default)

GLOBAL FLAGS
      --json  Format output as json.

DESCRIPTION
  Sets up two folders here: ./sanity, a Studio where you write and edit your
  content, and ./web, a Next.js website that reads it. Both are already
  connected to your new project, so you can start them straight away. Use
  --no-scaffold if you just want the project and nothing else.
  
  The project is real and works immediately, but it is only yours for 72 hours.
  Claim it with a Sanity account before the deadline and everything you have
  built stays exactly as it is. Claiming is free and takes about a minute. Miss
  the deadline and the project and its content are deleted.
  
  Two things to keep private: the claim link, because anyone who opens it
  becomes the owner, and the access token saved in ./sanity/.env.local, because
  it can read and change everything in the project. ./web/.env.local has only
  the project ID and dataset. Keep both env files out of git, and never put the
  token in code that runs in the browser.
  
  Run this command with --instructions for the full agent setup guide.

EXAMPLES
  Create a project with a Studio and a website

    $ sanity new

  Create a project called "My New Project"

    $ sanity new "My New Project"

  Create a project without being asked anything

    $ sanity new --yes

  Create the project only, with no Studio or website

    $ sanity new --no-scaffold

  Create a project and print its details as JSON

    $ sanity new --json

  Print the full setup guide for an AI agent, without creating anything

    $ sanity new --instructions
```



# OpenAPI

**npm**

```shell
npx sanity openapi --help
```

**pnpm**

```shell
pnpm dlx sanity openapi --help
```

**yarn**

```shell
yarn dlx sanity openapi --help
```

**bun**

```shell
bunx sanity openapi --help
```

## Commands

### `get`

**CLI output**

```sh
USAGE
  $ sanity openapi get SLUG [--web] [--format <value>]

ARGUMENTS
  SLUG  Slug of the OpenAPI specification to retrieve

FLAGS
  -w, --web             Open in web browser
      --format=<value>  Output format: yaml (default), json

DESCRIPTION
  Get an OpenAPI specification by slug

EXAMPLES
  Get a specification (YAML format, default)

    $ sanity openapi get query

  Get specification in JSON format

    $ sanity openapi get query --format=json

  Open specification in browser

    $ sanity openapi get query --web

  Pipe to file

    $ sanity openapi get query > query-api.yaml
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity openapi list [--web] [--json]

FLAGS
  -w, --web   Open HTTP Reference in web browser
      --json  Output JSON

DESCRIPTION
  List all available OpenAPI specifications

EXAMPLES
  List all available OpenAPI specs

    $ sanity openapi list

  List with JSON output

    $ sanity openapi list --json

  Open HTTP Reference in browser

    $ sanity openapi list --web
```



# Organizations

**npm**

```shell
npx sanity organizations --help
```

**pnpm**

```shell
pnpm dlx sanity organizations --help
```

**yarn**

```shell
yarn dlx sanity organizations --help
```

**bun**

```shell
bunx sanity organizations --help
```

## Commands

### `create`

**CLI output**

```sh
USAGE
  $ sanity organizations create [--default-role <value>] [--name <value>]

FLAGS
      --default-role=<value>  Default role assigned to new members
      --name=<value>          Organization name

DESCRIPTION
  Create a new organization

EXAMPLES
  Interactively create an organization

    $ sanity organizations create

  Create an organization named "Acme Corp"

    $ sanity organizations create --name "Acme Corp"

  Create an organization with a default member role

    $ sanity organizations create --name "Acme Corp" --default-role member
```

### `delete`

**CLI output**

```sh
USAGE
  $ sanity organizations delete ORGANIZATIONID [--force]

ARGUMENTS
  ORGANIZATIONID  Organization ID to delete

FLAGS
      --force  Do not prompt for delete confirmation - forcefully delete

DESCRIPTION
  Delete an organization

EXAMPLES
  Delete an organization (prompts for confirmation)

    $ sanity organizations delete org-abc123

  Delete an organization without confirmation

    $ sanity organizations delete org-abc123 --force
```

### `get`

**CLI output**

```sh
USAGE
  $ sanity organizations get ORGANIZATIONID

ARGUMENTS
  ORGANIZATIONID  Organization ID

DESCRIPTION
  Get details of an organization

EXAMPLES
  Get details of a specific organization

    $ sanity organizations get org-abc123
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity organizations list

DESCRIPTION
  List organizations you are a member of

EXAMPLES
  List all your organizations

    $ sanity organizations list
```

### `update`

**CLI output**

```sh
USAGE
  $ sanity organizations update ORGANIZATIONID [--default-role <value>] [--name <value>] [--slug <value>]

ARGUMENTS
  ORGANIZATIONID  Organization ID

FLAGS
      --default-role=<value>  New default role for new members
      --name=<value>          New organization name
      --slug=<value>          New URL slug (requires authSAML feature on the organization)

DESCRIPTION
  Update an organization

EXAMPLES
  Rename an organization

    $ sanity organizations update org-abc123 --name "New Name"

  Set the organization slug (requires authSAML feature)

    $ sanity organizations update org-abc123 --slug new-slug

  Change the default member role

    $ sanity organizations update org-abc123 --default-role viewer
```



# Preview



**CLI output**

```sh
USAGE
  $ sanity preview [OUTPUTDIR] [--host <value>] [--port <value>]

ARGUMENTS
  [OUTPUTDIR]  Output directory

FLAGS
      --host=<value>  Local network interface to listen on (default: localhost)
      --port=<value>  TCP port to start server on (default: 3333)

DESCRIPTION
  Start a local server to preview a production build

EXAMPLES
    $ sanity preview --host=0.0.0.0

    $ sanity preview --port=1942

    $ sanity preview some/build-output-dir
```



# Projects



**npm**

```shell
npx sanity projects --help
```

**pnpm**

```shell
pnpm dlx sanity projects --help
```

**yarn**

```shell
yarn dlx sanity projects --help
```

**bun**

```shell
bunx sanity projects --help
```

## Commands

### `create`

**CLI output**

```sh
USAGE
  $ sanity projects create [PROJECTNAME] [--yes] [--dataset <value>] [--dataset-visibility <value>] [--json] [--organization <slug|id>]

ARGUMENTS
  [PROJECTNAME]  Name of the project to create

FLAGS
  -y, --yes                         Skip prompts and use defaults (project: "My Sanity Project", dataset: production, visibility: public)
      --dataset=<value>             Create a dataset. Prompts for visibility unless specified or --yes used
      --dataset-visibility=<value>  Dataset visibility: public or private
      --json                        Output in JSON format
      --organization=<slug|id>      Organization to create the project in

DESCRIPTION
  Create a new Sanity project

EXAMPLES
  Interactively create a project

    $ sanity projects create

  Create a project named "My New Project"

    $ sanity projects create "My New Project"

  Create a project in a specific organization

    $ sanity projects create "My Project" --organization=my-org

  Create a project with a private dataset named "staging"

    $ sanity projects create "My Project" --dataset=staging --dataset-visibility=private

  Create a project non-interactively with JSON output

    $ sanity projects create "CI Project" --yes --json
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity projects list [--order <value>] [--sort <value>]

FLAGS
      --order=<value>  Sort direction
      --sort=<value>   Sort field

DESCRIPTION
  List your projects

EXAMPLES
  List projects

    $ sanity projects list

  List projects sorted by member count, ascending

    $ sanity projects list --sort=members --order=asc
```

### `unclaimed`

**CLI output**

```sh
USAGE
  $ sanity projects unclaimed [--project-id <value>]

FLAGS
      --project-id=<value>  Project ID to recover

DESCRIPTION
  Recover details for unclaimed projects created on this machine

EXAMPLES
  List locally recorded unclaimed projects

    $ sanity projects unclaimed

  Show recovery details for one project

    $ sanity projects unclaimed --project-id abc123
```



# Schemas

## Available commands

**npm**

```shell
npx sanity schemas --help
```

**pnpm**

```shell
pnpm dlx sanity schemas --help
```

**yarn**

```shell
yarn dlx sanity schemas --help
```

**bun**

```shell
bunx sanity schemas --help
```

## Commands

### `delete`

**CLI output**

```sh
USAGE
  $ sanity schemas delete [-d <name>] [-p <id>] [--yes] --ids <value> [--verbose]

FLAGS
  -d, --dataset=<name>  Delete schemas from a specific dataset
  -y, --yes             Delete schemas without prompting for confirmation
      --ids=<value>     Comma-separated list of schema ids to delete
      --verbose         Enable verbose logging

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete schema from (overrides CLI configuration)

DESCRIPTION
  Delete schema documents by id

EXAMPLES
  Delete a single schema

    $ sanity schemas delete --ids sanity.workspace.schema.workspaceName

  Delete multiple schemas

    $ sanity schemas delete --ids sanity.workspace.schema.workspaceName,prefix.sanity.workspace.schema.otherWorkspace
```

### `deploy`

**CLI output**

```sh
USAGE
  $ sanity schemas deploy [--extract-manifest] [--manifest-dir <directory>] [--tag <tag>] [--verbose] [--workspace <name>]

FLAGS
      --extract-manifest          Regenerate manifest before deploying (use --no-extract-manifest to skip)
      --manifest-dir=<directory>  Directory containing manifest file
      --tag=<tag>                 Add a tag suffix to the schema id
      --verbose                   Print detailed information during deployment
      --workspace=<name>          The name of the workspace to deploy a schema for

DESCRIPTION
  Deploy schema documents into workspace datasets.
  
  Note: This command is experimental and subject to change.
  
  Regenerates a manifest file by default. To re-use an existing manifest, use --no-extract-manifest.

EXAMPLES
  Deploy all workspace schemas

    $ sanity schemas deploy

  Deploy the schema for only the workspace "default"

    $ sanity schemas deploy --workspace default
```

### `extract`

**CLI output**

```sh
USAGE
  $ sanity schemas extract [--enforce-required-fields] [--force] [--format <format>] [--path <value>] [--watch] [--watch-patterns <glob>] [--workspace <name>]

FLAGS
      --enforce-required-fields  Makes the schema generated treat fields marked as required as non-optional
      --force                    Overwrite an existing schema file
      --format=<format>          Output format (currently only groq-type-nodes)
      --path=<value>             Optional path to specify destination of the schema file
      --watch                    Enable watch mode to re-extract schema on file changes
      --watch-patterns=<glob>    Additional glob pattern(s) to watch (can be specified multiple times)
      --workspace=<name>         The name of the workspace to generate a schema for

DESCRIPTION
  Extract a JSON representation of a Sanity schema within a Studio context.
  
  Note: This command is experimental and subject to change.

EXAMPLES
  Extracts schema types in a Sanity project with more than one workspace

    $ sanity schemas extract --workspace default

  Watch mode - re-extract on changes

    $ sanity schemas extract --watch

  Watch with custom glob patterns

    $ sanity schemas extract --watch --watch-patterns "lib/**/*.ts"
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity schemas list [--id <schema_id>] [--json]

FLAGS
      --id=<schema_id>  Fetch a single schema by id
      --json            Get schema as json

DESCRIPTION
  List all schemas in the current dataset.
  
  Note: This command is experimental and subject to change.
  
  Regenerates a manifest file by default. To reuse an existing manifest, use --no-extract-manifest.

EXAMPLES
  List all schemas found in any workspace dataset in a table

    $ sanity schemas list

  Get a schema for a given id

    $ sanity schemas list --id _.schemas.workspaceName

  Get stored schemas as pretty-printed json-array

    $ sanity schemas list --json

  Get singular stored schema as pretty-printed json-object

    $ sanity schemas list --json --id _.schemas.workspaceName
```

### `validate`

**CLI output**

```sh
USAGE
  $ sanity schemas validate [--debug-metafile-path <value>] [--format <value>] [--level <value>] [--workspace <value>]

FLAGS
      --format=<value>     The output format used to print schema errors and warnings
      --level=<value>      The minimum level reported out
      --workspace=<value>  The name of the workspace to use when validating all schema types

DEBUG FLAGS
      --debug-metafile-path=<value>  Optional path where a metafile will be written for build analysis. Only written on successful validation. Can be analyzed at https://esbuild.github.io/analyze/

DESCRIPTION
  Validates all schema types specified in a workspace

EXAMPLES
  Validates all schema types in a Sanity project with more than one workspace

    $ sanity schemas validate --workspace default

  Save the results of the report into a file

    $ sanity schemas validate > report.txt

  Report out only errors

    $ sanity schemas validate --level error

  Generate a report which can be analyzed with https://esbuild.github.io/analyze/

    $ sanity schemas validate --debug-metafile-path metafile.json
```



# Start

**CLI output**

```sh
USAGE
  $ sanity start [OUTPUTDIR]

ARGUMENTS
  [OUTPUTDIR]           Output directory

FLAGS
      --host=<HOST>                 The local network interface at which to listen.
      --port=<PORT>                 TCP port to start server on.

DESCRIPTION
  Starts a server to preview a production build

EXAMPLES
    sanity start --host=0.0.0.0

    sanity start --port=1942

    sanity start some/build-output-dir
```



# Telemetry

**npm**

```shell
npx sanity telemetry --help
```

**pnpm**

```shell
pnpm dlx sanity telemetry --help
```

**yarn**

```shell
yarn dlx sanity telemetry --help
```

**bun**

```shell
bunx sanity telemetry --help
```



## Commands

### `disable`

**CLI output**

```sh
USAGE
  $ sanity telemetry disable

DESCRIPTION
  Disable telemetry for your account

EXAMPLES
  Disable telemetry for your account

    $ sanity telemetry telemetry disable
```

### `enable`

**CLI output**

```sh
USAGE
  $ sanity telemetry enable

DESCRIPTION
  Enable telemetry for your account

EXAMPLES
  Enable telemetry for your account

    $ sanity telemetry telemetry enable
```

### `status`

**CLI output**

```sh
USAGE
  $ sanity telemetry status

DESCRIPTION
  Check telemetry status for your account

EXAMPLES
  Check telemetry status for your account

    $ sanity telemetry telemetry status
```



# TypeGen

#### New to the TypeGen?
This is reference documentation for the CLI's typegen command. If you're new to TypeGen, check out our getting started guide.
[Get started](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen)

**npm**

```shell
npx sanity typegen --help
```

**pnpm**

```shell
pnpm dlx sanity typegen --help
```

**yarn**

```shell
yarn dlx sanity typegen --help
```

**bun**

```shell
bunx sanity typegen --help
```

## Commands

### `generate`

**CLI output**

```sh
USAGE
  $ sanity typegen generate [--config-path <value>] [--watch]

FLAGS
      --config-path=<value>  [Default: sanity-typegen.json] Specifies the path to the typegen configuration file. This file should be a JSON file that contains settings for the type generation process.
      --watch                [Default: false] Run the typegen in watch mode

DESCRIPTION
  Sanity TypeGen
  
  Configuration:
  This command can utilize configuration settings defined in a `sanity-typegen.json` file. These settings include:
  
  - "path": Specifies a glob pattern to locate your TypeScript or JavaScript files.
    Default: "./src/**/*.{ts,tsx,js,jsx}"
  
  - "schema": Defines the path to your Sanity schema file. This file should be generated using the `sanity schema extract` command.
    Default: "schema.json"
  
  - "generates": Indicates the path where the generated TypeScript type definitions will be saved.
    Default: "./sanity.types.ts"
  
  The default configuration values listed above are used if not overridden in your `sanity-typegen.json` configuration file. To customize the behavior of the type generation, adjust these properties in the configuration file according to your project's needs.
  
  Note:
  - The `sanity schema extract` command is a prerequisite for extracting your Sanity Studio schema into a `schema.json` file, which is then used by the `sanity typegen generate` command to generate type definitions.

EXAMPLES
  Generate TypeScript type definitions from a Sanity Studio schema extracted using the `sanity schema extract` command.

    $ sanity typegen generate
```



# Undeploy

**CLI output**

```sh
USAGE
  $ sanity undeploy [--json] [--yes] [--dry-run]

FLAGS
  -j, --json     Output the result as JSON
  -y, --yes      Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults
      --dry-run  Report what would be undeployed without deleting anything

DESCRIPTION
  Removes the deployed Sanity Studio/App from Sanity hosting

EXAMPLES
  Undeploy the studio or application after confirming

    $ sanity undeploy

  Report what would be undeployed without deleting anything

    $ sanity undeploy --dry-run

  Undeploy without prompting and report the result as JSON

    $ sanity undeploy --json --yes
```

The `undeploy` command reads the studio host name from the `sanity.cli.ts` file in your studio directory. To enable support for deploying/undeploying multiple instances, follow the CI/CD instructions in the [deployment guide](https://www.sanity.io/docs/studio/deployment).

Once a studio is undeployed, the name (e.g., `<your-studio-name>`) becomes publicly available. Local instances of the studio are not affected.



# Users

**npm**

```shell
npx sanity users --help
```

**pnpm**

```shell
pnpm dlx sanity users --help
```

**yarn**

```shell
yarn dlx sanity users --help
```

**bun**

```shell
bunx sanity users --help
```

## Commands

### `invite`

**CLI output**

```sh
USAGE
  $ sanity users invite [EMAIL] [-p <id>] [--role <value>]

ARGUMENTS
  [EMAIL]  Email address to invite

FLAGS
      --role=<value>  Role to invite the user as

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to invite user to (overrides CLI configuration)

DESCRIPTION
  Invite a new user to the project

EXAMPLES
  Invite a new user to the project (prompt for details)

    $ sanity users invite

  Send a new user invite to the email "pippi@sanity.io", prompt for role

    $ sanity users invite pippi@sanity.io

  Send a new user invite to the email "pippi@sanity.io", as administrator

    $ sanity users invite pippi@sanity.io --role administrator

  Invite a user to a specific project

    $ sanity users invite pippi@sanity.io --project-id abc123
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity users list [-p <id>] [--invitations] [--order <value>] [--robots] [--sort <value>]

FLAGS
      --invitations    Includes or excludes pending invitations
      --order=<value>  Sort output ascending/descending
      --robots         Includes or excludes robots (token users)
      --sort=<value>   Sort users by specified column

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list users for (overrides CLI configuration)

DESCRIPTION
  List project members

EXAMPLES
  List all users of the project

    $ sanity users list

  List all users of the project, but exclude pending invitations and robots

    $ sanity users list --no-invitations --no-robots

  List all users, sorted by role

    $ sanity users list --sort role

  List users for a specific project

    $ sanity users list --project-id abc123
```



# Versions

**CLI output**

```sh
USAGE
  $ sanity versions

DESCRIPTION
  Show installed package versions

EXAMPLES
    $ sanity versions
```



# Tokens

**npm**

```shell
npx sanity tokens --help
```

**pnpm**

```shell
pnpm dlx sanity tokens --help
```

**yarn**

```shell
yarn dlx sanity tokens --help
```

**bun**

```shell
bunx sanity tokens --help
```



## Commands

### `create`

**CLI output**

```sh
USAGE
  $ sanity tokens create [LABEL] [-p <id>] [--yes] [--expires-at 2027-01-01] [--json] [--role viewer]

ARGUMENTS
  [LABEL]  Label for the new token

FLAGS
  -y, --yes                    Skip prompts and use defaults (unattended mode)
      --expires-at=2027-01-01  Date or timestamp the token expires (ISO 8601; tokens never expire by default)
      --json                   Output as JSON
      --role=viewer            Role to assign to the token (defaults to viewer in unattended mode)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to create token in (overrides CLI configuration)

DESCRIPTION
  Create a new API token for the project

EXAMPLES
  Create a token with a label

    $ sanity tokens create "My API Token"

  Create a token with editor role

    $ sanity tokens create "My API Token" --role=editor

  Create a token in unattended mode

    $ sanity tokens create "CI Token" --role=editor --yes

  Create a token that expires on a given date

    $ sanity tokens create "Build Token" --expires-at 2027-01-01

  Output token information as JSON

    $ sanity tokens create "API Token" --json

  Create a token for a specific project

    $ sanity tokens create "My Token" --project-id abc123 --role=editor
```

### `delete`

**CLI output**

```sh
USAGE
  $ sanity tokens delete [TOKENID] [-p <id>] [--yes]

ARGUMENTS
  [TOKENID]  Token ID to delete (will prompt if not provided)

FLAGS
  -y, --yes  Skip confirmation prompt (unattended mode)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete token from (overrides CLI configuration)

DESCRIPTION
  Delete an API token from the project

EXAMPLES
  Interactively select and delete a token

    $ sanity tokens delete

  Delete a specific token by ID

    $ sanity tokens delete silJ2lFmK6dONB

  Delete a specific token without confirmation prompt

    $ sanity tokens delete silJ2lFmK6dONB --yes

  Delete a token from a specific project

    $ sanity tokens delete --project-id abc123
```

### `list`

**CLI output**

```sh
USAGE
  $ sanity tokens list [-p <id>] [--json]

FLAGS
      --json  Output tokens in JSON format

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to list tokens for (overrides CLI configuration)

DESCRIPTION
  List API tokens for the project

EXAMPLES
  List tokens for the project

    $ sanity tokens list

  List tokens in JSON format

    $ sanity tokens list --json

  List tokens for a specific project

    $ sanity tokens list --project-id abc123
```

### `rotate`

**CLI output**

```sh
USAGE
  $ sanity tokens rotate [-t <token>] [--json]

FLAGS
  -t, --token=<token>  Token to rotate (prefer standard input to keep it out of shell history)
      --json           Output as JSON

DESCRIPTION
  Rotate an API token, replacing its secret with a new one

EXAMPLES
  Rotate the token piped on standard input

    $ echo "$SANITY_TOKEN" | sanity tokens rotate

  Rotate a token read from a file

    $ sanity tokens rotate < token.txt

  Output the rotated token as JSON

    $ echo "$SANITY_TOKEN" | sanity tokens rotate --json
```



# Skills

**npm**

```shell
npx sanity skills --help
```

**pnpm**

```shell
pnpm dlx sanity skills --help
```

**yarn**

```shell
yarn dlx sanity skills --help
```

**bun**

```shell
bunx sanity skills --help
```

## Commands

### `install`

**CLI output**

```sh
USAGE
  $ sanity skills install

DESCRIPTION
  Install Sanity agent skills for detected AI editors (Antigravity, Claude Code, Cline, Cline CLI, Codex CLI, Cursor, Gemini CLI, GitHub Copilot CLI, OpenCode, VS Code, VS Code Insiders)

EXAMPLES
  Install Sanity agent skills for detected AI editors

    $ sanity skills install
```



# Store and query structured content

#### Query and retrieve content

[GROQ introduction](https://www.sanity.io/docs/content-lake/groq-introduction)
GROQ (Graph-Relational Object Queries) is Sanity's powerful query language designed to help you describe exactly what information your application needs. 

[GraphQL](https://www.sanity.io/docs/content-lake/graphql)
How to deploy and query GraphQL API for your Sanity projects

[Perspectives for Content Lake](https://www.sanity.io/docs/content-lake/perspectives)
Perform the same query but with different results based on the published or draft status of a document.

[Libraries and clients](https://www.sanity.io/docs/libraries)
First and third-party libraries for interacting with your data in Content Lake

#### Document storage

[Documents](https://www.sanity.io/docs/content-lake/documents)
Sanity stores your data, and some system data, in JSON documents. 

[Drafts and versions](https://www.sanity.io/docs/content-lake/drafts)
How drafts work, and how you disable them

[IDs and paths](https://www.sanity.io/docs/content-lake/ids)
How document IDs work, and what you can do with them

[Datasets](https://www.sanity.io/docs/content-lake/datasets)
Managing multiple datasets within a project

[Assets](https://www.sanity.io/docs/content-lake/assets)
Sanity provides extensible UI for managing assets, and an API for dealing with storage, resizing and deletion.

#### Create and mutate documents

[Introduction to document mutations](https://www.sanity.io/docs/content-lake/mutations-introduction)
Sanity's Content Lake offers a variety of methods for creating, editing, and deleting documents.

[Mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions)
The Actions API let you use the same system Sanity Studio uses to mutate documents in Content Lake.

[Document mutation patterns](https://www.sanity.io/docs/content-lake/mutation-patterns)
Common patterns and snippets for mutating documents and data in the Sanity Content Lake.

#### Real-time & integration features

[Live Content API](https://www.sanity.io/docs/content-lake/live-content-api)
The Live Content API is perfect for fast-moving events like sports, news, and commerce. Deliver real-time experiences at scale.

[API CDN](https://www.sanity.io/docs/content-lake/api-cdn)
Description of the CDN-distributed, cached version of the Sanity API.

#### Content operations

[Migrating your schema and content](https://www.sanity.io/docs/content-lake/schema-and-content-migrations)
How to migrate schema and content within a Sanity project

[Connected content](https://www.sanity.io/docs/studio/connected-content)
Structured content is connected. It's what enables reusing and repurposing the same chunk of content in different contexts, and it's how you enable your content to be treated as data.



# Technical limits

This article describes limits in Content Lake. Note that a project may have additional limits depending on its plan. See the [pricing page](https://www.sanity.io/pricing) for plan-specific details. Most limits cause the API to reject the call with an error. A few are applied silently and are noted where they appear. If you need higher limits, contact Sanity support.

Sanity uses standard SI units, so 1 MB is 1,000,000 bytes.

## Datasets

The following limits apply to datasets:

- Maximum number of documents:- Free plan: 10,000 documents
- Growth plan: 50,000 documents
- Enterprise plan: 1,000,000 documents or more, depending on the plan


- Maximum total size of JSON documents: 10 GB
- Maximum number of unique attributes across all documents:- Free plan: 2,000 attributes
- Growth plan: 10,000 attributes
- Enterprise plan: custom number of attributes


- Maximum dataset name length: 64 characters

> [!NOTE]
> How attributes are counted
> An attribute here is considered to be any unique attribute/datatype combination, so an attribute `attr` containing a string, integer, and null value (in different documents) counts as 3 attributes. Additionally, arrays count as 1 extra attribute per unique datatype they contain, so the array `[2.718, 3.14, "abc", "def", true]` counts as 4 attributes (1 for the array itself, and 3 for the datatypes float, string, and boolean).

## Documents

The following limits apply to individual documents:

- Maximum JSON document size: 32 MB
- Maximum number of attributes:- Free and Growth plans: 1,000 attributes
- Enterprise plan: 8,000 attributes


- Maximum attribute nesting depth: 20 levels
- Maximum searchable term length: 1,024 UTF-8 characters. Terms longer than this are not indexed, and no error is returned.

## Listeners

Maximum concurrent listeners for the various project plans:

- Free plan: 1,000 listeners
- Growth plan: 5,000 listeners
- Enterprise plan: 10,000 listeners

> [!TIP]
> What happens when you exceed the listener limit
> If you hit the maximum concurrent listener limit, you get the error `Max listener limit exceeded at <LIMIT>`.

## API calls

The following limits apply to API calls:

- Maximum working set retrieved from Content Lake: 500 MB
- Maximum query execution time: 1 minute
- Maximum mutation execution time: 3 minutes
- Maximum export execution time: 5 minutes
- Maximum listener connection lifetime: 4 hours. Connections are dropped between 3 hours 50 minutes and 4 hours.
- Maximum execution time for the deprecated [Scheduling API](https://www.sanity.io/docs/http-reference/scheduling): 1 minute. Use Scheduled Drafts or Content Releases instead.

## Rate limits

### Resource rate limits

Creation limits are enforced for the following resources.

- Maximum new organizations per user: 5 per hour
- Maximum new projects per organization: 5 per hour

### API rate limits

Two rate limits apply: one per source IP and one for the number of concurrent queries.

#### API rate limits per IP

API rate limits are enforced per client IP address per second. If you exceed a rate limit, the API returns HTTP `429` responses for any further requests of that type until the next period begins.

- Maximum mutation rate: 25 req/s (combined `POST` to `/data/mutate` and `POST` to `/data/actions`)
- Maximum upload rate: 25 req/s (`POST` to `/assets/`)
- Maximum global API call rate: 500 req/s
- Maximum global API Content Delivery Network (CDN) call rate: unlimited for cached responses

#### API concurrent rate limits

Concurrent API requests are also rate limited per dataset:

- Maximum concurrent queries to API: 500
- Maximum concurrent mutations to API: 100

The API CDN itself isn't rate limited, but misses (uncached requests) are subject to these concurrency limits.

## HTTP requests

The following limits apply to HTTP requests:

- Maximum combined request headers size: 15 KB
- Maximum request body size: 100 MB
- Maximum mutation request body size: 4 MB

> [!TIP]
> When request headers exceed 15 KB
> If your request headers exceed 15 KB, switch from `GET` to `POST` and put the payload in the request body. See [Queries — the POST form](https://www.sanity.io/docs/http-reference/query).

## API CDN HTTP requests

The following limits apply to `/data` endpoints (not asset endpoints):

The maximum `POST` size is 307,200 bytes.

## Assets

The following limits apply to assets:

- Maximum image size: 256 megapixels
- Maximum output dimensions (from transforms): 8,192 pixels
- Supported image upload formats:- `png`
- `jpg`
- `jpeg`
- `bmp`
- `gif`
- `tiff`
- `svg`
- `psd`
- `webp`
- `heif`
- `avif`


- Maximum upload duration:- Dataset assets: 5 minutes
- Media Library assets: 1 hour



### Animated images with transforms

- Maximum size of animated images with transforms: 256 megapixels
- Calculated as: width × height × frame count = total pixels.
- Divide total pixels by 1,000,000 to get megapixels.

This limit applies to on-demand transforms for supported animated image formats. Animated output is supported for `gif` and `webp` only. A transform that exceeds 256 megapixels across all frames does not fail — it returns a static image.

## Users

The following limits apply to users:

- Maximum number of users per project: 1,000

User attributes are available on certain Enterprise plans:

- Maximum attributes per user: 50
- Maximum user attribute name length: 32 characters
- Maximum user attribute value length: 1,024 characters

[Contact Sanity](https://sanity.io/contact) if you need more than 1,000 users on a single project.

## Content releases

The following limits apply to content releases:

- Maximum number of document versions in a release: 1,000.
- Maximum total size of JSON documents in a release: 100 MB.

The total size of JSON documents in a release refers to the sum of the size of each JSON document in bytes.

Releases batch in sets of 10 MB. Documents may not publish at the exact same moment if they exist in different 10 MB blocks.

> [!TIP]
> For best practices on avoiding rate limits, including guidance on static site builds, retry logic, and CDN usage, see the rate limiting section on the [API CDN](https://www.sanity.io/docs/content-lake/api-cdn) page.

## Hierarchy

> [!WARNING]
> Public beta
> The hierarchy primitive is in public beta. The limits and validation rules in this section may change before general availability.

The following limits apply to documents that participate in the [hierarchy primitive](https://www.sanity.io/docs/content-lake/hierarchy) (`sanity.tree`, `sanity.directory`, `sanity.symlink`, and any other document that carries a `parent` reference).

- Maximum hierarchy depth: 20 levels. The `sanity.tree` is level 0; a `sanity.directory` immediately under it is level 1.
- Maximum `name` length on `sanity.directory`: 255 characters.
- `parent` and `target` must be strong references (`_weak: true` is rejected).
- `parent` must reference a `sanity.tree` or `sanity.directory`. Other types are rejected.
- `parent` must not reference itself.
- Cycles are rejected at mutation time (a directory cannot become its own ancestor).
- `sanity.symlink` documents cannot target themselves.

For the full list of validation errors, see the [hierarchy error reference](https://www.sanity.io/docs/content-lake/hierarchy).

> [!NOTE]
> Where these limits are enforced
> Some of these constraints are enforced by the Content Lake API, but consuming applications such as [Media Library](https://www.sanity.io/docs/media-library/limits-and-usage) may enforce extra limitations.



# API Versioning

Every Sanity API request is pinned to a version, written as a date. That version fixes the API's behavior, so your code keeps working the same way as the service evolves.

> [!TIP]
> The short version
> Use today's date when you start something new, written as a static string: `apiVersion: '2026-07-28'`.
> Then leave it alone. The next time you want to adopt a new feature, check whether it requires a newer version. If it does, update the date and confirm your existing queries and mutations still behave as expected.
> That covers what most projects need. The rest of this page explains what the version controls and what to watch for when you change it.

Every Sanity client takes the version as a configuration option, including the [official Sanity JavaScript client](https://www.sanity.io/docs/js-client). A client configured with 2026-07-28 uses the most recent API version released on or before July 28, 2026.

```javascript
const {createClient} = require('@sanity/client')

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-07-28', // a static UTC date string
  token: '<sanity-auth-token>', // or leave blank for unauthenticated usage
  useCdn: true, // `false` if you want to ensure fresh data
})
```

## How API versions work

The URL of every API call includes an explicit version as the first path segment. The following call uses API version `v2026-06-24`:

`https://example.api.sanity.io/v2026-06-24/data/query/production?query=*`

From time to time, we may need to make breaking changes to our API functionality. We try very hard to avoid this, but it is *sometimes* necessary to fix bugs. A versioned API lets us make those improvements without breaking existing code.

To the best of our ability, we try to ensure that old versions of the API don't change, even as our services evolve. This may sometimes include "wrong" behavior that we choose to maintain if we deem that the fix might negatively impact users.

As long as we don't see the change as a breaking change, we may choose to introduce it also in older versions of the API.

## Version dates

Version dates are ISO 8601-formatted and use the UTC time zone. Any past or present date is valid, and today's date always resolves to the latest version, so there's no need to check the release history.

[Stripe inspired us](https://stripe.com/blog/api-versioning) to use dates instead of incremental version numbers (although our initial version is `v1`). We much prefer to release frequent small improvements rather than saving them up for a huge `v2` release. This allows us to get fixes into the hands of our users much sooner and makes it easier for our users to upgrade incrementally. With new versions released regularly, we believe it is more informative to use dates rather than rapidly increasing numbers.

## Client configuration

Clients should be configured with an explicit, static API version. See the [individual clients' documentation](https://www.sanity.io/docs/client-libraries) for details on how to do this. When starting new projects, clients should typically be given today's UTC date to get the most recent bugfixes and improvements. Older clients which do not support versioning will default to `v1`, our initial and outdated API version.

Write the date as a literal string. Computing it at runtime (for example from `new Date()`) means your API version changes every day, so a change to the API can alter your app's behavior without you deploying anything. A hardcoded date pins the behavior until you decide to move it.

The `apiVersion` property of the JavaScript client is optional. Omit it and the client issues a deprecation warning, then defaults to `v1` of the API. Passing the property with an undefined value throws an error instead.

When using the HTTP API, the version number is prefixed with the `v` character. The JavaScript client accepts the version with or without the prefix, and adds it when building the request URL.

## Upgrade to a newer version

> [!NOTE]
> Changelog
> Looking for the latest changes? Check them out in [the changelog](https://www.sanity.io/changelog).

To upgrade an application to a newer API version, first read our list of API changes to determine which (if any) modifications must be made to the application. Then, with a local instance of the application, set the newer API version for the client, make the necessary changes to the code, and then test the application either locally or in a staging environment. Once you are confident that the application correctly handles the new API version you can deploy it to production.

We recommend making multiple smaller upgrades rather than a single larger upgrade, to reduce the chance of anything breaking and make the job more manageable, but this is up to you to decide.

## Experimental API version

The special version `X` is used to test experimental changes. This version may change at any time in any way and is used at your own risk. Not only will it be backward-incompatible, but it may also cause data loss and other problems.

When using a version that isn't considered completely stable, the API will return a warning message in the `X-Sanity-Warning` header.

## Deprecation and removal

At times we will have to deprecate and then remove certain older versions of the API. We will always give appropriate notice when this is necessary.

In addition to notices on our website and to your registered email address, these versions will receive a warning through the `X-Sanity-Warning` header and also be tagged as deprecated via an `X-Sanity-Deprecated: true` HTTP header. Once an API version is removed, all calls to that version will return errors with code `410`.

## Backward-compatible changes

We consider the following changes to be backward-compatible and may therefore introduce them retroactively in old API versions. This list is not exhaustive.

- Adding new object attributes in JSON responses (outside of documents or query results)
- Changing the order of object attributes in JSON responses
- Adding new `_`-prefixed metadata attributes to stored or modified documents
- Adding new functionality to GROQ, such as operators, functions, and data types
- Adding new, optional parameters to API calls
- Adding new HTTP headers in responses
- Adding new endpoints to the API, or new methods to existing API endpoints

## GraphQL API versions

> [!WARNING]
> Gotcha
> **API versioning for v1 GraphQL queries. **
> `/v2023-03-01/graphql/**` endpoints use the same GROQ resolution as `/v1/graphql/**`.
> GraphQL API v2023-08-01 introduced [breaking changes](https://www.sanity.io/changelog/9ec89318-a340-4e23-91d9-3154da5b6244#5712319a77e4) to v1. You can opt in to new features or continue using the v1 API. See the [GraphQL documentation](https://www.sanity.io/docs/content-lake/graphql) for additional GraphQL endpoint and usage details.



# API CDN

When querying content for your frontend, choosing between Sanity's two content delivery APIs affects response speed, freshness, and rate limits.

1. `api.sanity.io`: the uncached API. This is the default and will always give you the freshest data, but requests will be slower because they need to reach the backend on every request. Requests are also more costly because they trigger more computation on the servers.
2. `apicdn.sanity.io`: the CDN-distributed, cached API. This opt-in feature provides fast responses for cached requests. Use the API CDN for frontends that serve end users. For static builds, the live uncached API is a better fit to ensure you get the latest content.

To use the API CDN, use `apicdn.sanity.io` instead of `api.sanity.io`. Most clients provide a `useCdn` option that makes this switch seamless.

> [!NOTE]
> Supported endpoints
> The API CDN supports `/<version>/data/query` for [GROQ queries](https://www.sanity.io/docs/http-reference/query) and `/<version>/graphql` for [GraphQL queries](https://www.sanity.io/docs/content-lake/graphql).

> [!TIP]
> Choosing the right API
> Make sure to pick the right tool for your workload.
> If you are going to fetch content from a browser, we recommend the API CDN so your requests can scale.
> When building integrations with Sanity or responding to webhooks, we recommend using the API to capture the latest saved content.

## Cache policy

The API CDN is primarily meant to cache query results for end users:

- GET, HEAD, and OPTION requests are cached.
- POST requests to [/graphql](https://www.sanity.io/docs/content-lake/graphql) and `/data/query` are also cached, as these endpoints are read-only.
- Maximum HTTP POST size is 300 KB.
- All other POST requests are rejected since they can contain mutations.
- Responses larger than 10 MB are not cached.
- Non-200 responses are not cached.
- Cookies are ignored when identifying cache hits and for authentication.
- [Authenticated requests](https://www.sanity.io/docs/content-lake/http-auth) are cached. Caching is segmented for each unique authentication token.
- [Authenticated requests](https://www.sanity.io/docs/content-lake/http-auth) must use a bearer token in the `Authorization`-header
- Listeners, including the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api), are redirected to the API and do not query cached content.

During periods of high content traffic (mutations or requests), we prioritize the cache invalidation queue to ensure consistent caching windows for customers with our High Frequency CDN.

If Sanity's Content Lake is unavailable, the API CDN will return the last cached content for up to two hours.

All official clients will automatically fall back to using the live API where appropriate.

> [!TIP]
> Caches are based on the URLs, including query parameters and other URL fragments, of your requests. Optimize your performance by ensuring that your request URLs will be shared across your traffic and will benefit from caching.

## Locations

Sanity currently has CDNs for the API in these locations:

- Asia- Mumbai, India


- Oceania- Sydney, Australia


- Europe- Saint-Ghislain, Belgium


- South America- São Paulo, Brazil


- North America- Oregon, United States
- Iowa, United States
- Northern Virginia, United States



A short-lived global CDN also sits in front of these locations, with points of presence on all continents. This global CDN does not cache private datasets or POST queries. Using the API CDN is still recommended for both public and private datasets. For how the API CDN handles unauthenticated requests to private datasets, see [Private datasets](https://www.sanity.io/docs/content-lake/datasets).

## IP addresses in use

We maintain a unified list of all IPs that may be useful to permit in instances where you have egress filtering enabled. See the [IP addresses used by Sanity document](https://www.sanity.io/docs/content-lake/ip-addresses) for details.

#### Related articles

[Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide)
Learn to use the Live Content API with Next.js or your own integration for real-time content updates in your app.

[Technical limits](https://www.sanity.io/docs/content-lake/technical-limits)
A list of Content Lake limits.

[Getting started with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started)
Learn how to install and configure the official Sanity JavaScript client for querying and mutating content across different environments.

## Rate limiting and concurrency

Cached responses from the API CDN are not rate limited. However, requests that result in a cache miss are forwarded to the direct API, which enforces rate limits and concurrency limits per dataset.

### Concurrency limits

Concurrency limits restrict how many requests can be in-flight at the same time for a single dataset:

- **Queries:** 500 concurrent requests per dataset
- **Mutations:** 100 concurrent requests per dataset

### Per-IP rate limits

The direct API also enforces per-IP rate limits:

- **API calls:** 500 requests per second per IP
- **Mutations:** 25 requests per second per IP
- **Uploads:** 25 requests per second per IP

When you exceed a limit, the API returns an HTTP `429 Too Many Requests` response. `@sanity/client` [retries rate-limited queries automatically](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced) with exponential backoff. It does not retry mutations, so back those off yourself or send them through a rate-limited queue.

### Best practices

- **Use the API CDN for reads:** set `useCdn: true` in your client configuration to serve cached responses and avoid hitting direct API limits.
- **Limit concurrency in static builds:** static site generators that fetch many pages at build time can exceed the 500 concurrent query limit. Use a concurrency limiter to cap parallel requests. The [Importing data](https://www.sanity.io/docs/content-lake/importing-data) guide covers similar patterns for managing request throughput.
- **Retry rate-limited requests:** `@sanity/client` already [retries a query that returns 429](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced), five times by default with exponential backoff. Mutations are not retried, so cap those in your application with a concurrency limiter such as `p-limit`.
- **Batch mutations:** combine multiple mutations into a single transaction instead of sending them individually.

> [!NOTE]
> For the complete list of API limits, including document size, query result size, and asset limits, see [Technical limits](https://www.sanity.io/docs/content-lake/technical-limits).



# Datasets

A dataset is a collection of JSON documents that can be of different types and have references to each other. You can think of a dataset as a “database” where all of your content is stored, whereas the document‘s types would constitute “tables”. Using GROQ or GraphQL you can always query and join data across documents within a dataset, but not across them. Typical applications of datasets are:

- Operate with different environments for testing, staging, and production.
- Localization and segmentation across all content types.
- Different purpose content, but with same user access and billing.

```
https://<projectId>.api.sanity.io/v2021-06-07/data/query/<dataset>?query=*
```

You can also specify which dataset to use with the [client libraries](https://www.sanity.io/docs/client-libraries) (configured when initializing a client) and [Sanity Studio](https://www.sanity.io/docs/studio) (configured in [sanity.config.ts](https://www.sanity.io/docs/studio/config-api-reference) or using [environment variables](https://www.sanity.io/docs/studio/environment-variables)).



![Explainer: Projects, Users, Datasets](https://www.youtube.com/watch?v=hgMl5dofhoU)

## Dataset management

Datasets can be created and managed using the `sanity` [command-line tool](https://www.sanity.io/docs/cli-reference/cli-datasets) by running `sanity dataset create <name>` or `sanity dataset list`. To see all dataset-related subcommands, run `sanity dataset`. 

Datasets can also be created and deleted in the project's [management console](https://www.sanity.io/manage), under the "Datasets" tab.

A dataset name must be between 1 and 64 characters long. It may only contain lowercase characters (`a-z`), numbers (`0-9`), hyphens (`-`), and underscores (`_`), and must begin and end with a lowercase letter or number.

## Private datasets

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

Private datasets allow you to create authenticated-only access to your data. Unlike public datasets, which anyone can query, private datasets will require a valid personal or robot token with access in order to read any data. 

It’s common to use private datasets behind another layer of authentication specific to your organization, then use an authenticated token to present the data. You can also use private datasets in conjunction with the [App SDK](https://www.sanity.io/docs/app-sdk) to build internal dashboards that Sanity-authenticated users can access.

> [!WARNING]
> Datasets revert to public when trials end
> For users on a trial account, private datasets revert to public if the trial ends. To avoid unexpected data access, upgrade to a full plan that supports private datasets.

### Unauthenticated requests to private datasets

Unauthenticated requests to a private dataset return an HTTP 200 with an empty response in the requested shape, not a 401. For example, a query for a list returns an empty array.

When a query against a private dataset returns no results, check that your request is sending a valid bearer token before assuming the data is missing.

## Add-on datasets

Some features automatically create "add-on" datasets and pair them to your dataset. These are complimentary and don't count toward your plan's dataset limit.

![Comments add-on dataset for the production dataset](https://cdn.sanity.io/images/3do82whm/next/4958f8aaa9759681a7e3df864abc13d1c0fd1951-1790x474.png)
*Comments "add-on" dataset for the production dataset*

You can manage these as you would any other dataset. Learn more about configuring [comments](https://www.sanity.io/docs/studio/configuring-comments) and [tasks](https://www.sanity.io/docs/studio/tasks), which both create complimentary add-on datasets.

## Dataset migration

You can [export](https://www.sanity.io/docs/http-reference/export) and [import](https://www.sanity.io/docs/content-lake/importing-data) content to datasets, as well as performing [mutations](https://www.sanity.io/docs/http-reference/mutation) and [patches](https://www.sanity.io/docs/content-lake/http-patches) to documents in them.

Dataset exports are billed against your API quota. Documents are streamed to minimize quota usage, but using cursor mode for large datasets will use more requests than stream mode.

An export includes the binaries for every asset in the dataset, including assets linked from Media Library. Pass `--no-assets` to export documents only. For details, see [Media Library limits and usage details](https://www.sanity.io/docs/media-library/limits-and-usage).

Every asset document is queued for download, whether or not a document references it. If a download returns HTTP 401, 403, or 404, the export prints `⚠ Asset failed with HTTP 404 (ignoring)` with the asset document ID, skips that asset, and continues. The export still completes. No flag limits the download to referenced assets. `--no-assets` skips asset binaries altogether rather than filtering them.

## Advanced dataset management

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You can initiate dataset copying directly in the cloud and create aliases to hot swap between datasets without changing the underlying code for your project.

- [Full documentation for cloning datasets in the cloud](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets)
- [Full documentation for hot swapping your datasets without changing your code ](https://www.sanity.io/docs/content-lake/how-to-use-hot-swapping-for-datasets)



# Documents

As you build out your schema, you'll create different document types that represent your various content types. In most cases, the shape of a document will come from how you configure your schema. 

In reality, the document store doesn't know about your schema. It is only concerned with a few key requirements—such as `_id,_type`, and a few other system metadata properties. When you're working with APIs that aren't schema-aware, such as mutation or patch, you are able to create or edit documents regardless of the schema.

## Document types

Document types identify the kind of document. You create document types in your schema configuration, specifying fields that make up each type of content. Document types are the foundation of your content model and appear in the Studio's content list.

> [!NOTE]
> Type vs _type
> Your schema defines a `type` of `document`, but the schema's `name` defines the document's eventual `_type`.

**authorType.ts**

```
export default defineType({
  name: 'author', // this sets the document's _type
  title: 'Author',
  type: 'document',
  fields: [
    defineField({
      name: 'name',
      title: 'Name',
      type: 'string',
    }),
    // ...
  ]
})
```

**Example output**

```json
{
  "_id": "200e44f2-14a9-4c7a-a621-a4ca4d9b559c",
  "_type": "author",
  "_createdAt": "2025-04-25T15:03:54Z",
  "_originalId": "200e44f2-14a9-4c7a-a621-a4ca4d9b559c",
  "_rev": "DOcXr0KgQH6faBzvMdKRNc",
  "_updatedAt": "2025-05-02T20:44:48Z",
  "name": "Mark",
}
```

[Learn more about the document schema type.](https://www.sanity.io/docs/studio/document-type)

## Document IDs

Every document has a unique ID stored in the `_id` property. By default, Sanity Studio generates random UUIDs for new documents. IDs cannot be modified once created and must follow specific formatting rules. Sanity uses a document's ID and special reserved prefixes to associate published documents with drafts and versions.

#### More on IDs

[IDs and paths](https://www.sanity.io/docs/content-lake/ids)
How document IDs work, and what you can do with them

[@sanity/id-utils](https://github.com/sanity-io/id-utils/tree/main)
Utility library for reading and verifying IDs

## Document variants

### Published documents

When you think of a document you often think of the source, or published, variation. This document is public (in most cases) and generally has an non-prefixed document ID, like `post-123`, or a UUID.

### Drafts

When you create or edit a document in Sanity Studio, a draft document is created. Drafts capture in-progress changes while the original published document remains intact. Draft documents have IDs prefixed with `drafts.` , like `drafts.post-123`, and are only visible to authenticated users.

[Drafts](https://www.sanity.io/docs/content-lake/drafts)
How drafts work, and how you disable them

### Versions

Versions are documents used by the [Content Releases](https://www.sanity.io/docs/user-guides/content-releases) feature. They have a unique ID prefixed with `versions.release-name.` , like `versions.r1234.post-123`, and are only visible to authenticated users.

Like drafts, they are self-contained documents that can be associated with a published document. They can also exist on their own.

Unlike drafts, you can have multiple versions associated with a published document. When a version is published as part running a content release, the version document is deleted after the updates are applied to the published document.

> [!TIP]
> Isolating document states
> Use GROQ path filters to target specific document states: `_id in path('drafts.**')` for drafts only, or `_id in path('versions.**')` for versions only.

## Publishing

When you publish a draft or version document, the contents of the draft/version are applied to the published document if one exists. If it's a brand new document, the contents are copied into a new document. In both cases, the original draft or version document is deleted. Changes made to draft and release versions are still available as part of the [history experience](https://www.sanity.io/docs/user-guides/history-experience).

Once published, a document is available on public APIs and can be referenced by other documents. You can learn more about what's visible at a given time in the [perspectives documentation](https://www.sanity.io/docs/content-lake/perspectives).

## Document lifecycle

Documents don't have a defined *state* property, but they do exist in various implied states based on a variety of factors. When interacting with content lake, these are some of the events you'll encounter in a document's lifecycle:

- Created: When a document is first created.
- Updated: When an existing document changes.
- Deleted: When an existing document is deleted.

> [!NOTE]
> What about published?
> While many parts of Sanity refer to *published* documents—including this very article—published really means any document not designated as a draft or version document. Therefor, published is a catch-all event for **creating** or **updating** a standard(published) document.
> There are [actions](https://www.sanity.io/docs/http-reference/actions) and other APIs that handle copying the contents of a draft/version document over to a non-prefixed, published document, thus *publishing* it.

These lifecycle actions help inform features throughout Sanity's ecosystem, such as acting as triggers for [Functions](https://www.sanity.io/docs/functions/functions-introduction) or [Webhooks](https://www.sanity.io/docs/content-lake/webhooks).

Some example lifecycle changes are:

- When you begin editing a document in Studio, a new draft is **created**.
- As you work on the draft document, it is **updated.**
- If you *publish* a draft, it replaces (and **updates**) the published document, and then the draft is **deleted.**
- If you *unpublish* a document, the published document is **deleted**, and its contents are used to create a draft if one doesn't already exist.

## References

Documents can reference other documents using reference fields. This creates relationships between your content, allowing you to build connected content structures. This is one of the core features of structured content and page-building.

#### More on references

[Connected content](https://www.sanity.io/docs/studio/connected-content)
Structured content is connected. It's what enables reusing and repurposing the same chunk of content in different contexts, and it's how you enable your content to be treated as data.

[Reference schema type](https://www.sanity.io/docs/studio/reference-type)
A schema type for referencing other documents.

[GROQ joins](https://www.sanity.io/docs/specifications/groq-joins)
A description of joining multiple documents in GROQ

## Asset documents

Sanity even uses JSON documents for storing details about assets. These documents use the `sanity.imageAsset` and `sanity.fileAsset` types.

#### Learn more about assets

[Assets](https://www.sanity.io/docs/content-lake/assets)
Sanity provides extensible UI for managing assets, and an API for dealing with storage, resizing and deletion.

## System documents

In addition to the documents you define when building a schema, Sanity also stores data in various system documents. If you write an authenticated query for all documents, such as `*[]`, you will see additional document types beyond those you've created. 

Unless documented, these system documents should not be relied upon or modified.

#### Learn more about documents

[Common Sanity document types](https://www.sanity.io/docs/content-lake/document-reference)
Reference documentation for common Sanity document shapes.





# Drafts

In Sanity Studio, when you create a new document or edit one that has already been published, a *draft document* is created. Drafts capture in-flight updates while the original published document remains intact. This enables keeping changes separated from what is presented to users until those changes are ready to be explicitly rolled-out.

A draft document does not appear on the APIs to unauthenticated users. While you may refer to it as a reference, you can only publish a document that references a draft if that reference field is a weak reference.

When you publish a document it becomes available on the public APIs and you may (strongly) reference it from other documents.

When you start working on a published document a new draft gets created. This creates a new event in the [document history](https://www.sanity.io/docs/user-guides/history-experience). You can access the document history from the context menu:

![Screenshot from Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/bd2bcff0a9e3b951ce43206a3965dfdd965a2bca-658x335.png)
*Access the document history from the context menu*

## Behind the scenes

Drafts are saved in a document with an id beginning with the path `drafts.`. When you publish a document it is copied from the draft into a document without the `drafts.`-prefix (e.g. `drafts.ca307fc7-4413-42dc-8e38-2ee09ab6fb3d` vs `ca307fc7-4413-42dc-8e38-2ee09ab6fb3d`). When you keep working a new draft is created and kept read protected in the drafts document until you publish again.

### Timestamps

The published and draft document both have `_createdAt` and `_updatedAt` fields.

- `_createdAt` is the same value for **both** and reflects the time when the document was first created.
- `_updatedAt` on the **draft** is the time of when it was last edited
- `_updatedAt` on the **published** is the time when it last  got published

### A matter of perspectives

When querying your content from a frontend it's common to face a situation where you are interested in *either* drafted changes *or* published content, and specifically *not* both at the same time. You can use Content Lake's [Perspectives](https://www.sanity.io/docs/content-lake/perspectives) feature to have your queries return with all in-flight changes applied – useful for previewing – or with all changes ignored entirely – useful for production deployments. Visit the article [Presenting and Previewing Content](https://www.sanity.io/docs/content-lake/presenting-and-previewing-content) to learn more about how Perspectives can be used in your presentation layers.

## Disable draft documents

Sometimes you might not need drafts at all, such as when using real-time 'live' documents, or when using a structured publishing flow like [Content Releases](https://www.sanity.io/docs/user-guides/content-releases).

### Disable all draft creation

To disable all draft creation and limit editing to "live edit" documents, API mutations, and content releases, set the `document.drafts.enabled` setting to `false` in your `sanity.config.ts` file.

**sanity.config.ts**

```typescript
export default defineConfig({
  // ...
  document: {
    drafts: {
      enabled: false
    }
  }
})
```

### Disable for live editing

To disable drafts for a data type that you want to be "live only", include `liveEdit: true` in the schema definition:

```javascript
export default {
  name: 'author',
  title: 'Author',
  type: 'document',
  liveEdit: true,
  // ...rest of schema
}

```

> [!NOTE]
> Live Edit differs from the Live Content API
> Live Edit is the "published only" mode where drafts are disabled. It doesn't change how rendering works in your apps, but rather how Sanity Studio handles edits. Your applications and front ends need to render these changes as they happen. [The Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) tooling will work out of the box with live mode, or you can rely on traditional rendering modes to serve changes on new visits or refreshes.



# IDs and paths

## IDs

Every document in a Sanity dataset must have an ID that identifies it, an arbitrary string of maximum 128 characters made up of the characters `a-zA-Z0-9._-`.  Note that an ID cannot start with a `-` (dash) character, and must not have more than one consecutive `.` character. E.g. `-abcde-12345` would be an invalid ID, as would -`a..bcde-12345`.

The ID is specified in the document’s `_id` property, and must be unique within the dataset. The ID cannot be modified once a document is created, since it is used to track the document’s history and relations.

The Sanity Studio automatically generates a random [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) for new documents (e.g. `189bc292-e41b-42a0-91b5-bfaa33a34af2`), and does not allow you to specify an ID yourself.

> [!WARNING]
> Gotcha
> For technical reasons, every document ID ever written to a dataset will be retained in our systems until the dataset is deleted, even if the document itself is deleted. For this reason, we strongly recommend you never put personal data or other sensitive data in document IDs.

IDs with multiple segments separated by periods must not include the segment `versions` unless it is the first segment. When the first segment is `versions` the ID must have at least three segments. For example, `versions.abc.xyz` is permitted, but `versions.abc` and  `abc.versions.bar` are not. These restrictions are due to internal implementation requirements.

> [!WARNING]
> Gotcha
> We advise against using our APIs to create document IDs prefixed with `drafts.` or `versions.` as these are used internally. Such documents may react with platform functionality in unexpected ways. 
> See the section on [using custom IDs](https://www.sanity.io/docs/content-lake/ids) for more.

## Paths

IDs are also considered [paths](https://www.sanity.io/docs/content-lake/json-match), separated by periods. 

Sanity also uses paths for storing various internal data in your datasets. For example, internal objects like groups are stored under the `_.` path, and the content studio stores draft documents under the `drafts.` path and Content Release version documents under the `versions.` path. For path syntax used when [patching documents](https://www.sanity.io/docs/content-lake/http-patches), see JSONMatch.

GROQ provides a `path()` function that allows you to filter documents by path, such as fetching all drafts with `_id in path("drafts.*")` or fetching all versions with `_id in path("versions.**")`. In path expressions, `*` is taken to mean “anything up to the next period”, while `**` means “anything including periods”. The `*` and `**` wildcards are only available at the end of the string. For matching mid-string, use [match](https://www.sanity.io/docs/specifications/groq-operators) instead. The `path()` function currently only works with the `_id` attribute, since it requires special indexing.

To work with drafts, versions, and document ID paths we recommend using the [@sanity/id-utils](https://github.com/sanity-io/id-utils) helper library.

> [!WARNING]
> Gotcha
> The default, fixed access control rules give unauthenticated users read access to documents under the root path only, which means that it is not possible to make documents under a sub-path (i.e. containing a `.` in the ID) publicly available. 

## Using custom IDs

The Content Lake and Studio automatically generate unique identifiers for documents. These system-generated `_id` values are designed to ensure consistency and prevent conflicts across your dataset. 
There is **no way to override the default ID logic**, but you can set custom ID strings for documents created via [our APIs](https://www.sanity.io/docs/content-lake/http-urls) or the client.

While it might be tempting to create custom IDs for documents, we recommend a more flexible approach, which offers several key advantages and generally scales better.

Instead of attempting to override the native ID system, create a custom field in your schema to store your preferred identifier.

```typescript
// product.ts
import { defineType, defineField } from 'sanity'

export default defineType({
  name: 'product',
  title: 'Product',
  type: 'document',
  fields: [
    defineField({
      name: 'customId',
      title: 'Custom Identifier',
      type: 'string',
      initialValue: () => yourCustomIdGenerator()
    })
  ]
})
```

By following these guidelines, you can effectively manage document identifiers while maintaining flexibility and adhering to Sanity.io's best practices.

> [!NOTE]
> Any document ID containing a dot is considered private and has restricted accessibility
> All documents that contain a `.` in their _id can only be accessed when a user is logged in or a valid authentication token is provided for client and HTTP API calls (minimum `read` permission required).
> The root path (also known as the published ID) is accessible without authentication, while all subpaths are private (such as drafts `drafts.<publishedId>` or releases `versions.<release-name>.<publishedId>`).

### Generating Sanity UUIDs

If you need to generate IDs in a script or function which are compatible with Sanity's system, you can use `uuid()` from the  `@sanity/uuid` package.

```typescript
import {uuid} from '@sanity/uuid'

// Generate a unique ID compatible with Sanity's system
const newDocumentId = uuid()
```





# Perspectives

> [!NOTE]
> Note on data privacy, drafts, and authentication
> Sanity offers a range of tools for granularly managing access to your content. The rest of this article presumes that all requests are made from an authenticated client with permissions to see both drafts and published content. 
> You can learn more about data security, the drafts model, or how Sanity limits access to content in public datasets using IDs and paths at the following destinations.
> - [Security overview: Keep your data safe and access it securely](https://www.sanity.io/docs/security)
> - [Drafts: How they work and how to disable them](https://www.sanity.io/docs/content-lake/drafts)
> - [IDs and Paths: How document IDs work](https://www.sanity.io/docs/content-lake/ids)

The Perspectives feature allows you to query your datasets from a different viewpoint with minimal configuration. You can use the `drafts` perspective to treat all drafts as published, a perspective stack of release IDs to view a custom perspective with [Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration), or the `published` perspective to exclude all unpublished changes from your results. The `raw` perspective returns all drafts, versions, and published content side by side for authenticated requests.

```typescript
// Example JS/TS client configuration
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  perspective: 'published', // 'raw' | 'drafts' | 'published' | ['release-id1', 'release-id2']
})
```

[Introducing Perspectives: See your content from any angle](https://www.sanity.io/blog/introducing-perspectives-sanity-previews)

[Configuring Perspectives using the Sanity JS/TS client](https://github.com/sanity-io/client#using-perspectives)

> [!WARNING]
> Gotcha
> With the release of API version 2025-02-19, the default perspective changed from `raw` to `published`.

## Look at your content from a different point of view

Core to the idea of composable, structured content is the ability to weave content from any number of independent but interconnected documents into whatever shape is required on the consuming end. Sanity’s Content Lake lets you write queries that can filter, combine, merge, expand references, and apply transformations to the original content in your dataset.

This flexibility also means that often your query results will be made up of bits and pieces from a multitude of source documents and that sometimes previewing what your app, website, or experience will look like after you hit that publish button can become complicated and cumbersome. Previewing content changes before committing to production in an environment that is as realistic as possible is vital to a smooth editorial experience.

### Using the `drafts` and `published` perspectives

Perspectives allows your GROQ queries to run against an alternate view of the content in your dataset, very similar to how “views” work in traditional databases. The perspective is set as an additional parameter in the client config or API call so that your queries can remain identical between different implementations, such as a preview and production deployment. In addition to the `raw` perspective (the default for API versions before v2025-02-19), two other built-in perspectives are available:

- The `drafts` perspective, in which queries return your content “as if” all draft documents (i.e., unpublished changes in Studio) were published.
- The `published` perspective, in which queries return your content “as if” no in-flight unpublished changes existed.

> [!TIP]
> Protip
> The `drafts` perspective used to be called `previewDrafts`. They both work, but if you're using the latest APIs, you should transition to `drafts`. You may see both mentioned throughout the documentation.

Requesting either of these alternative perspectives is a matter of adding one line to your [client configuration](https://github.com/sanity-io/client#using-perspectives) or passing a URL parameter if you’re using the [HTTP API](https://www.sanity.io/docs/http-reference/query). 

```typescript
// Example JS/TS client configuration
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  useCdn: false, // must be false when using 'drafts'
  perspective: 'drafts', // 'raw' | 'drafts' | 'published'
})
```

```text
// Example using HTTP API
/data/query/production?query=*[]&perspective=drafts
```

> [!WARNING]
> Gotcha
> Queries using the `drafts` perspective are not cached in the CDN, and the client will bypass the CDN and log a warning if `useCdn` is not set to `false`. You should always explicitly set `useCdn` to `false` when using `drafts`!

## Example output from different perspectives

Let’s look at a very minimal example dataset with different perspectives applied.

### `raw`

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  perspective: 'raw', // default value, optional 
})

const authors = await client.fetch('*[_type == "author"]')
```

Making our initial query with the default `raw` perspective (explicitly set in this example but can safely be omitted) reveals that we are looking at a dataset of authors that contains the following documents:

- A published document (Ursula Le Guin)- With unpublished changes in a corresponding draft document (Ursula K. Le Guin)


- A draft document that has never been published (Stephen King)
- A published document with no pending changes (Terry Pratchett)

```json
[
  {
    "_type": "author",
    "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43",
    "name": "Ursula Le Guin"
  },
  {
    "_type": "author",
    "_id": "drafts.ecfef291-60f0-4609-bbfc-263d11a48c43",
    "name": "Ursula K. Le Guin"
  },
  {
    "_type": "author",
    "_id": "drafts.f4898efe-92c4-4dc0-9c8c-f7480aef17e2",
    "name": "Stephen King"
  },
  {
    "_type": "author",
    "_id": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75",
    "name": "Terry Pratchett"
  }
]
```

### `published`

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  perspective: 'published',
})

const authors = await client.fetch('*[_type == "author"]')
```

Running the same query with the `published` perspective specified yields a result where all drafted changes and unpublished documents have been excluded. This perspective is useful for ensuring that unpublished content never ends up in a production deployment.

```json
[
  {
    "_type": "author",
    "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43",
    "name": "Ursula Le Guin"
  },
  {
    "_type": "author",
    "_id": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75",
    "name": "Terry Pratchett"
  }
]
```

### `drafts`

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  useCdn: false, // must be false, required for this perspective
  perspective: 'drafts',
})

const authors = await client.fetch('*[_type == "author"]')
```

Viewed through the `drafts` perspective, our content is returned with all drafts applied. Documents are deduped in favor of the draft version, and unpublished draft documents are returned as if published. Note also that each document now has an `_originalId` property which identifies its origin.

```json
[
  {
    "_type": "author",
    "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43",
    "_originalId": "drafts.ecfef291-60f0-4609-bbfc-263d11a48c43",
    "name": "Ursula K. Le Guin"
  },
  {
    "_type": "author",
    "_id": "f4898efe-92c4-4dc0-9c8c-f7480aef17e2",
    "_originalId": "drafts.f4898efe-92c4-4dc0-9c8c-f7480aef17e2",
    "name": "Stephen King"
  },
  {
    "_type": "author",
    "_id": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75",
    "_originalId": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75",
    "name": "Terry Pratchett"
  }
]
```

[Presenting and previewing content](https://www.sanity.io/docs/content-lake/presenting-and-previewing-content)
Read more about how to use Perspectives to set up a separate environment for content previews

## Perspective layers

The Content Releases feature introduces the concept of perspective layering. This allows you to create a custom perspective containing documents from multiple Content Releases, as well as published and even draft content.

To create a custom layered perspective, pass a list of release names anywhere you would normally set the perspective. For example, in the client it looks like this:

```typescript
// Example JS/TS client configuration
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  useCdn: false, // must be false when using preview content
  perspective: ['release-a', 'release-b', 'release-c'],
})
```

Layers are prioritized from left to right. In the example above, updates in release "a" will override release "b", updates in release "b" will override release "c", and so on. The published perspective is automatically added to the end of the list. 

In this diagram, you can see releases mixing with draft and published content. Note that while the published perspective is automatically added to the end of a stack, the drafts perspective is not.

![Diagram showing documents overriding one another based on perspective order.](https://cdn.sanity.io/images/3do82whm/next/22b1e851041ed4cb3a3663f326d375828fdc3ac6-794x1273.png)
*Perspective layering lets you customize the perspective for multiple releases.*

For more on using perspective with releases, see the [Content Releases API documentation](https://www.sanity.io/docs/content-lake/content-release-document-flow).



# Attribute limit

## What is the attribute limit?

The attribute limit determines how many unique combinations of path and data type you can have in your dataset. Depending on what plan your project is on, your limit is one of the following:

- Free: 2,000 attributes
- Growth: 10,000 attributes
- Enterprise: custom number of attributes

> [!WARNING]
> Gotcha
> The attribute limit is a hard technical limit right now. For this reason, we do not currently offer a pay-as-you-go option for extra attributes.

## What counts as an attribute?

As shown above, an attribute is officially defined as *a unique combination of path and data type*. An alternative way to think about them is as the different paths through your content.

Let's take a basic data structure:

```json
{
  "sections": [
    {
      "heading":…,
      "body":…
    },
    {
      "heading":…,
      "body":…
    },
    {
      "callout": {
        "heading":…
      }
    }
  ]
}
```

This structure contains six unique paths or attributes:

1. `sections` -> an array
2. `sections[]` -> an object
3. `sections[].heading` -> a string
4. `sections[].body` -> a string
5. `sections[].callout` -> an object
6. `sections[].callout.heading` -> a string

Paths only count toward your attribute limit when they hold actual content. Solely changing your schema definitions will not affect the attribute count. Schema definitions define the structure of your content, a bit like a blueprint defines the structure of a building. Until you add or remove content using the Studio or the HTTP API, your attribute count will remain unchanged.

Each unique path is counted once, no matter how often it is used. Removing a path from your attribute count requires deleting every piece of content on that path across all documents.

In short, your attribute count:

- Goes up when you first add content on a path.
- Goes down when a path no longer holds any content.
- Stays the same regardless of whether a path is used once or many times.

## Best practices

When structuring your content, there are a few pitfalls to keep in mind to avoid hitting the attribute limit. Although this is not an exhaustive list, following the best practices below should go a long way in keeping your attribute count in check.

### Use arrays for page building

A common use case for Sanity is using structured content for [page building](https://www.sanity.io/docs/developer-guides/how-to-use-structured-content-for-page-building). In setting up a page builder, it may be tempting to use the block content type as the editor gives a lot of flexibility and allows adding any number of custom objects that can then be used inline.

However, a block content field has quite an extensive data structure by default:

- a `blockContent` array, with inside of it:
- `blocks` objects, with inside of them:
- `markDefs` and `children` arrays; the `children` array contains `span` objects, each with a `marks` array and a `text` field, while `markDefs` holds annotation objects (such as links)

This nested structure is further extended by any custom types you add to it, all with their own unique paths. A block content field with many custom objects may therefore lead to a hefty number of attributes.

Another issue with this approach is that people sometimes want to use block content fields *inside* of custom objects. This is likely to lead to even more attributes as a result of now having the above structure embedded in the same structure. Moreover, when the exact same block content component is used, allowing this type of nesting gives editors the freedom to nest to an arbitrarily deep level, which can then drag a project over the attribute limit.

To avoid any of these challenges and keep the attribute count as low as possible, we recommend using arrays for page building. In addition to fewer attributes, greater control over the exact content structure, and reduced risk of getting into nesting situations, this approach has the added advantage of not having to deal with serializers for complex custom objects. 

### Avoid excessive nesting and recursive data structures

Nesting compounds your attribute count because every additional level introduces a new set of unique paths for the same fields. Recursive structures are the extreme case: if the page builder described above uses the same block content configuration for block content fields inside its custom objects, editors can nest the entire page builder inside itself, and each level of nesting adds another full set of attributes. To stay in control, limit nesting to a fixed depth in your schema definitions, and avoid structures that can contain themselves, directly or indirectly.

### Focus on meaning, not presentation

Before responsive web design made its entrance and people started optimizing for different devices, it was customary to mix content with presentation. A headline could be blue, have font size 24px, line-height 30px, and a bottom padding of 10px. Although it may still be tempting today to offer that same level of control to editors, there are several downsides to this approach. For one, whenever you want to change your frontend's design, editors will have to review all relevant content.

Most importantly for this guide, adding all these presentational attributes is likely to boost your attribute count significantly as they would exist for nearly every piece of content.

Instead of mimicking CSS properties in your schema definitions, we recommend a separation of concerns. Leave the presentational aspects to wherever you implement your content and instead stick to semantics in your content structure. In other words, focus on the *meaning* of your content.

### Beware of multipliers in translation/localization

There is a variety of internationalization (i18n) and localization (l10n) approaches out there, some of which have a greater impact on your attribute count than others. For example, one approach suggests wrapping all your fields inside a language object, so you get the following structure:

```json
{
  "de": {
    ...
  },
  "en": {
    ...
  }
}
```

This multiplies the number of attributes by the number of languages added, as all fields get duplicated on a language path. Adding more than a few languages this way means trouble.

Instead of duplicating the fields inside a document, thereby creating all these extra paths, a more frugal approach is to duplicate the *document*. To differentiate between the different languages and more easily query for them, you can consider adding a (hidden) internationalization field to your document type, adding the language to the document ID, or both. As you will be reusing the same fields across different documents, adding an extra language no longer affects your attribute count at all.

## What to do if you hit the limit?

If you inadvertently hit the attribute limit on one of your datasets, you will see the following error when opening the Studio: `Total attribute count exceeds limit`.

### Export your data

Before deleting any content or changing your data structure, we highly recommend running a full export of your dataset to prevent any unintended data loss. To do so, you can run the [datasets export](https://www.sanity.io/docs/cli-reference/cli-datasets) command in your terminal. For example:

```sh
sanity datasets export production production.tar.gz
```

### Get unblocked

The first step after exporting your data is to get unblocked so you and other users on your project can work in the Studio again. In other words, the challenge is to get back below the attribute limit.

Perhaps there is a heavily nested structure with block content *and* translations that could be optimized. Or maybe you have singletons for different pages that could be folded into a single page type instead to further reduce the number of unique paths.

A final note is that it also helps to remove any unused content from schema revisions. For example, if you used to have a particular document type with a bunch of documents, but later removed that type, or even some fields within a type, make sure to clean up the content so there are no leftovers in the datastore that will count toward the attribute limit.

### Restructure your content

How to restructure your content depends on your content model and is therefore different per project. However, the two examples below show common ways to reduce the attribute count. Please note that in all cases, it is highly recommended to run a full dataset export *before *proceeding. 

For example, say you enrich product information with a separate string field for each specification:

```json
{
  "product": {
    "color": "Blue",
    "material": "Cotton",
    "weight": "230 g"
  }
}
```

This structure already uses four attributes (`product`, `product.color`, `product.material`, and `product.weight`), and every new specification adds another one. Restructuring the specifications into an array of name and value pairs caps the count:

```json
{
  "product": {
    "specifications": [
      { "name": "Color", "value": "Blue" },
      { "name": "Material", "value": "Cotton" },
      { "name": "Weight", "value": "230 g" }
    ]
  }
}
```

This version starts slightly higher at five attributes (`product`, `product.specifications`, `product.specifications[]`, `product.specifications[].name`, and `product.specifications[].value`), but the count stays the same no matter how many specifications you add, because each unique path is counted once regardless of how often it is used.

The same idea applies at the document level. If every page is its own document type with uniquely named fields, each page type introduces its own set of paths:

```json
[
  { "_type": "homePage", "homeHeading": "Welcome", "homeIntro": "…" },
  { "_type": "aboutPage", "aboutHeading": "About us", "aboutIntro": "…" }
]
```

These two documents use four attributes between them, and every new page type adds more. Folding them into a single shared page type keeps the paths constant:

```json
[
  { "_type": "page", "heading": "Welcome", "intro": "…" },
  { "_type": "page", "heading": "About us", "intro": "…" }
]
```

Both documents now share the same two attributes (`heading` and `intro`), so adding more pages no longer affects the count.

### Track your progress

To keep an eye on your attribute limit while restructuring your content, you can use this URL: `https://<projectId>.api.sanity.io/v1/data/stats/<datasetName>`

The attribute count is the value of `fields.count.value`, and the limit is inside `fields.count.limit`.

## Closing remarks

Although this guide was specifically about the attribute limit, the principles outlined above are best practices that are likely to lead to a more solid, flexible, and future-proof content model in any situation.

To keep going, learn more about [content modeling](https://www.sanity.io/guides/introduction-to-content-modeling), review the [datasets export command](https://www.sanity.io/docs/cli-reference/cli-datasets), or explore [localization approaches](https://www.sanity.io/docs/studio/localization) for handling multiple languages.



# Hot swap

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

![Flow diagram showcasing a dataset being changed beneath a single Alias used by the frontend codebase](https://cdn.sanity.io/images/3do82whm/next/58a6d2df8dbca8ca674a181dacddb2cd6e668100-590x590.svg)

Dataset Hot Swapping allows a codebase to reference a single, named entity, which can then point to different datasets depending on the need of the project.

By utilizing a Dataset Alias in a codebase, the underlying dataset referenced can be swapped without having to copy or migrate data. When preparing a large content change or a new feature, the data can be prepared in a staging or feature dataset and then Hot Swapped behind the "production" Alias with no code or data changes when ready and approved.

> [!WARNING]
> Gotcha
> Dataset aliases are **read-only** references to the underlying dataset. Write actions will fail when run against an alias. For example, any Studio deployments must use the underlying dataset name to edit content.

## Using Dataset Hot Swapping

This enterprise feature is included in [the Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli). By running a series of commands, an alias can be created, linked, unlinked, or deleted.

```sh
# List all aliases and datasets
sanity dataset list

# Create a new alias
sanity dataset alias create <new-alias> <dataset-to-alias>

# Change what dataset an alias points to
sanity dataset alias link <alias-to-point> <dataset-to-point-to>

# Unlink an alias
sanity dataset alias unlink <alias-to-unlink>

# Delete an Alias
sanity dataset alias delete <alias-to-delete>

# List all aliases and datasets
sanity dataset list
```

## Creating a new alias 

### `sanity dataset alias create`

Before a dataset can be swapped, a new alias must be created and referenced by code. To create a new alias for use as a `production` alias run the following code:

```sh
# Creates a dataset alias and prompts for an alias name
sanity dataset alias create

# Creates an unlinked dataset alias named "production"
sanity dataset alias create production

# Creates a dataset alias named "production" linked to "productionDataset"
sanity dataset alias create production productionDataset
```

Anywhere the code references the `productionDataset` it can now be swapped for `~production`. 

In order to identify incoming alias requests from a client, an alias should be prefixed with the special character `~`. In this example, even though the `create` command was given an alias name of `production`, when referencing it in requests, the string `~production` should be used.

> [!WARNING]
> Gotcha
> When creating a new alias, the name should not include the `~` character. This special character is only used to reference the alias in your code.

```javascript
// Example from sanity.js file
// This code creates a connection to the datastore
// and connects to productionDataset through the production alias

import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'abc123',
  dataset: '~production',
  apiVersion: '2021-03-25',
  useCdn: false,
})
```

## Hot-swapping the dataset behind an alias 

### `sanity dataset alias link`

Once an alias is created, the dataset behind it can be hot-swapped at any time. In this example, the codebase references the `~production` alias. When a new release is ready, a dataset of `featureDataset` can be created via [Cloud Clone](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets) from the current dataset behind the `production` alias. Any data changes can be made, then the `production` alias can be Hot Swapped to the new `featureDataset`.

```sh
# Hot swap the production Alias to point to the featureDataset
sanity dataset alias link production featureDataset
```

> [!TIP]
> Protip
> A single dataset can be linked to multiple aliases. When the dataset in this example is linked to the `production` alias, it can still be linked to a `staging` or `development` alias, as well.

## Unlink an alias 

### `sanity dataset alias unlink`

When an alias is not currently being used, but should be included in the alias list, it can be unlinked from a dataset.

```sh
# Unlinks the development alias.
# The development alias will still appear 
# in the sanity dataset alias link command
sanity dataset alias unlink development
```

## Delete an alias

### `sanity dataset alias delete`

When an alias is no longer needed, it can be deleted from the alias list. The `delete` command only deletes the alias and not any datasets associated with it.

```sh
# Deletes the outdated alias
sanity dataset alias delete outdated
```

## List all aliases and datasets on a project

### `sanity dataset list`

The `list` command provides a list of all Datasets and aliases associated with the current project and shows the linked dataset for each alias.

```sh
# Returns a list of datasets and aliases with their associated datasets
sanity dataset list
```



# Cloud clone

> [!NOTE]
> Enterprise Feature
> This feature is part of our Advanced Dataset Management offering on [the enterprise plan](https://www.sanity.io/enterprise). [Contact us](https://www.sanity.io/contact/sales) if you need this feature and want to discuss this plan.

Cloud Clone provides a more efficient way of duplicating datasets and is ideal for situations when:

- you want to run tests against real production data in a CI flow
- you regularly copy datasets from production for developing new features

Instead of [exporting](https://www.sanity.io/docs/cli-reference/cli-config) and [importing](https://www.sanity.io/docs/content-lake/importing-data) a dataset with the CLI, you can have that process happen inside of Sanity's infrastructure which will be more efficient and reliable.

There are two methods of initiating and monitoring the cloning of datasets in the cloud: through [the Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli) or with [the HTTP API](https://www.sanity.io/docs/http-reference/copy).

Once a copy is successful, the new dataset will appear in [Manage](https://www.sanity.io/manage). Depending on the size of a dataset this may take hours, so we encourage monitoring the outcome of a copy using the `jobId`, discussed below.

## Copying a dataset with the CLI

The quickest way to begin developing with a freshly-copied dataset is to use the CLI. 

> [!WARNING]
> Gotcha
> As with other project-specific CLI commands, this command will only work from within a configured Sanity project.

By default, the CLI command runs the copy synchronously. If you don't want to wait for the process to be completed, you can use the `--detach` flag to skip the progress. It will log a job ID that you can use to watch the progress again with the `--attach <jobId>` flag.

Depending on the size of the dataset, skipping document history with the `--skip-history` flag can make the copy process significantly faster. In cases where document history is not important in the target dataset, this may be a flag worth considering.

Depending on how many content releases the source dataset has, skipping them with the `--skip-content-releases` flag prevents them from counting against your organization's release quota. In cases where content releases are not needed in the target dataset, this may be a flag worth considering.

```sh
# Syntax:
# sanity dataset copy
# sanity dataset copy <source-dataset>
# sanity dataset copy <source-dataset> <target-dataset>

# This command will ask for which dataset to copy and what to call the new dataset
sanity dataset copy

# This command will copy the production dataset and request a name for the new dataset
sanity dataset copy production

# This command will copy the production dataset into a new dataset named new-feature
sanity dataset copy production new-feature

# This command will initiate the copy between production and new-feature
# It will run in the background and not display progress while it works
sanity dataset copy production new-feature --detach

# This command will initiate the copy between production and new-feature
# It does not copy document history, speeding the copy action 
# at the expense of the history retention
sanity dataset copy production new-feature --skip-history

# This command will copy the production dataset into new-feature
# It does not copy content releases, leaving your organization's release quota untouched
sanity dataset copy production new-feature --skip-content-releases
```

> [!WARNING]
> Gotcha
> This process creates a new dataset given the specified name. If a dataset already exists with that name—or if a copy job is in progress and a copy is re-attempted using the same dataset name—the command will throw an error `Target dataset <name> already exists`.

It's encouraged to use this feature instead of exporting/importing your data to another dataset. In most cases, this will be a faster method. On large datasets or datasets with a large number of assets and/or large assets, the process will take some time to complete.

## Copying a dataset with the HTTP API

If you'd prefer to use the HTTP API instead of the CLI, there are API endpoints for copying datasets and for monitoring copy completion.

`PUT /v2021-06-07/projects/:projectId/datasets/:datasetName/copy`

In order to start a copy, a PUT request is sent to the specific dataset's `/copy` endpoint.

```text
https://api.sanity.io/v2021-06-07/projects/<project-id>/datasets/<dataset-name>/copy
```

The request needs to be authorized via a Bearer token, which can be generated from the [Manage dashboard](https://www.sanity.io/manage).

The body of the request must be an object containing the following fields:

1. `targetDataset`: Property to name the new dataset. The value must be consistent with [dataset name requirements](https://www.sanity.io/docs/content-lake/datasets).

2. `skipHistory`: Boolean property which allows skipping document history while copying the dataset. It potentially reduces copying duration on datasets with large amount of edit history. Check the [retention period](https://www.sanity.io/docs/user-guides/history-experience) to know how long a dataset's history is kept for.

3. `skipContentReleases`: Boolean property which allows skipping content release documents while copying the dataset. Content releases included in the copy count against your organization's release quota. Excluding them is useful when releases aren't needed in the target dataset.

```json
{
    "targetDataset": "production-copy",
    "skipHistory": true,
    "skipContentReleases": true
}
```

### The full request

```text
curl --location --request PUT 'https://api.sanity.io/v2021-06-07/projects/<project-id>/datasets/<dataset-name>/copy' \
  -H 'Authorization: Bearer <token-here>' \
  -H 'Content-Type: application/json' \
  --data-raw '{
    "targetDataset": "production-copy",
    "skipHistory": true,
    "skipContentReleases"
  }'
```

### The JSON response

```json
{
    "datasetName": "production",
    "message": "Starting copying dataset production to production-copy...",
    "aclMode": "public",
    "jobId": "jobIdString"
}
```

> [!WARNING]
> Gotcha
> When copying a dataset, documents‘ `_createdAt` and `_updatedAt` date time fields in the target dataset will remain the same as documents in the source dataset.

## Getting the current status of a copy

`GET /v2021-06-07/jobs/:jobId`

When you run a copy via the HTTP API, you'll receive a Job ID. This ID can be used to query the status of the clone job.

### The Full Request

```text
curl --location --request GET 'https://api.sanity.io/v2021-06-07/jobs/<jobid>' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token here>'
```

### The JSON response

```json
// Running
{
    "id": "jacsfsmnxp",
    "state": "running",
    "authors": [
        "authorId"
    ],
    "created_at": "2020-11-09T17:34:28.071123Z",
    "updated_at": "2020-11-09T17:34:28.144826Z"
}

// Completed
{
    "id": "jarrwsdptf",
    "state": "completed",
    "authors": [
        "authorId"
    ],
    "created_at": "2020-11-09T17:07:41.304227Z",
    "updated_at": "2020-11-09T17:08:30.457692Z"
}

```

## Listening for copy status

`GET /v2021-06-07/jobs/:jobId/listen`

Each job has a `/listen` endpoint to allow you to monitor its status programmatically. Much like the static status endpoint, this endpoint accepts the Job ID that is returned by starting a copy action.

### The full request

```text
curl --location --request GET 'https://api.sanity.io/v2021-06-07/jobs/<jobid>/listen' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token here>'
```

### The response

While listening, event data will be sent back at intervals providing updates on the status of your copy. The response contains the event name as well as a JSON object containing information about the current status of the copy.

```json
event: welcome
data: {"listener_id": "ladaicdbdo"}

event: job
data: {"job_id":"jacsfsmnxp","state":"running","progress":60}

event: job
data: {"job_id":"jacsfsmnxp","state":"running","progress":80}

event: job
data: {"job_id":"jacsfsmnxp","state":"completed","progress":100}
```





# Backups

Sanity offers a backup feature that provides a robust solution for disaster recovery and content history auditing, ensuring your data's safety and integrity. With the ability to restore your production environment seamlessly and to inspect historical data states, this feature is a powerful tool for maintaining data continuity and compliance. Don't have a plan that supports backups? You can manually export your data with the [CLI's datasets command](https://www.sanity.io/docs/cli-reference/cli-datasets).

Backups work at the dataset level. To recover individual deleted documents, see [Find and restore deleted documents](https://www.sanity.io/docs/developer-guides/find-and-restore-deleted-documents).

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

## Core concepts

### Backup contents

A "backup" in the context of this article is an archived snapshot of the state of your dataset (documents and assets) at a specific time. You can use it to audit the history of your content or roll it back to a known safe state in case of data loss or unintended changes.

Each backup contains all documents and assets from your dataset in their state from when the backup job ran. This includes hidden documents used for settings and configuration by the Studio and installed plugins. [Comments](https://www.sanity.io/docs/studio/comments) and [document history](https://www.sanity.io/docs/http-reference/history) (the timeline shown when you select "Review changes" in the Studio) are not included in the backup.

When you download a backup, the resulting file contains an archive with all your documents exported into a single [NDJSON](https://github.com/ndjson/ndjson-spec) file alongside your files and images in separate folders, neatly collected into a single gzip-compressed archive file with a `.tar.gz` file type, colloquially known as a "tarball."

```bash
production-backup-2024-02-23-a9bfa2d7-9ba1-42cc-beb2-f9f448bec656/
├── data.ndjson
├── files
│   └── file.txt
└── images
    └── image.png
```

### Backup frequency and retention time

Once you enable the backup service, as described in the next section of this article, Sanity will perform a backup of your dataset daily.

> [!WARNING]
> Gotcha
> The backup service runs at set regular intervals, so your initial backup may take up to 24 hours to become available.

Your backups are managed by Sanity in an offsite third-party storage location for data redundancy and security. Daily backups are stored for 365 days. Weekly backups are stored for an additional two years on top of the one year of daily backups.

### Deleted datasets

If you delete a dataset, then no new backups will be created. Any existing backups will continue to be accessible. If you later create a new dataset with the same name, then:

1. You will need to actively enable backups for this new dataset to start the backup service up again.
2. Backups for the older, deleted dataset will no longer be accessible directly through the CLI. Contact support if you require them.

## Enabling and disabling backups

Enabling and disabling the backup service is mainly done with the Sanity CLI.

### Prerequisites

- The relevant project is on a supported plan.
- The backup feature is enabled for the project.
- The Sanity CLI is up to date (v3.31.0 or later is required).
- The user has administrator permissions for the project.

### Enable backups

To enable backups for a dataset, use the `sanity backups enable` CLI command in your project folder:

```sh
sanity backups enable [DATASET_NAME]
```

You should see a confirmation message in your CLI, and your first backup should be available within 24 hours.

### Disable backups

To disable backups for a dataset, use the `sanity backups disable` CLI command in your project folder:

```sh
sanity backups disable [DATASET_NAME]
```

No further backups will be scheduled. Your existing backups will continue to exist and be available for downloading.

## Common commands

### List available backups

To list all available backups for a dataset, use the following CLI command in your project folder:

```sh
sanity backups list [DATASET_NAME]
```

Running this command will list the available backups for the dataset in question.

```bash
┌──────────┬─────────────────────┬─────────────────────────────────────────────────┐
│ RESOURCE │ CREATED AT          │ BACKUP ID                                       │
├──────────┼─────────────────────┼─────────────────────────────────────────────────┤
│ Dataset  │ 2024-02-21 16:57:34 │ 2024-02-21-cf51334d-4caa-4487-a746-75a49b078e82 │
│ Dataset  │ 2024-02-22 02:40:30 │ 2024-02-22-c66adb69-cbed-4e4f-88a2-f97b5feeb464 │
│ Dataset  │ 2024-02-23 01:43:28 │ 2024-02-23-e1b1dcd3-fa9b-45a2-ab58-9f1c1eae45c7 │
└──────────┴─────────────────────┴─────────────────────────────────────────────────┘
```

By default, this command will list the 30 most recent backups. You can use the `--limit` parameter to increase the listing threshold to a maximum of 100, or you can use the `--after` and `--before` parameters to target a specific time period from which to list backups.

```sh
sanity backups list production --after 2024-01-10 --before 2024-01-31 --limit 10
```

### Download backups

To download a specific backup for a dataset, use the following CLI command in your project folder:

```sh
sanity backups download [DATASET_NAME] --backup-id [BACKUP_ID] --out [FILE_NAME]
```

`[BACKUP_ID]` needs to match the ID of an existing backup, and `[FILE_NAME]` should be a valid file name for the resulting downloaded file. A more realistic example is shown below.

```sh
sanity backups download production --backup-id 2024-02-23-a9bfa2d7-9ba1-42cc-beb2-f9f448bec656 --out backup_2024.tar.gz
```

If you don't specify the file name in the `--out` flag, the backup file will follow the `[dataset name]-backup-[backup ID].tar.gz` convention.

To learn about all the options for this command, refer to the CLI reference article, or run `sanity backups download --help` in the CLI.

### Restore from a backup

You can use the `sanity datasets import` CLI command to restore from a downloaded backup. Include the `--replace` flag so the backup overwrites what is currently in the dataset. Without it, the import fails for every document whose ID already exists in the target dataset.

```sh
sanity datasets import ~/Downloads/backup_2024.tar.gz production --replace
```

> [!WARNING]
> Gotcha
> If you are importing a backup into a different dataset than the one the backup originated from, you will have to use the `--allow-assets-in-different-dataset` option on import. Read about this and other parameters and options available for this command in the relevant [CLI reference article](https://www.sanity.io/docs/cli-reference/cli-datasets), or by running `sanity datasets import --help` in the CLI.

Downloaded backups are structured to be ready for importing, both in their original compressed file state and in their decompressed file structure.

#### Restoring is not a point-in-time reset

An import only writes the documents contained in the backup. `--replace` overwrites documents that share an `_id` with a document in the backup. It never removes documents that exist in the target dataset but not in the backup, so drafts and documents created after the backup was taken survive the restore.

To return a dataset to exactly the state captured in a backup, you need one of two additional steps:

1. Delete the dataset and recreate it with the same name before importing. Read the Deleted datasets section of this article first, because backups taken under the deleted dataset are no longer accessible through the CLI and you have to enable backups again for the new dataset. [Restore a deleted dataset from a backup](https://www.sanity.io/docs/content-lake/restore-deleted-dataset) covers the full procedure.
2. Keep the dataset and delete the leftover documents with a [content migration](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) that compares the document IDs currently in the dataset against the IDs in the backup's `data.ndjson` file.

## Conclusion

The backup feature from Sanity offers a solution for securing your content, providing data redundancy, means of compliance, and peace of mind. Contact your account manager to have backups enabled for your enterprise project, or visit our [pricing page](https://www.sanity.io/pricing) to learn more about Sanity's enterprise plan offerings.



# Embeddings

Dataset embeddings add semantic search to GROQ. For enabled datasets, search your content for semantic meaning using the `text::semanticSimilarity()` GROQ function.

## Quickstart

### Create an embeddings-enabled dataset

**TERMINAL**

```sh
sanity datasets create <name> --embeddings
```

Optionally scope what gets embedded with a projection.

**TERMINAL**

```sh
sanity datasets create <name> --embeddings --embeddings-projection='{ title, summary, category }'
```

### Check that embeddings are ready

Embeddings generation may take a few minutes, especially on larger datasets. When the status shows `ready`, your dataset is set up for semantic search.

**TERMINAL**

```sh
sanity datasets embeddings status <name>
```

### Query with semantic similarity using [GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)

Query results are ranked by semantic relevance, even when there's no exact keyword overlap. Each result includes a _score field. This is an opaque, unitless value used only for ranking results relative to each other within a single query. It is not a measure of general match quality and should not be compared across different queries.

```groq
* | score(text::semanticSimilarity("how to handle user authentication"))
```

**Next steps:** See [Control your embeddings with projections](https://www.sanity.io/docs/content-lake/dataset-embeddings) to fine-tune what content gets embedded, or [Querying with embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) for additional search patterns, keyword matching and boosting.

## Core concepts

### What are embeddings?

An embedding is a numerical representation of text (a vector) that captures meaning rather than just characters. Words and phrases that are semantically close end up with similar vectors, even if they share no words in common. "Authentication flow" and "login process" would be close together, "authentication flow" and "authentic basketball jersey" would be far apart.

When you enable embeddings on a dataset, Sanity processes each document's content (or the subset you define with a projection) into a vector. At query time, your search term is converted into a vector too, and results are ranked by proximity in that vector space.

Embeddings in Sanity datasets give your GROQ queries the ability to understand the meaning behind text content your team or customers might want to understand, not just match specific keywords in the text.

### Why use embeddings?

Traditional keyword search relies on matching exact words in your content. If your docs say "authentication" but someone searches "login," traditional search misses it. Embeddings close that gap by matching on concepts.

Embeddings on datasets bring this capability directly into GROQ, so you don't need an external vector database or a separate search pipeline. Your content stays in the Content Lake, your queries stay in GROQ, and semantic scoring is just another function you can use alongside the filters and boosts you already use.

## Plan availability and billing

Dataset embeddings are available on all plans. Generating and updating embeddings is included at no additional cost. Queries that use `text::semanticSimilarity()` count against your organization's monthly semantic search quota.

For the semantic search quota included on each plan and overage rates, see [Pricing](https://www.sanity.io/pricing).

## Getting started

When you create an embeddings-enabled dataset, Sanity asynchronously analyzes and computes embeddings for its documents, in its entirety or according to any projection you provide.

Each document's content is processed into a vector representation, its "embedding", which is what makes semantic search possible.

A few key points:

- Enabling embeddings on a dataset triggers an **initial embeddings generation**, where all existing documents are processed. This can take some time, particularly on large datasets. You can track progress using the status command (see [Checking embedding status](https://www.sanity.io/docs/content-lake/dataset-embeddings) below).
- After the generation is complete, **embeddings are kept up to date automatically**. When a document is updated, its embedding is recomputed asynchronously. Mutations are batched to avoid constant recomputation on frequently updated datasets, which means embedding results may lag slightly behind the document update. Normally this lag will be less than 1 minute, but may in some cases be longer depending on the size and frequency of document updates.
- The embedding model is managed by Sanity and may be updated for optimized performance. When this happens, your dataset will be recomputed automatically.

> [!WARNING]
> Performance considerations
> Depending on system load, write speeds may be slower on datasets with embeddings enabled. Sanity may apply rate limits to manage resource usage and ensure system stability. These behaviors are subject to change as we continue to optimize the feature.

## Enabling embeddings

You can enable embeddings through the CLI or the HTTP API.

### When creating a dataset

**TERMINAL**

```sh
sanity datasets create <name> --embeddings
```

To include a projection at creation time:

**TERMINAL**

```sh
sanity datasets create <name> --embeddings --embeddings-projection='{ title, summary, category }'
```

Note that expanding references will not work in these projections. Only what’s in the document!

### For an existing dataset

**TERMINAL**

```sh
sanity datasets embeddings enable <name>
```

By default this returns immediately and runs asynchronously in the background. Add `--wait` to block until the embeddings generation completes:

**TERMINAL**

```sh
sanity datasets embeddings enable <name> --wait
```

To enable with a projection:

**TERMINAL**

```sh
sanity datasets embeddings enable <name> --projection='{ title, summary, category }'
```

### Via the HTTP API

Create a new dataset with embeddings:

```text
PUT /projects/:projectId/datasets/:name HTTP/1.1
Content-Type: application/json

{
  "aclMode": "public",
  "embeddings": {
    "enabled": true,
    "projection": "{ title, summary, category }"
  }
}

```

Enable or update embeddings on an existing dataset:

```text
PUT /projects/:projectId/datasets/:name/settings/embeddings HTTP/1.1
Content-Type: application/json

{
  "enabled": true,
  "projection": "{ title, summary, category }"
}
```

This endpoint returns `202 Accepted` immediately. Embeddings generation will then complete asynchronously.

Read current configuration and status:

```text
GET /projects/:projectId/datasets/:name/settings/embeddings HTTP/1.1

# Response
{
  "enabled": true,
  "projection": "{ title, summary, category }",
  "status": "ready"  // "updating" | "ready" | "error"
}

```

## Control your embeddings with projections

Projections define what content gets embedded. This directly affects the size of your embeddings, the time of initial generation and ongoing recomputation, the efficiency of each query, and the relevance of your search results. If no projection is specified, Sanity embeds the entire document for you.

For small datasets with simple content, this may be fine. For most production datasets, a targeted projection is recommended, as every field you include in a projection increases the size of each document's embedding and the time it takes to generate.

> [!WARNING]
> Document size limits
> Documents have a maximum number of chunks (see [How documents are chunked](https://www.sanity.io/docs/content-lake/dataset-embeddings)) that can be embedded, currently 10 and subject to change. 
> If a document's projected content exceeds this limit, later chunks are dropped and not included in search results. Use a projection to scope what gets embedded to the fields your users will search against.

By scoping your projection to only the fields your users actually search against, you speed up initial generation, recomputation and query times, and improve result relevance by keeping noise out of the vector space.

Avoid including fields that update frequently but have no semantic value for search, since each change will trigger a recomputation cycle without improving results.

### Basic projection

If your users only search by a few shared fields, a simple projection may be all you need:

**GROQ**

```groq
{ 
  title, 
  description, 
  category 
}  
```

### Type-specific projections

Many datasets contain multiple document types with different schemas. Use conditional projections to target the right fields per document type:

**GROQ**

```groq
{
  _type == "article" => {
    title,
    description,
    "body": body
  },
  _type == "product" => {
    name,
    "description": description,
    category
  },
  _type == "helpArticle" => {
    title,
    "body": body
  }
}
```

This projection generates embeddings for articles, products, and help articles only, pulling different fields from each. Document types not listed in the projection are not embedded.

You can also combine shared fields with type-specific ones:

**GROQ**

```groq
{
  title,
  _type == "article" => { description, "body": body },
  _type == "product" => { "specs": specifications }
}
```

Here, `title` is embedded for all document types, while each type contributes additional fields specific to its schema.

Field names from your projection are preserved as metadata and used as semantic context during embeddings computation. For example, `{ "musical_genre": category }` helps the model interpret a value like "classical" in a musical context rather than an engineering one. Field names and position data are also returned as part of search result metadata (see Search result metadata below).

## Checking embedding status

To check the current state of embedding processing on a dataset:

**TERMINAL**

```sh
sanity datasets embeddings status <name>
```

The underlying status values are `updating`, `ready`, and `error`.

## Disabling embeddings

**TERMINAL**

```sh
sanity datasets embeddings disable <name>
```

> [!WARNING]
> Destructive operation
> Disabling embeddings should be treated as a destructive operation. The computed embedding data may be immediately deleted, and re-enabling will trigger a full recompute of all documents. Do not disable embeddings unless you intend to permanently remove them or are prepared for a full recomputation cycle.

## Querying with embeddings

`text::semanticSimilarity()` is a GROQ function introduced with dataset embeddings. It converts your search term into a vector and ranks results by proximity to each document's embedding. The function is only valid as an argument to `score()`; using it elsewhere returns an error.

For longer documents, the projected content may be split into multiple chunks, each embedded and scored separately. This affects the `_embeddings` metadata returned with results (see [How documents are chunked](https://www.sanity.io/docs/content-lake/dataset-embeddings) below).

Once embeddings are enabled, you can use `text::semanticSimilarity()` inside a `score()` expression in any GROQ query. 

### Semantic search

**GROQ**

```groq
* | score(text::semanticSimilarity("leather waterproof boots"))
```

### Filtered semantic search

Use a filter to restrict which documents are scored. Documents that don't match the filter are excluded entirely:

**GROQ**

```groq
*[_type == "product" && category == "footwear"]
    | score(text::semanticSimilarity("leather waterproof boots"))
```

In this example, only products in the footwear category are considered. Results are ranked by semantic similarity.

### Hybrid search, combining filters, keyword matching, and semantic scoring

The previous examples show two of the three tools available in a search query: filters to narrow the candidate set, and semantic scoring to rank by meaning. The third is keyword matching, which rewards documents containing the exact search terms.

Start with filtered semantic search. For many use cases, it's sufficient on its own. Add keyword matching when your users are likely to search for proper nouns, brand names, model numbers, or other specific identifiers that carry meaning as exact strings but don't embed well as concepts. A search for "Gore-Tex" needs to match that exact term. Semantic similarity alone would only capture the broader concept of waterproofing.

Use `score()` with multiple expressions to combine keyword matching and semantic scoring. Each expression contributes independently to the document-level `_score`. Documents don't need to match every expression to appear in results:

**GROQ**

```groq
*[_type == "product" && category == "footwear"]
    | score(
        [title, body] match text::query("Gore-Tex waterproof boots"),
        text::semanticSimilarity("Gore-Tex waterproof boots")
      )
```

The filter restricts results to footwear products. The keyword match catches products that mention "Gore-Tex" by name. Semantic similarity surfaces products that are conceptually related to waterproof boots, even when they use different language.

When keyword matches on shorter fields like `title` produce scores that outweigh semantic matches on longer fields like `body`, use `boost()` to adjust the balance:

```groq
*[_type == "product" && category == "footwear"]
    | score(
        boost([title, body] match text::query("Gore-Tex waterproof boots"), 0.5),
        text::semanticSimilarity("Gore-Tex waterproof boots")
      )
```

When the search input is conceptual rather than specific—descriptions of problems, features, or topics rather than exact names—filtered semantic search alone often produces better results, since there's no keyword score to compete with the semantic signal:

```groq
*[_type == "product" && category == "footwear"]
    | score(text::semanticSimilarity("comfortable shoes for long walks"))
```

> [!WARNING]
> Running a `text::semanticSimilarity()` query against a dataset that does not have embeddings enabled will return an error.

### Search result metadata

When a query uses `text::semanticSimilarity()`, Sanity automatically includes an `_embeddings` field on each result. This contains the specific text fragments that contributed to the match, along with their source fields and character positions, which is useful for highlighting matches or tracing which part of a document drove the result.

**Response**

```json
{
  "_score": 8.341205,
  "_embeddings": [
    {
      "fragments": [
        "OAuth 2.0 provides a secure delegation protocol for authorizing third-party access.",
        "Implementing token-based authentication with refresh tokens",
        "Security"
      ],
      "fields": ["body", "title", "category"],
      "startPositions": [0, 0, 0],
      "endPositions": [74, 55, 8],
      "score": 8.341205
    }
  ],
  "_id": "article-auth-guide",
  "_type": "article"
}
```

Each entry in `_embeddings` contains the text fragments that contributed to the semantic match, along with metadata about where they came from.

- `fragments` are extracts of the original text
- `fields` are the GROQ-style field paths they came from (e.g. `reviews[0].text`)
- `startPositions` and `endPositions` are character offsets within each field

### How documents are chunked

A document's projected content may be split into one or more chunks before embedding. Short documents typically fit in a single chunk, while longer documents are split across multiple. Each chunk is embedded as a separate vector and scored independently at query time.

This chunking is why the `_embeddings` array can vary in length across results. A short document produces a single `_embeddings` entry whose `fragments` and `fields` arrays cover all embedded fields together. A longer document produces multiple entries, each representing a different portion of the content. A short field like `title` may appear in its own chunk while a long `body` field spans several.

The number of chunks depends on the total text length of the projection output for a given document, not on how many fields the projection includes.

### Per-chunk scores vs. document-level `_score`

Each `_embeddings` entry includes a `score` field representing the semantic similarity of that individual chunk to the query. Entries are sorted by this score, highest first.

The document-level `_score` is separate. It combines all scoring expressions in your `score()` function; both `text::semanticSimilarity()` and any keyword matching via `match` or `text::query()`. The per-chunk `score` tells you how well a specific portion of the document matched semantically; the document-level `_score` determines where the result appears in the overall ranking.

## Troubleshooting

### Results don't seem relevant to my query

Check your projection. If no projection is set, the entire document is being embedded, which can lead to matches against irrelevant fields. Define a projection that scopes your embeddings to the content your users actually search against.

### Status shows `error`

Some failed enablements require manual intervention. [Ask in Discord](https://www.sanity.io/community/join) (community) for assistance. Enterprise customers with enterprise-level support should contact support through their dedicated channels.

### Query results appear stale

Embedding updates are asynchronous and debounced. After a document is updated, its embedding may take a few minutes to reflect the change.

### Keyword matches seem to outweigh semantic results

In hybrid queries that combine `match` with `text::semanticSimilarity()`, keyword matches on short fields like titles can produce high scores that outweigh strong semantic matches on longer content fields. This happens because each matching term represents a larger share of a short field's total content, resulting in a higher keyword score.

To address this, use `boost()` to reduce the weight of the keyword expression, broaden the keyword match to include the same fields you're embedding so the signal is spread across all content, or test whether semantic similarity alone produces good enough results for your use case.



# Content release document flow

Content Releases let you organize and schedule updates across multiple documents. You can plan, preview, and validate significant changes in advance, then publish them together.

This document explores interacting with Content Releases using Sanity's APIs. For details on using Content Releases in Sanity Studio, or customizing the experience, follow these links:

[Content Releases user guide](https://www.sanity.io/docs/user-guides/content-releases)
Create, schedule, and publish releases from Sanity Studio.

[Content Releases configuration](https://www.sanity.io/docs/studio/content-releases-configuration)
Configure Content Releases in Sanity Studio

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

> [!NOTE]
> Scheduled Drafts is also available
> For teams on Growth or above plans, or that don’t need to schedule groups of documents to go out at once, the [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) feature is available.

APIs that interact with Content Releases require API version `v2025-02-19` or later. A single release can contain a maximum of 1,000 documents, and the combined JSON of all documents in a release cannot exceed 100 MB. Asset files linked from those documents don't count toward the size limit.

## Releases and document versions

Releases are Sanity documents with a type of `system.release`. The top-level `state` property holds the release state, and `metadata` holds the user-supplied fields such as `metadata.title`, `metadata.description`, `metadata.releaseType`, and `metadata.intendedPublishAt`.

> [!TIP]
> Protip
> If you use [content resources and custom roles](https://www.sanity.io/docs/user-guides/roles), you can restrict access for:
> 1. Editing documents *in* releases by using a filter like `_id in path("versions.**")` for any release or `_id in path("versions.rA29bfjqa.**")` for documents in a specific release.
> 2. Performing release actions such as creating, publishing and archiving releases by using a filter like `_id in path("_.releases.**")` for any release or `_id == "_.releases.rA29bfjqa"` for a specific release.

Releases and documents are connected by a document ID system similar to the `drafts.` syntax. For releases, document IDs start with the `versions.` prefix. For example:

- The published version: `movie_70981`
- A release version: `versions.RELEASE_NAME.movie_70981`

Releases have a name, not to be confused with the user-supplied title. This name matches the end of the `_id`. When you create a release through the API, the `releaseId` you supply becomes the name; `client.releases.create()` generates one for you if you omit it. For example, a release name of `rSC2jjcUJ` results in an `_id` of `_.releases.rSC2jjcUJ`.

## Release states

The current status of a release is known as the release `state`. Releases begin in the `active` state. This information is available on the `state` property in documents with a `_type` of `system.release`.

<div style="display:none">Unknown block type "mermaidDiagram", specify a component for it in the `components.types` option</div>A release may have the following states (`state`):

- `active`: The general state of a release that is not within one of the other states. *This is the default state of a new release*.
- `scheduled`: A state resulting from calling the `sanity.action.release.schedule` action on the release or scheduling the release in Studio.
- `published`: A state resulting from either calling the `sanity.action.release.publish` action, publishing the release in Studio, or when a scheduled release is published due to reaching its `publishAt` time.
- `archived`: A state resulting from calling the `sanity.action.release.archive` action or archiving the release in Studio.

There is no `deleted` state. The `sanity.action.release.delete` action, or deleting the release in Studio, removes the release document. You can only delete a release that is `published` or `archived`.

> [!WARNING]
> Gotcha
> When `scheduled`, any version documents that are part of the release are locked. To mutate these documents, either in Studio or programmatically, the release must have a `state` of `active`.

Additional transient states exist to indicate the asynchronous points when releases move between states:

- `scheduling`/`unscheduling`: Intermediate states that exist when moving to or from the `scheduled` state.
- `archiving`/`unarchiving`: Intermediate states that exist when moving to or from the `archived` state.
- `publishing`: Intermediate state that exists before reaching the `published` state. A scheduled release also transitions through `publishing`.

### State transitions

Releases begin in `active`. Every other state is reached through one of these transitions:

- Scheduling or publishing an `active` release moves it to `scheduling`, then `scheduled`.
- Unscheduling a `scheduled` release moves it to `unscheduling`, then back to `active`.
- A `scheduled` release moves to `publishing` when its publish time arrives, then to `published`.
- Archiving an `active` release moves it to `archiving`, then `archived`.
- Unarchiving an `archived` release moves it to `unarchiving`, then back to `active`.
- Deleting a `published` or `archived` release removes the release document.

If publishing or archiving fails, the release returns to `active` and the `error` property holds the reason. Large releases publish, archive, and unarchive in batches, so they can stay in `publishing`, `archiving`, or `unarchiving` across several updates.

A scheduled release stores its publish time in the top-level `publishAt` property. This is distinct from `metadata.intendedPublishAt`, which records the time an editor picked in Studio. Where both are set, `publishAt` takes precedence.

Even releases set for immediate publishing move through the scheduling and scheduled states. They do not stay there; they immediately move on to publishing. Keep this in mind if you listen for state changes on release documents.

## Query releases and versions

Releases are Sanity documents and respect the existing query and mutation APIs. The Content Releases API cheat sheet provides examples of querying and interacting with releases and their documents.

[Content Releases API cheat sheet](https://www.sanity.io/docs/apis-and-sdks/content-releases-cheat-sheet)
Common patterns for querying and interacting with releases and their documents

## Additional resources

[Actions API reference](https://www.sanity.io/docs/http-reference/actions)
Reference documentation for the Actions HTTP endpoint, including the release actions.

[GROQ functions](https://www.sanity.io/docs/specifications/groq-functions)
GROQ queries can use the releases::all(), sanity::partOfRelease(), and sanity::versionOf() functions to retrieve release information.

[@sanity/id-utils](https://github.com/sanity-io/id-utils)
This utility library helps parse and convert between the various ID formats.



# Hierarchy

Sanity’s hierarchy primitive is built around three document types: `sanity.tree`, `sanity.directory`, and `sanity.symlink`, and a single `parent` reference field that any document can carry.

> [!WARNING]
> Public beta
> The hierarchy primitive is in public beta. The shape of the documents and the validation rules described on this page may change before general availability. The first product built on top of the primitive is folders in [Media Library](https://www.sanity.io/docs/media-library/folders).

## Document types

#### Properties

**sanity.tree**

The root of your library’s hierarchy. Every library has one tree, created on demand the first time you create a folder.

**sanity.directory**

A folder. Has a name and a parent reference to its containing tree or directory.

Required fields: parent

**sanity.symlink**

A shortcut. Makes an asset appear inside a folder in addition to its primary location.

Required fields: parent, target

## How the data model works

Each document in the hierarchy stores only its **direct parent** via the `parent` reference field. There is no children list; children are discovered by querying:

```groq
*[parent._ref == $parentId]
```

To walk upward from a document to the root, fetch its `parent._ref` and repeat until you reach a `sanity.tree`.

The `parent` field can appear on any document type that you want to participate in the hierarchy. In Media Library, for example, `sanity.asset` and `sanity.asset.collection` both carry `parent` to place themselves inside folders.

## Creating the tree

A hierarchy starts with a single `sanity.tree`. Use `createIfNotExists` with a deterministic `_id` so multiple writers can call this safely without producing duplicate trees.

**@sanity/client**

```typescript
await client.createIfNotExists({
  _id: 'root-tree',
  _type: 'sanity.tree',
})
```

**HTTP**

```sh
curl -X POST "https://api.sanity.io/v2025-02-19/{mutate-endpoint}" \
  -H "Authorization: Bearer ${SANITY_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "mutations": [
      {"createIfNotExists": {"_id": "root-tree", "_type": "sanity.tree"}}
    ]
  }'
```

> [!NOTE]
> Consuming applications may require a specific ID
> For example, the [Media Library](https://www.sanity.io/docs/media-library/folders) requires the tree’s `_id` to be `tree.{libraryId}`

## Creating directories

A `sanity.directory` carries a `name` (string) and a `parent` reference. Top-level directories point at the tree; nested directories point at another directory.

```typescript
const tree = await client.fetch(`*[_type == "sanity.tree"][0]{_id}`)

const topLevel = await client.create({
  _type: 'sanity.directory',
  name: 'Top Level',
  parent: {_ref: tree._id},
})

await client.create({
  _type: 'sanity.directory',
  name: 'Nested',
  parent: {_ref: topLevel._id},
})
```

## Querying the hierarchy

The hierarchy is queried using standard [GROQ](https://www.sanity.io/docs/groq).

Direct children of a parent:

```groq
*[parent._ref == $parentId]
```

All directories:

```groq
*[_type == "sanity.directory"]
```

Sibling documents (same parent):

```groq
*[parent._ref == $parentId && _id != $currentId]
```

Walking ancestors is done client-side. Fetch a document’s `parent` and repeat until the type is `sanity.tree`. Depth is bounded by the [maximum hierarchy depth](https://www.sanity.io/docs/content-lake/technical-limits).

## Moving directories

Patch `parent` to point at the new parent. The server validates the destination before applying any change.

```typescript
await client
  .patch(directoryId)
  .set({parent: {_ref: newParentId}})
  .commit()
```

If the new `parent` would create a cycle, exceed the maximum depth, or point at an unsupported type, the mutation fails. See the [error reference](https://www.sanity.io/docs/content-lake/hierarchy).

## Symlinks

A `sanity.symlink` is a pointer document. It has a `parent` (the folder it appears in) and a `target` (the document it refers to). It does not constrain the type of the target document.

```typescript
await client.create({
  _type: 'sanity.symlink',
  parent: {_ref: destinationFolderId},
  target: {_ref: targetDocumentId},
})
```

Consuming applications may further constrain valid target types. For example, [Media Library shortcuts](https://www.sanity.io/docs/media-library/folders) only support `sanity.asset` targets and will not render correctly if `target` references a `sanity.directory`.

## Deleting

Documents in the hierarchy are deleted with the standard `delete` mutation.

```typescript
await client.delete(directoryId)
```

> [!WARNING]
> No cascade delete
> Deleting a `sanity.directory` does not cascade to its children. The server rejects any delete where another document still references this directory via `parent`. Remove or reparent the children before deleting the parent, or include them in the same transaction.

## Error reference

- `circular reference detected`: 
The mutation would create a cycle (for example, A → B → A). 
Choose a different `parent`.
- `hierarchy depth exceeds maximum`: 
The mutation would push a document past the [depth limit](https://www.sanity.io/docs/content-lake/technical-limits). 
Flatten the structure or reparent under a shallower ancestor.
- `parent must be a directory or a tree`: 
`parent` resolves to a document that is not a `sanity.tree` or `sanity.directory`. 
Correct the `parent` value.
- `parent reference cannot be weak`: 
`parent` includes `_weak: true`. 
Remove the `_weak` flag.



# Introduction

With GROQ, you can join information from multiple documents, filter with precision, and stitch together specific responses containing only the exact fields you need.

Unlike other query languages, GROQ gives you complete control over your data's shape and structure, allowing you to transform your content at the API level rather than in your application code.

Benefits and uses:

- **Extract precisely what you need** from your documents, reducing bandwidth usage and simplifying your frontend code.
- **Join related content** by following references between documents, creating rich, nested responses.
- **Filter and sort** your content with powerful expressions and functions.
- **Transform your data** at the query level with projections and computed fields.
- **Create modular, reusable queries** with custom GROQ functions.

#### Ready to get started?

[How GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work)
A tutorial on using the Sanity query language GROQ.

[GROQ Sanity Learn Course](https://www.sanity.io/learn/course/between-groq-and-a-hard-place)
Get familiar with GROQ, the query language for Sanity data, webhooks and roles.

[Cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet)
Explore common patterns and ready-made queries.

## Core concepts

GROQ operates as a pipeline where data flows from left to right through various operations. Understanding these core concepts will help you build effective queries.

### Where can you use GROQ?

While GROQ is primarily thought of as a means of querying data for your front-ends, GROQ queries are available in most places where you interact with data in a Sanity dataset. You can use GROQ with the [@sanity/client](https://github.com/sanity-io/client)'s fetch method, directly to the [/query HTTP endpoint](https://www.sanity.io/docs/http-reference/query), and more.

In some use cases, you'll pass an entire query as part of your request. In others, such as in [Functions](https://www.sanity.io/docs/functions/functions-introduction) or [Webhooks](https://www.sanity.io/docs/content-lake/webhooks), you will configure the filter and projection separately.

### Syntax

The following are the most common parts of the GROQ syntax. For a full list, explore the [GROQ Syntax reference](https://www.sanity.io/docs/specifications/groq-syntax).

#### Filters

Filters let you select specific documents from your dataset based on criteria. Most GROQ queries start with `*` (representing all documents) followed by a filter in square brackets.

```groq
*[_type == "movie" && releaseYear >= 1979]
```

This query selects all movie documents released in or after 1979.

#### Projections

Projections define the shape of your results, letting you pick exactly which fields to include. They're enclosed in curly braces.

```groq
*[_type == "movie"]{ _id, title, releaseYear }
```

This returns only the ID, title, and release year for each movie, even if the documents contain many more fields.

#### References and joins

GROQ makes it easy to follow references between documents using the dereferencing operator (`->`). This lets you include related content directly in your results.

```groq
*[_type == "movie"]{
  _id, 
  title,
  "director": director->name
}
```

This query follows the reference in the `director` field and includes just the director's name in the results.

#### Sorting and slicing

You can order your results and select specific portions of the result set.

```groq
*[_type == "movie"] | order(releaseYear desc) [0...10]
```

This sorts movies by release year (newest first) and returns only the first 10 results.

### Functions

GROQ functions provide powerful tools for manipulating and retrieving data. GROQ includes built-in functions like `count()`, `order()`, and `references()` that help you analyze, sort, and manipulate your content. You can see all available functions in the [GROQ functions reference documentation](https://www.sanity.io/docs/specifications/groq-functions).

You can also create your own [custom GROQ functions](https://www.sanity.io/docs/content-lake/custom-groq-functions) to make your queries more modular and reusable. 

### Perspectives

Perspectives control which version of a document your GROQ query returns. Every document in the Content Lake can exist in multiple states (published, draft, or as part of a content release), and the perspective determines which state you see.

#### Setting a perspective

Set the perspective in your client configuration or on individual queries:

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2025-02-19',
  perspective: 'published',
  useCdn: true,
})

// Override per query
const drafts = await client.fetch(
  '*[_type == "article"]',
  {},
  {perspective: 'drafts', useCdn: false}
)
```

Or pass it as a query parameter in the HTTP API:

```text
GET /v2025-02-19/data/query/<dataset>?query=*[_type == "article"]&perspective=drafts
```

For a complete guide to perspectives, including how they interact with content releases and preview workflows, see [Perspectives for Content Lake](https://www.sanity.io/docs/content-lake/perspectives).

### The Vision plugin

The Vision plugin adds a playground for testing GROQ queries directly to Sanity Studio. You can use it to try out queries, analyze your data, and experiment.

#### Learn more

[The Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin)
Quickly test your GROQ queries using this studio plugin.

### The GROQ specification

GROQ has a formal specification that defines its syntax and behavior. If you're building your own tools on top of GROQ, specification is available at [spec.groq.dev](https://spec.groq.dev/) and serves as the authoritative reference for how GROQ should work.

## Limitations

- Custom GROQ functions have some limitations, including no support for recursion, multiple parameters, or accessing the parameter more than once in the function body. For more details, see the [Custom GROQ functions guide](https://www.sanity.io/docs/content-lake/custom-groq-functions).
- Complex queries with many joins or large result sets may impact performance. Learn more about [how to write performant GROQ queries](https://www.sanity.io/docs/developer-guides/high-performance-groq).



# How queries work

The idea behind our query language GROQ (Graph-Relational Object Queries) is to be able to describe exactly what information your application needs, potentially joining together information from several sets of documents, then stitching together a very specific response with only the exact fields you need.

If you need help setting up a client to perform these queries in your front end, you should check out the documentation for the client for [JavaScript](https://www.sanity.io/docs/js-client) or [PHP](https://www.sanity.io/docs/php-client). You can also check out the [GROQ Arcade](https://groq.dev) if you want to query any JSON source and get familiar with the language.

## Introduction

Let us start with the basics. We will take this simple query and pick it apart:

```groq
*[_type == 'movie' && releaseYear >= 1979] 

```

A query typically starts with `*`. This asterisk represents every document in your dataset. To do any useful work this is typically followed by a *filter* in brackets. The filter above has two terms:

### The filter

First, we filter by document type. Every document in Sanity is required to have a type, and the type is always in the `_type` field. (We prefix any Sanity-specific fields with an underscore in an attempt to avoid clashing with any of *your *field names.) So `_type == 'movie'` limits this query to documents of the type ‘movie’. `&&` is the operator “and”.

The second term `releaseYear >= 1979` assumes that the movies have a field called `releaseYear` that contains numbers. It will match any document where this number is larger than or equal to 1979.

### Projections

So if we run this query, the result will be an array containing all movies from the year 1979 onwards in the dataset. Nice! However in a typical application movies might be huge documents containing information on actors, staff, posters, tag-lines, show-times, ratings, and whatnot. If our goal is to render a list of movies in an overview, we are wasting bandwidth. *Projections* to the rescue.

The typical projection is wrapped in braces and describes the data we want to see for each movie. A nice and simple projection for this query would give us the id, title, and release year for each movie. It could look like this: `{_id, title, releaseYear}`. Putting it all together:

```groq
*[_type == 'movie' && releaseYear >= 1979]{ _id, title, releaseYear } 
```

### Basic sorting

Now there is another problem. Our movies appear in some unspecified order. Let’s say we want to sort our movies by year. For this, we use the `order`-function. Order takes a number of fields and sort directions and orders your documents accordingly. We wanted to sort our movies by `releaseYear`. This is easily accomplished with `order(releaseYear)`, like this:

```groq
*[_type == 'movie' && releaseYear >= 1979] | order(releaseYear) {
  _id, title, releaseYear 
} 

```

*(We need the *`|`* operator here in front of the order()-function, we'll discuss that more later.)*

We think of GROQ statements as describing a data flow from left to right. First everything (`*`) flows through the filter` [_type == 'movie' && …]`, then all those movies flow through the `order()`-function which is then all mapped through the projection `{_id, title, ...}` which picks out the bits we want to be returned.

The order function accepts a list of fields, and optionally you can specify the sort direction for each field. If you wanted to sort the movies by year, and then within each year we want them alphabetical by title, we could use this ordering: `order(releaseYear, title)` And if we wanted the newest movies first, we could reverse the direction like this: `order(releaseYear desc, title)`.

> [!TIP]
> Protip
> `asc` means “ascending” and `desc` means descending in this context. If you leave out the sort-direction, Sanity will assume you want the ascending order.

### Slicing the result set

This brings us to our final problem for this query: There are many movies in the world. Maybe our dataset contains tens of thousands. We need a way to describe which slice of that list we want to show. This is done using a *selector*. Let’s say we just wanted the first movie, we could add a `[0]` at the end. This works exactly like an array accessor and would return only the first element. If we want a slice, we can add [the range operator](https://www.sanity.io/docs/specifications/groq-operators) like this: `[0...100]`. This would return the first hundred movies from index 0 through 99. We can just as well ask for `[1023...1048] `or any other slice we desire. So there we are, our first basic query with filtering, ordering, projections, and selector:

```groq
*[_type == 'movie' && releaseYear >= 1979] | order(releaseYear) {
  _id, title, releaseYear
}[0...100]

```

### References and joins

A [reference](https://www.sanity.io/docs/content-lake/ids) in Sanity is a link from one document to another. Standard references are “hard” meaning when a document references another document, the target document *must* exist, and is actually prevented from being deleted until the reference is removed. (There are also weak-references that do not "hold on to" the target. You make them by adding a `_weak`-key to the reference object like this: `{_ref: "<document-id>", _weak: true}`)

Let’s say we have “person”-type documents that looks something like this:

```javascript
{
  _id: "ridley-scott",
  _type: "person",
  name: "Ridley Scott"
}

```

Keeping it simple, maybe our movies had a field `director` that contained a reference to a person. It could look something like this:

```javascript
{
  _id: "alien",
  _type: "movie",
  title: "Alien",
  releaseYear: 1979,
  director: { _ref: "ridley-scott" }
}
```

Remember Sanity-specific fields are prefixed with an underscore, and an object containing a `_ref` key appearing anywhere in the document becomes a hard reference.

### Expanding references

Now we can do a number of useful things with this reference. The most basic thing is expanding the reference in place. Let’s revisit our movie queries from the introduction.

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear
}

```

Let’s say we wanted to include the director in the returned result. If we didn't know any better, we'd perhaps try something like this:

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear,
  director
}

```

But if we just naïvely include the director in like this, we will just get whatever is in the director field on this document, which is the literal reference description:

```javascript
[
  {
    _id: "alien",
    title: "Alien",
    releaseYear: "1979",
    director: {
      _ref: "ridley-scott"
    }
  },
  … (more movies)
]


```

This is not what we wanted, we wanted to follow that reference! By adding the dereferencing operator `->` we ask Sanity to follow the reference and replace it with the actual content of the document referenced:

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear,
  director->
}

```

Now, this is useful. We’d get something like this:

```javascript
[
  {
    _id: "alien",
    title: "Alien",
    releaseYear: "1979",
    director: {
      _id: "ridley-scott",
      _type: "person",
      name: "Ridley Scott"
    }
  },
  … (more movies)
]


```

Then maybe we didn’t want all that metadata with our director? We can add a separate projection for our director:

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear,
  director->{name}
}

```

Our query now returns the director with just the name property we wanted:

```javascript
{
  _id: "alien",
  title: "Alien",
  releaseYear: "1979",
  director: {
    name: "Ridley Scott"
  }
}

```

But we can do one better. We are not limited to the existing fields in the document in our projections, we can actually declare new fields. Let’s say we are building our compact movie list and we wanted just the title, year, and director name. We can get minimal cruft by extracting just the name and putting it in a new field, like this:

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear,
  "directorName": director->name
}

```

Now our query returns exactly what we want in the form we want it:

```javascript
{
  _id: "alien",
  title: "Alien",
  releaseYear: "1979",
  directorName: "Ridley Scott"
}

```

#### Expanding an array of references

The example above shows how to expand a reference, but sometimes you'll be working with an *array* of references. In the above example, let's say we wanted to add producers. Details on how to set this up in your schema can be found in the [Array](https://www.sanity.io/docs/studio/array-type) documentation, but we'll consider how you might query that data.

In this revised example, let's look at a query like this:

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear, director,
  producers[]
}

```

We use square brackets after `producers` because it's an array. Note that we used `producers` with an `s`. The naming convention of your schema doesn't matter to GROQ (as long as you get the name right); it is our recommendation to [use the plural form for arrays](https://www.sanity.io/docs/apis-and-sdks/naming-things).

Now, you might get this:

```javascript
[
  {
    _id: "alien",
    title: "Alien",
    releaseYear: "1979",
    director: {
      _ref: "ridley-scott"
    }
    producers: [
      {
        _key: "<uniqueKey1>",
        _type: "reference",
        _ref: "gordon-carroll"
      },
      {
        _key: "<uniqueKey2>",
        _type: "reference",
        _ref: "david-giler"
      },
      {
        _key: "<uniqueKey3>",
        _type: "reference",
        _ref: "walter-hill"
      },
    ]
  },
  … (more movies)
]
```

Like before, this isn't returning the details for each producer. We're getting references like we did at the beginning of the single reference example (and a `_key`, which [ensures uniqueness](https://www.sanity.io/docs/studio/array-type)). To expand references in an array, we will use the dereferencing operator (`->`) again. However, the square brackets are mandatory to traverse the array.

```groq
*[_type == 'movie' && releaseYear >= 1979]{
  _id, title, releaseYear, director,
  producers[]->
}

```

This will return the full details for each of the three producers referenced. Projections and [naked projections](https://www.sanity.io/docs/content-lake/how-queries-work) can be used just as with single references (the projection would go *after* the dereference operator).

> [!WARNING]
> Gotcha
> It would be easy to forget the square brackets when expanding an array of references (i.e., querying `producers->` instead of `producers[]->`, with the former returning a single null value). This is perhaps complicated by the fact that both `producers` and `producers[]` **will** return the array (albeit with unexpanded references). This is the nature of [how GROQ traversals work](https://sanity-io.github.io/GROQ/draft/#sec-Traversal-expression).

### Filtering by references

When dealing with references, we have a useful function called `references()` which can be used in filters to select only documents that reference specific other documents. Let’s say we want to list every movie Ridley Scott has been involved in. It looks like this:

```groq
*[_type == 'movie' && references('ridley-scott')]
```

### Our first join

It is time to write our first proper join: Say we wanted to list people and include all the movies they were involved in? We’ll be querying the “person”-type documents, but in the projections for each person, we’ll ask for the movies they have been involved in. To do this we have to briefly cover the parent-operator `^`. Let’s look at the query first:

```groq
*[_type == "person"]{
  _id, name,
  "movies": *[_type == "movie" && references(^._id)].title
}

```

In a join, the parent operator is a way to reference the “parent” document. In this example the outer query for “person”-type documents fetches a bunch of people, and for each person, it returns the `_id` and `name`. Then we want to fetch the movies referencing that person. 

Now we declare the new field “movies” where we start a new query for “movie”-type documents, but for each person, we want to limit our movie query to movies referencing that person. To achieve this we need the _id of the person, but if we just wrote `_id` in the movies-query we’d reference the _id of the movie. 

To get to the fields of the person record we go “up” one level using the parent operator `^`. So `^` means the specific “person”-document that our movie query is about, and then `^._id` is the _id of that person, just as `^.name` would be her name. So when we say `references(^._id)` in the query above, we limit our movies to movies referencing the current person.

### Naked projections

There is one more new thing we haven’t talked about in this query. We could have written the movies-sub-query like this:

```groq
*[_type == "movie" && references(^._id)]{title}
```

Our list of movies would have looked something like this:

```javascript
”movies”: [{title: “Alien”}, {title: “Blade Runner”}, …]
```

Since we just wanted the titles, we can use a [“naked projection”](https://www.sanity.io/docs/specifications/groq-syntax). By naming the field we want, like this:

```groq
*[_type == "movie" && references(^._id)].title 
```

We get a nice, simple array of values, like this:

```javascript
”movies”: [“Alien”, “Blade Runner”, …]
```

So, for completeness, the result of the full person w/movies query above could look something like this:

```javascript
[
  {
    _id: "river-phoenix",
    name: "River Phoenix",
    movies: ["My Own Private Idaho", "Stand By Me", …]
  },
  {
    _id: "ridley-scott",
    name: "Ridley Scott",
    movies: ["Alien", "Blade Runner", …]
  },
  …
]

```

## More ways to filter

Sanity supports a growing number of ways to [filter your documents](https://www.sanity.io/docs/specifications/groq-operators). We have shown simple attribute comparisons with `_type == ‘movie’` and  `releaseYear >= 1979`. We have shown filtering by references using the `references()`-function. In addition, we support:

- Text search using the match operator, e.g. `*[title match "Alien*"]`
- Filtering by the presence of a field, e.g. `*[defined(status)]` which only match documents that have the status property set to any value.
- The `in`-operator which matches values in arrays, as in `*["sci-fi" in genres]`, that matches all documents where `genres` is an array and that array contains the value `"sci-fi"`.
- You can of course combine these filters using the boolean operators `&&` (and), `|| `(or), `!` (not), like this `*[_type == "movie" && (!("sci-fi" in genres) || releaseYear >= 1979)]`.

We are working on a full reference for the GROQ feature set. In the meantime, you'll find a comprehensive set of examples in the [cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet).

## Queries in projections

A useful thing in GROQ is that filtering and [projections](https://www.sanity.io/docs/specifications/groq-syntax) also can be used inside your projections. Let’s say you work for an architect and every project has a number of milestones. A document might look something like this:

```javascript
{
  _id: "timmerhuis"
  _type: "project",
  title: "Timmerhuis",
  milestones: [
    {status: "competition", year: 2009},
    {status: "design-development", year: 2011},
    {status: "breaking-ground", year: 2013},
    {status: "completed", year: 2015}
  ]
}

```

And let’s say the view we are producing is about showing the current status of the project. We could achieve this by finding the latest milestone and extracting its status tag. This can be done in GROQ like this:

```groq
*[_type == "project"]{
  _id, title,
  "status": milestones|order(year desc)[0].status
}

```

Let’s pick apart the status query `milestones|order(year desc)[0].status` in some detail:

First, we take the field `milestones` which contain the (potentially unordered) list of milestones for the project. Using the pipe-operator `|` we send the contents of this array to the order function, which is instructed to sort the array by year in descending order `order(year desc)`. Then we take only the first element `[0]` (which is the latest milestone) and return the value of its `status` field. So now our project list would look something like this:

```javascript
[
  {
    _id: "timmerhuis",
    title: "Timmerhuis",
    status: "completed"
  },
  …
]

```

Let’s try another clever trick querying the contents of this object. Instead of a status field, we just want a boolean flag telling whether the project is completed. We could achieve this like this:

```groq
*[_type == "project"]{
  _id, title,
  "completed": count(milestones[status == 'completed']) > 0
}

```

Here we take the milestones, but select only the ones having the status “completed”. Then we `count()` the number of milestones matching this filter. If that count is `> 0` the result is `true`. So now our result would look something like this:

```javascript
[
  {
    _id: "timmerhuis",
    title: "Timmerhuis",
    completed: true
  },
  …
]

```

## Some comments on the pipe operator

In the project-status example above we used the pipe operator `|` for a second time. Let's explore that in some detail:

```groq
*[_type == "project"]{
  _id, title,
  "status": milestones | order(year desc)[0].status
}

```

The pipe operator takes the output from its left-hand side and sends it to the operation to its right. "But isn’t this what all GROQ statements do?", I hear you ask. And you’d be right.

In some situations, like when using pipe functions (e.g., `order()` in the project-status example), an explicit pipe operator is required. `milestones order(year desc)` would be a syntax error, so pipe functions must be preceded by a pipe operator, like this: `milestones | order(year desc)`. `score()` is another example of a pipe function, which must therefore be preceded by a pipe operator.

Projections may be preceded by a pipe operator, though it is optional. `Expression { Projection }` and `Expression | { Projection }` are equally valid.

The pipe operator is not valid in any other contexts and will return an error.

## Some fine points on arrays and projections

Let’s consider this document with some deep structure:

```javascript
{
  _id: "alien",
  _type: "movie",
  title: "Alien",
  poster: {
    asset: {_ref: "image-1234"}
  },
  images: [
    {
      caption: "Sigourney Weaver and the cat Jones on set",
      asset: {_ref: "image-1235"}
    },
    {
      caption: "Bolaji Badejo suiting up for the role of the Alien",
      asset: {_ref: "image-1236"}
    },
  ]
}

```

So we have a movie with a poster image and an array of other images. Each image has some metadata represented here by a caption, then a reference to an asset record containing all the metadata on the specific image including its URL. A simplified asset record could look something like this:

```javascript
{
  _id: "image-1234",
  _type: "sanity.imageAsset",
  url: "http:///cdn.sanity.io/images/…"
}

```

Now we can retrieve the poster image url and attach it to our result for each movies like this:

```groq
*[_type == "movie"]{
  title,
  "posterImage": poster.asset->url
}

```

But what if we wanted to do the same thing for the other images? Since the `images` field is an array, we can’t just `images.asset->url`. We somehow have to apply the `asset->url`-part to each member of the array. This is accomplished by adding a blank filter, like this: `images[].asset->url` which will return the image URLs as a simple array. So the full query would look like this:

```groq
*[_type == "movie"]{
  title,
  "imageUrls": images[].asset->url
}

```

This would yield something like this:

```javascript
[
  {
    title: "Alien",
    imageUrls: ["http://cdn.sanity.io/…", "http://cdn.sanity.io/…"]
  },
  …
]

```

If you wanted a richer data-set with your images you could use a normal projection like this (taking care to add the blank filter to apply the projection to every array member):

```groq
*[_type == "movie"]{
  title,
  "images": images[]{
    caption,
    "url": asset->url,
  }
}

```

Now your result looks something like this:

```javascript
[
  {
    title: "Alien",
    images: [
      {
        caption: "Sigourney Weaver and the cat Jones on set",
        url: "http://cdn.sanity.io/…"
      },
      {
        caption: "Bolaji Badejo suiting up for the role of the Alien",
        url: "http://cdn.sanity.io/…"
      }
    ]
  },
  …
]

```

## The ellipsis operator

Sometimes you might want to compute some properties of a document, but still want the entire set of attributes returned. This can be a problem since the moment you specify a [projection](https://www.sanity.io/docs/specifications/groq-syntax), you'll have to list all the fields you want to be included. Let's say we wanted to count the actors in a movie doing something like this:

```groq
*[_type == "movie"]{
  "actorCount": count(actors)
}
```

There is a problem with this. We just wanted to add a custom field, but since we needed a projection to do it, now all we got is something like this:

```javascript
[
  {actorCount: 3},
  {actorCount: 27},
  {actorCount: 15}
]
```

What we wanted was our custom field in *addition* to the normal fields. This can be achieved with the ellipsis operator. By appending it like this, we effectively say we want the fields we just specified, but also everything else:

```groq
*[_type == "movie"]{
  "actorCount": count(actors),
  ...
}
```

Which brings us a result that could look something like this:

```javascript
{
  {
    title: "Alien",
    releaseYear: 1979,
    actorCount: 23,
    // And loads more fields, probably
  },
  // and many more movies
}
```

### Placement of the ellipsis operator

In `v1` of the GROQ API, the placement of the ellipsis operator didn't matter. An explicit property would override the ellipsis even when the ellipsis comes last in the projection.

Consider a case where a projection returns some number of properties, with one being `age`. Let's say that `age` is equal to `23`.

```groq
// GROQ API v1

*[]{
  ...,
  'age': 45 // This will override the age property
            // returned from the ellipsis, so age == 45
}


*[]{
  'age': 45,
  ... // The age value returned from the ellipsis does *not*
      // override the explicitly set value, so age == 45
}
```

As of `v2021-03-25` of the GROQ API, the placement of the ellipsis operator matters. An explicitly-set property will only override the property returned by the ellipsis if it comes after the ellipsis. In other words, as of `v2021-03-25`, the property that comes last in the projection wins, even if it's returned by the ellipsis.

```groq
// GROQ API v2021-03-25 or later

*[]{
  ...,
  'age': 45 // This will override the age property
            // returned from the ellipsis, so age == 45
}


*[]{
  'age': 45,
  ... // The age value returned from the ellipsis *does*
      // override the explicitly set value, so age == 23
}
```

Such a distinction might be observed when [dereferencing](https://www.sanity.io/docs/specifications/groq-operators). In `v1`, the explicit dereference operator could be placed before *or* after the ellipsis operator in a projection, and the reference would be followed in either case. As of `v2021-03-25`, an explicit dereference after the ellipsis would give expected behaviour, returning the contents of the document that was referenced. However, placing the ellipsis last will actually cause the original (non-dereferenced) property to win, returning just the `_ref` and `_type`.

> [!TIP]
> Protip
> When using the ellipsis operator, you will want to list it first in your projection. Any explicitly-listed properties that follow will overwrite that same property that *would* have been returned by the ellipsis, which is likely the behaviour you're after.

## Queries that don't start with an `*`

We said initially that most GROQ queries start with the asterisk, but they don't have to. Any valid GROQ expression can be the entire query. This is a valid query:

```groq
count(*)

```

It will return the number of documents in the dataset. This is also valid:

```groq
count(*[name match "sigourney"]) > 0

```

It will return `true` if any document in the entire dataset has a `name`-field containing the word "sigourney".

More usefully, you can actually have a projection be your outer statement. Like this:

```groq
{
  "mainStory": *[_id == "story-1234"],
  "campaign": *[_id == "campaign-1234"],
  "topStories": *[_type == "story"] | order(publishAt desc) [0..10]
}
```

This combines three completely separate queries into one query and returns an object containing the result of all of them. This can be a useful way to speed up page loads. By combining queries in this manner you can often get all of the core content for a web page to load in a single, cacheable query.

## Query optimization

Like with any query language, it's important to be aware of performance as you develop and iterate on your GROQ queries. Learn more about optimizing your queries in [High Performance GROQ](https://www.sanity.io/docs/developer-guides/high-performance-groq).

## Finally

You should now check out our [Query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet), [the GROQ Arcade](https://groq.dev), and [the reference docs](https://www.sanity.io/docs/groq-reference) which contain examples of all operators and functions currently supported.

#### Related articles

[Introduction to schemas](https://www.sanity.io/docs/apis-and-sdks/introduction-to-schemas)
Learn how schemas define content structure across Sanity and design effective, evolving content models that grow with your business needs.

[GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet)
Data query examples.

[Custom GROQ functions](https://www.sanity.io/docs/content-lake/custom-groq-functions)
Learn how to create your own GROQ functions.

[Querying content with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-querying)
Learn how to fetch content from your Sanity dataset using GROQ queries, perspectives, and document lookup methods with @sanity/client.



# Custom functions

Sometimes you find yourself repeating the same portion of a GROQ query across multiple queries, or even within a single complex query. Custom functions for GROQ allow you to create modular, reusable sub-queries.

Prerequisites:

- Custom GROQ functions are available on all API versions except v1.

## Function anatomy

Custom functions look similar to other GROQ functions, but with some limitations. They include a namespace and accept a parameter. Let's look at an example function that follows a reference and returns a projection that combines an author's first and last name.

```groq
fn ex::name($author) = $author-> { "name": firstName + " " + lastName };

*[_type == 'post']{
  "author": ex::name(author)
}
```

All functions start with the `fn` keyword and contain a namespace, name, parameter, and function body. In the example above:

- `ex` is the namespace.
- `name` is the function name.
- `$author` is the parameter.
- `$author-> { "name": firstName + " " + lastName }` is the body.

Custom function declarations must happen at the start of the GROQ query and each declaration must end with a semicolon (`;`). You can use them anywhere you'd normally send a GROQ query, such as a Sanity client, the [HTTP query API](https://www.sanity.io/docs/http-reference/query), or [the Vision tool](https://www.sanity.io/docs/content-lake/the-vision-plugin). For example, in `@sanity/client`:

```
const QUERY = `
fn ex::name($author) = $author-> { "name": firstName + " " + lastName };

*[_type == "post"]{
  "author": ex::name(author)
}`

const posts = await client.fetch(QUERY)

```

See the [GROQ functions reference](https://www.sanity.io/docs/specifications/groq-functions) for additional details.

## Examples

Custom functions support a limited set of formats at this time:

- `$param{...}`
- `$param->{...}`
- `$param[]{...}`
- `$param[]->{...}`

Let's use the following documents as an example to explore each format. There is a `person` document, an `occupation` document, and two `pet` documents.

**Person**

```json
{
  "_id": "a",
  "_type": "person",
  "name": [
    {
      "first": "Jane",
      "last": "Doe"
    }
  ],
  "age": 99,
  "occupation": { "_ref": "developer" },
  "belongings": [
    {"name": "laptop"},
    {"name": "badge"},
    {"name": "backpack"}
  ],
  pet: [
    {"_ref": "dog"},
    { "_ref": "dog2" }
  ]
}
```

**Occupation**

```json
{
  "_id": "developer",
  "_type": "occupation",
  "title": "Software Engineer"
}
```

**Pet 1**

```json
{
  "_id": "dog",
  "_type": "pet",
  "name": "Pookie"
}
```

**Pet 2**

```json
{
  "_id": "dog2",
  "_type": "pet",
  "name": "Snookie"
}
```

### Basic projection

First we'll define a function that returns a basic projection. This function, `ex:: details`, takes a `$person` parameter and returns a projection containing their `name` and `age`. To use the function, we pass in `@` to represent the person returned by the filter.

**Query**

```groq
fn ex::details($person) = $person{name, age}; 
*[_type == "person"] { "info": ex::details(@) }

```

**Response**

```json
[{
  "info": {
    "age": 99,
    "name": {
      "first": "Jane",
      "last": "Doe"
    }
  }
}]
```

### Follow references

It's common to follow references to include part or all of their contents in the referencing object. This function follows the person's `occupation` reference and returns a projection with their title.

**Query**

```groq
fn ex::title($ref) = $ref->{title};
*[_type == "person"] { "occupation": ex::title(occupation) }
```

**Response**

```json
[{
  "occupation": {
    "title": "Software Engineer"
  }
}]
```

### Array projection

This function iterates through the person's `belongings` to display their names.

**Query**

```groq
fn ex::items($arr) = $arr[]{name}; 
*[_type == "person"] { "stuff": ex::items(belongings) }
```

**Response**

```json
[{
  "stuff": [
    {"name": "laptop"},
    {"name": "badge"},
    {"name": "backpack"},
  ]
}]
```

### Array of references

This function follows each reference in the person's `pet` key.

**Query**

```groq
fn ex::pets($items) = $items[]->{name};
*[_type == "person"] {"pet": ex::pets(pet)}

```

**Response**

```json
[{
  "pet": [
    {"name": "Pookie"},
    {"name": "Snookie"}
  ]
}]
```

### PTE blocks

Reusing the logic for parsing PTE blocks is a common use-case for functions. This example parses a set of blocks regardless of the incoming blocks.

```groq
fn ex::blocks($arr) = $arr {
   ...,
  _type == 'docsCallout' => {
    ...,
    content[] {
      ...,
      markDefs[] {
        ...,
        _type == 'link' => {
          isInternal,
          _key,
          _type,
          reference->,
          url
        },
        _type == 'acronym' => {
          _key,
          _type,
          value
        },
        // etc
      }
    }
  }
};

*[_type == "article"] {
  _id,
  title,
  "slug": slug.current,
  "content": ex::blocks(content[]),
  "description": ex::blocks(description[])
}
```

## Limitations

At this time, functions are limited to the formats displayed above. Additionally, custom functions do not yet support:

- Recursion.
- Accessing the parent scope.
- Passing multiple parameters.
- Accessing the function parameter more than once in the function body.

#### Related articles

[GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet)
Data query examples.

[How GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work)
A tutorial on using the Sanity query language GROQ.

[GROQ feature support across Sanity](https://www.sanity.io/docs/content-lake/groq-feature-support-by-context)
A summary of GROQ language support and limitations across different Sanity contexts.

[Paginating with GROQ](https://www.sanity.io/docs/developer-guides/paginating-with-groq)
Learn efficient pagination in GROQ using cursor-based filtering instead of array slicing. Covers tiebreakers for non-unique fields and batch processing.

## TypeGen integration

Since December 2025, TypeGen supports custom GROQ functions. Use `defineQuery` as usual, and TypeGen generates TypeScript types that account for your function's return shape:

```typescript
import {defineQuery} from 'groq'

const query = defineQuery(`
  fn ex::name($author) = $author-> { "name": firstName + " " + lastName };
  *[_type == 'post']{ title, "author": ex::name(author) }
`)

// TypeGen generates types that include the function's return shape
```

## Reusing functions across queries

Custom functions must be declared at the start of each query. To reuse a function across multiple queries, define it as a string constant and prepend it:

```typescript
import {defineQuery} from 'groq'

const authorNameFn = `fn ex::name($author) = $author-> { "name": firstName + " " + lastName };`

// Reuse the same function in different queries
const postsQuery = defineQuery(`
  ${authorNameFn}
  *[_type == 'post']{ title, "author": ex::name(author) }`)
const articlesQuery = defineQuery(`
  ${authorNameFn}
  *[_type == 'article']{ title, "author": ex::name(author) }`)
```

> [!NOTE]
> There is no global function registry. Each query must include its own function declarations. The string interpolation pattern above keeps your function definitions in one place while allowing reuse.



# Query cheat sheet

Here are some typical queries in GROQ. You can also check out [our introduction to GROQ](https://www.sanity.io/docs/content-lake/how-queries-work) and [the complete reference documentation](https://www.sanity.io/docs/specifications/groq-functions). To actually run queries you can:

- Hit your content lake's query [HTTP endpoint](https://www.sanity.io/docs/http-reference/query) directly
- Use the [JavaScript](https://www.sanity.io/docs/js-client) or [PHP](https://www.sanity.io/docs/php-client) SDKs, or [another client](https://www.sanity.io/exchange/type=plugins/solution=apis)
- Install the [Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin) that runs queries right inside Sanity Studio
- Go to [groq.dev](https://groq.dev) to run queries against any JSON dataset

> [!WARNING]
> Gotcha
> If your query doesn't work as expected, it might be related to:
> - [API versioning](https://www.sanity.io/docs/content-lake/api-versioning)
> - [Perspectives](https://www.sanity.io/docs/content-lake/perspectives)

## Filters

> [!TIP]
> Protip
> You will get null as a value on a query if the key you ask for doesn't exist. That means you can filter on `key != null` to check if it exists with a value or not.

```groq
* // Everything, i.e. all documents
*[] // Everything with no filters applied, i.e. all documents
*[_type == "movie"] // All movie documents
*[_id == "abc.123"] // _id equals
*[_type in ["movie", "person"]] // _type is movie or person
*[_type == "movie" && popularity > 15 && releaseDate > "2016-04-25"] // multiple filters AND
*[_type == "movie" && (popularity > 15 || releaseDate > "2016-04-25")] // multiple filters OR
*[popularity < 15] // less than
*[popularity > 15] // greater than
*[popularity <= 15] // less than or equal
*[popularity >= 15] // greater than or equal
*[popularity == 15]
*[releaseDate != "2016-04-27"] // not equal
*[!(releaseDate == "2016-04-27")] // not equal
*[!(releaseDate != "2016-04-27")] // even equal via double negatives "not not equal"
*[dateTime(_updatedAt) > dateTime('2018-04-20T20:43:31Z')] // Use zulu-time when comparing datetimes to strings
*[dateTime(_updatedAt) > dateTime(now()) - 60*60*24*7] // Updated within the past week
*[name < "Baker"] // Records whose name precedes "Baker" alphabetically
*[awardWinner == true] // match boolean
*[awardWinner] // true if awardWinner == true
*[!awardWinner] // true if awardWinner == false
*[defined(awardWinner)] // has been assigned an award winner status (any kind of value)
*[!defined(awardWinner)] // has not been assigned an award winner status (any kind of value)
*[title == "Aliens"]
*[title in ["Aliens", "Interstellar", "Passengers"]]
*[_id in path("a.b.c.*")] // _id matches a.b.c.d but not a.b.c.d.e
*[_id in path("a.b.c.**")] // _id matches a.b.c.d, and also a.b.c.d.e.f.g, but not a.b.x.1
*[!(_id in path("a.b.c.**"))] // _id matches anything that is not under the a.b.c path or deeper
*["yolo" in tags] // documents that have the string "yolo" in the array "tags"
*[status in ["completed", "archived"]] // the string field status is either == "completed" or "archived"
*["person_sigourney-weaver" in castMembers[].person._ref] // Any document having a castMember referencing sigourney as its person
*[slug.current == "some-slug"] // nested properties
*[count((categories[]->slug.current)[@ in ["action", "thriller"]]) > 0] // documents that reference categories with slugs of "action" or "thriller"
*[count((categories[]->slug.current)[@ in ["action", "thriller"]]) == 2] // documents that reference categories with slugs of "action" and "thriller". set == 2 based on the total number of items in the array
*[sanity::dataset() == 'production'] // compare dataset where query is run. Useful in webhooks or functions
*[sanity::dataset() in ['prod', 'staging', 'next']] // compare dataset name with list
*[string::startsWith(sanity::dataset(), 'prod_')] // check for prefixed dataset names, such as prod_marketing, prod_cs, etc.
*[_type == "movie" && genre == user::attributes().genre] // All movie documents that have a genre that matches the genre attribute on the current user (premium feature)
```

## Text matching

> [!WARNING]
> Gotcha
> The match operator is designed for human-language text and might not do what you expect!

```groq
// Text contains the word "word"
*[text match "word"]

// Title contains a word starting with "wo"
*[title match "wo*"] 

// Inverse of the previous query; animal matches the start of the word "caterpillar" (perhaps animal == "cat")
*["caterpillar" match animal + "*"] 

// Title and body combined contains a word starting with "wo" and the full word "zero"
*[[title, body] match ["wo*", "zero"]] 

// Are there aliens in my rich text?
*[body[].children[].text match "aliens"] 

// Note how match operates on tokens!
"foo bar" match "fo*"  // -> true
"my-pretty-pony-123.jpg" match "my*.jpg"  // -> false
```

## Slice operations

> [!TIP]
> Protip
> There is no default limit, meaning that if you're not explicit about slice, you'll get *everything*.
> However, very large result sets may be subject to API working-set and execution-time limits. See the [Technical limits](https://www.sanity.io/docs/content-lake/technical-limits) reference for current values.

```groq
*[_type == "movie"][0] // a single movie (an object is returned, not an array)
*[_type == "movie"][0..5] // first 6 movies (inclusive)
*[_type == "movie"][0...5] // first 5 movies (non-inclusive)
*[_type == "movie"]{title}[0...10] // first 10 movie titles
*[_type == "movie"][0...10]{title} // first 10 movie titles
*[_type == "movie"][10...20]{title} // first 10 movie titles, offset by 10
*[_type == "movie"] // no slice specified --> all movies are returned
```

**Also note**: The above queries don't make much sense without also specifying an order. E.g. the "first 6 movies" query only returns "first" movies in the sense that these are the first six movies the backend happens to pull out.

## Ordering

> [!TIP]
> Protip
> Documents are returned by default in ascending order by `_id`, which may not be what you're after. You can only sort by a property if it’s part of the projection. If you're querying for a subset of your documents, it's usually a good idea to specify an order. 
> No matter what sort order is specified, the ascending order by `_id` will always remain the final tie-breaker.

```groq
// order results
*[_type == "movie"] | order(_createdAt asc)

// order results by multiple attributes
*[_type == "movie"] | order(releaseDate desc) | order(_createdAt asc)

// order todo items by descending priority,
// where priority is equal, list most recently updated
// item first
*[_type == "todo"] | order(priority desc, _updatedAt desc) 

// the single, oldest document
*[_type == "movie"] | order(_createdAt asc)[0]

// the single, newest document
*[_type == "movie"] | order(_createdAt desc)[0]

// oldest 10 documents
*[_type == "movie"] | order(_createdAt asc)[0..9]

// BEWARE! This selects 10 documents using the default
// ordering, and *only the selection* is ordered by
// _createdAt in ascending order
*[_type == "movie"][0..9] | order(_createdAt asc)

// order results alphabetically by a string field
// This is case sensitive, so A-Z come before a-z
*[_type == "movie"] | order(title asc)

// order results alphabetically by a string field,
// ignoring case
*[_type == "movie"] | order(lower(title) asc)
```

GROQ doesn't include a built-in function for random ordering. To display results in a random order, fetch your results with a standard query and randomize them in your application code.

## Joins

```groq
// Fetch movies with title, and join with poster asset with path + url
*[_type=='movie']{title,poster{asset->{path,url}}}

// Say castMembers is an array containing objects with character name and a reference to the person:
// We want to fetch movie with title and an attribute named "cast" which is an array of actor names
*[_type=='movie']{title,'cast': castMembers[].person->name}

// Same query as above, except "cast" now contains objects with person._id and person.name
*[_type=='movie']{title,'cast': castMembers[].person->{_id, name}}

// Using the ^ operator to refer to the enclosing document. Here ^._id refers to the id
// of the enclosing person record.
*[_type=="person"]{
  name,
  "relatedMovies": *[_type=='movie' && references(^._id)]{ title }
}

// Books by author.name (book.author is a reference)
*[_type == "book" && author._ref in *[_type=="author" && name=="John Doe"]._id ]{...}

```

## Objects and arrays

```groq
// Create your own objects
// https://groq.dev/lcGV0Km6dpvYovREqq1gLS
{
  // People ordered by Nobel prize year
  "peopleByPrizeYear": *[]|order(prizes[0].year desc){
  	"name": firstname + " " + surname,
    "orderYear": prizes[0].year,
    prizes
  },
  // List of all prizes ordered by year awarded
  "allPrizes": *[].prizes[]|order(year desc)
}

// Get all Nobel prizes from all root person documents
// https://groq.dev/v8T0DQawC6ihbNUf4cUeeS
*[].prizes[]

array::join(tags, ", ")                    // tags = ["Rust", "Go", null, "GROQ"] => "Rust, Go, <INVALID>, GROQ"
array::join(["a", "b", "c"], ".")          // "a.b.c"
array::join(year, ".")                     // year = 2024 => null (not an array)
array::join(values, 1)                     // values = [10, 20, 30] => null (separator must be a string)
array::compact(numbers)                    // numbers = [1, null, 2, null, 3] => [1, 2, 3]
array::unique(items)                       // items = [1, 2, 2, 3, 4, 5, 5] => [1, 2, 3, 4, 5]
array::unique(records)                     // records = [[1], [1]] => [[1], [1]] (arrays are not comparable)
array::intersects(firstList, secondList)   // firstList = [1, 2, 3], secondList = [3, 4, 5] => true
array::intersects(tags, keywords)          // tags = ["tech", "science"], keywords = ["art", "design"] => false
```

## Object projections

```groq
// return only title
*[_type == 'movie']{title} 

// return values for multiple attributes
*[_type == 'movie']{_id, _type, title} 

// explicitly name the return field for _id
*[_type == 'movie']{'renamedId': _id, _type, title} 

// Return an array of attribute values (no object wrapper)
*[_type == 'movie'].title
*[_type == 'movie']{'characterNames': castMembers[].characterName}

// movie titled Arrival and its posterUrl
*[_type=='movie' && title == 'Arrival']{title,'posterUrl': poster.asset->url} 

// Explicitly return all attributes
*[_type == 'movie']{...} 

// Some computed attributes, then also add all attributes of the result
*[_type == 'movie']{'posterUrl': poster.asset->url, ...} 

// Default values when missing or null in document
*[_type == 'movie']{..., 'rating': coalesce(rating, 'unknown')}

// Number of elements in array 'actors' on each movie
*[_type == 'movie']{"actorCount": count(actors)} 

// Apply a projection to every member of an array
*[_type == 'movie']{castMembers[]{characterName, person}} 

// Filter embedded objects
*[_type == 'movie']{castMembers[characterName match 'Ripley']{characterName, person}} 

// Follow every reference in an array of references
*[_type == 'book']{authors[]->{name, bio}}

// Explicity name the outer return field
{'threeMovieTitles': *[_type=='movie'][0..2].title}

// Combining several unrelated queries in one request
{'featuredMovie': *[_type == 'movie' && title == 'Alien'][0], 'scifiMovies': *[_type == 'movie' && 'sci-fi' in genres]}

```

## Special variables

```groq
// *
*   // Everything, i.e. all documents

// @
*[ @["1"] ] // @ refers to the root value (document) of the scope
*[ @[$prop]._ref == $refId ] // Select reference prop from an outside variable.
*{"arraySizes": arrays[]{"size": count(@)}} // @ also works for nested scopes

// ^
// ^ refers to the enclosing document. Here ^._id refers to the id
// of the enclosing person record.
*[_type=="person"]{
  name,
  "relatedMovies": *[_type=='movie' && references(^._id)]{ title }
}
```

## Conditionals

```groq
// select() returns the first => pair whose left-hand side evaluates to true
*[_type=='movie']{..., "popularity": select(
  popularity > 20 => "high",
  popularity > 10 => "medium",
  popularity <= 10 => "low"
)}

// The first select() parameter without => is returned if no previous matches are found
*[_type=='movie']{..., "popularity": select(
  popularity > 20 => "high",
  popularity > 10 => "medium",
  "low"
)}

// Projections also have syntactic sugar for inline conditionals
*[_type=='movie']{
  ...,
  releaseDate >= '2018-06-01' => {
    "screenings": *[_type == 'screening' && movie._ref == ^._id],
    "news": *[_type == 'news' && movie._ref == ^._id],
  },
  popularity > 20 && rating > 7.0 => {
    "featured": true,
    "awards": *[_type == 'award' && movie._ref == ^._id],
  },
}

// The above is exactly equivalent to:
*[_type=='movie']{
  ...,
  ...select(releaseDate >= '2018-06-01' => {
    "screenings": *[_type == 'screening' && movie._ref == ^._id],
    "news": *[_type == 'news' && movie._ref == ^._id],
  }),
  ...select(popularity > 20 && rating > 7.0 => {
    "featured": true,
    "awards": *[_type == 'award' && movie._ref == ^._id],
  }),
}


// Specify sets of projections for different content types in an array
content[]{
  _type == 'type1' => {
    // Your selection of fields for type1
  },
  _type == 'type2' => {
    // Your selection of fields for type2
    "url": file.asset->url // Use joins to get data of referenced document
  }
}

```

### Handling references conditionally

In cases where an array contains both [references and non-references](https://www.sanity.io/docs/studio/array-type), it's often desirable for a GROQ query to conditionally return the inline object (where dealing with non-references) or the referenced document (where dealing with references). This can be done by considering the `_type` of each array item and dereferencing the item (`@->`) if it's a reference or getting the whole object (`@`) if it's not a reference.

```groq
'content': content[]{
  _type == 'reference' => @->,
  _type != 'reference' => @,
}
```

## Functions

```groq
// any document that references the document 
// with id person_sigourney-weaver, 
// return only title
*[references("person_sigourney-weaver")]{title}

// Movies which reference ancient people
*[_type=="movie" && references(*[_type=="person" && age > 99]._id)]{title}

*[defined(tags)] // any document that has the attribute 'tags'

// coalesce takes a number of attribute references
// and returns the value of the first attribute
// that is non-null. In this example used to
// default back to the English language where a
// Finnish translation does not exist.
*{"title": coalesce(title.fi, title.en)} 

// count counts the number of items in a collection
count(*[_type == 'movie' && rating == 'R']) // returns number of R-rated movies

*[_type == 'movie']{
  title, 
  "actorCount": count(actors) // Counts the number of elements in the array actors
}

// round() rounds number to the nearest integer, or the given number of decimals
round(3.14) // 3
round(3.14, 1) // 3.1


// score() adds points to the score value depending 
// on the use of the string "GROQ" in each post's description 
// The value is then used to order the posts 
*[_type == "post"] 
  | score(description match "GROQ") 
  | order(_score desc) 
  { _score, title }

// boost() adds a defined boost integer to scores of items matching a condition 
// Adds 1 to the score for each time $term is matched in the title field
// Adds 3 to the score if (movie > 3) is true
*[_type == "movie" && movieRating > 3] | 
  score(
    title match $term,
    boost(movieRating > 8, 3)
  )

// Creates a scoring system where $term matching in the title
// is worth more than matching in the body
*[_type == "movie" && movieRating > 3] | score(
  boost(title match $term, 4),
  boost(body match $term, 1)
)

// Returns the body Portable Text data as plain text
*[_type == "post"] 
  { "plaintextBody": pt::text(body) }

// text::semanticSimilarity() ranks results by semantic meaning
// Requires dataset embeddings to be enabled
// Only valid inside score()
*[_type == "product"]
  | score(text::semanticSimilarity("leather waterproof boots"))
  | order(_score desc)
  { _score, title }

// Hybrid search: combine keyword matching with semantic scoring
*[_type == "product"]
  | score(
      @ match text::query("waterproof boots"),
      text::semanticSimilarity("waterproof boots")
    )
  | order(_score desc)

// Get all versions and drafts of a document. Use with the raw perspective or a perspective stack to ensure accurate results.
*[sanity::versionOf('document-id')]

// Get all documents that are part of a release. Use with the raw perspective to ensure accurate results.
*[sanity::partOfRelease('release-id')]
```

## Geolocation

```groq
// Returns all documents that are storefronts
// within 10 miles of the user-provided currentLocation parameter
*[
  _type == 'storefront' &&
  geo::distance(geoPoint, $currentLocation) < 16093.4
]

// For a given $currentLocation geopoint and deliveryZone area
// Return stores that deliver to a user's location
*[
  _type == "storefront" &&
  geo::contains(deliveryZone, $currentLocation)
]

// Creates a "marathonRoutes" array that contains
// all marathons whose routes intersect with the current neighborhood
*[_type == "neighborhood"] {
  "marathonRoutes": *[_type == "marathon" && 
                      geo::intersects(^.neighborhoodRegion, routeLine)  
                    ]
}
```

## Arithmetic and concatenation

```groq
// Standard arithmetic operations are supported
1 + 2  // 3 (addition)
3 - 2  // 1 (subtraction)
2 * 3  // 6 (multiplication)
8 / 4  // 2 (division)
2 ** 4 // 16 (exponentiation)
8 % 3  // 2 (modulo)

// Exponentiation can be used to take square- and cube-roots too
9 ** (1/2)  // 3 (square root)
27 ** (1/3) // 3 (cube root)

// + can also concatenate strings, arrays, and objects:
"abc" + "def" // "abcdef"
[1,2] + [3,4] // [1,2,3,4]
{"a":1,"b":2} + {"c":3} // {"a":1,"b":2,"c":3}

// Concatenation of a string and a number requires the number be
// converted to a string. Otherwise, the operation returns null
3 + " p.m."         // null
string(3) + " p.m." // "3 p.m."
```

#### Related articles

[Custom GROQ functions](https://www.sanity.io/docs/content-lake/custom-groq-functions)
Learn how to create your own GROQ functions.

[How GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work)
A tutorial on using the Sanity query language GROQ.

[GROQ feature support across Sanity](https://www.sanity.io/docs/content-lake/groq-feature-support-by-context)
A summary of GROQ language support and limitations across different Sanity contexts.

[Querying content with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-querying)
Learn how to fetch content from your Sanity dataset using GROQ queries, perspectives, and document lookup methods with @sanity/client.



# Search with GROQ

## Overview

Search in GROQ is built out of a small set of pieces that combine. Two operators do the actual work:

- `match` checks whether a field's tokens contain the search terms. Used inside `*[...]` it filters; used inside `score(...)` it produces a BM25 relevance score.
- `| score(...)` ranks results by one or more expressions and exposes `_score`.

The remaining pieces produce values that those operators consume:

- `text::query("...")` parses a search string with phrase, exclusion, and wildcard syntax into a value you pass to `match`.
- `text::semanticSimilarity("...")` produces a similarity score for use inside `score(...)`.
- `boost(expr, weight)` reweights any expression inside `score(...)`.
- `text::highlight()` (experimental, `vX`) annotates which parts of a document matched.

### A complete example

Here's a production-grade hybrid search query that combines many of the techniques covered in this guide. Don't worry if it looks dense, each piece is explained in the sections that follow.

```groq
// Search articles using both keyword and semantic relevance,
// with extra weight for editorial quality and recency.
*[_type == "article"]    // 1. Filter: articles only
  | score(
      // 2. Keyword relevance on title, weighted 2x for precision.
      boost([title] match text::query($searchQuery), 2),

      // 3. Semantic similarity across all fields, weighted 1x.
      //    Returns conceptually related content even without exact keyword overlap.
      boost(text::semanticSimilarity($searchQuery), 1),

      // 4. Recency boost: articles from the last 30 days score 1.5x higher.
      boost(_updatedAt > now() - 60*60*24*30, 1.5)
    )
  | order(_score desc)                    // 5. Most relevant first
  [0...$pageSize]                         // 6. Paginate
  {
    _id,
    title,
    slug,
    excerpt,
    _updatedAt,
    _score,                               // The computed relevance score
  }
```

Call it with parameters:

```json
{
  "searchQuery": "summer fashion trends",
  "pageSize": 20
}
```

The rest of this guide unpacks each technique used here.

## Basic text matching with `match <str>`

The `match` operator performs tokenized text matching on *specific fields*. It breaks both the field value and the search term into tokens, then checks that all search tokens appear in the field. Reach for `match` when you need to filter documents, build a faceted search, or power autocomplete with a prefix wildcard.

### Simple field match

```groq
*[_type == "article" && title match "summer dresses"]
```

This finds articles where the `title` field contains both "summer" AND "dresses" (in any order).

### Multiple field match

```groq
*[_type == "article" && (title match "summer" || body match "summer")]
```

Each `match` clause still requires all of its tokens to appear within the same field. The `||` combines results across fields, so this query returns articles where "summer" appears in either `title` or `body`.

### Array field match

```groq
*[_type == "article" && tags[] match "fashion"]
```

The `match` operator works with both string fields and arrays of strings.

### Portable Text match

To search within Portable Text (rich text) fields, use `pt::text()`:

```groq
*[_type == "article" && pt::text(body) match "summer collection"]
```

### How `match` works

The `match` operator:

1. Tokenizes both the search term and the field value.
2. Requires all tokens to be present (AND logic).
3. Folds case, but not diacritics: `configura` does not match `configurá`.
4. Supports wildcards within tokens (see Wildcards and prefix search).

## Richer search syntax with `text::query()`

`text::query()` parses a search string into a structured query (supporting phrases, term exclusion, and wildcards) that you pass to `match` on the right-hand side. The `match` operator does the searching; `text::query()` is what lets you express a richer query than a plain string can.

Plain strings work for the simple cases:

```groq
*[_type == "article" && title match "summer dresses"]
```

Reach for `text::query()` when you need phrase matching, exclusion, or other search-syntax features:

```groq
*[_type == "article" && title match text::query('"summer collection" -discontinued')]
```

`text::query()` also gives a higher score when your search terms appear adjacent in the matched field; a plain string scores each word independently (see "Scoring note" under Phrase matching).

### Basic usage

```groq
*[_type == "article"] | score([title] match text::query("summer fashion trends"))
```

When using `text::query()`, what goes before `match` can be more than a single attribute:

```groq
// Single attribute
*[_type == "article" && title match text::query("summer")]

// Array of attributes — searches both title and body
*[_type == "article" && [title, body] match text::query("summer")]

// Nested object — searches every string field under "details"
*[_type == "article" && details match text::query("summer")]

// Entire document — searches every string field
*[_type == "article" && @ match text::query("summer")]
```

### Phrase matching

Use double quotes to search for exact phrases:

```groq
*[_type == "article"] | score([title] match text::query('"summer dress collection"'))
```

This finds documents containing the exact phrase "summer dress collection," not just documents with those words scattered throughout. Quoted phrases are also more precise than unquoted words, so prefer them when you want tight matches.

When your search string contains double quotes, wrap the GROQ argument in single quotes:

```groq
*[] | score([title] match text::query('"exact phrase" -excluded'))
```

Scoring note: When you search for individual words (without quotes), `text::query()` automatically boosts documents where those words appear as a phrase. Searching for `summer dress collection` will rank documents containing the exact phrase higher while still returning documents that contain the words separately.

## Plus-minus syntax

The `text::query()` function supports a rich query syntax for including and excluding terms.

### Required context (default)

By default, at least one term must match:

```groq
// Documents matching "summer" OR "fashion" (or both)
*[] | score([title] match text::query("summer fashion"))
```

### Excluding terms

Prefix a term with `-` to exclude documents containing it:

```groq
// Documents about "summer" but NOT containing "winter"
*[] | score([title] match text::query("summer -winter"))
```

A standalone `-` (not followed by a term) is treated as a regular character, not an exclusion.

### Excluding phrases

Combine `-` with quotes to exclude exact phrases:

```groq
// Documents about "dresses" but not "winter collection"
*[] | score([title] match text::query('dresses -"winter collection"'))
```

### Complex queries

Combine multiple operators:

```groq
// Find "summer fashion" excluding "winter" and "sale"
*[] | score([title] match text::query('summer fashion -winter -sale'))

// Find exact phrase, exclude a term
*[] | score([title] match text::query('"summer collection" -discontinued'))

// Prefix search excluding a term
*[] | score([title] match text::query('fash* -"fast fashion"'))
```

### Syntax summary

| Syntax | Meaning | Example |
| --- | --- | --- |
| word | Match this word | summer |
| "phrase" | Match exact phrase | "summer dress" |
| -word | Exclude this word | -winter |
| -"phrase" | Exclude exact phrase | -"out of stock" |
| word* | Prefix match | fash* matches "fashion", "fashionable" |
| "phrase*" | Phrase prefix match | "summer dr*" |
| *word* | Wildcard match | *dress* |

## Wildcards and prefix search

### Prefix search (trailing wildcard)

Add `*` at the end of a word to match any word starting with that prefix:

```groq
// Matches "fashion", "fashionable", "fashionista"
*[] | score([title] match text::query("fash*"))
```

### Prefix phrase search

Combine quotes with a trailing wildcard:

```groq
// Matches "summer dress", "summer dresses", "summer dressing"
*[] | score([title] match text::query('"summer dress*"'))
```

### General wildcards

Use `*` anywhere in a word for flexible matching:

```groq
// Matches "underdressed", "overdressed", etc.
*[] | score([title] match text::query("*dressed"))
```

Performance warning: Wildcards at the beginning of a word (like `*dressed`) can be slow because they must scan many terms. Use prefix wildcards (`dressed*`) whenever possible.

### Wildcards in `match`

The `match` operator also supports wildcards:

```groq
*[_type == "article" && title match "fash*"]
```

## Case, accents, and sorting

Text matching folds case but not diacritics, and `order()` sorts strings by Unicode codepoint rather than by a language-aware collation. Both behaviors show up as soon as your content leaves plain ASCII.

### Case folding and diacritics

Both `match` and `text::query()` lowercase tokens before comparing them, so case never affects whether a document matches. Diacritics are compared as stored: an accented character in the content matches only an accented character in the search term.

These four cases cover the behavior:

| Expression | Result | Notes |
| --- | --- | --- |
| "CONFIGURA" match "configura" | true | Case is folded. |
| "configurá" match "configura" | false | The content has an accent; the search term does not. |
| "configura" match "configurá" | false | The search term has an accent; the content does not. |
| "configurá" match "configurá" | true | Both sides carry the same accent. |
| "configurá" match text::query("configura") | false | text::query() folds case the same way, and also leaves accents alone. |

You can check all of them in a single query:

```groq
{
  "caseIsFolded":       "CONFIGURA" match "configura",              // true
  "accentInContent":    "configurá" match "configura",              // false
  "accentInSearchTerm": "configura" match "configurá",              // false
  "sameWithTextQuery":  "configurá" match text::query("configura")  // false
}
```

### Sorting by Unicode codepoint

`order()` compares strings by Unicode codepoint, not by a locale-aware collation. Uppercase letters sort before lowercase letters because their codepoints are lower, and accented characters sort after the entire ASCII range.

```groq
{
  "codepointOrder":     ["Zebra", "apple", "banana", "Ápple", "Émile", "árbol"] | order(@),
  "lowerOnlyFixesCase": ["Zebra", "apple", "banana", "Ápple", "Émile", "árbol"] | order(string::lower(@))
}
```

That returns:

```json
{
  "codepointOrder": ["Zebra", "apple", "banana", "Ápple", "Émile", "árbol"],
  "lowerOnlyFixesCase": ["apple", "banana", "Zebra", "Ápple", "árbol", "Émile"]
}
```

`string::lower()` fixes the uppercase-before-lowercase problem, but the accented values still sort after every ASCII value.

### Sort and search across accents

GROQ has no locale-aware collation and no accent-folding option, so normalization has to happen in your content or in your application.

Precomputed key: store an accent-stripped, lowercased copy of the field, then filter and sort on that. The work stays in the query, so pagination keeps working.

**lib/searchKey.js**

```javascript
export function toSearchKey(value) {
  return value
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')
    .toLowerCase()
}
```

Write the result to a field on the document, then query that field instead:

```groq
*[_type == "product" && searchKey match $term] | order(searchKey) [0...20]{
  _id,
  title
}
```

Apply the same function to the search term before sending it, so both sides are normalized the same way.

Application-side collation: when the result set is small enough to fetch in full, sort it with `Intl.Collator` instead.

```javascript
const collator = new Intl.Collator('es', {sensitivity: 'base'})

const products = await client.fetch(`*[_type == "product"]{_id, title}`)
const sorted = products.sort((a, b) => collator.compare(a.title, b.title))
```

Sorting after the query breaks GROQ-side pagination, so keep this for short, bounded lists.

## Semantic search with `text::semanticSimilarity()`

Semantic search finds documents by meaning rather than exact keyword matches. It uses vector embeddings generated by a language model to understand the intent behind your query. Use it for "find similar" features, content discovery, or any time the user phrases a query in natural language.

> [!NOTE]
> Usage limits and pricing
> Semantic search usage limits vary by plan. See the [Pricing page](https://www.sanity.io/pricing) for details.

### Basic usage

```groq
*[_type == "article"] | score(text::semanticSimilarity("comfortable clothes for hot weather"))
```

This finds articles about summer dresses, lightweight fabrics, and breathable clothing, even if they don't contain the exact words "comfortable," "clothes," or "hot weather."

### When to use semantic search

- Natural language queries: "What should I wear to a beach wedding?"
- Conceptual search: finding content about a topic without knowing the exact terminology.
- Cross-language concepts: finding related content across different phrasings.
- Exploratory search: when users don't know exactly what they're looking for.

### Requirements

Semantic search requires [embeddings to be enabled for your dataset](https://www.sanity.io/docs/content-lake/dataset-embeddings). The system automatically:

1. Extracts text from your documents.
2. Splits long texts into chunks (with overlap for context).
3. Generates vector embeddings for each chunk.
4. Indexes embeddings for fast similarity search.

> [!WARNING]
> text::embedding() is deprecated
> Use `text::semanticSimilarity()` instead. It has the same functionality with a clearer name.

## Scoring and ranking with `| score()`

The `| score()` pipeline operator ranks documents by relevance. It takes one or more scoring expressions and assigns each document a `_score` value.

### Basic scoring

```groq
*[_type == "article"] | score([title] match text::query("summer fashion")) | order(_score desc)
```

### How scoring works

1. All documents from the filter pass through the score pipeline.
2. Each scoring expression contributes to the document's `_score`.
3. Results can be sorted by `_score` (descending means most relevant first).
4. By default, results are sorted by `_score desc` when using text search.

### Filter before scoring

Reduce the candidate set with filters before scoring. Scoring runs over every document the filter returns, so a narrower filter means faster, more relevant results.

```groq
// Good: filter first, then score
*[_type == "article" && category == "fashion"] | score([title] match text::query("summer"))

// Less efficient: scoring everything
*[_type == "article"] | score([title] match text::query("summer"), boost(category == "fashion", 5))
```

### Paginate with slicing

Always cap the number of results with a slice. This keeps response sizes predictable and pagination cursors meaningful.

```groq
*[_type == "article" && publishedAt > "2024-01-01"]
  | score([title] match text::query("summer fashion"))
  | order(_score desc)
  [0...10]
```

### Multiple scoring signals

You can combine multiple expressions in `score()`:

```groq
*[_type == "article"] | score(
  text::query("summer fashion"),
  boost(category == "featured", 2)
) | order(_score desc)
```

### Accessing the score

The `_score` field is available in projections:

```groq
*[_type == "article"] | score([title] match text::query("summer fashion")) {
  title,
  _score,
  "relevance": _score
} | order(_score desc)
```

## Boosting with `boost()`

The `boost()` function lets you weight different scoring signals to fine-tune relevance.

### Syntax

```groq
boost(expression, weight)
```

- `expression`: any boolean expression or search function.
- `weight`: a numeric multiplier (higher means more important).

### Boosting field matches

```groq
*[_type == "article"] | score(
  text::query("summer fashion"),
  boost(category == "editorial", 3),
  boost(featured == true, 5)
) | order(_score desc)
```

This query:

1. Scores all articles by text relevance to "summer fashion."
2. Triples the score for editorial articles.
3. Quintuples the score for featured articles.

### Boosting specific fields

```groq
*[_type == "article"] | score(
  boost(title match "summer fashion", 3),
  boost(body match "summer fashion", 1)
) | order(_score desc)
```

Title matches are weighted 3x more than body matches.

### Boosting recency

```groq
*[_type == "article"] | score(
  text::query("summer fashion"),
  boost(publishedAt > "2024-06-01", 2)
) | order(_score desc)
```

Recent articles get a 2x score boost.

## Hybrid search (text and semantic)

Hybrid search combines BM25 text search with semantic search. Use it when you want relevance ranking informed by both exact-match and meaning-based signals, typically the right choice for production search.

### Basic hybrid search

```groq
*[_type == "article"] | score(
  [title] match text::query("summer fashion trends"),
  text::semanticSimilarity("summer fashion trends")
) | order(_score desc)
```

This combines:

- BM25 scoring: exact keyword matches, phrase matches.
- Semantic scoring: conceptual similarity, meaning-based matching.

### Weighted hybrid search

Use `boost()` to control the balance between text and semantic signals:

```groq
*[_type == "article"] | score(
  boost([title] match text::query("summer fashion"), 2),
  boost(text::semanticSimilarity("summer fashion"), 1)
) | order(_score desc)
```

This weights keyword matches 2x more than semantic similarity.

### Full hybrid with business logic

```groq
*[_type == "article" && !(_id in path("drafts.**"))] | score(
  boost([title] match text::query("summer fashion"), 2),
  boost(text::semanticSimilarity("comfortable warm weather clothing"), 1),
  boost(category == "editorial", 1.5),
  boost(publishedAt > "2024-01-01", 1.2)
) | order(_score desc) [0...20] {
  title,
  slug,
  excerpt,
  _score,
  "highlights": text::highlight()
}
```

This query:

1. Filters to published articles only.
2. Scores by keyword relevance (2x weight).
3. Scores by semantic similarity to a natural language concept (1x weight).
4. Boosts editorial content (1.5x).
5. Boosts recent content (1.2x).
6. Returns the top 20 results with highlights.

## Highlighting with `text::highlight()`

`text::highlight()` returns information about which parts of a document matched the search query, enabling you to show relevant snippets in search results.

> [!NOTE]
> Experimental
> The `text::highlight()` function is experimental and only supported on API version `vX` at this time.

### Basic usage

```groq
*[_type == "article"] | score([title] match text::query("summer fashion")) {
  title,
  _score,
  "highlights": text::highlight(@, text::query("summer fashion"))
}
```

### What highlighting returns

`text::highlight()` returns a map of field paths to highlight information:

```json
{
  "highlights": {
    "body": {
      "fragments": [{
        "end": 9,
        "matches": [[0,9]],
        "start": 0
      }],
      "matchLevel": "full",
      "matchedWords": ["fashion"],
      "score": 1
    }
  }
}
```

You can then use this information to highlight or annotate the matched words in your apps.

### How it works

Highlighting is performed after the search query returns results:

1. The system walks through every string field in each result document.
2. For each field, it checks if the search terms appear in the text.
3. Fields with matches are included in the highlights map.
4. The match level indicates how well the field matched (full, partial, or no match).

### Requirements

- Highlighting only works with `text::query()`. It uses the parsed query to identify matches.
- When highlighting is enabled, the full document is fetched (even if you only project specific fields).



# Vision plugin

Vision is a plugin that lets you quickly test your [GROQ](https://www.sanity.io/docs/specifications/groq-syntax) queries right from the Studio. It shows up as a tool in the navigation bar when installed, and is part of the default Studio setup when running in development mode.

![Sanity Studio using the Vision plugin](https://cdn.sanity.io/images/3do82whm/next/7f4903586477a14beabf92fca429c92730b77fb8-2430x1352.png)
*Use the Vision plugin to test your GROQ queries*

## Installing the plugin

New projects should have the plugin installed already. For existing projects, or if it is not part of your studio configuration, you can install it by adding `@sanity/vision` as a dependency of your project (adding it to `package.json` and reinstalling dependencies).

With the plugin installed, you should add it to your Sanity configuration (`sanity.config.js` / `sanity.config.ts`):

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

export default defineConfig({
  // ...
  plugins: [structureTool(), visionTool()],
})
```

Should you want to only include the plugin in development mode, you can import and use the `isDev` boolean and conditionally add the plugin:

```typescript
import {defineConfig, isDev} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

export default defineConfig({
  // ...
  plugins: isDev
    ? [structureTool(), visionTool()]
    : [structureTool()],
})

```

## Configuring the plugin

The plugin can be configured by passing it an object of options. 

```typescript
// sanity.config.js / sanity.config.ts
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

export default defineConfig({
  // ...
  plugins: [
    structureTool(),
    visionTool({
      defaultApiVersion: 'v2021-03-25',
      defaultDataset: 'development',
    }),
  ],
})

```

Currently supported properties are:

- `defaultApiVersion` - The default API version for queries, unless the user specifically selects a different version. See [API versioning](https://www.sanity.io/docs/content-lake/api-versioning) for the list of available values.
- `defaultDataset` - The default dataset to use unless a specific one has been chosen in the user interface.
- `datasets` - Limits the datasets that are displayed in Vision's dataset selector. Example:

**Array**

```
export default defineConfig({
  // ...
  plugins: [
    visionTool({
      datasets: ["production", "development"]
    })
  ]
})
```

**Callback**

```
export default defineConfig({
  // ...
  plugins: [
    visionTool({
      // this example omits addon datasets, like comments
      datasets: (datasets) => datasets.filter(d => !d.addonFor)
    })
  ]
})
```

Additionally, these base properties are available should you want to customize what the item appears as in the navigation bar:

- `name` - Name used to identify the tool in URLs. Defaults to `vision`.
- `title` - Title that appears in the navigation bar. Defaults to `Vision`.
- `icon` - React icon that appears in navigation bar. Defaults to an eye icon (`EyeOpenIcon` from `@sanity/icons`)

## Using the Vision plugin

### Getting familiar with Vision

The Vision plugin allows you to quickly test a GROQ query against any of the [datasets](https://www.sanity.io/docs/content-lake/datasets) in your [Content Lake](https://www.sanity.io/docs/content-lake). At the top of the tool, you'll find dropdowns to select your dataset, API version and perspective.

![Dataset, API version and perspective dropdowns in Vision.](https://cdn.sanity.io/images/3do82whm/next/f2234ee7e0c483e090c75f4d6cd1c56df49157a6-994x318.png)

Each time you run a query (we'll see how in a moment), you'll see a fourth field at the top containing a URL for your query. This URL contains the API call to the Content Lake that's querying for your data.

If your dataset is public, that URL can be run in a browser, cURL, or an app like Postman or Insomnia, and it will return the same JSON as you see in Vision. If your dataset is private, the request must be authenticated in order to return data. The decision on [dataset visibility](https://www.sanity.io/docs/content-lake/keeping-your-data-safe) is up to you, and can be changed if necessary.

On the left side of the Vision plugin are two panes: query and params. In the query pane, you can enter any valid [GROQ query](https://www.sanity.io/docs/content-lake/how-queries-work).

Query parameters works the same way as with Sanity client libraries. Given an object `{minSeats: 2}` in the Params field, you may use the keys in the object as parameters in the query: `*[_type == "bike" && seats >= $minSeats] {name, seats}`. Note that every param key is prefixed with `$` in the *query*, but does *not* have a prefix in the parameters object.

> [!WARNING]
> Gotcha
> You can only use Vision to test queries and listeners. You cannot use it for mutations.

To learn more about how to write queries, read [how GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work).



# Syntax reference

#### New to GROQ?
If you are just getting started with GROQ, check out the getting started guide first.
[Get started with GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)





A typical GROQ query has this form:

```groq
*[ <filter> ]{ <projection> }
```

1. `*`  returns all documents in the dataset that the current user has permissions to read. 
2. The documents are passed to a filter (`[]`), which retains documents for which the expression evaluates to `true`. 
3. The retained documents are passed to an optional projection. The projection determines how the result should be formatted. If no projection is specified, all data is returned.

A GROQ query of this form operates as a query pipeline, where the results from each component are passed as inputs to the next. The filter and projection are optional, and a query can have any number of them in any order.

In pipeline components, document attributes can be accessed by name. For example, this query would fetch directors born since 1970 and return their name, year of birth, and a list of their movies:

```groq
*[ _type == "director" && birthYear >= 1970 ]{
  name,
  birthYear,
  "movies": *[ _type == "movie" && director._ref == ^._id ]
}
```

For a complete introduction to GROQ, please see the [how-to](https://www.sanity.io/docs/content-lake/how-queries-work).

## JSON Superset

GROQ's syntax is a superset of JSON, so any valid JSON value is a valid GROQ query (that returns the given value). Below are a few examples of JSON values:

```json
"Hi! 👋"
```

```json
["An", "array", "of", "strings"]
```

```json
{
  "array": ["string", 3.14, true, null],
  "boolean": true,
  "number": 3.14,
  "null": null,
  "object": {"key": "value"},
  "string": "Hi! 👋"
}
```

For more information on JSON syntax, see the [JSON specification](https://tools.ietf.org/html/rfc8259).

## Whitespace

Whitespace is not significant in GROQ, except for acting as a token separator and comment terminator. Any sequence of the following characters is considered whitespace, with Unicode code points in parenthesis:

- Tab (`U+0009`)
- Newline (`U+000A`)
- Vertical tab (`U+000B`)
- Form feed (`U+000C`)
- Carriage return (`U+000D`)
- Space (`U+0020`)
- Next line (`U+0085`)
- Non-breaking space (`U+00A0`)

Whitespace inside a string literal is interpreted as-is.

## Comments

Comments serve as query documentation and are ignored by the parser. They start with `//` and run to the end of the line:

```groq
{
  // Comments can be on a separate line
  "key": "value" // Or at the end of a line
}
```

Comments cannot start inside a string literal.

## Expressions

An expression is one of the following:

- A literal, attribute lookup, parameter, or constant.
- An operator invocation (and, by extension, a pipeline).
- A function call.

Expressions can be used anywhere that a value is expected, such as object values, array elements, operator operands, or function arguments. The expression is in effect replaced by the value which it evaluates to.

> [!WARNING]
> Gotcha
> Due to parser ambiguity with filters, the following access operators can only take literals, not arbitrary expressions: array element access (e.g. `array[0]`), array slices (e.g. `array[1..3]`), and object attribute access (e.g. `object["attribute"]`).

### Selectors

A selector is a subset of an expression used to search for fields inside a document. You can only use them in certain functions—at this time, Delta GROQ functions, to select part of a document. See the [Delta GROQ functions](https://www.sanity.io/docs/specifications/groq-functions) and the Selectors section for a list of available functions and selectors.

## Literals

Literals are inline representations of constant values, e.g., `"string"` or `3.14`. GROQ supports all JSON literals, with a few enhancements and additional data types.

For more information on the data types themselves, see the [data types](https://www.sanity.io/docs/specifications/groq-data-types) reference.

### Boolean and Null Literals

The constants `true`, `false`, and `null`.

### Integer Literals

A sequence of digits, e.g., `42`. Leading zeroes are ignored.

### Float Literals

Floats have an integer part, a fractional part, and an exponent part. The integer part is required, and at least one of the fractional or exponent parts must be given.

The integer part is equivalent to an integer literal. The fractional part is a decimal point `.` followed by a sequence of digits. The exponent part is `e` or `E`, followed by an optional `+` or `-` sign followed by an integer specifying base-10 exponentiation.

The following are examples of float literals:

```json
3.0
3.14
3e6      // Equivalent to 3000000.0
3.14e0  // Equivalent to 3.14
3.14e-2  // Equivalent to 0.0314
```

### String Literals

A sequence of zero or more UTF-8 encoded characters surrounded by single or double quotes, e.g., `"Hello world! 👋"`. The following escape sequences are supported (mirroring JSON), all of which are valid in both single- and double-quoted string literals:

- `\\`: backslash
- `\/`: slash
- `\'`: single quote
- `\"`: double quote
- `\b`: backspace
- `\f`: form feed
- `\n`: newline
- `\r`: carriage return
- `\t`: tab
- `\uXXXX`: UTF-16 code point, where `XXXX` is the hexadecimal character code
- `\uXXXX\uXXXX`: UTF-16 surrogate pair

### Array Literals

A comma-separated list of values enclosed by `[]`, e.g. `[1, 2, 3]`. An optional trailing comma may follow the final element.

### Object Literals

A comma-separated list of key-value pairs enclosed by `{}`, where the key and value of each pair is separated by `:`, e.g. `{"a": 1, "b": 2}`. Keys must be strings. An optional trailing comma may follow the final pair.

### Pair Literals

Two values separated by `=>`, e.g. `"a" => 1`.

### Range Literals

Two values separated by `..` (right-inclusive) or `...` (right-exclusive), e.g. `1..3` or `1...3`.

## Identifiers

Identifiers name query entities such as attributes, parameters, functions, and some operators. Identifiers must begin with `a-zA-Z_`, followed by any number of characters matching `a-zA-Z0-9_`. Parameters are prefixed with `$`.

### Reserved Keywords

The following keywords are reserved and cannot be used as identifiers:

- `false`
- `null`
- `true`

## Attribute Lookup

A bare identifier looks up the value of the corresponding attribute in the document or object at the root of the current scope. For example, the following query `category` returns the value of the `category` attribute of the document currently being considered by the filter:

```groq
*[ category == "news" ]
```

If the attribute does not exist, or if the root value of the scope is not a document or object, then the identifier will return `null`.

> [!TIP]
> Protip
> JSON allows attribute keys to be any arbitrary UTF-8 string. In cases where the key is not a valid GROQ identifier, it can instead be accessed by using the `@` operator (typically returning the current document) and the `[]` attribute access operator, e.g. `@["1 illegal name 🚫"]`.

### Attribute Scope

Attribute lookups are scoped such that the same identifier may refer to different attributes in different contexts. New scopes are created by pipeline components, typically by iterating over the piped array elements and evaluating an expression in the scope of each element.

#### @ Operator – Access current scope

The `@` operator can be used to access the root value of the current scope.

```groq
// @ refers to the current number being evaluated
// Returns numbers in the array if they're greater than or equal to 10
numbers[ @ >= 10 ]

// @ refers to the myArray value
// This query returns the number of items in the myArray array
*{"arraySizes": myArray[]{"size": count(@)}} 
```

#### ^ Operator – Access the parent scope

Scopes can also be nested, in which case the `^` operator can be used to access the root value of the parent scope. Consider the following query:

```groq
*[ _type == "movie" && releaseYear >= 2000 ]{
  title,
  releaseYear,
  crew{name, title},
  "related": *[ _type == "movie" && genre == ^.genre ]
}
```

In the filter, `_type` and `releaseYear` access the corresponding attributes of each document passed from `*`. Similarly, in the projection, `title`, `releaseYear`, and `crew` access the corresponding attributes from each document passed from the filter. However, in the nested `crew` projection, `name` and `title` access the attributes of each object passed from the `crew` object - notice how the outer and inner `title` identifiers refer to different attributes (one is from the movie, the other is from the crew member).

The `related` pipeline components also create new scopes where `_type` and `genre` refer to the attributes of each document fetched from the preceding `*` operator, not those of the surrounding projected document. Notice how the `^` operator is used to access the document at the root of the parent (outer) scope and fetch its `genre` attribute.

## Operators

GROQ supports nullary, unary, and binary operators, which return a single value when invoked. Unary operators can be either prefix or postfix (e.g. `!true` or `ref->`), while binary operators are always infix (e.g. `1 + 2`). Operators are made up of the characters `=<>!|&+-*/%@^`, but identifiers can also be used to name certain binary operators (e.g., `match` which case they are considered reserved keywords.

## Functions

GROQ function calls are expressed as a function identifier immediately followed by a comma-separated argument list in parentheses, e.g., `function(arg1, arg2)`. An optional trailing comma may follow the final argument. Functions can take any number of arguments (including zero), and return a single value.

## Pipe Functions

Pipe functions ([order()](https://www.sanity.io/docs/specifications/groq-pipeline-components) and [score()](https://www.sanity.io/docs/specifications/groq-functions)) must be preceded by the [pipe operator](https://www.sanity.io/docs/specifications/groq-operators) (`|`). The left-hand expression will be an array that the pipe operator will pass to the right-hand pipe function, returning a new array.

`*[_type == "post"] | order(_createdAt desc)` will pass an array of all documents with a `_type` of `post` into the `order()` function, returning a new array of those documents sorted by the `_createdAt` property in descending order.



# GROQ feature support across Sanity

#### New to GROQ?
If you are just getting started with GROQ, check out the getting started guide first.
[Get started with GROQ](https://www.sanity.io/docs/content-lake/groq-introduction)

GROQ is available across many parts of the Sanity platform, but not every feature or tool supports the full language. This summary maps out what's available where.

## Query API and @sanity/client

The Query HTTP API (`/query` endpoint) and `@sanity/client`'s `fetch` method provide the most complete GROQ support. This includes the full pipeline syntax, joins and sub-queries, reference dereferencing (`->`), all built-in functions and namespaces (`string::`, `array::`, `math::`, `pt::`, `geo::`, `dateTime::`, etc.), the `score()` and `order()` pipe functions, custom GROQ functions (`fn`), and perspectives.

**Not available:** Delta functions (`before()`, `after()`, `delta::changedAny()`, etc.) as there's no change event in this context.

## Webhooks (filters and projections)

Webhook filters and projections operate in a **delta context** scoped to the document that triggered the event. This is the main place where Delta-GROQ shines.

**Available:** All standard filter operators, `before()` and `after()` for comparing document states, `delta::changedAny()`, `delta::changedOnly()`, `delta::operation()`, reference dereferencing in projections, the `sanity::` namespace functions, and string/array manipulation.

**Not available:** Sub-queries are not supported in webhook projections or filters. You cannot use `*[...]` inside a projection to query other documents. If you need data from other documents, the recommended approach is to handle the sub-query in your receiving endpoint after the webhook fires.

## Sanity Functions (filters and projections)

Functions use the same delta-context model as webhooks, with filters and projections configured separately in the function definition. 

**Available:** All standard filter operators, `before()` and `after()` for comparing document states, `delta::changedAny()`, `delta::changedOnly()`, `delta::operation()`, reference dereferencing in projections (`->`) syntax), the `sanity::` namespace functions, and string/array manipulation.

**Not available:** Sub-queries are not supported in projections or filters. You cannot use `*[...]` inside a projection to query other documents. If you need data from other documents, the recommended approach is to handle the sub-query in the function.

**Current caveat:** Locally invoked functions (via `npx sanity functions test` or `npx sanity functions dev`) do not yet support the expanded GROQ features (delta functions, dereferences, etc.) and may return errors. Sanity recommends testing locally without these features and using logs to test deployed functions with the added features.

## Listen API (client.listen())

The Listen API establishes real-time subscriptions via HTTP GET requests with the GROQ query passed as a URL parameter.

**Limitations:**

- Joins and complex projections are ignored. The API only uses the filter portion to determine which document changes to stream.
- No Delta-GROQ support.

**Recommendation:** For real-time use cases, the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) is the preferred modern alternative. It uses a different architecture integrates directly with framework-specific tooling.

## groq-js (JavaScript GROQ implementation)

`groq-js` is the open-source JavaScript implementation of GROQ used by tools like the [functions dev playground](https://www.sanity.io/docs/functions/functions-local-testing) and the [GROQ Arcade](https://www.groq.dev). It evaluates queries against an in-memory dataset rather than the Content Lake.

**Limitations:**

- While `groq-js` does support many functions like `sanity::projectId()`, `sanity::dataset()`, and Delta functions, these may not return the expected results when not used with in conjunction with the Content Lake.
- Performance characteristics differ from the Content Lake engine; it runs against local data in the browser or Node.js.

## TypeGen

Sanity TypeGen generates TypeScript types from GROQ queries but only [supports a subset of the language](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) for type inference.

**Supported:** Basic data types, selectors (`*`, `@`, `^`), common functions (`coalesce()`, `select()`, `round()`, `upper()`, `lower()`, array functions), traversals, pipe functions, most operators, and most named functions.

**Limitations:** Any unsupported functions, complex expressions, and newer GROQ features are typed as `unknown`.

## Access control (role filters)

GROQ filters used in [custom content resources](https://www.sanity.io/docs/user-guides/roles) for role-based access control have their own constraints. They only support simple expressions, such as field comparisons (*type == “article”), boolean composition (*`type == “article” && defined(example)`), and checks with `in` (`”example-id-” in store[]._ref`).

**Not supported:** Dereferencing (`->`) is not allowed in access control filters. Instead of `referenceField->`, you must check against the `_ref` property directly.

## Custom GROQ functions

[Custom functions](https://www.sanity.io/docs/content-lake/custom-groq-functions) (`fn namespace::name($param) = ...;`) work anywhere you can send a full GROQ query (client, HTTP API, Vision), but have [their own limitations](https://www.sanity.io/docs/content-lake/custom-groq-functions).

**Not supported**: Places where sending a full GROQ query isn’t available, such as when projections or filters are defined separately.

## Summary table

The following table summarizes feature support across the different contexts where GROQ is used.

##### GROQ support by context

| Feature | Query API | Webhooks | Functions (deployed) | Listen API | groq-js | TypeGen | Access control |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Filters | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Projections | ✅ | ✅ | ✅ | Ignored | ✅ | ✅ | N/A |
| Joins / sub-queries | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | N/A |
| Dereferences (->) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ |
| Delta functions | ❌ | ✅ | ✅ | ❌ | Partial | ❌ | ❌ |
| Custom GROQ functions (fn) | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| sanity:: namespace | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |





# Introduction

Sanity has powerful APIs for [querying](https://www.sanity.io/docs/content-lake/how-queries-work), [patching](https://www.sanity.io/docs/content-lake/http-patches), and [mutating](https://www.sanity.io/docs/http-reference/mutation) data in the real-time [Content Lake](https://www.sanity.io/docs/content-lake). In addition to our [GROQ](https://www.sanity.io/docs/content-lake/how-queries-work) API, we also support deploying GraphQL APIs to query your content. 

GraphQL APIs are deployed [using our command-line interface](https://www.sanity.io#04501f1778aa). The command inspects your studio's schema definitions and generates a GraphQL schema that closely resembles it (type names have their first letter capitalized – *bookAuthor* becomes *BookAuthor*), then adds queries allowing you to find and filter the documents stored in your Sanity dataset. 

> [!TIP]
> Give GROQ a try
> Sanity supports generating and deploying a GraphQL API from your schema, but we really recommend trying GROQ. It covers most—if not all—features you're familiar with from GraphQL including a [playground](https://www.sanity.io/docs/content-lake/the-vision-plugin), [custom functions](https://www.sanity.io/docs/content-lake/custom-groq-functions), and [type generation](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen). [Learn more about GROQ here](https://www.sanity.io/docs/content-lake/groq-introduction).

This article explains how to prepare your Sanity schema, generate and deploy a GraphQL schema, how to query and interact with the schema, and additional advice on working with GraphQL and Sanity.

## GraphQL requires strict schemas

The schemas for Sanity Studio are more flexible than what GraphQL is able to represent. That means that we can't promise that you'll be able to deploy a GraphQL API without any changes to your Sanity projects. Usually, these changes are backward-compatible and do not require any data migration.

You may find that “anonymous“ object types have to be given a name and declared in the top-level scope. Take this example:

**schemas/blogPost.ts**

```typescript
import {defineType} from 'sanity'

export default defineType({
  name: 'blogPost',
  title: 'Blog post',
  type: 'document',
  fields: [
    // ... other fields ...
    {
      name: 'sponsor',
      title: 'Sponsor',
      type: 'object',
      fields: [
        {
          name: 'name',
          title: 'Name',
          type: 'string'
        },
        {
          name: 'url',
          title: 'URL',
          type: 'url'
        }
      ]
    }
  ]
})
```

In the code above, the `sponsor` field is an object type declared inline. This means it cannot be used outside of the `blogPost` type. This is not compatible with GraphQL—all object types have to be defined in a global scope. To fix this, you should move the sponsor declaration to a separate file and import it into your schema explicitly, then have the `sponsor` field refer to it by name. 

Example:

**schemas/blogPost.js**

```typescript
import {defineType} from 'sanity'

export default defineType({
  name: 'blogPost',
  title: 'Blog post',
  type: 'document',
  fields: [
    // ... other fields ...
    {
      name: 'sponsor',
      title: 'Sponsor',
      type: 'sponsor'
    }
  ]
})

// schemas/sponsor.js
import {defineType} from 'sanity'

export default defineType({
  name: 'sponsor',
  title: 'Sponsor',
  type: 'object',
  fields: [
    {
      name: 'name',
      title: 'Name',
      type: 'string'
    },
    {
      name: 'url',
      title: 'URL',
      type: 'url'
    }
  ]
})

```

> [!TIP]
> Protip
> While "lifting"/"hoisting" the type to the top-level scope, it can be helpful to consider whether the type should be altered to make it more reusable in other contexts. If you think the type is only relevant to the specific schema type, consider prefixing it to make it clearer (e.g., `blogPostSponsor` in the above case).

> [!WARNING]
> Gotcha
> The type names `reference` and `crossDatasetReference` are considered reserved words by the Sanity CLI and cannot be used as the value of the `name` field in a document.



## Deploying GraphQL APIs

GraphQL APIs are deployed using the Sanity CLI tool. In the many cases, running `sanity graphql deploy` in your Sanity Studio project folder is enough to get started. It will use the default settings and deploy the API to the project ID and dataset configured in your `sanity.config.ts` file.

You can deploy multiple APIs per project/dataset with different API configurations. To do so, you will want to either edit or create a `sanity.cli.ts | js` file.

The configuration file should export a configuration object containing a `graphql` key, which is an array of GraphQL API definitions. Here is an example configuration file:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  graphql: [
    {
      playground: false,
      tag: 'experiment',
      workspace: 'staging',
      id: 'schema-experiment',
    },
  ]
})
```

In the example above, we are telling the CLI:

- We do not want a playground to be deployed for this API.
- We want to use the custom tag "experiment," which allows us to deploy multiple APIs for a single dataset.
- We want to use the [workspace](https://www.sanity.io/docs/studio/workspaces) with the id "staging" from the studio configuration file. This allows us to use different project IDs, datasets, schemas, and similar.
- We want the ID of this GraphQL API to be "schema-experiment." If multiple GraphQL APIs are defined, this lets us deploy specific ones by using the `--api` flag.

Running `sanity graphql deploy` from your Sanity Studio project folder will now deploy all of the configured APIs from the CLI configuration. To learn about the `deploy` command's available flags and options, visit the [reference article](https://www.sanity.io/docs/cli-reference/cli-graphql).

Deploying multiple GraphQL APIs in a single configuration is not an atomic operation. While the CLI tool attempts to validate/verify the API configuration and schemas ahead of time, there is a theoretical possibility that some APIs might be deployed while others might fail. This may be improved/fixed in the future.

### Keeping the API up to date

Keep in mind that changing the schema in your local Sanity studio does not automatically change the GraphQL API. You'll have to run `sanity graphql deploy` to make the API reflect the changes.

### Tagged endpoints

We also support deploying multiple endpoints of the GraphQL schema to the same dataset by using the `tag` option in the CLI configuration file. This tag will be the last segment in the endpoint URL. This will let you test schema changes without breaking existing applications. If you don't specify any tag, the tag will be `default`.

Since we provide a way to deploy multiple GraphQL endpoints, you can use this CLI command to list all your existing endpoints:

```sh
sanity graphql list
```

> [!WARNING]
> Gotcha
> Dataset names with dashes `-` in the name currently list incorrectly in the `sanity graphql list` command. If this causes issues for you please use a different delimiter in your dataset names. This is something we are aware of and looking to fix in the future. 

### Breaking/dangerous changes 

When a GraphQL API has already been deployed, and you want to deploy a new version, the Sanity CLI tool will generate a new API definition and compare it with the previously deployed version. If any changes are considered breaking or dangerous, the CLI will warn and ask for confirmation before deploying. 

In a CI environment, the CLI will exit with a non-zero exit code and fail the build. You can use the `--dry-run` flag to only check for breaking/dangerous changes (that is, without deploying the changes), and the -`-force` flag if you are sure you want to deploy even with breaking changes. The rules for determining breaking/dangerous changes are defined in the `findBreakingChanges` and `findDangerousChanges` of the [graphql npm package](https://github.com/graphql/graphql-js). Note that this is currently considered an implementation detail and may change.

### The playground

GraphQL APIs have the option to deploy a "playground". This is an interactive GraphQL user interface that will allow you to more easily run/test queries. This is handy for development, but might not necessarily be something you want to deploy in production - which is why it is configurable. Do note that users can still run an [introspection query](https://graphql.org/learn/introspection/) to discover the properties of the schema without the playground being deployed, however.

> [!TIP]
> Did you know GROQ has a playground too?
> Much like GraphQL playgrounds, GROQ has the [Vision tool](https://www.sanity.io/docs/content-lake/the-vision-plugin) that integrates with your studio and lets you run queries directly.

If you want to enable/disable this feature, it can be done by using the boolean `playground` flag in the GraphQL CLI configuration.

## GraphQL endpoints

> [!NOTE]
> GraphQL API Versioning
> This documentation describes the latest version of the GraphQL API: 2025-02-19. If you're running previous versions, please see the changelog entries for details on what differs.
> On 2023-08-01 the first major upgrade to the Sanity GraphQL API with breaking changes was released. If you are working in a project that queries the legacy `v1` API, you can safely continue to do so until you are ready to upgrade. To learn about the new features and breaking changes introduces in `v2023-08-01`, refer to the [release notes](https://www.sanity.io/changelog/9ec89318-a340-4e23-91d9-3154da5b6244).
> You can tell which API version you are targeting by looking at the version segment of the endpoint URL, as shown below:
> **Dated** endpoints, for example:
> `https://<yourProjectId>.api.sanity.io/v2023-08-01/graphql/<dataset>/<tag>`
> Legacy **v1** endpoint:
> `https://<yourProjectId>.api.sanity.io/v1/graphql/<dataset>/<tag>
> `

GraphQL queries can be executed against the [API or API CDN](https://www.sanity.io/docs/content-lake/api-cdn).

- **API** is recommended in development environments or for use cases where you need the latest content to be immediately available.
- **API CDN** is recommended for most use cases to return faster results and scale for high-volume traffic.

The subdomain of your query URL directs the request to the API or API CDN:

`https://<yourProjectId>.apicdn.sanity.io/v2023-08-01/graphql/<dataset>/<tag>`

## Basic querying concepts

### Queries

For each document type in your Sanity schema, two top-level query fields are added:

- `all<TypeName>` - used to fetch all documents of the given type. You can add additional filters, sorting, limits, and offsets. Read more about filters below.
- `<TypeName>` - used to fetch a specific document of the given type by specifying its document ID.

### Filters

For each object and document type in your Sanity schema, an equivalent *filter* type is generated. This can be used to constrain which documents are returned for a given query, much like an SQL query.

Most fields in your schema type will have a corresponding field in the filter. For instance, a book schema type may have a `title` field, which would then have a title *filter*:

Input

```graphql
{
  allBook(where: {title: {eq: "A Game of Thrones"}}) {
    title
    author {
      name
    }
  }
}
```

Result

```json
{
  "allBook": [
    {
      "title": "A Game of Thrones",
      "author": {
        "name": "George. R. R. Martin"
      }
    }
  ]
}
```

In a similar fashion, the `author` field would also have a filter type:

Input

```graphql
{
  allBook(where: {author: {name: {eq: "George R.R. Martin"}}}) {
    title
    author {
      name
    }
  }
}
```

Response

```json
{
  "allBook": [
    {
      "title": "A Game of Thrones",
      "author": {
        "name": "George R. R. Martin"
      }
    },
    {
      "title": "A Storm of Swords",
      "author": {
        "name": "George R. R. Martin"
      }
    }
  ]
}
```

Which comparator functions exist depend on the field type. For instance, a number field will have the comparators `eq`, `neq`, `gt`, `gte`, `lt` and  `lte`, while a boolean field will only have `eq` and `neq`.

In addition to filtering on a per-field basis, document types have additional filters available under the `_` field: `references` and `is_draft`:

Input

```graphql
{
  allBook(where: {_: {references: "jrr-tolkien"}}) {
    title
    author {
      name
    }
  }
}
```

Response

```json
{
  "allBook": [
    {
      "title": "The Lord of the Rings",
      "author": {
        "name": "J. R. R. Tolkien"
      }
    }
  ]
}
```

For a full overview of the available filters, see the [GraphQL filter reference](https://www.sanity.io#ba117ddb05ce) section down below.

### Sorting

You can sort on multiple fields on your top-level documents. You can also sort on your nested objects.

Input

```graphql
{
  allBook(sort: [ { title: ASC }, { published: DESC } ]) {
    title
  }
}
```

Result

```json
{
  "allBook": [
    {
      "title": "A Game of Thrones",
      "author": {
        "name": "George. R. R. Martin"
      }
    },
    {
      "title": "The Fellowship of the Ring",
      "author": {
        "name": "J. R. R. Tolkien"
      }
    }
  ]
}
```

### Pagination

We support pagination in the form of the take and skip concept. Pagination can easily be achieved like this:

Input

```graphql
{
  allBook(limit: 10, offset: 10) {
    title
  }
}
```

Result

```json
{
  "allBook": [
    {
      "title": "The Two Towers",
      "author": {
        "name": "J. R. R. Tolkien"
      }
    },
    {
      "title": "The Return of the King",
      "author": {
        "name": "J. R. R. Tolkien"
      }
    }
  ]
}
```

### Query parameters

#### Perspectives

[Perspectives](https://www.sanity.io/docs/content-lake/perspectives) allow your GraphQL queries to run against an alternate view of the content in your dataset. You can set a perspective by adding the query parameter `perspective` to your request. The available options are:

- `published`: The default option if no perspective is set. Excludes all unpublished changes from your results.
- `raw`: Returns drafts, versions, and published content side-by-side for authenticated requests.
- `drafts`: Treats all draft documents and in-flight changes as if they were published.
- Perspective stack: You can also pass a list of perspectives to display content releases. Layers take priority from left to right. For example: `releaseA,releaseB,releaseC`. The `published` perspective is automatically applied to the end.

```text
https://<yourProjectId>.apicdn.sanity.io/v2025-02-19/graphql/<dataset>/<tag>?perspective=raw
```

[Perspectives for Content Lake](https://www.sanity.io/docs/content-lake/perspectives)
Read more about the Perspectives feature in the docs

## Additional features and considerations

### Deprecated fields

You can explicitly deprecate fields in your GraphQL APIs by using the `deprecated` property in [schema-type definitions](https://www.sanity.io/docs/schema-types):

Input

```typescript
export const name = defineField({
  name: 'firstName',
  type: 'string',
  description: `The person's first name`,
  deprecated: {
    reason: 'Use the name field instead'
  }
})
```

GraphQL schema

```json
{
  "name": "type",
  "description": "The person's first name",
  "args": [],
  "type": {
    "kind": "SCALAR",
    "name": "String",
    "ofType": null
  },
  "isDeprecated": true,
  "deprecationReason": "Use fullName and lastName instead"
}
```

### Content Source Maps

[Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps) (CSM) is an [open specification by Sanity](https://github.com/sanity-io/content-source-maps) that enables the embedding of source metadata with your content, and lays the foundation for powerful features such as [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing). 

[Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps)
Read about Content Source Maps in the docs

[Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
Read about Visual Editing in the docs

To use CSM with GraphQL, add the query parameter `resultSourceMap=true` to your request.

```text
https://<yourProjectId>.apicdn.sanity.io/v2025-02-19/graphql/<dataset>/<tag>?resultSourceMap=true
```

The CSM metadata will then be returned in the `sanitySourceMap` extension in the response:

```json
{
  "data": {
    "allPost": [
      {
        "title": "GraphQL CSM"
      }
    ]
  },
  "extensions": {
    "sanitySourceMap": {
      "documents": [
        {
          "_id": "75bbbd60-0aa9-4b20-9c00-0b40cb010ff6"
        },
      ],
      "paths": [
        "$['title']"
      ],
      "mappings": {
        "$['allPost'][0]['title']": {
          "source": {
            "document": 0,
            "path": 0,
            "type": "documentValue"
          },
          "type": "value"
        }
      }
    }
  }
}
```

For an example of how to use Content Source Maps to implement Visual Editing using GraphQL you can visit these repositories which demonstrates how to set things up in Next.JS:

- [Sanity Presentation with Next.JS and GraphQL – App router](https://github.com/sanity-io/demo-graphql-presentation-nextjs)
- [Sanity Presentation with Next.JS and GraphQL – Pages router](https://github.com/sanity-io/demo-graphql-presentation-nextjs/tree/pages-router)

### Security

The GraphQL API generally has the same rules as the GROQ API—dataset visibility is respected. Authenticated users see only the documents they have access to.

However, remember that your GraphQL schema is public, so all types and fields will be [introspectable](https://graphql.org/learn/introspection/) by anonymous users.

### Mutations

Mutations are not exposed through the GraphQL API but rather through our powerful [Mutation API](https://www.sanity.io/docs/http-reference/mutation).

## Troubleshooting

### Schema generation issues

Since the schema is generated in Node.js instead of in a browser environment, certain imported modules might cause issues. Things that reference the `window` in a global context are a prime example. If you encounter issues, we'd be interested in hearing which modules cause problems to see if we can work around them. We invite you to reach out to us in our [Discord community](https://discord.com/servers/sanity-1304483263171264613).

## Filters reference

### Scalars

#### ID, String, Datetime, Date

- Equals: `field { eq: "" }`
- Not equals: `field { neq: "" }`
- In: `field { in: [ "apple", "banana", "pineapple" ] }`
- Not in: `field { nin: [ "apple", "banana", "pineapple" ] }`
- Matches: `field { matches: "" }`

#### Int

- Equals: `field { eq: "" }`
- Not equals: `field { neq: "" }`
- Greater than: `field { gt: 42 }`
- Greater than or equal: `field { gte: 42 }`
- Lesser than: `field { lt: 42 }`
- Lesser than or equal: `field { lte: 42 }`

#### Float

- Equals: `field { eq: 42.0 }`
- Not equals: `field { neq: 42.0 }`
- Greater than: `field { gt: 42.0 }`
- Greater than or equal: `field { gte: 42.0 }`
- Lesser than: `field { lt: 42.0 }`
- Lesser than or equal: `field { lte: 42.0 }`

#### Boolean

- Equals: `field { eq: true|false }`
- Not equals: `field { neq: true|false }`

### Types

The schema generator will generate filtering types for your documents. It will provide filtering options for most fields defined in your schema. On top-level documents, it provides some special filters which can be accessed through `_`.

#### Document

- References: `field { references: "jrr-tolkien" }`
- Is draft: `field { is_draft: true }`

#### Array

Unfortunately, we don't provide any filtering for your array fields yet.

#### Portable Text

The schema generator will expose a `<your-type-name>Raw` field, which gives you all Portable Text content in raw JSON. It will not resolve references by default, but if you use one of our source plugins for [Gatsby](https://github.com/sanity-io/gatsby-source-sanity/) or [Gridsome](https://github.com/sanity-io/gridsome-source-sanity/), there are arguments you can pass to resolve references.

> [!WARNING]
> Gotcha
> Since [Portable Text](https://www.sanity.io/guides/introduction-to-portable-text) by nature is somewhat loosely typed, the generation doesn't take into account all the types you provide for it, yet.



# GROQ for GraphQL developers

If you're familiar with GraphQL, GROQ will feel familiar in some places and different in others. This page maps common GraphQL patterns to their GROQ equivalents and highlights what GROQ adds beyond what the GraphQL spec describes. If you want to use Sanity's GraphQL API instead, see [the GraphQL guide](https://www.sanity.io/docs/content-lake/graphql).

GROQ is the native query language for the Sanity Content Lake. GraphQL is a generated, typed layer based on [your schema](https://www.sanity.io/docs/apis-and-sdks/introduction-to-schemas). This guide is here to help GraphQL users decide whether to switch and to translate what they already know.

## GraphQL concepts in GROQ

### Fetch by ID

In GraphQL, you call a schema-declared root field. In GROQ, you filter the document space and project the fields you want. Any field can be the lookup key, not only `_id`.

**GraphQL**

```graphql
{
  Post(id: "abc") {
    title
  }
}
```

**GROQ**

```groq
*[_id == "abc"][0]{title}
```

### Field selection

GraphQL selection sets become GROQ projections. GROQ lets you rename fields inline (`"heading": title`) and spread the rest with `...`.

**GraphQL**

```graphql
{
  allPost {
    title
    body
  }
}
```

**GROQ**

```groq
*[_type == "post"]{title, body}
```

### Filtering

GraphQL `where` arguments are schema-declared. GROQ filters are arbitrary boolean expressions inside `[ ]`. You can combine equality, comparison, `match`, `in`, `defined()`, `references()`, and date math without changing the schema.

**GraphQL**

```graphql
{
  allPost(where: {title: {matches: "hello"}}) {
    title
  }
}
```

**GROQ**

```groq
*[_type == "post" && title match "hello*"]{title}
```

### Reference traversal

GraphQL traverses references through nested selection on schema-declared relation fields. GROQ uses the `->` operator on any field that contains a `_ref` value, with no schema declaration required. Chains compose: `author->company->address`.

**GraphQL**

```graphql
{
  allPost {
    author {
      name
      bio
    }
  }
}
```

**GROQ**

```groq
*[_type == "post"]{author->{name, bio}}
```

### Pagination

Sanity's GraphQL API exposes `limit` and `offset` arguments on list fields. GROQ uses slice syntax, which works on any array, including sub-arrays inside projections.

**GraphQL**

```graphql
{
  allPost(limit: 10, offset: 0) {
    title
  }
}
```

**GROQ**

```groq
*[_type == "post"][0...10]{title}
```

### Ordering

GraphQL passes sort arguments to a list field. GROQ uses the `| order()` pipe function, which supports multi-key ordering and inline transforms such as `lower(title)` for case-insensitive sorting.

**GraphQL**

```graphql
{
  allPost(sort: [{title: ASC}]) {
    title
  }
}
```

**GROQ**

```groq
*[_type == "post"] | order(title asc){title}
```

### Aggregation

GraphQL has no aggregation in the query language. GROQ has `count()`, `math::sum()`, `math::avg()`, `math::min()`, and `math::max()` as first-class functions you can inline in projections. Neither language has a native group-by.

**GROQ**

```groq
{
  "orderCount": count(*[_type == "order"]),
  "totalRevenue": math::sum(*[_type == "order"].amount)
}
```

### Real-time updates

GraphQL subscriptions are schema-declared and limited to a single root field per operation. Sanity does not support GraphQL subscriptions, but offers two real-time options driven by GROQ filters: the [Listen API](https://www.sanity.io/docs/content-lake/realtime-updates) for server-sent change events on any GROQ filter, and the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api), which handles caching and reconnection for production workloads.

### Typed queries

GraphQL fragments plus a codegen pipeline give you typed clients. The equivalent in Sanity is `defineQuery()` from the `groq` package combined with [Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen), which generates TypeScript result types from your queries automatically. Query reuse in GROQ is achieved with string composition or [GROQ custom functions](https://www.sanity.io/docs/content-lake/custom-groq-functions).

**Typed GROQ**

```typescript
import {defineQuery} from 'groq'

const postsQuery = defineQuery(`*[_type == "post"]{title, body}`)
const posts = await client.fetch(postsQuery)
// posts is fully typed when TypeGen runs
```

## What GROQ adds beyond GraphQL

Some capabilities are inline in GROQ that would require server-side resolver code in a GraphQL implementation, and one is specific to Sanity's platform regardless of the query language.

### Inline computed fields

GraphQL is not a computation language; computed values must be defined as schema fields with server-side resolvers. GROQ lets you compute values directly in the projection with `coalesce()`, `select()`, string functions, math functions, and arbitrary expressions.

**GROQ**

```groq
*[_type == "product"]{
  title,
  "displayPrice": coalesce(salePrice, regularPrice),
  "label": select(stock > 0 => "In stock", "Out of stock"),
  "reviewCount": count(reviews)
}
```

### Dynamic reference traversal

GraphQL requires every join point to be a schema-declared field with a resolver. GROQ traverses any `_ref` value at query time, with no schema changes or server redeploy. Adding a new traversal is a query edit, not a deployment.

### Reverse lookups in projections

GROQ's `^` parent reference and `references()` function let you express a reverse join inside a projection, without a schema-declared reverse relation. In GraphQL, the same query requires a schema field with a resolver that searches for documents referring to the current one.

**GROQ**

```groq
*[_type == "author"]{
  name,
  "posts": *[_type == "post" && references(^._id)]{title, publishedAt}
}
```

### Full-text and semantic search

GROQ provides `text::query()` for structured text search with phrase, prefix, and negation syntax, and `text::semanticSimilarity()` for vector-based ranking. GraphQL has no text or semantic search in the spec; implementations need custom resolvers or external integrations. Semantic similarity requires dataset embeddings to be enabled.

**GROQ**

```groq
*[_type == "article" && body match text::query("machine learning -python")]

*[_type == "article"] | score(text::semanticSimilarity("how to handle authentication"))
```

### Cross-dataset references

Sanity's [cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-references) let a document in one dataset link to a document in another. GROQ traverses them with the same `->` operator. Sanity's GraphQL API does not support cross-dataset references; only GROQ can dereference them. Cross-dataset references are available on the Enterprise plan.

## Mutations

Sanity's GraphQL API does not support mutations. Writes go through [the Mutation API](https://www.sanity.io/docs/content-lake/mutation-patterns) (or the equivalent client methods such as `client.create()`, `client.patch()`, and `client.delete()`). This is the same for GROQ users and GraphQL users; mutations are a separate concern from queries.

## When to keep GraphQL

Some projects are better served by Sanity's GraphQL API:

- You have an existing investment in GraphQL tooling such as Apollo Client, Relay, or established codegen pipelines that you want to keep using.
- Your team has deep GraphQL expertise and the cost of switching mental models would outweigh the gain.

See [the GraphQL guide](https://www.sanity.io/docs/content-lake/graphql) for setup, deployment, perspectives, security, and the full filter and sort reference.

## Next steps

- [GROQ introduction](https://www.sanity.io/docs/content-lake/groq-introduction): the tutorial that walks through the language one concept at a time.
- [GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet): one-page reference for the most common query shapes.
- [GROQ functions reference](https://www.sanity.io/docs/specifications/groq-functions): every built-in function, including the math, text, string, and array namespaces.
- [Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen): typed clients for GROQ queries, the equivalent of GraphQL codegen.



# Introduction to document mutations

Document mutations are how you change content in Sanity's Content Lake programmatically. They provide a structured approach to modifying your documents while maintaining data integrity and enabling collaborative editing.

Some of the things you can do with document mutations include:

- Create new documents.
- Delete documents when they're no longer needed.
- Apply targeted patches to specific fields, or replace entire documents.
- Make transactional changes across multiple documents.

> [!TIP]
> Validation is client-side only
> Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See [Schema validation and the Content Lake](https://www.sanity.io/docs/content-lake/schema-validation-and-the-content-lake) for details.

#### Want to jump right in?

[Mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions)
The Actions API let you use the same system Sanity Studio uses to mutate documents in Content Lake.

[Document mutation patterns](https://www.sanity.io/docs/content-lake/mutation-patterns)
Common patterns and snippets for mutating documents and data in the Sanity Content Lake.

## Core concepts

### Transactions

When you submit mutations or dispatch actions to Content Lake, they are processed as part of a transaction—a single unit of work that either succeeds completely or fails entirely. Transactions make up a document’s [history](https://www.sanity.io/docs/http-reference/history) and trigger [listeners](https://www.sanity.io/docs/content-lake/realtime-updates).

#### Learn more about transactions

[Transactions](https://www.sanity.io/docs/content-lake/transactions)
How transactions for Content Lake work

### Patches

The Mutations API, and some actions in the Actions API, use patches to make small, targeted changes to documents. Patches allow you to modify specific parts of a document without having to replace the entire document, which is especially useful for collaborative workflows where multiple changes need to be reconciled.

Common patch operations include:

- `set`: Update specific fields with new values.
- `setIfMissing`: Set values only if the fields don't already exist.
- `unset`: Remove fields from a document.
- `insert`: Add, remove, or replace elements in arrays.
- `inc`/`dec`: Increment or decrement numeric values.
- `diffMatchPatch`: Apply text changes using Google's diff-match-patch algorithm.

#### Learn more about patches

[Patches](https://www.sanity.io/docs/content-lake/http-patches)
The valid patch types when using the direct HTTP mutations api.

### Actions API

The Actions API is the preferred method for mutating documents in Sanity—it’s also the underlying API that powers Sanity Studio’s mutations. It's designed to support an authoring model where drafts and versions of a document are iterated on and eventually published.

The Actions API is transactional, meaning that multiple actions will be executed in a single transaction—either all changes are applied, or none of them.

#### Use the Actions API

[Mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions)
The Actions API let you use the same system Sanity Studio uses to mutate documents in Content Lake.

[Actions API reference](https://www.sanity.io/docs/http-reference/actions)
Reference documentation for the Actions HTTP endpoint.

[Document mutation patterns](https://www.sanity.io/docs/content-lake/mutation-patterns)
Common patterns and snippets for mutating documents and data in the Sanity Content Lake.

### Mutations API

The Mutations API is the traditional way of creating and modifying documents in Sanity. It provides low-level operations that give you precise control over your content.

Available mutation types include:

- `create`: Create a new document
- `createOrReplace`: Create a document or replace it if it exists
- `createIfNotExists`: Create a document only if it doesn't already exist
- `delete`: Remove a document
- `patch`: Apply targeted changes to specific parts of a document

Like the Actions API, the Mutations API is transactional, ensuring data consistency across multiple operations.

#### Use the Mutations API

[Mutation API reference](https://www.sanity.io/docs/http-reference/mutation)
Reference documentation for the Mutatation HTTP reference.



# Mutate documents with actions

The Actions API is the preferred way to programmatically interact with Sanity documents. It lets you dispatch multiple actions in a single transaction, in an interface that is higher-level than the Mutations API.

You can interact with the Actions API with:

- The official Sanity JavaScript client (`@sanity/client`).
- The [Actions HTTP API](https://www.sanity.io/docs/http-reference/actions).

This guide covers some common actions and workflows. For a full list of available actions and their properties, see the [Actions API reference documentation](https://www.sanity.io/docs/http-reference/actions).

Prerequisites:

- `@sanity/client` v7.13.2 or later: All examples use the Sanity client, either directly or exported through Sanity Studio or SDK (see configuration below).
- `@sanity/id-utils` v1.0 or later (optional): Many examples use this helper library to generate unique identifiers.
- Release and version actions require setting your `apiVersion` to `2025-02-19` or later.

The examples below use the official JavaScript client. When any code blocks reference `client`, this refers to a configured Sanity client. For example:

**@sanity/client**

```
import { createClient } from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2025-12-15',
  token: '<your-token>'
})
```

**Studio**

```
import {useClient} from 'sanity' // If you're using Sanity Studio

const client = useClient({apiVersion: '2025-12-16'})
```

**SDK**

```
import {useClient} from '@sanity/sdk-react' // If you're using App SDK

const client = useClient({apiVersion: '2025-12-16'})
```

Mutating documents requires a token with permission to modify the documents. [Learn more about tokens and authenticating requests here](https://www.sanity.io/docs/content-lake/http-auth).

## Actions and transactions

The Actions API bundles one or more actions into a single transaction. Both the client’s `action` method and the HTTP API return a `transactionId` that you can use with the [History API](https://www.sanity.io/docs/http-reference/history) to identify a transaction in a document’s history.

Transactions work on an all-or-nothing system. If any one action fails in the transaction, the whole transaction will fail. [Learn more about transactions](https://www.sanity.io/docs/content-lake/transactions).

## Documents and the action workflow

When working with actions, it's helpful to understand what a Sanity [document](https://www.sanity.io/docs/content-lake/documents) *is*. Drafts, versions, and published documents are all individual documents. They're linked by a shared identifier. For example:

- Published document: `my-document-id`
- Draft document: `drafts.my-document-id`
- Version document: `versions.release-id.my-document-id`

With this in mind, actions like creating or editing a document refer to the individual document.

Actions follow the document lifecycle from Sanity Studio.

1. Create a draft or version.
2. Make edits to the document.
3. Publish the document (or release, in the case of version documents).

By dispatching multiple actions in a single transaction, you combine some or all of these steps.

> [!NOTE]
> What are versions?
> Version documents are unique to the [Content Release](https://www.sanity.io/docs/studio/content-releases-configuration) and [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) features. The Actions API treats both drafts and versions similarly, with the key difference being that versions need an associated Content Release. Learn more about the [different document types](https://www.sanity.io/docs/content-lake/documents).

## Document actions

### Create a new document

Creating a new document isn't the same as publishing a new document. Using the workflow described earlier, you must create a draft or version first. 

The `sanity.action.document.create` action requires a `publishedId`, even though the published document doesn't exist yet. This ensures that the draft or version links up correctly with the future published document. 

To create a `publishedId`, you can create one yourself or use the [@sanity/id-utils](https://github.com/sanity-io/id-utils) helper library. If you choose not to use the library, make sure your drafts and version identifiers match the [patterns described here](https://www.sanity.io/docs/content-lake/ids).

The following examples create a new draft / version based off of an automatically generated identifier.

**Draft**

```
import {createPublishedId, createDraftId} from '@sanity/id-utils'

const documentId = createPublishedId()
await client.action({
  actionType: 'sanity.action.document.create',
  publishedId: documentId,
  attributes: {
    _id: createDraftId(documentId),
    _type: 'article',
    title: 'Title of the document',
  },
  // Throws an error if the published document already exists. 
  // Set to ignore to fail silently.
  ifExists: 'fail', 
});
```

**Version**

```
import {createPublishedId, createVersionId} from '@sanity/id-utils'

const documentId = createPublishedId()
await client.action({
  actionType: 'sanity.action.document.create',
  publishedId: documentId,
  attributes: {
    _id: createVersionId('yourReleaseName', documentId),
    _type: 'article',
    title: 'Title of the document',
  },
  ifExists: 'fail',
});
```

The `attributes` key takes an object of the initial document properties. It requires at least and `_id` and `_type`, but you can supply a full document shape.

This action is only for new documents that don't have a published version. For drafts or versions of an existing document, use the `sanity.action.document.version.create` action.

### Create a draft/version document with edits

Use the `sanity.action.document.edit` action to apply a [Patch](https://www.sanity.io/docs/content-lake/http-patches) operation as part of creating a draft or version. The action copies over the published version, 

**Draft**

```
import {createDraftId} from '@sanity/id-utils'

await client.action({
  actionType: 'sanity.action.document.edit',
  publishedId: documentId,
  draftId: createDraftId(documentId),
  patch: {
    set: {
      title: 'new title'
    }
  }
});
```

**Version**

```
import {createDraftId} from '@sanity/id-utils'

const {dataset} = client.config()

await client.request({
  uri: `/data/actions/${dataset}`,
  method: 'POST',
  body: {
    actions: [{
      actionType: 'sanity.action.document.edit',
      publishedId: 'published-document-id',
      versionId: createVersionId('release-id', 'published-document-id'),
      patch: {
        set: {
          title: 'new title'
        }
      }
    }],
  },
});
```

> [!NOTE]
> At the time of publication, v7.13 of the client's `action` method does not support versions with `sanity.action.document.edit`. The included example uses the Actions API directly instead.

### Publish a draft document

Use the `sanity.action.document.publish` action to publish, or promote, a draft document into a published document.

**Publish a draft document**

```
import {createDraftId} from '@sanity/id-utils'

const documentId = 'an-existing-document-id'
await client.action({
  actionType: 'sanity.action.document.publish',
  publishedId: documentId,
  draftId: createDraftId(documentId),
});
```

This updates or creates a document with an ID of `an-existing-document-id` with the contents of the draft (`drafts.an-existing-document-id`, obscured above by the helper function). It then deletes the draft document.

> [!NOTE]
> Where's the version example?
> You can't publish version documents. Instead, use the `sanity.action.release.publish` action to publish the release that the version is a part of.

### Unpublish a document

Use the `sanity.action.document.unpublish` action to unpublish a published document. It requires a `draftId`. If no draft exists, the action copies the contents of the published document to the draft. If a draft already exists, the contents of the published document is discarded.

**Unpublish document**

```
import {createDraftId} from '@sanity/id-utils'

const documentId = 'an-existing-document-id'
await client.action({
  actionType: 'sanity.action.document.unpublish',
  draftId: createDraftId(documentId),
  publishedId: documentId,
});
```

### Delete a published document and all drafts and versions

Use the `sanity.action.document.delete` action to delete a document and it’s versions. This action requires you to explicitly supply any draft or version identifiers. If you only want to delete the published version, use the unpublish action instead.

**Delete document**

```
import {createDraftId} from '@sanity/id-utils'

const documentId = 'an-existing-document-id'
await client.action({
  actionType: 'sanity.action.document.delete',
  publishedId: documentId,
  includeDrafts: [createDraftId(documentId)],
  // purge: true // set purge to true to delete all history for document
});
```

**Delete document (HTTP request)**

```
import {createDraftId, createVersionId} from '@sanity/id-utils'

const documentId = 'an-existing-document-id'
await client.request({
  uri: `/data/actions/${dataset}`,
  method: 'POST',
  body: {
    actions: [{
      actionType: 'sanity.action.document.delete',
      publishedId: documentId,
      includeVersions: [createDraftId(documentId), createVersionId('target-release-id', documentId)],
    }],
  },
});
```

> [!NOTE]
> The shorthand `client.action` method on the client has not renamed `includeDrafts` yet, but you can add versionIds to the `includeDrafts` array alongside the draftId.
> Alternatively, see the HTTP example to use the raw action directly.

## Version actions

Version actions are a layer on top of document actions that allow you to mutate version and draft documents.

### Create a draft/version of an existing document

Use the `sanity.action.document.version.create` action to create a draft or version of an existing document, with the existing content intact.

These examples use the `baseId` to define the source document, and the `versionId` to define the target. For example, setting the source to a draftId and the version to a versionId will create a version document with the. contents from draft.

**Draft from published**

```
import {createDraftId} from '@sanity/id-utils'

const documentId = 'published-document-id'

await client.action({
  actionType: 'sanity.action.document.version.create',
  publishedId: documentId,
  baseId: documentId,
  versionId: createDraftId(documentId)
});
```

**Version from published**

```
import {createVersionId} from '@sanity/id-utils'

const documentId = 'published-document-id'

await client.action({
  actionType: 'sanity.action.document.version.create',
  publishedId: documentId,
  baseId: documentId,
  versionId: createVersionId(documentId)
});
```

**Version from draft**

```
import {createDraftId, createVersionId} from '@sanity/id-utils'

const documentId = 'published-document-id'

await client.action({
  actionType: 'sanity.action.document.version.create',
  publishedId: documentId,
  baseId: createDraftId(documentId),
  versionId: createVersionId(documentId)
});
```

If you prefer to make edits to the document as part of creating a version or draft, you may prefer the `sanity.action.document.edit` action.

### Discard a version or draft document

Use the `sanity.action.document.version.discard` action to discard a version or draft document. This works like discarding a draft or version in the Sanity Studio interface. If used with the `purge` option, editing history for that draft/version will be removed.

**Discard a version or draft**

```
import {createVersionId} from '@sanity/id-utils'

const documentId = 'document-id'
await client.action({
  actionType: 'sanity.action.document.version.discard',
  versionId: createVersionId('rRU0cStZz', documentId),
  // purge: true, // optionally pass to delete history
});
```

### Replace a version document

Use the `sanity.action.document.version.replace` action to replace an existing version or draft. This action accepts a `document` with at least an `_id` and `_type`. It replaces the draft or version matching the `_id` completely, so any missing properties will be left undefined in the document.

The following example replaces a version, but providing a draft-style ID (with a `draft.` prefix or with the `createDraftId` helper) will work for drafts as well.

**Replace a version**

```
import {createVersionId} from '@sanity/id-utils'

const documentId = 'document-id'
await client.action({
  actionType: 'sanity.action.document.version.replace',
  document: {
    _id: createVersionId('release-id', documentId),
    _type: 'article',
    title: 'new title'
  }
});
```

### Set a version to unpublish

Use the `sanity.action.document.version.unpublish` action to set a version document to unpublish when the release runs. This is the programatic equivalent of selecting the “[Unpublish when releasing](https://www.sanity.io/docs/user-guides/content-releases)” action from the content releases interface.

**Set version to unpublish**

```
import {createVersionId} from '@sanity/id-utils'

const documentId = 'an-existing-document-id'
await client.action({
  actionType: 'sanity.action.document.version.unpublish',
  publishedId: documentId,
  versionId: createVersionId('release-id', documentId),
});
```

> [!NOTE]
> The version document doesn’t need to exist to run this action. If a version document matching `versionId` exists, the special attribute `_system.delete` is set to `true`. If not, a copy of the `publishedId` document is created, again with `_system.delete` set to `true`, for use by the release.

## Release actions

Release actions allow you to programmatically control [Content Releases](https://www.sanity.io/docs/content-lake/content-release-document-flow) and [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts). They mutate release documents, but don’t control version documents in the release. To add or or manipulate documents in a release, use the [Version actions above](https://www.sanity.io/docs/content-lake/dispatch-actions).

### Create a new release

Use the `sanity.action.release.create` action to create a new release. You must supply a new releaseId that hasn’t been used in your current retention period.

**Create a release with metadata**

```
await client.action({
  actionType: 'sanity.action.release.create',
  releaseId: 'custom-release-id',
  metadata: {
    title: 'New release',
    description: 'Example content release',
    releaseType: 'undecided'
  }
})
```

**Create a release**

```
await client.action({
  actionType: 'sanity.action.release.create',
  releaseId: 'custom-release-id',
})
```

> [!NOTE]
> This action is useful when bundling multiple actions into a single transaction, but if you only need to create a single release, the `client.releases` method can help by autogenerating an identifier for you. See the [Content Releases API cheatsheet](https://www.sanity.io/docs/apis-and-sdks/content-releases-cheat-sheet) for an example.

### Edit a release

Use the `sanity.action.release.edit` action to mutate the release metadata. It uses [Patch operations](https://www.sanity.io/docs/content-lake/http-patches).

**Edit a release**

```
await client.action({
  actionType: 'sanity.action.release.edit',
  releaseId: releaseId,
  patch: {
    set: {
      metadata: {
        title: 'new title'
      }
    }
  }
})
```

### Publish a release

Use the `sanity.action.release.publish` action to publish a release. This is also how you publish version documents, as they must be part of a release.

**Publish a release**

```
await client.action({
  actionType: 'sanity.action.release.publish',
  releaseId: 'release-id',
});
```

### Schedule a release

Use the `sanity.action.release.schedule` action to schedule a release for publish. It requires an existing `releaseId` and a UTC timestamp. Scheduling a release locks the documents associated with the release.

**Schedule a release**

```
await client.action({
  actionType: 'sanity.action.release.schedule',
  releaseId: 'release-id',
  publishAt: '2026-01-01T00:00:00.000Z',
})
```

### Unschedule a release

Use the `sanity.action.release.unschedule` action to unschedule a release for publish. This returns the release to the active and unlocked, editable state. This may fail if another release is scheduled to be published after this one and has a reference to a document created by this one.

**Unschedule a release**

```
await client.action({
  actionType: 'sanity.action.release.unschedule',
  releaseId: 'release-id',
})
```

### Archive a release

Use the `sanity.action.release.archive` action to archive, and effectively remove, an active release. The version documents in the releases are deleted and therefore no longer queryable, but last version can still be accessed using document history endpoint as long as they are still in your retention period.

**Archive a release**

```
await client.action({
  actionType: 'sanity.action.release.archive',
  releaseId: 'release-id',
});
```

### Unarchive a release

Use the `sanity.action.release.unarchive` action to restore an archived release. This is only possible during your retention period.

**Archive a release**

```
await client.action({
  actionType: 'sanity.action.release.unarchive',
  releaseId: 'release-id',
});
```

### Delete a release

Use the `sanity.action.release.delete` action to delete an archived or published release. To remove active, unpublished releases, from your releases list [use the archive action](https://www.sanity.io/docs/content-lake/dispatch-actions).

**Delete a release**

```
await client.action({
  actionType: 'sanity.action.release.delete',
  releaseId: 'release-id',
});
```



# Transactions

Document updates in Sanity are called mutations, and a group of one or more mutations are executed as a single unit called a transaction. Transactions are submitted through mutations, as groups of actions in the Actions API, and a few other places throughout the Sanity ecosystem. They may look like the following:

**Actions with client**

```typescript
// import a configured client and id helpers
import {client} from './client'
import {createPublishedId, createDraftId, createVersionId} from '@sanity/id-utils'

const { dataset } = client.config();
  const documentId = createPublishedId();
  const transaction = await client.request({
    uri: `/data/actions/${dataset}`,
    method: 'POST',
    body: {
      actions: [
        {
          actionType: 'sanity.action.document.create',
          publishedId: documentId,
          document: {
            _type: 'post',
            _id: createDraftId(documentId),
            title: 'new title',
          }
        },
        {
          actionType: 'sanity.action.document.publish',
          publishedId: documentId,
          draftId: createDraftId(documentId)
        },
        {
          actionType: 'sanity.action.document.edit',
          publishedId: documentId,
          versionId: createVersionId('rRU0cStZz', documentId),
          patch: {
            set: {
              title: 'new title 2'
            }
          }
        },
      ]
    }
  });
```

**Mutations**

```json
{
   "mutations":[
      {
         "create":{
            "_id":"alien",
            "_type":"movie",
            "title":"Alien"
         }
      },
      {
         "patch":{
            "id":"alien",
            "set":{
               "year":1979,
               "genre":"Science Fiction"
            }
         }
      },
      {
         "delete":{
            "id":"blade-runner"
         }
      }
   ]
}
```

Transactions are atomic: either all of the mutations succeed or they all fail.

All transactions are recorded in an internal transaction log. This log is available through the [document history API](https://www.sanity.io/docs/http-reference/history). Once a transaction is committed, any [real-time listeners](https://www.sanity.io/docs/content-lake/realtime-updates) will be notified about the changes.

## Eventual Consistency

Internally, the Sanity data store consists of two main components: a document store where transactions are executed, and a search store where GROQ queries are executed. Document changes are continuously synced between the document and search stores, but this happens outside of transactions, so there is a delay between a transaction being committed and the changes being visible to queries.

As a result, transactions are strongly consistent (they always see the latest data), but queries are eventually consistent (they may see outdated data, but will eventually see the latest data given enough time). Under normal circumstances the convergence time for queries is generally short (less than 1 second), but during operational anomalies such as network failures or heavy load it can be much longer.

> [!WARNING]
> Gotcha
> Transactions using the `query` parameter are not strongly consistent, since the query is first executed against the search store, which may see outdated data.

When submitting transactions, the `visibility` parameter can be used to control how documents should be synced to the search store. `sync` (the default) causes the transaction request to return only after both the transaction has been committed and the changes have been synced to the search store. `async` causes the request to return once the transaction has been committed, and then syncs the changes to the search store afterwards (typically within a second). `deferred` causes the request to return once the transaction has been committed, but does not trigger syncing to the search store at all, and instead relies on a background process to sync the changes at a later time (within seconds to minutes) - this allows for much higher throughput when submitting a large number of mutations.

> [!WARNING]
> Gotcha
> By default, real-time listeners receive change notifications as soon as a transaction has been committed, but before changes have been synced to the search store. This means that a listening client running a GROQ query in response to a change will usually not see the updated document in the query result. The client can specify `visibility=query` for the listener to receive notifications after they have been synced to the search store, when possible.

## ACID Compliance

Sanity transactions are ACID-compliant, which means that they have the following properties:

- **Atomicity:** the transaction constitutes a single unit, such that either all of its mutations succeed or they all fail.
- **Consistency:** if a transaction succeeds then the resulting documents are guaranteed to satisfy all data store constraints, i.e. the transaction cannot leave the data in an inconsistent state. For example, this guarantees that there cannot exist two documents with the same ID. Note that this is a different concept than eventual consistency as described above.

> [!WARNING]
> Gotcha
> Sanity schemas are currently only enforced client-side by the Sanity studio, and thus the consistency guarantees do not extend to constraints specified in the schema. Non-studio clients may submit data which does not satisfy the schema, and schema changes may leave old data which no longer satisfies the new schema.

- **Isolation:** transactions have [repeatable read isolation](https://en.wikipedia.org/wiki/Isolation_(database_systems)#Repeatable_reads) via exclusive locks. When a document is first accessed by a transaction it is locked, blocking concurrent transactions from both reading and writing the document until the initial transaction completes. Since locks are acquired on first access and not on transaction start, it is possible for a mutation to see the effects of a concurrent transaction that was committed after the current transaction began but before the document was accessed and locked.

> [!WARNING]
> Gotcha
> When using the `query` option for mutations, the mutation first executes the given GROQ query against the search store, and then executes mutations against the matching documents. Since the search store is eventually consistent, it is possible for the query to return outdated results, which can cause the mutations to incorrectly affect or ignore documents that have recently been modified. This effectively reduces the transaction isolation level to [read committed](https://en.wikipedia.org/wiki/Isolation_(database_systems)#Read_committed), and can cause multiple data anomalies including lost updates, non-repeatable reads, phantom reads, and write skew.

- **Durability:** once a transaction succeeds, it is guaranteed to have been written to disk. However, it is not guaranteed to have been replicated to other servers. This means it is possible to lose a transaction in the rare scenario where a primary server crashes and is replaced by a replica server after the transaction has been committed but before it has been replicated.

## Concurrency Control

Transactions use exclusive locks to prevent concurrent transactions from interfering with each other (see description of transaction isolation above). However, clients often use read-write cycles that run a GROQ query and then submit transactions based on the results. This pattern does not have the same isolation guarantees as transactions. For example, if a different client writes a value after our client has read a document but before our client writes its new value, then the value that the other client wrote may be lost (an anomaly known as a lost update).

Clients can use optimistic locking to prevent these kinds of data anomalies. `patch` mutations take an optional `ifRevisionID` parameter containing a document revision ID (typically from the document's `_rev` attribute), and are only accepted if the given revision ID matches the document's current revision ID. If a different client has modified the document in the meanwhile then the mutation will be rejected with a `409 Conflict` HTTP status code, allowing the client to fetch the updated document and retry the operation with fresh data. Optimistic locking will also guard against submitting mutations based on outdated query results caused by the data store's eventual consistency model.



# Patches

It is good practice to use **patches** when modifying Sanity documents programmatically instead of replacing entire documents. Patches should make the smallest, most specific change possible so that if multiple scripts or users are modifying the same documents at the same time, Sanity is able to merge those changes in a sensible way.

A patch is a special mutation you can use with the [Mutations API](https://www.sanity.io/docs/http-reference/mutation),  some actions in the [Actions API](https://www.sanity.io/docs/http-reference/actions), and in [migrations](https://www.sanity.io/docs/content-lake/schema-and-content-migrations). Since these endpoints are transactional, you may submit one or several patches at once, potentially changing any number of documents in one single transaction. Here is an example of a full transaction submitting two patches at once (This sets the name property of the document with id "person-123" to "Remington Steele" and adds a reference to it to the end of the people-array of the document with the id "remingtons":

```json
{
  "mutations": [
    {
      "patch": {
        "id": "person-1234",
        "set": {
          "name": "Remington Steele"
        }
      }
    },
    {
      "patch": {
        "id": "remingtons",
        "insert": {
          "after": "people[-1]",
          "items": [
            {
              "_type": "reference",
              "_ref": "person-1234"
            }
          ]
        }
      }
    }
  ]
}
```

**Note:** Generally the keys of the patches use [JSONMatch syntax](https://www.sanity.io/docs/content-lake/json-match) to target values for change. This syntax generally allows for paths like `some.array[8].attribute`, but can also do pattern matching, recursive search, and target multiple values at once. The full syntax is [documented here](https://www.sanity.io/docs/content-lake/json-match).

> [!TIP]
> Protip
> `JSONMatch` is a variant of `JSONPath` that simplifies the syntax and eliminating to the maximum extent the number of special characters required to express a path.

## Field name restrictions

When using patch operations, [field names](https://www.sanity.io/docs/apis-and-sdks/naming-things) that start with digits must use bracket notation. For example, if you have a field named `123field` or a UUID like `37819f29-cd8e-438a-bf53-27953351677a`, you cannot use:

```
{
    "set": {
        "123field": "value",
        "37819f29-cd8e-438a-bf53-27953351677a": "value"
    }
}
```

Instead, use bracket notation:

```
{
    "set": {
        "['123field']": "value",
        "['37819f29-cd8e-438a-bf53-27953351677a']": "value"
    }
}
```

This applies to all patch operations (`set`, `setIfMissing`, `inc`, `dec`, etc.). The API will return a helpful error message if you attempt to use numeric field names without brackets, preventing silent failures that could occur in earlier API versions.

**Note**: This validation was introduced in API version 2025-08-18. Earlier versions may silently fail or return unclear errors when using numeric field names.

## Patch types

### set

`set` performs a shallow merge of its argument into the document. Each key in the argument is either an attribute or a JSON path.

#### Usage

```
{
  "set": {
    attributeOrJSONPathExpression: any
  }
}
```

#### Examples

**Object properties**

Set the field `name` to the value `Bob` and the nested field `personalMetrics.height` to `201`:

```json
{
  "set": {
    "name": "Bob",
    "personalMetrics.height": 201
  }
}
```

**Arrays**

Set the `text` property to the value `Do the thing!` in all objects in the `body` array where the `_type` is `cta`:

```json
{ 
  "set": {
    "body[_type==\"cta\"].text": "Do the thing!" 
  }
}
```

> [!WARNING]
> Gotcha
> Notice that the array filter (`[_type == \"cta\"]`) must use double quotes. If you are in JSON, you must [escape them](https://stackoverflow.com/a/15637481/1285253) (`\"`). 

### setIfMissing

`setIfMissing` is like `set`, except existing keys will be preserved and not overwritten.

### unset

Deletes one or more attributes. Each entry in the argument is either an attribute or a JSON path. Missing attributes are ignored. Unset can also be used to delete elements of an array.

#### Usage

```json
{
  "unset": [ attributeOrJSONPathExpression, ... ] 
}
```

#### Example

```json
{ 
  "unset": ["foo", "bar"] 
}
```



### insert

`insert` provides methods for modifying arrays, by inserting, appending and replacing elements via a JSONPath expression.

#### Append to the end of an array

Inserts the string `"a"` at the end of the array `some.array:`

```json
{
  "insert": {
    "after": "some.array[-1]",
    "items": ["a"]
  }
}
```

#### Insert into an array

Inserts the string `"a"` at index 2 before whatever was there:

```json
{
  "insert": {
    "before": "some.array[2]",
    "items": ["a"]
  }
}
```

#### Prepend to the start of an array

This inserts the string `"a"` at the beginning of the array.

```json
{
  "insert": {
    "before": "some.array[0]",
    "items": ["a"]
  }
}
```

#### Replace an item in an array

This removes index 2 through the end of the array, replacing the content with `"a"`.

```json
{
  "insert": {
    "replace": "some.array[2:]",
    "items": ["a"]
  }
}
```

#### Advanced use of JSONMatch

*NOTE: see the article on JSONMatch for more details*

Finds the element that has `key == 'abc-123'` and inserts `"a"` after it.

```json
{
  "insert": {
    "after": "some.array[key == \"abc-123\"]",
    "items": ["a"]
  }
}
```

Finds any object that has `key == 'list-123'` adds `"a"` at the beginning of its items array:

```json
{
  "insert": {
    "before": "blocktext..[key=\"list-123\"].items[0]",
    "items": ["a"]
  }
}
```

> [!WARNING]
> Gotcha
> Since single quotes are used to denote field names, regular strings *must* be enclosed in double quotes. When defining patches in JSON, the double quotation marks needs to be [escaped](https://stackoverflow.com/a/15637481/1285253) (`\"`).

### inc/dec

`inc` and `dec` change a numeric value. Each entry in the argument is either an attribute or a JSON path. For each entry, the attribute is looked up, modified and stored. The value may be a positive or negative integer or floating-point value. The operation will fail if target value is not a numeric value, or doesn't exist.

`inc` increments; `dec` is the same as `inc`, except the value is decremented.

#### Examples

```json
{
  "inc": {
    "stats.visitorCount": 1
  }
}
```

If it's not certain whether the attribute exists, you can provide a default with `setIfMissing`:

```json
{
  "setIfMissing": {
    "stats.visitorCount": 0
  },
  "inc": {
    "stats.visitorCount": 1
  }
}
```

### diffMatchPatch

This operation supports robust incremental text patches according to the [Google diff-match-patch algorithm](https://github.com/google/diff-match-patch), which has wide library support in practically all programming languages in common use. Given the document:

```json
{
  "_id": "dog-1",
  "_type": "someType",
  "aboutADog": "The rabid dog"
}
```

The following patch applies a diff-match-patch patch to the string:

```json
{
  "patch": {
    "id": "dog-1",
    "diffMatchPatch": {
      "aboutADog": "@@ -1,13 +1,12 @@\n The \n-rabid\n+nice\n  dog\n"
    }
  }
}
```

The document is transformed to read:

```json
{
  "_id": "dog-1",
  "_type": "someType",
  "aboutADog": "The nice dog"
}
```

The beauty of diff-match-patch patches is that they allow you to modify huge strings with small patches, and that they compose well, generally giving sane results even when several users or scripts are modifying the same text.







# Document mutation patterns

This guide describes common patterns and options you may find useful when mutating documents. If you haven’t already, take a look at the [introduction to document mutations](https://www.sanity.io/docs/content-lake/mutations-introduction) and the [mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions) guide.

The examples below display techniques across different APIs when available, and use the following client configuration when referencing `client`.

**client.ts**

```
import { createClient } from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2025-12-15',
  token: '<your-token>'
})
```

## Perform a dry run

To test actions and mutations without applying the mutations, you can use the dryRun option. Set it to true to perform a “dry run”.

**Action**

```
await client.action([{
    actionType: 'sanity.action.document.create',
    publishedId: 'example-id',
    attributes: {
      _id: `drafts.example-id`,
      _type: 'article',
      title: 'Title of the document',
    },
    ifExists: 'fail',
  }], 
  // take note of the new object passed as a second value to `action`
  {
    dryRun: true,
  });
```

**Mutation**

```
const { dataset } = client.config();
await client.request({
  uri: `/data/mutate/${dataset}`,
  method: 'POST',
  body: {
    mutations: [
      {
        delete: {
          id: '123'
        }
      }
    ],
    dryRun: true
  },
});
```

## Dispatching multiple actions

The Actions API is transactional. It accepts an array of actions that will be executed in a single transaction so that either all the effects will be applied, or none of them.

**Multiple actions with client**

```
await client.action([
  {
    actionType: 'sanity.action.release.create',
    releaseId: 'custom-release-id',
    metadata: {
      title: 'new release'
    }
  },
  {
    actionType: 'sanity.action.release.schedule',
    releaseId: 'custom-release-id',
    publishAt: '2026-01-01T00:00:00.000Z',
  }
])
```

Note that you cannot mix document/version and release actions in a single trans

## Fully purging a document from the transaction history when deleting it

You can use the optional flag `purge` to request the document history to be fully purged from the Content Lake. When using this option, all transactions related to the document will be immediately removed, consistent with our [data retention policy](https://www.sanity.io/security#67486cfc1499), and no longer show on [Studio's history experience](https://www.sanity.io/docs/user-guides/history-experience), nor on the [Content Lake history API endpoint](https://www.sanity.io/docs/http-reference/history).

The purge option is available on actions and mutations that delete or discard documents.

**Action example**

```
import {createVersionId} from '@sanity/id-utils'

const documentId = 'document-id'
await client.action({
  actionType: 'sanity.action.document.version.discard',
  versionId: createVersionId('version-id', documentId),
  purge: true, // optionally pass to delete history
});
```

**Mutation example**

```json
const { dataset } = client.config();
await client.request({
  uri: `/data/mutate/${dataset}`,
  method: 'POST',
  body: {
    mutations: [
      {
        delete: {
          id: '123',
          purge: true
        }
      }
    ]
  },
});
```

## Deleting multiple documents by query

By submitting a GROQ `query` instead of an id, multiple documents can be deleted in a single mutation. 

```json
const { dataset } = client.config();
await client.request({
  uri: `/data/mutate/${dataset}`,
  method: 'POST',
  body: {
    mutations: [
      {
        delete: {
          query: "*[_type == 'feature' && viewCount < $views]",
          params: {
            views: 5
          },
        }
      }
    ]
  },
});
```

Deletes all documents of type "feature" where the `visitCount` is less than 5. See the GROQ documentation for valid queries.

> [!WARNING]
> Gotcha
> A mutation that specifies a query can only operate on up to 10,000 documents! This means that a mutation based on a query such as `*[_type == "article"]` is in fact executed as if the query were written `*[_type == "article"][0..10000]`. 
> To perform mutations on larger sets of documents, you will need to split them into multiple transactions. We recommend paginating by `_id`. 
> E.g., `*[_type == "article" && _id > $lastId]`. This works because GROQ will, by default, sort documents by ascending `_id`. Since each transaction returns the `_id`s of modified documents, you can use the last returned `_id` as the next `lastId` parameter.

## Patching multiple documents by query

By submitting a query instead of an id, you may patch multiple documents at once. This will reset the score and add a bonus point to any person that has more than 100 points:

```json
const { dataset } = client.config();
await client.request({
  uri: `/data/mutate/${dataset}`,
  method: 'POST',
  body: {
    mutations: [
      {
        patch: {
          query: "*[_type == 'person' && points >= $threshold]",
          params: {
            threshold: 100
          },
          dec: {
            points: 100
          },
          inc: {
            bonuses: 1
          }
        }
      }
    ]
  },
});
```

## Set your own transactionId

Mutations automatically set a transactionId, but if you need that value to be more predictable or queryable, you can define your own when dispatching actions or posting mutations. Note that the transactionId must be unique in the dataset.

**Action**

```
await client.action([{
    actionType: 'sanity.action.document.create',
    publishedId: 'example-id',
    attributes: {
      _id: `drafts.example-id`,
      _type: 'article',
      title: 'Title of the document',
    },
    ifExists: 'fail',
  }], 
  // take note of the new object passed as a second value to `action`
  {
    transactionId: 'custom-identifier',
  });
```

**Mutation**

```
const { dataset } = client.config();
await client.request({
  uri: `/data/mutate/${dataset}`,
  method: 'POST',
  body: {
    mutations: [
      {
        delete: {
          id: '123'
        }
      }
    ],
    transactionId: 'custom-transaction-id'
  },
});
```

## Learn more

To dive deeper, you can explore the reference documentation for the Actions and Mutations APIs.

#### HTTP reference

[Actions API reference](https://www.sanity.io/docs/http-reference/actions)
Reference documentation for the Actions HTTP endpoint.

[Mutation API reference](https://www.sanity.io/docs/http-reference/mutation)
Reference documentation for the Mutatation HTTP reference.



# Introduction

Assets are files, such as images, PDFs, and other media, that exist alongside your structured content in Sanity. [Studio](https://www.sanity.io/docs/studio) and [Media Library](https://www.sanity.io/docs/media-library) provide intuitive interfaces for uploading assets, while the image pipeline offers powerful tools for manipulation.

With Sanity's asset management, you can:

- **Upload, store, and manage** various file types, including images, documents, audio files, and more. 
- **Transform images** on the fly with parameters for resizing, cropping, and format conversion.
- **Extract metadata** from images, including color palettes, camera information, and location data.
- **Deliver content globally** through Sanity's high-performance CDN.

For details on how to display images, see [Presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images).

## Core concepts

Sanity's asset system is built around several key concepts that work together to provide a complete solution for managing digital assets.

### Asset types

Sanity currently supports two primary asset types:

- **Image assets**: For many kinds of images including JPG, PNG, WebP, SVG, TIFF, GIF, HEIF, AVIF (8-bit), [and more](https://www.sanity.io/docs/content-lake/technical-limits).
- **File assets**: For all other file types such as PDFs, audio files, videos, documents, and archives.

#### Video assets

Content Lake treats video assets as files. If you'd like additional options, like streaming, for your video assets you have a few choices:

- Third party plugins: Sanity supports third-party options for managing video, including an [official Mux integration](https://www.mux.com/docs/integrations/sanity). You can find additional integrations in the [Sanity Exchange](https://www.sanity.io/exchange).
- Media Library (Enterprise only): Media Library customers can manage their [video assets in Media Library](https://www.sanity.io/docs/media-library/working-with-video).

### Where assets are stored

Every asset is a document, but those documents don't all live in the same place. An asset document in your project's dataset is either stored in that dataset or linked to it from somewhere else.

- **Dataset-stored assets**: Uploaded through Sanity Studio or the Assets API. The document has no `media` field, and often no `source` field either, because `source` is optional.
- **Media Library assets**: Stored in your organization's Media Library, which holds them in its own dataset. Linking one to a project creates a separate `sanity.imageAsset` document in your dataset that carries a `media` reference back to the library.
- **Custom asset source assets**: Added by a third-party integration, which sets `source.name` to its own name. The file may be hosted outside your dataset.

> [!NOTE]
> Check media, not source
> The `source` field is optional, and some Media Library links don't carry it. The `media` reference is the reliable signal: its `_ref` has the form `media-library:LIBRARY_ID:ASSET_INSTANCE_ID`. Treat a `source.name` of `sanity-media-library` as a confirmation, not as the test.

To find the image assets in a dataset that aren't linked from Media Library, query for the documents that fail both tests:

**Find assets not linked from Media Library**

```groq
*[
  _type == "sanity.imageAsset" &&
  ( !defined(source.name) || source.name != "sanity-media-library" ) &&
  ( !defined(media._ref) || !string::startsWith(media._ref, "media-library:") )
]{ _id, originalFilename, source, media }
```

### Image pipeline and asset CDN

The image pipeline allows you to transform images on-the-fly by appending query parameters to image URLs. This enables resizing, cropping, format conversion, and other manipulations without creating multiple versions of the same asset.

All assets in Sanity are served through a [global content delivery network (CDN)](https://www.sanity.io/docs/content-lake/api-cdn). This ensures fast loading times for users worldwide. Assets are cached indefinitely based on content hashes, meaning any content changes generate new URLs automatically. Combined with the image pipeline, it can cache specific transformations of your images.

[Image transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls)
Reference documentation for image transformations.

[Asset CDN](https://www.sanity.io/docs/apis-and-sdks/asset-cdn)
Describes the CDN used for delivering assets

### Asset metadata

Images in Sanity can include rich metadata that's generated from the file during upload:

- **Always included**: Dimensions, transparency information (hasAlpha, isOpaque).
- **Included by default**: Placeholders (lqip, blurHash) and color palette information.
- **Optional**: Camera data (exif) and location information.

This metadata can be used to enhance your application with features like color-based search, loading placeholders, or location-based filtering. Your schema defines which metadata is included in the asset document. 

[Image metadata](https://www.sanity.io/docs/apis-and-sdks/image-metadata)
This article takes a closer look at the types of metadata available for images and the values they might return.

[Image](https://www.sanity.io/docs/studio/image-type)
Schema type for uploading, selecting, and editing images. 

### Managing assets

Depending on your team's needs, you can manage assets in multiple ways.

For single project, smaller teams that just only need the occasional image or file linked in a document, you can upload straight from Studio. The [Media Plugin](https://www.sanity.io/plugins/sanity-plugin-media) is also available for further control.

For organizations requiring centralized asset management across multiple projects, [Media Library](https://www.sanity.io/docs/media-library) enables teams to create custom groupings, filter and sort, and maintain a single source of truth for all assets.

For programmatic control, you can query asset documents directly using GROQ. All assets are represented as documents in your dataset (or Media Library's dataset) with types like `sanity.imageAsset` or `sanity.fileAsset.` You can search, filter, and manipulate them through the Query API just as you would with other document types. Learn more in the [Upload, query, and delete assets](https://www.sanity.io/docs/content-lake/manage-assets) guide.





# Upload, query, and delete assets

In cases where the UI doesn't offer enough control or automation, you can use Sanity's APIs to interact with assets. 

The techniques in this guide apply to assets stored in a project's dataset. Reference the *Managing assets* portion of the [Media Library documentation](https://www.sanity.io/docs/media-library) for details on working with Media Library assets.

For details on rendering and transforming assets for your front end applications, see the [Presenting Images](https://www.sanity.io/docs/apis-and-sdks/presenting-images) documentation.

## Upload an asset

> [!WARNING]
> Gotcha
> The path of an asset is in part determined by the result of hashing the content of the asset. If the same asset is uploaded multiple times, but with different filenames, only one asset will be created. For example, if `image.jpg` and `image-copy.jpg` are the same image, uploading both will only create one asset.

In cases where uploading assets in the UI is impractical, like batch uploads or migrations, you can use the API directly.

**JS Client (Node.js)**

```typescript
import {createClient} from '@sanity/client'
import {basename} from 'path'
import {createReadStream} from 'fs'

const client = createClient({
  projectId: 'myProjectId',
  dataset: 'myDatasetName',
  apiVersion: '2021-08-29',
  token: 'myToken'
})

const filePath = '/Users/mike/images/bicycle.jpg'

client.assets
  .upload('image', createReadStream(filePath), {
    filename: basename(filePath)
  })
  .then(imageAsset => {
    // Here you can decide what to do with the returned asset document. 
    // If you want to set a specific asset field you can with the following:
    return client
      .patch('some-document-id')
      .set({
        theImageField: {
          _type: 'image',
          asset: {
            _type: "reference",
            _ref: imageAsset._id
          }
        }
      })
      .commit()
  })
  .then(() => {
    console.log("Done!");
  })
```

**From within Studio (Browser)**

```typescript
import {useClient} from 'sanity'
import {basename} from 'path'
import {createReadStream} from 'fs'

// ... omitted for brevity
// inside a React component
const client = useClient({apiVersion: '2025-02-19'})

const file = new File(['foo'], 'foo.txt', {type: 'text/plain'})
// Upload it
client.assets
  .upload('file', file)
  .then((document) => {
    console.log('The file was uploaded!', document)
  })
  .catch((error) => {
    console.error('Upload failed:', error.message)
  })
```

**curl**

```sh
curl \
  -X POST \
  -H 'Content-Type: image/jpeg' \
  --data-binary "@/Users/mike/images/bicycle.jpg" \
  'https://myProjectId.api.sanity.io/v2021-06-07/assets/images/myDataset'

```

**Asset response shape**

```json
{
  "_id": "image-abc123_0G0Pkg3JLakKCLrF1podAdE9-538x538-jpg",
  "_type": "sanity.imageAsset", // type is prefixed by sanity schema
  "assetId": "0G0Pkg3JLakKCLrF1podAdE9",
  "path": "images/myproject/mydataset/abc123_0G0Pkg3JLakKCLrF1podAdE9-538x538.jpg",
  "url": "https://cdn.sanity.io/images/myproject/mydataset/abc123_0G0Pkg3JLakKCLrF1podAdE9-538x538.jpg",
  "originalFilename": "bicycle.jpg",
  "size": 2097152, // File size, in bytes
  "metadata": {
    "dimensions": {
      "height": 538,
      "width": 538,
      "aspectRatio": 1.0
    },
    "location":{ // only present if the original image contained location metadata
      "lat": 59.9241370,
      "lon": 10.7583846,
      "alt": 21.0
    }
  }
}
```

#### Resources

[Assets API reference](https://www.sanity.io/docs/http-reference/assets)
Upload images and files to Content Lake, and link Media Library assets to your dataset.

[@sanity/client](https://reference.sanity.io/_sanity/client/)
Documentation for the @sanity/client library

## Query and browse assets

To browse project assets from the Studio interface, install the [Sanity Media plugin](https://www.sanity.io/plugins/sanity-plugin-media). This adds a new tool to the toolbar and enables Studio users to browse and manage assets. 

You can also query all images with GROQ using the following query:

**Images**

```groq
*[_type == "sanity.imageAsset"]
```

**Files**

```groq
*[_type == "sanity.fileAsset"]
```

Run GROQ queries through [Vision](https://www.sanity.io/docs/content-lake/the-vision-plugin), the [client](https://reference.sanity.io/_sanity/client/), or with the [Query API](https://www.sanity.io/docs/http-reference/query).

You can also use this method to query [Media Library](https://www.sanity.io/docs/media-library) assets that have been linked to your dataset. 

If viewing asset data within another document type, you'll need to follow the asset's reference to view the metadata or URL. For example:

```groq
*[_type == 'post'] {
  mainImage {
    asset->
  }
}
```

## Deleting assets

Deleting an asset can be performed by deleting the associated asset document. 

```javascript
import {createClient} from '@sanity/client'
const config = {
  projectId: 'myProjectID',
  dataset: 'mydataset',
  apiVersion: '2021-08-29',
  token: 'myToken'
}
const client = createClient(config)
// Note: this is the _id of the asset document.
client.delete('image-abc123_0G0Pkg3JLakKCLrF1podAdE9-538x538-jpg')
  .then(result => {
    console.log('deleted image asset', result)
  })

```

It's important to note that while the file is deleted, the CDN might have your asset cached so it may not disappear immediately.

## Download assets

In order to download an asset you need to append `?dl=<asset-of-your-choice.jpg>` to the asset URL. If you leave the filename blank, the original filename will be used if present. If the original filename is not available, the id of the file will be used instead.

```javascript
// GROQ query

*[_type == "post"] {
  title,
  mainImage{
    asset->url
  }
}
// Then you can use the URL in HTML for example like this:
// <a href={`${mainImage}?dl=`}>Hero Image</a>
```



# Metadata

The `metadata` option for image fields controls which types of metadata Sanity extracts or generates from uploaded images and saves alongside the asset. 

Image assets in your Content Lake may include a range of helpful metadata. 

- **Always included:** Essential facts about your image, including height, width, aspect ratio, and information about transparency.
- **Included by default:** Useful information generated from the image on upload: minified placeholders and palette values.
- **Excluded by default: **Potentially private information about the place and circumstances under which the image was created, held in the `exif`, `image`, and `location` values.

An example of an image field with every metadata option specified looks as follows:

```javascript
{
  name: 'metaImage',
  title: 'Image with metadata',
  type: 'image',
  options: {
    metadata: [
      'blurhash',   // Default: included
      'thumbhash',  // Default: included
      'lqip',       // Default: included
      'palette',    // Default: included
      'image',      // Default: not included
      'exif',       // Default: not included
      'location',   // Default: not included
    ],
  },
},
```

There are three additional metadata options that are always included and cannot be disabled: `dimensions`, `hasAlpha`, and `isOpaque`. Specifying an invalid option in the `metadata` array (including any of those three terms) will throw an error.

The metadata fields fall into one of three "default behaviors": **always included**, **included by default**, and **excluded by default**. We'll look at each default setting and the metadata fields that adhere to it.

> [!WARNING]
> Gotcha
> Some metadata is computed synchronously on upload, while other values are added *asynchronously*. If your query for image metadata returns unexpectedly empty, wait a moment and try again.

> [!WARNING]
> Gotcha
> Metadata is applied to an image asset when the image is uploaded and based on the schema settings at that time. If a `metadata` array is set to include `exif` or `location` data, **changing the schema later will not remove those details**. If removing those details is desired, you can do so with a script or using the [Media browser plugin](https://www.sanity.io/plugins/sanity-plugin-media), among other options. Likewise, adding options to the `metadata` array will not add those details to images previously uploaded.

## Alpha channel, opaqueness, and dimensions

> [!NOTE]
> Always included
> These values are *always available*, and you do not need to ask for them. In fact, they are not [valid options](https://www.sanity.io/docs/studio/image-type) in the `options.metadata` array, so including them will throw an error.

### `hasAlpha`

`hasAlpha` will return `true` if the image has an alpha channel, even if unused.

### `isOpaque`

`isOpaque` returns `true` if the image is fully opaque (i.e., has no transparency).

### `dimensions`

The `dimensions` object contains the numeric values `aspectRatio`, `height`, and `width`, which together describe the physical features of the image. A photo taken in portrait mode might yield the following payload:

```json
{
  "dimensions" : {
    "_type": "sanity.imageDimensions",
    "aspectRatio": 0.75,
    "height": 4032,
    "width": 3024
  }
}
```

## Placeholders and colors

> [!NOTE]
> Included by default
> These values are *available by default.* If you don't ask for any metadata at all (that is, if you don't specify a `metadata` array), you will get these values. **Beware though:** If you *do* specify a `metadata` array and explicitly leave these out, they will not be returned.

### `lqip`, `blurHash`, and `thumbHash`

Sanity will generate low-fidelity representations of your images automatically. These are useful for creating placeholders for loading images in your frontend. These downsampled previews come in three different flavors: LQIP, BlurHash, and ThumbHash.

**LQIP** (Low-Quality Image Preview) is a 20-pixel-wide version of your image (height is set according to aspect ratio) in the form of a base64-encoded string and can be used as-is in your frontend, as shown below. A typical value for `lqip` might look like this:

```json
"lqip": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAbCAYAAAB836/YAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGE0lEQVRIiV2W6VNb1xnGbw1oQ/sCkgABWgAZEPsiFoFALJIQi9gECASCYtmsNjaYFAzjOCYkxonjpu6Stc20+dbMtDP50D/u1zkXTNJ++M3Rvfe9z/OeM6P3uZJapUCgUSso1CjRFirR61QY9WpMBjVmgwazUSOv4tqgU6PXquS6Qo0CtVqBSlWAUlmAQpGPJItolOgKVeh1alnIYtRgMxVSZNZSbNHdIq6tpkJZXNT9v7BaVYCk16ox6tSY9BosxkJsZi12qx6HTY+zyEBpsZEyuwmX3USZ3SjfK7bq5DpRb9RrfiWsRLIYtFiNWorMOuxWA84iI6XyyyZcDjMVJVbcpTbcThOVDgMuh0l+LuqKLXqsJh0mQ6F8FDqtCsluMeCwGSkpFgJWKkpsuMuK8ZXbqa504veUUON24iu14C0x4i2z4XYVy0aldjMOm0FuxnzTrSREymURO74KJzWeUuqqymn0u2mp89IaqKKlzkNjVQkNPgf1VS5qfS7ZTBi7nFacRabrbs06JF9FCdXuUmp95TT4PbJAV0stoY4A4WAjke5mBoKNdDf5CDZ4CDbX0NZQTYPfjd9bhrfcIe9KdCuOQWq466G5zkdHo5/e9gCRnhbGBoMkR0PMxsMsTESYGwsT7W1mKFhPtL9druluraOlvor66gq5IU+5ncqyIqSetnr6g40Mh9oYH+phPjHA2lyc+8tJdtdmOdhcYCczTSoWYmakm+WpYbkmPtAp76CjyU9TnZf6mgruVrmQ4oNBJkd6SI0PsjYXY2d1huOtZT58vMnl8RavTnY528uSnR4hOzPCo415tjPTLCdHmBzuZai3ld6OAMGWu7Q3VSMtTkbIzI6SS09wsJni/GGW16c7/OniiO9en/K3N+d8frbP9sok+2vTfPTkHmf76+ytzbI6E2U62kd8sJPh/jYioRak3y6NsbU6xZPcwrXYsx2+/vSYH798zj+/uuRf313xzdUJR7kFTnczfPniMZ892+V0d1XeTWYmyvz4ADNj/STjIaTd9SQHuTlO91a4fHqPdx8d8Pe35/z09SU///AZ//nxLX99c8bR/QU+PFjnm1cf8O7lIReHOQ5zi/LO1uajrMyNkJ4ZRnq4OcPRgxRn+xk+Oc7xx5eP+cfvz/npqwv+/f0rfv7hNX++eMR2OsrJgxR/+fgJf3hxwMXhJk/vL7KdmWIzPcbGUpzsYhRpd2OKg3uz/G57kRePs7w52+Hbq2O+vXrKuxd7fH91yNvTLHupXk42E3xxmuP5foaDbJKH2Wm2M5NspuNsLMVkpM3lOA8yCfayUzzNzfP80Sqfn23z8dE6O+kYJ/eTfHGU4nI7xqf7Sc63ZslM9LEQ6ya3GCeXTpCdH2V1foS11AjS4lSYdHKQ1dkh7i3GeJhN8mw3zQcPFlhMhJgZauXR0gAvtxKcbMRYigcZ7W5gLtrDxnyM1dlRFicHSE2EZaTYQBvxwXYmhjuZjfeykhwktxRnJzPBytQgsb5mUtEgO+lRslP9xEJNJAbaWZqIsDQZYSYWYmK4i0Skk7FIJ1JXq5/uVvG3q2WgK0C0v5Wp4S5SiX6WJgdZGA+zPBVhfW6UtBCI9jI92ktypId4uIOhnmbCwQb6OgOEOuuR6qtdCAI15TTVVtIe8NHd4ifcGWA01EJisJPJ4W6Z8UiQeLid4Z5m+jrq6Wr20y6mUb2X5jovTXUepMpSK9fY8LiKqap0cNdbSqBaGLhpC3jpaKyis7GajsZq2gI+mmvdNNRUUOsrw+8ppcYjJpZTRvolM8TYN1AiRr7DTLnTcmviqxDD1kFVhUMevF5XMe6yIipLxEC2yLUup1l+TzLoVAjkXLlJOJEVFtN1vhRb9dhv8uV/sIlJr5fzx25935QWSa3KR60ukJNLpJ8Im/dJJlaRE7KhXkTqdSIKrDerRTQhx60IOjWSoiAPhSIPpTIflbLgJmPzUSpuUObL8ai5MdWJ3NYqZROB+C3QCQqVSHl5vyE//w4FBXm3iGvBL8/uIIyvxfNvxbUa8XEguDYUyILvyc+7FhHcuSPd8v6eEFYq8m5Ff418dKp8/gutMmaHeMkQagAAAABJRU5ErkJggg=="
```

And can be used like this:

```html
<!-- 
  The LQIP value is actual image data
  encoded into a base64-string which can be 
  used directly as the src property of an img tag!
  Remember to set the height and width 
  properties, though, or it'll be very small
-->
<img
  height="100"
  width="100"
  src="data:image/png;base64,iVBORw0KGgo[...50 lines of this stuff omitted for brevity...]Jggg=="
/>
```

**BlurHash** is a more [advanced method](https://blurha.sh/) of creating a lightweight image preview that can give a superior result and comes in a more concise format. The trade-off is that you'll need to decode the value using a [helper library](https://github.com/woltapp/blurhash) before use. A `blurHash` value might look something like this:

```json
"blurHash": "d79Z$I-o4:IoxaofR*WC00Io?GxtM{Rkt7s:~VxaNGRk"
```

Example of use in a JavaScript project: 

```javascript
import { decode } from "blurhash";

const pixels = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 32, 32);

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
document.body.append(canvas);
```

**ThumbHash** is a [similar approach](https://evanw.github.io/thumbhash/) to BlurHash that also encodes the approximate aspect ratio of the image and supports transparency. Sanity stores the value as a base64-encoded string. As with BlurHash, you'll need to decode the value using a [helper library](https://github.com/evanw/thumbhash) before use. A `thumbHash` value might look something like this:

```json
"thumbHash": "tigGFISGr2Wbhdc+d5r0MEUPUw=="
```

Example of use in a JavaScript project:

```javascript
import { thumbHashToDataURL } from "thumbhash";

// The stored value is base64-encoded, so decode it to bytes first
const binary = atob("tigGFISGr2Wbhdc+d5r0MEUPUw==");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));

const image = new Image();
image.src = thumbHashToDataURL(bytes);
document.body.append(image);
```

### `palette`

Sanity will generate a color palette by analyzing your image. Along with the dominant swatches, a collection of suggestions for colors that contrast nicely with them is returned, as well as a numeric indication of how prominently each color is represented in the image. A palette object might look like:

```json
{
  "_type": "sanity.imagePalette",
  "darkMuted": {
    "_type": "sanity.imagePaletteSwatch",
    "background": "#653a2d",
    "foreground": "#fff",
    "population": 3.8,
    "title": "#fff"
  },
  "darkVibrant": {
    "_type": "sanity.imagePaletteSwatch",
    "background": "#c4850b",
    "foreground": "#fff",
    "population": 0.08,
    "title": "#fff"
  },
  "dominant": {
    "_type": "sanity.imagePaletteSwatch",
    "background": "#d5c3ba",
    "foreground": "#000",
    "population": 7.17,
    "title": "#fff"
  },
  "lightMuted": {
		// [...] truncated for brevity
  },
  "lightVibrant": {
		// [...] truncated for brevity
  },
  "muted": {
		// [...] truncated for brevity
  },
  "vibrant": {
		// [...] truncated for brevity
  }
}
```

> [!TIP]
> Protip
> If `lqip`, `blurHash`, `thumbHash`, or `palette` values are absent from your image asset, it's likely that at the time the image was uploaded, a `metadata` array was specified and the value in question was not included in the array.

## Camera and location

> [!NOTE]
> Excluded by default
> These values are *not included* in your image metadata unless a `metadata` array is specified and these values are specifically requested. This is because camera and location data generally contain private or identifying information.

### `image`

This field contains basic information about the image such as camera make and model, resolution, and orientation. For more detailed information, use the `exif` field. The following is an example readout:

```json
{
  "_type": "sanity.imageExifTags",
  "Make": "Apple",
  "Model": "iPhone 6",
  "Orientation": 1,
  "XResolution": 72,
  "YResolution": 72,
  "ResolutionUnit": 2,
  "Software": "Photos 1.0",
  "ModifyDate": "Sat Feb 28 2015 17:13:57 GMT-0800 (PST)",
  "ExifOffset": 198,
  "GPSInfo": 1008
}
```

### `exif`

Short for [Exchangeable Image File](https://en.wikipedia.org/wiki/Exif) format, this field contains information about the image file itself and the conditions under which it was produced, typically camera settings. Exactly what data is contained here depends on the origins of the file. Below is an example readout of the Exif object for a photo taken with an iPhone camera:

```json
{
  "_type": "sanity.imageExifMetadata",
  "ApertureValue": 1.6959938128383605,
  "BrightnessValue": 1.7619172145845785,
  "DateTimeDigitized": "2020-03-19T12:25:17.000Z",
  "DateTimeOriginal": "2020-03-19T12:25:17.000Z",
  "ExposureBiasValue": 0,
  "ExposureMode": 0,
  "ExposureProgram": 2,
  "ExposureTime": 0.020833333333333332,
  "FNumber": 1.8,
  "Flash": 16,
  "FocalLength": 4.25,
  "FocalLengthIn35mmFormat": 26,
  "ISO": 250,
  "LensMake": "Apple",
  "LensModel": "iPhone 11 Pro back triple camera 4.25mm f/1.8",
  "LensSpecification": [
    1.5399999618512084,
    6,
    1.8,
    2.4
  ],
  "MeteringMode": 5,
  "PixelXDimension": 4032,
  "PixelYDimension": 3024,
  "SceneCaptureType": 0,
  "SensingMethod": 2,
  "ShutterSpeedValue": 5.586024712398807,
  "SubSecTimeDigitized": "900",
  "SubSecTimeOriginal": "900",
  "SubjectArea": [
    2323,
    710,
    1410,
    1412
  ],
  "WhiteBalance": 0
}
```

### `location`

This field, as you might expect, returns geographical data, usually representing the coordinates where the photo was taken. It conforms to the specification of the [geopoint](https://www.sanity.io/docs/studio/geopoint-type) schema type, and might look like this:

```json
{
  "_type": "geopoint",
  "alt": 168.32554596241746,
  "lat": 59.948811111111105,
  "lng": 10.867780555555557
}
```





# Transformations

## The anatomy of the image URL

This article provides a detailed rundown of all the options for transforming images with Sanity. You can find a general introduction to our image pipeline and tools in [Presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images).


Let's start by dissecting this Sanity image URL:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg
```

- `https://cdn.sanity.io/images/` is the common base for all Sanity image URLs. 
- `zp7mbokg` is the project ID 
- `production` is the dataset name
- `G3i4emG6B8JnTmGoN0UjgAp8` is the asset ID and the asset metadata document `_id`
- `300x450` is the width and height of the original image
- `jpg` is the file format of the *original* asset file

The image URLs can always be found in the asset metadata document referred to in an asset reference. Still, you don't have to fetch this document as the asset document ID contains all the information and represents a stable, documented interface you can trust.

The asset ID corresponding to the URLs above looks like this: `"image-G3i4emG6B8JnTmGoN0UjgAp8-300x450-jpg"`.  It provides the name, dimensions, and format. Given the project ID and dataset name, you have every piece you need to assemble the URLs without fetching the asset document:

```text
https://cdn.sanity.io/images/<project id>/<dataset name>/<asset name>-<original width>x<original height>.<original file format>
```

> [!TIP]
> Prettier image file names
> While the naming format described above contains lots of info about the original asset, it does leave something to be desired for readability and memorability when read with human eyes. If you'd like to specify a more legible file name you can do this by appending `/vanity-name.png` after the actual file name provided by Sanity. (Substituting both name and extension to fit your actual case, of course.)

This represents the base URL. If you fetch this, you will be served the original asset. This potentially uses a lot of bandwidth as content managers are advised to upload full-resolution assets. With the Sanity image pipeline, you can scale, crop, and process images on the fly based on URL parameters. E.g. by appending `?h=200` to the base URL, you instruct Sanity to scale the image to be 200 pixels tall:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?h=200
```

You can specify any number of parameters. This will extract a rectangle from the image starting at 70 pixels from the left and 20 pixels from the top at a width of 120 pixels and a height of 150 pixels, scale it to 200 pixels tall, and blur it:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?rect=70,20,120,150&h=200&blur=10
```

Even though the Sanity image backend is fast, you get a tremendous performance boost if your front end limits the number of sizes and crops you ask for. Sanity will cache the result in the global CDN, and if we see the same URLs again, we serve the same data directly from the edge cache closest to the user.

> [!WARNING]
> Gotcha
> Non-integer values for parameters expecting integers may cause performance issues or timeouts. It is recommended that you always use integer values when the parameter calls for it (e.g., `w` and `h`), including when returning calculated values.
> `&h=200` - Correct
> `&h=200.0` - May be problematic

## Supported image types

While the [Image schema type](https://www.sanity.io/docs/image-type) supports a wide range of [image formats](https://www.sanity.io/docs/content-lake/assets), transformations are limited to JPEG, PNG, WebP, PJPG, TIFF, AVIF, and GIF. For all other formats, you should convert the image to one of the supported file types before performing additional transformations.

> [!TIP]
> Protip
> The image pipeline supports transforming animated GIFs up to a maximum size of 256 megapixels, calculated as (width x height x frame count) / 1,000,000. If an animated file exceeds this limit, only the first frame is returned. See [Technical limits](https://www.sanity.io/docs/content-lake/technical-limits) for more about asset limits.

## The URL parameters

> [!WARNING]
> Gotcha
> Small images get scaled up to the width or height you specify. To avoid this use `&fit=max`.

#### Properties

**auto** (string)

Set auto=format to automatically return an image in in the most optimized format supported by the browser as determined by its Accept header. To achieve the same result in a non-browser context, use the fm parameter instead to specify the desired format, for example fm=webp.

**bg** (string)

Fill in any transparent areas in the image with a color. The string must be resolve to a valid hexadecimal color (RGB, ARGB, RRGGBB, or AARRGGBB). E.g. bg=ff00 for red background with no transparency.

**blur** (integer)

Blur 1-2000.

**crop** (string)

Use with fit=crop to specify how cropping is performed:

top, bottom, left and right: The crop starts from the edge specified. crop=top,left will crop the image starting in the top left corner.

center: Will crop around the center of the image

focalpoint: Will crop around the focal point specified using the fp-x and fp-y parameters.

entropy: Attempts to preserve the "most important" part of the image by selecting the crop that preserves the most complex part of the image.

**dl** (string)

Configures the headers so that opening this link causes the browser to download the image rather than showing it. The browser will suggest to use the file name you provided.

**dlRaw** (string)

As dl but requests the original file/image asset. Requires authentication.

**dpr** (number)

Specifies device pixel ratio scaling factor. From 1 to 3.

**fit** (string)

Affects how the image is handled when you specify target dimensions.

clip: The image is resized to fit within the bounds you specified without cropping or distorting the image.

crop: Crops the image to fill the size you specified when you specify both w and h

fill: Like clip, but any free area not covered by your image is filled with the color specified in the bg parameter.

fillmax: Places the image within box you specify, never scaling the image up. If there is excess room in the image, it is filled with the color specified in the bg parameter.

max: Fit the image within the box you specify, but never scaling the image up.

scale: Scales the image to fit the constraining dimensions exactly. The resulting image will fill the dimensions, and will not maintain the aspect ratio of the input image.

min: Resizes and crops the image to match the aspect ratio of the requested width and height. Will not exceed the original width and height of the image.

**flip** (string)

Flipping. Flip image horizontally, vertically or both. Possible values: h, v, hv

**fm** (string)

Convert image to jpg, pjpg, png, or webp.

Note that avif is not a valid option for this parameter as AVIF transformations are generated asynchronously. See the AVIF format details below.

This property also accepts a value of json, which does not convert the image but returns information about the image including width, height, frame count, content length, and content type.

**fp-x** (coordinate)

Focal Point X. Specify a center point to focus on when cropping the image. Values from 0.0 to 1.0 in fractions of the image dimensions. (See crop)

**fp-y** (coordinate)

Focal Point Y. Specify a center point to focus on when cropping the image. Values from 0.0 to 1.0 in fractions of the image dimensions. (See crop)

**frame** (integer)

The frame of an animated image. The only valid value is 1, which is the first frame.

**h** (integer)

Height of the image in pixels. Scales the image to be that tall.

**invert** (boolean)

Invert the image.

**max-h** (integer)

Maximum height. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**max-w** (integer)

Maximum width in the context of image cropping. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**min-h** (integer)

Minimum height. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**min-w** (integer)

Minimum width. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**or** (integer)

Orientation. Possible values: 0, 90, 180 or 270.Rotate the image in 90 degree increments.

**pad** (integer)

The number of pixels to pad the image.  Applies to both width and height.

**q** (integer)

Quality 0-100. Specify the compression quality (where applicable). Defaults are 75 for JPG and WebP.

**rect** (coordinates)

Crop the image according to the provided coordinate values (left, top, width, height). 

left: Number of pixels from the left of the image

top: Number of pixels from the top of the image

width: Width, in pixels, of the crop from the left value

height: Height, in pixels, of the crop from the top value

**sat** (number)

Saturation. The asset pipeline only supports sat=-100, which renders the image with grayscale colors.

**sharp** (integer)

Sharpen 0-100.

**w** (integer)

Width of the image in pixels. Scales the image to be that wide.

**cs** (string)

Output the image in a specifying color space.

Supported color spaces:

origin: render the image in the original color space

srgb: The default, output the image in web-friendly sRGB.

cmyk: Output the image in the CMYK color space

b-w: Output will be black and white.

## Vanity filenames

In addition to the query parameters, you can also append a `/my-filename.jpg` style vanity filename to the end of the URL. This will set the filename if users save the image. For example:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg/easier-to-read-name.jpg
```

## AVIF transformations

Images that have the query parameter `auto` set to `format` and are requested from a browser that supports the AVIF format will potentially get an AVIF returned. 

There are a few exceptions/quirks:

The first few requests for an AVIF may get the "second best option" (WebP if supported, otherwise PNG/JPG depending on the source image). Subsequent requests will eventually get an AVIF back. This is done to ensure a speedy response, since encoding AVIFs is a slow process. 

Image requests made prior to the AVIF rollout may already be cached in our CDN and will not return an AVIF response until they expire/fall out of the cache. In other words: if you are not seeing AVIF images being returned, don't worry — they should eventually return AVIF. 

You can use `curl` to verify the behavior:

```sh
# Replace the URL with an actual URL from your project.
# Remember to include `?auto=format`!
curl -sS -I \
  -H 'accept: image/avif,image/webp,image/*' \
  'https://cdn.sanity.io/images/:projectId/:dataset/:filename?auto=format' \
  | grep 'content-type:'
```

On the first request, you will likely see `image/webp` returned. After waiting 30 seconds, run the same command again, and you should see `image/avif`. If you don't, wait a little longer and retry. If you still do not see AVIF, ensure that the accept header includes `image/avif` (before other formats) and that the query parameters includes `auto=format`.

> [!WARNING]
> Gotcha
> Because AVIF transformations are generated asynchronously, you cannot explicitly request AVIF transformations using the `fm` query parameter. Instead, use the `accept` header as described above.

## Troubleshooting images for social and Open Graph previews

When a social platform or chat app renders a link, its crawler fetches the URL in your `og:image` and `twitter:image` tags. Those crawlers are less tolerant of modern image formats than browsers are, so a preview card with no image is often a format problem rather than a missing tag.

With `auto=format`, the Image API picks the format from the `Accept` header of whoever requests the URL: a browser that advertises AVIF gets AVIF, and a client that sends `Accept: */*` gets the source format. AVIF transformations are also generated asynchronously, so the first few requests for one return the second-best format. A crawler that fetches an image once can end up with a different format than the one you see in your browser.

To make crawler-facing URLs predictable, pin the format with `fm`. It takes precedence over `auto`, so you can keep `auto=format` for your on-page images and set `fm=jpg` only in the URLs your metadata tags point to:

```text
https://cdn.sanity.io/images/PROJECT_ID/DATASET/ASSET_ID-2400x1260.jpg?w=1200&h=630&fit=crop&fm=jpg
```

Set `w` and `h` to the dimensions the platform expects, and add `fit=crop` so the image fills them exactly. This example targets a 1200x630 card, a widely supported size, but each platform documents its own requirements.

> [!WARNING]
> Gotcha
> Platforms cache preview data, including failed fetches, so an existing post can keep showing the old result after you fix the image. Changing the URL parameters produces a new URL, which the platform fetches as a new image.

## Read more

[Client library for generating urls](https://github.com/sanity-io/image-url)





# IIIF

The Sanity asset pipeline supports the [International Image Interoperability Framework API (IIIF)](https://iiif.io/). The URL schema for IIIF supported APIs looks like this: `{scheme}://{server}{/prefix}/{identifier}`

For the Sanity asset pipeline, that translates to:

`https://cdn.sanity.io/images/{projectId}/{dataset}/iiif/{identifier}`

You can consult the IIIF Image API 2.0 specification to find all the identifiers and different ways of querying images in your dataset.

## Examples

### General image info

If you go to [https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/info.json](https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/info.json) it will return this JSON structure:

```json
{
  "@context": "http://iiif.io/api/image/2/context.json",
  "@id": "https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg",
  "protocol": "http://iiif.io/api/image",
  "profile": ["http://iiif.io/api/image/2/level2.json"],
  "width": 500,
  "height": 750,
  "sizes": [
    { "width": 50, "height": 75 },
    { "width": 200, "height": 300 },
    { "width": 600, "height": 900 },
    { "width": 1200, "height": 1800 },
    { "width": 2000, "height": 3000 }
  ],
  "tiles": [{ "width": 512, "scaleFactors": [1, 2, 4, 8, 16] }]
}

```

### Default, full-size

Identifier: `/full/full/0/default.jpg`

[https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/full/full/0/default.jpg](https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/full/full/0/default.jpg)

![The late, great actor Alan Rickman smiling awkwardly at the camera](https://cdn.sanity.io/images/3do82whm/next/d798944dd22b8ecf96704607b8e7d7d09ea828fd-500x750.png)
*Alan Rickman in full proportions*

### Square crop, 75% size, gray color, png format

Identifier: `square/pct:25/0/gray.png`

[https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/square/pct:75/0/gray.png](https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/square/pct:75/0/gray.png)

![Alan Rickman in a square crop and grey color](https://cdn.sanity.io/images/3do82whm/next/e995baaa8b4536f9bccec63a9f1ec842caa30be2-375x375.png)
*Alan Rickman in a square crop and grey color*





# Importing data

> [!NOTE]
> Media Library available
> This guide outlines details for importing documents, including images and files, into a dataset. The Media Library allows images and files to be used in any dataset in your organization.
> For details on importing assets to a centralized library, review our guide on [importing assets](https://www.sanity.io/docs/media-library/importing-assets).

The recommended way of importing data is to use the [Command Line Interface](https://www.sanity.io/docs/apis-and-sdks/cli). You can run  `sanity datasets import --help` for a quick summary of syntax and options. Your other option is to use one of our client libraries and handle it yourself. 

> [!TIP]
> Validation is client-side only
> Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See [Schema validation and the Content Lake](https://www.sanity.io/docs/content-lake/schema-validation-and-the-content-lake) for details.

> [!WARNING]
> Avoid unexpected webhook and function invocations
> Consider disabling any webhooks and functions you might have that could cause high volumes of traffic to the receiving endpoint on importing data.

## Import using the CLI

The Sanity import tool operates on [newline-delimited JSON](https://github.com/ndjson/ndjson-spec) (NDJSON) files. Basically, each line in a file is a valid JSON-object containing a document you want to import.

Documents should follow the structure of your [data model](https://www.sanity.io/docs/studio/connected-content) – most importantly, the requirement of a `_type` attribute. The `_id` field is optional – but helpful – in case you want to make references or be able to re-import your data replacing data from an old import. `_id`s in Sanity are [usually a GUID](https://www.sanity.io/docs/content-lake/ids), but any string containing only letters, numbers, hyphens, and underscores are valid.

During import, all references are automatically set to *weak*, then flipped to *strong* after all documents are in place. This ensures that you can import documents that reference other documents in any order you like.

[Assets (images and files)](https://www.sanity.io/docs/content-lake/assets) are stored using references in Sanity. To make it easy to import these and refer to them within your documents, you can use a special `_sanityAsset` property where you would normally put a `_ref`. For instance, let's say you want your document to end up like this:

```javascript
{
  "_id": "movie_123",
  "_type": "movie",
  "title": "Rogue One",
  "poster": {
    "_type": "image",
    "asset": {
      "_ref": "image_234",
      "_type": "reference"
    }
  }
}
```

This is what your ready-to-import document should look like:

```javascript
{
  "_id": "movie_123",
  "_type": "movie",
  "title": "Rogue One",
  "poster": {
    "_type": "image",
    "_sanityAsset": "image@file:///local/path/to/rogue-one-poster.jpg",
  }
}
```

However, ndjson uses the newline character as delimiter (NDJSON == Newline Delimited JSON), therefore your ndjson file must be structured with one document on each line, like this:

```json
{"_id": "movie_123", "_type": "movie", "title": "Rogue One", "poster": {"_type": "image", "_sanityAsset": "image@file:///local/path/to/rogue-one-poster.jpg"}}
{"_id": "another_movie", "_type": "movie"}
{"_id": "yet_another_movie", "_type": "movie"}

```

Note that you need to prefix the asset URL with a type declaration – either `image@` or `file@`.

If your asset is on the Internet use `image@https://example.com/path/to/rogue-one-poster.jpg` instead of `image@file:///local/path/to/rogue-one-poster.jpg`.

> [!TIP]
> File URIs are absolute so include the entire path.

Once you have prepared your ndjson file, you can run the import using the Sanity CLI.

> [!NOTE]
> What should I import?
> In some cases you will want to import your ndjson file, such as when you've exported your dataset, made changes to the ndjson file, and are importing it back into the same dataset.
> In other cases you will want to compress your dataset back into a tarball / tar file (`.tar`, `.tar.gz`, or `.tgz`), which includes the ndjson file and your assets. You might take this approach when [migrating data](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) to a new dataset, as you'll want to maintain references to assets.
> If you're getting an import error like `Error: Error while fetching asset from "file://./images/<image-name>.<ext>": File does not exist at the specified endpoint`, you can either (1) make the filenames absolute or (2) import a tarball (including assets) rather than an ndjson file.

**npm**

```shell
npx sanity@latest datasets import <file> <targetDataset>
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets import <file> <targetDataset>
```

**yarn**

```shell
yarn dlx sanity@latest datasets import <file> <targetDataset>
```

**bun**

```shell
bunx sanity@latest datasets import <file> <targetDataset>
```



### Changes to the _updatedAt field

When you import documents that reference assets or other documents, Sanity initially preserves the value of the `_updatedAt` field of these documents.

However, references in documents are first imported as [weak references](https://www.sanity.io/docs/studio/reference-type), and strengthened later in the import process. To strengthen references, patch mutations are submitted for the containing documents.

**These patches run in new transactions, which sets _updatedAt to the time the patch executes successfully**. Documents without references will keep their original `_updatedAt`.

### Handling existing documents

The import will fail if an incoming document already exists in the dataset. A couple of options allow you to amend this:

- `--replace` Overwrite existing documents. If you specify `_id` in the imported data, this flag can be very useful. It will let you reimport stuff that you got wrong in an earlier pass.
- `--missing` Only create documents which don't exist, leave the rest alone.

The import will also fail if an asset is unavailable. This typically happens if the file isn't at the given path on your local system or the asset URL returns 404. You can tell the import *not* to fail on a missing asset by passing the `--allow-failing-assets` option.

> [!TIP]
> Protip
> Check out our [reference-type docs](https://www.sanity.io/docs/studio/reference-type) page for more ways on how to reference different documents.

## Import using a client library

If you prefer not to use our CLI import tool, you may of course do the import yourself with help from one of our client libraries.

There are some common pitfalls to keep in mind:

### Concurrency

While you may have thousands of documents to import, you shouldn't trigger thousands of requests in parallel. The API allows 25 mutations per second per IP, and requests over that limit return `429 Too Many Requests`. `@sanity/client` [retries rate-limited queries automatically, but not mutations](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced). Use a queue with a reasonably low concurrency to keep your import below the [API rate limit](https://www.sanity.io/docs/content-lake/technical-limits):

```javascript
const {default: PQueue} = require('p-queue')
const queue = new PQueue({
  concurrency: 1,
  interval: 1000 / 25
})

queue.add(() => client.create(...))
queue.add(() => client.patch('id').inc('visits').commit())
```

### API usage limits

Importing large data sets can quickly cause a lot of requests, especially if you import a single document per request. It is usually a good idea to send [multiple mutations within a single transaction](https://www.sanity.io/docs/js-client).

### Mutation size limits

While it's a good idea to do multiple mutations per transaction, you need to make sure that the size of the request is [within our limits](https://www.sanity.io/docs/content-lake/technical-limits), in terms of byte size.

### Mutation visibility

A Sanity client will use the visibility mode of `sync` by default, which means that it will wait for the documents to be searchable before returning. This should not be necessary when importing large datasets, so we recommend you use `deferred`. If you have a lot of documents, it can take a little while for them to be searchable, but the import job will move along much faster.

### References

If you are referring to one document from another, they either need to be imported in the right order, or the reference needs to be flagged as *weak* by setting the `_weak` property to `true`. After importing, you probably want to remove the weak property in order to prevent referenced documents from being deleted.

> [!WARNING]
> Gotcha
> When a weak reference is desired, you should use the `weak` property when [defined in the schema](https://www.sanity.io/docs/studio/reference-type) but `_weak` when set up using a client. Using the `weak` property with the client will likely return the error: `key "weak" not allowed in ref`.
> `weak` in the schema, `_weak` in the JSON.

### Assets

Since assets (e.g., files and images) in Sanity are stored using references, you'll need to upload the assets first and put the returned document ID in your reference.

With this in mind, do check out our [client libraries](https://www.sanity.io/docs/client-libraries) documentation to see how to perform mutations.



# Restore a deleted dataset from a backup

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

The [Backups](https://www.sanity.io/docs/content-lake/backups) feature allows you to automatically create daily and weekly backups. This article guides you through the process required to restore a deleted dataset from a backup. 

Prerequisites:

- Project admin access.
- Sanity CLI v3+ (use `npx` so you don't need a global install).
- The dataset name that was deleted (e.g., `production`).
- A backup ID or backup file (.tar.gz) for that dataset from before deletion. See the next section for details on creating and retrieving a backup.

> [!WARNING]
> If you have accidentally deleted a dataset without backups enabled or dowloaded, contact Support as soon as possible.

## Before you delete a dataset

### Ensure you have enabled backups for your dataset

Always enable backups for critical datasets before any deletion. The recovery process requires a Backup tarball of your deleted dataset. If you don't have Backups enabled, or cannot obtain the relevant backup, stop here and contact Support.

**npm**

```shell
npx sanity backups enable <DATASET_NAME>
```

**pnpm**

```shell
pnpm dlx sanity backups enable <DATASET_NAME>
```

**yarn**

```shell
yarn dlx sanity backups enable <DATASET_NAME>
```

**bun**

```shell
bunx sanity backups enable <DATASET_NAME>
```

> [!NOTE]
> It may take up to 24 hours before the first backup is created. New datasets or datasets deleted less than 24 hours since enabling backups may not have backups available.

### Pause writes and automations

Ask editors to pause changes. Temporarily disable webhooks, functions, and automations to avoid a downstream rebuild storm. Re-enable them after you validate the restore.

### Download and secure your backup

Before deleting a dataset, download the backup file locally:

**npm**

```shell
npx sanity backups download <DATASET_NAME> --backup-id <BACKUP_ID> --out ./backup-<DATASET_NAME>.tar.gz
```

**pnpm**

```shell
pnpm dlx sanity backups download <DATASET_NAME> --backup-id <BACKUP_ID> --out ./backup-<DATASET_NAME>.tar.gz
```

**yarn**

```shell
yarn dlx sanity backups download <DATASET_NAME> --backup-id <BACKUP_ID> --out ./backup-<DATASET_NAME>.tar.gz
```

**bun**

```shell
bunx sanity backups download <DATASET_NAME> --backup-id <BACKUP_ID> --out ./backup-<DATASET_NAME>.tar.gz
```

> [!TIP]
> Dataset exports
> If you don't have backups enabled or need to immediately create a backup, [CLI's datasets export command](https://www.sanity.io/docs/cli-reference/cli-datasets) can initiate and download an export that's compatible with the import steps below. This is only possible if you have not yet deleted the dataset.

## Restore from the backup

### Create a new dataset with the same name

Make sure that your previous dataset has successfully deleted, then create the new dataset with the same name. 

- It is important that the same name is used to ensure asset names and document IDs are correctly referenced.
- Cross‑dataset references store the target dataset name in the document. If the target dataset’s name changed, those references still point to the old name.
- Circular references *inside the dataset* are handled automatically during import—you don’t need to order documents manually.

**npm**

```shell
npx sanity@latest datasets create <DATASET_NAME> --visibility private
# or for public datasets:
npx sanity@latest datasets create <DATASET_NAME> --visibility public
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets create <DATASET_NAME> --visibility private
# or for public datasets:
pnpm dlx sanity@latest datasets create <DATASET_NAME> --visibility public
```

**yarn**

```shell
yarn dlx sanity@latest datasets create <DATASET_NAME> --visibility private
# or for public datasets:
yarn dlx sanity@latest datasets create <DATASET_NAME> --visibility public
```

**bun**

```shell
bunx sanity@latest datasets create <DATASET_NAME> --visibility private
# or for public datasets:
bunx sanity@latest datasets create <DATASET_NAME> --visibility public
```

If you have multiple datasets to restore, create each with the names of the previously deleted datasets.

### Locate and download the backup for the deleted dataset

If the dataset still existed when you enabled Backups, you should have one or more backups from before deletion.

> [!TIP]
> What's inside the backup tarball?
> A complete snapshot of your dataset documents and assets (images/files).

#### List backups with the CLI

List backups for the original dataset name (the one that was deleted):

**npm**

```shell
npx sanity@latest backups list <DATASET_NAME>
```

**pnpm**

```shell
pnpm dlx sanity@latest backups list <DATASET_NAME>
```

**yarn**

```shell
yarn dlx sanity@latest backups list <DATASET_NAME>
```

**bun**

```shell
bunx sanity@latest backups list <DATASET_NAME>
```

Backups for deleted datasets won't appear in the CLI popup, but you can still access them by specifying the dataset name directly.

The output of available backups should look similar to this example:

**Output**

```text
┌──────────┬─────────────────────┬─────────────────────────────────────────────────┐
│ RESOURCE │ CREATED AT          │ BACKUP ID                                       │
├──────────┼─────────────────────┼─────────────────────────────────────────────────┤
│ Dataset  │ 2025-09-24 13:08:44 │ 2025-09-24-ca5a2833-dc31-457e-9a74-b0ebe99a6753 │
│ Dataset  │ 2025-09-23 02:40:30 │ 2025-09-23-c66adb69-cbed-4e4f-88a2-f97b5feeb464 │
└──────────┴─────────────────────┴─────────────────────────────────────────────────┘
```

Select the correct `BACKUP ID` by verifying the timestamp, then substitute it and the dataset name in the command below:

**npm**

```shell
npx sanity@latest backups download <DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz
```

**pnpm**

```shell
pnpm dlx sanity@latest backups download <DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz
```

**yarn**

```shell
yarn dlx sanity@latest backups download <DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz
```

**bun**

```shell
bunx sanity@latest backups download <DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz
```

> [!TIP]
> Important timing consideration
> All changes made after the backup timestamp and before dataset deletion cannot be restored, including uploaded assets.

#### If you cannot list backups for the deleted dataset

Reach out to Support as soon as possible. Provide your project ID, the deleted dataset name, and the approximate deletion time.

### Import the backup into your new dataset

Import the tarball (.tar.gz file) into the new dataset you created earlier:

**npm**

```shell
npx sanity@latest datasets import ./<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> --replace --allow-assets-in-different-dataset
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets import ./<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> --replace --allow-assets-in-different-dataset
```

**yarn**

```shell
yarn dlx sanity@latest datasets import ./<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> --replace --allow-assets-in-different-dataset
```

**bun**

```shell
bunx sanity@latest datasets import ./<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> --replace --allow-assets-in-different-dataset
```

A few things to note about this command:

- `--replace` ensures the backup becomes the new source of truth. This shouldn't be necessary because you're importing into a brand new dataset, but it's good practice.
- `--allow-assets-in-different-dataset` is needed because the tarball’s assets originated in the old dataset name.
- Document references *within* the dataset are handled automatically during import.
- If restoring multiple datasets that reference each other, repeat this step for each dataset.

#### Common import failures and how to handle them

The following are some of the most common causes of import failures and how to resolve them.

**Cross-dataset references (CDRs) to another deleted dataset**

If your CDRs aren't circular—for example: dataset A references dataset B, but not the other way around—you can resolve this by importing the other dataset first.

If this isn't an option, you can disable CDR validation with the `--skip-cross-dataset-references` flag.

**npm**

```shell
npx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references
```

**yarn**

```shell
yarn dlx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references
```

**bun**

```shell
bunx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references
```

**Documents referencing non-existent assets**

Because an asset can be deleted without deleting the asset document reference, you may run into this error. Skip asset validation with the `--allow-failing-assets` flag.

**npm**

```shell
npx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --allow-failing-assets
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --allow-failing-assets
```

**yarn**

```shell
yarn dlx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --allow-failing-assets
```

**bun**

```shell
bunx sanity@latest datasets import ./backup-<DELETED_DATASET_NAME>-backup.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --allow-failing-assets
```

### Update any alias or dataset references in your apps

If you're using the same dataset name for the new dataset as instructed in this guide, you shouldn't need to update any of your applications. If you previously used an alias, you can confirm it is still active with `sanity datasets list`. 

If you pointed an alias at a different dataset for this restore, you can link it again with the following command:

**npm**

```shell
npx sanity@latest datasets alias link production <DATASET_NAME>
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets alias link production <DATASET_NAME>
```

**yarn**

```shell
yarn dlx sanity@latest datasets alias link production <DATASET_NAME>
```

**bun**

```shell
bunx sanity@latest datasets alias link production <DATASET_NAME>
```

### Validate your data and enable automations

#### Confirm data exists as expected

Launch your Studio and spot check critical documents. Confirm the following:

- References resolve without "unavailable" warnings.
- Assets load in previews
- Compare totals for key document types between the restored dataset an the backup.

If assets don't load, contact Support promptly. (Assets are only retained for a limited time after deletion.)

The following are some optional CLI checks you can run. Update the queries with relevant types for your data.

**CLI**

```sh
# Find documents with references
npx sanity@latest documents query '*[count(* references(^._id)) > 0][0..20]{_id, _type}'

# Find broken references
npx sanity@latest documents query '*[defined(yourRefField._ref) && !defined(yourRefField->._id)][0..20]{_id, _type}'
```

#### Enable webhooks, functions, and automations

Re-enable any webhooks and automations you disabled in the earlier step.

### Enable backups for this "new" dataset

As this is a new dataset, albeit with the same name, enable backups as described in the beginning of this guide.

**npm**

```shell
npx sanity backups enable <DATASET_NAME>
```

**pnpm**

```shell
pnpm dlx sanity backups enable <DATASET_NAME>
```

**yarn**

```shell
yarn dlx sanity backups enable <DATASET_NAME>
```

**bun**

```shell
bunx sanity backups enable <DATASET_NAME>
```

## Troubleshooting

Common troubleshooting errors:

- **"Document exists" warnings while importing**: Use `--replace` (as shown) for a clean rollback from a complete backup.
- **Assets missing after import**: Always import from the backup tarball. Try using `--allow-failing-assets` flag if assets were deleted.
- **Can't list backups for a deleted dataset**: Contact Support with your project ID, deleted dataset name, and deletion time to retrieve the correct backup.
- **Cross-dataset reference errors**: Use `--skip-cross-dataset-references` flag during import if references point to other deleted datasets.

## Common commands

The following are commands commonly used when restoring from a backup.

**npm**

```shell
# Enable backups (do this before any deletion)
npx sanity backups enable <DATASET_NAME>

# Create a new dataset
npx sanity@latest datasets create <DATASET_NAME> --visibility private/public

# List & download backups (if available via CLI)
npx sanity@latest backups list <DELETED_DATASET_NAME>
npx sanity@latest backups download <DELETED_DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz

# Import the backup tarball into the *new* dataset
npx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset

# Import with additional flags if needed
npx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references \
  --allow-failing-assets

# Alias cutover (if you use an alias like "production")
npx sanity@latest datasets alias link production <DATASET_NAME>
```

**pnpm**

```shell
# Enable backups (do this before any deletion)
pnpm dlx sanity backups enable <DATASET_NAME>

# Create a new dataset
pnpm dlx sanity@latest datasets create <DATASET_NAME> --visibility private/public

# List & download backups (if available via CLI)
pnpm dlx sanity@latest backups list <DELETED_DATASET_NAME>
pnpm dlx sanity@latest backups download <DELETED_DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz

# Import the backup tarball into the *new* dataset
pnpm dlx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset

# Import with additional flags if needed
pnpm dlx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references \
  --allow-failing-assets

# Alias cutover (if you use an alias like "production")
pnpm dlx sanity@latest datasets alias link production <DATASET_NAME>
```

**yarn**

```shell
# Enable backups (do this before any deletion)
yarn dlx sanity backups enable <DATASET_NAME>

# Create a new dataset
yarn dlx sanity@latest datasets create <DATASET_NAME> --visibility private/public

# List & download backups (if available via CLI)
yarn dlx sanity@latest backups list <DELETED_DATASET_NAME>
yarn dlx sanity@latest backups download <DELETED_DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz

# Import the backup tarball into the *new* dataset
yarn dlx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset

# Import with additional flags if needed
yarn dlx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references \
  --allow-failing-assets

# Alias cutover (if you use an alias like "production")
yarn dlx sanity@latest datasets alias link production <DATASET_NAME>
```

**bun**

```shell
# Enable backups (do this before any deletion)
bunx sanity backups enable <DATASET_NAME>

# Create a new dataset
bunx sanity@latest datasets create <DATASET_NAME> --visibility private/public

# List & download backups (if available via CLI)
bunx sanity@latest backups list <DELETED_DATASET_NAME>
bunx sanity@latest backups download <DELETED_DATASET_NAME> \
  --backup-id <BACKUP_ID> \
  --out ./backup-<DATASET_NAME>.tar.gz

# Import the backup tarball into the *new* dataset
bunx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset

# Import with additional flags if needed
bunx sanity@latest datasets import ./backup-<DATASET_NAME>.tar.gz <DATASET_NAME> \
  --replace \
  --allow-assets-in-different-dataset \
  --skip-cross-dataset-references \
  --allow-failing-assets

# Alias cutover (if you use an alias like "production")
bunx sanity@latest datasets alias link production <DATASET_NAME>
```



# Migrating your schema and content

Most projects will require changes to [the schema](https://www.sanity.io/docs/schema-types), that is, your content model. At the start of a project, these changes are often additive and only involve building out the schema with new document and field types. There will be no actual content that needs to be changed.

However, sometimes, you must change the existing schema and content in your Sanity Studio and Content Lake dataset (and maybe for your GraphQL API). Maybe you want to rename a field, add a new validation rule, check how existing documents will be affected, or move fields into an array or object type. There are many valid and necessary reasons to change and evolve your schema and the content that goes with it.

While changing a Sanity Studio schema is most often straightforward, if you have content in documents that assumes a certain structure, you will want to migrate these to match your updated schema. This is where our tools for schema change management come in.

[Introduction to Schema Change Management](https://www.sanity.io/learn/course/handling-schema-changes-confidently)
This course introduces you to schema and content migrations in a step-by-step manner.

[Content migration cheat sheet](https://www.sanity.io/docs/content-lake/content-migration-cheatsheet)
Copy-paste common content migration code snippets

[Important considerations for schema change management](https://www.sanity.io/docs/content-lake/important-considerations-for-schema-and-content-migrations)
How to approach schema changes for projects in and out of production

[CLI tooling for validations](https://www.sanity.io/docs/cli-reference/documents)
Reference docs for checking document validation status across a dataset

[CLI tooling for content migrations](https://www.sanity.io/docs/cli-reference/cli-migrations)
Reference docs for working with migrations in the CLI

The command-line sections of this article use the Sanity CLI. You also need write access to the dataset you’re changing.

## What is schema change management?

The process of changing your schema and existing content is called “schema change management.” This is comparable to what other content management systems call “content migrations,” but it goes beyond that. Schema change management is about what you must think about when changing the structure of your content or the validation rules of your field and document types.

In some contexts, “content migrations” might refer to cases where you go from one content management system to another. While the following can be useful as part of that process, this article and the features we address focus on migrations *within* a dataset in a Sanity project.

Common examples of schema change management:

- You need to change a schema for a Sanity Studio workspace and wish to update existing content to validate against the new schema update
- You have imported content from another content management system (CMS) and wish to change and improve its structure
- You have added new validation rules and wish to introspect and list documents that need to be updated by a content team or a content migration script
- You are crafting an NDJSON import file and want to validate the documents before importing them into your dataset
- You just want insight into the validation status across your whole dataset (as opposed to per document in the Studio) to troubleshoot implementation bugs

> [!WARNING]
> Gotcha
> It’s important to note that changing the schema for a Sanity Studio workspace will **not** automatically change or delete existing content in your dataset. 
> We consider this a feature that helps prevent unintended breakage for applications that rely on your content model having a certain shape. As with databases, you want changes to the content model (or data model) to be intentional, preferably reproducible, and part of your development workflow.

Sanity offers tooling to bring existing content up to date so you have flexibility in adding, removing, and changing the shape of your content.

## Tooling for schema and content migrations in a dataset

Sanity offers tooling and capabilities that support schema migrations:

- The [deprecated](https://www.sanity.io/docs/schema-types) property lets you mark document and field types that shouldn’t be used anymore with a defined `reason`. This configuration will appear visually in the Sanity Studio and as part of the [GraphQL API schema](https://www.sanity.io/docs/content-lake/graphql) for those using this feature.
- Command Line Interface (CLI) commands: - [sanity documents validate](https://www.sanity.io/docs/cli-reference/documents) lets you check the validation status of documents in a dataset or export file
- [sanity schemas validate](https://www.sanity.io/docs/cli-reference/cli-schemas) lets you identify potential errors in your schema configuration (is also run by `sanity documents validate`)
- [sanity migrations [command]](https://www.sanity.io/docs/cli-reference/cli-migrations) lets you create, list, and run content migrations (defined in code) with helper functions for defining how documents should change
- [sanity datasets import|export](https://www.sanity.io/docs/cli-reference/cli-datasets) (or using [Advanced Dataset Management](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets)) lets you export/import datasets to test and validate a migration in a non-production environment


- If you have needs beyond what the current tooling gives you, you can also use the [Sanity Content Lake APIs](https://www.sanity.io/docs/http-api) for directly querying and mutating content.

## Deprecating document and field types

While you can add, change, and remove schema types freely without cascading changes to existing content, chances are you want to be more considered, especially for projects running in production. Often, you want content teams to be able to see and (sometimes) edit a deprecated field and explain why and how a schema type has been changed.

All user-configurable schema types support explicit deprecation through a configuration property called `deprecated` where the value is an object with a defined `reason` as a string (required):

```typescript
export const person = defineType({
  name: 'person',
  type: 'document',
  deprecated: {
    reason: 'Use the Author document type instead.'
  },
  fields: [],
  readOnly: true // to prevent further edits
})

```

You can use the `readOnly: true` configuration to prevent deprecated fields and documents from being edited.

```tsx
export const name = defineField({
  name: 'firstName',
  type: 'string',
  description: `The person's first name`,
  deprecated: {
    reason: 'Use the name field instead.'
  },
  readOnly: true, // to prevent further edits
})

```

This configuration will show up visually in the Studio:

*In this example, both the document type and the title field have been deprecated.*

Our GraphQL API also supports the deprecation property, which translates the property and reason into the [directives for deprecations in the GraphQL specification](https://spec.graphql.org/October2021/#sec--deprecated).

## Checking validation status across all documents in a dataset

Validation rules for document and field types are primarily shown within a document form for users of Sanity Studio. However, when working with schema and content migration, it is useful to review the validation status for all documents in a dataset, especially for quickly getting insight into the state of your datasets and helping you decide what migration jobs to create.

The Sanity CLI offers this functionality with `sanity documents validate`. This command runs all `validation` rules in your current schema configuration on documents in a virtual browser environment from where the command is run. In addition, it can also report on document types, that is, documents with a `_type` that is not covered in your schema configuration.

Without any extra flags, `sanity documents validate` will output a pretty formatted list of validation errors and warnings from the project and dataset defined in `sanity.cli.ts`. It will also give you actionable Studio links, provided you have configured a Studio URL in the project settings on [sanity.io/manage](https://www.sanity.io/manage).

You can use flags to specify a specific Studio workspace (if your project has more than one), dataset name, validation level (`error`, `warning`, `info`), and output formats (`pretty`, `json`, `ndjson`).

You can also use the `--file [file path]` flag to run validations against a dataset import/export file (supports both `filename.ndjson` or `filename.tar.gz`).

### The anatomy of the validation output

When exporting to a JSON format, the output of the CLI gives you actionable data such as the document ID (the same as `_id`) and type (the same as `_type`), the revision ID, the URL to find the document in a deployed Studio, as well as an array with all validation notices that it can have. The `level` property on the root of this object will always reflect the most severe level in the markers array, from “error,” “warning,” to “info.”

```json
{
    "documentId": "person_robin-sachs",
    "documentType": "person",
    "revision": "GspWPjs815p7KTxv2q3x76",
    "intentUrl": "https://schema-change-management-demo.sanity.studio/intent/edit/id=person_robin-sachs;type=person",
    "markers": [
      {
        "path": ["fullName"],
        "level": "warning",
        "message": "Field 'fullName' does not exist on type 'person'"
      }
    ],
    "level": "warning"
  }

```

### A lot of validation errors? Pipe the output to a file!

If you have a sizeable dataset and/or a bunch of validation errors, the output will likely overflow your terminal’s buffer. In these cases, piping the output to a file in the terminal can be useful. For most shell environments, it will look something like this:

**npm**

```shell
npx sanity documents validate -y --format ndjson > documentValidations.ndjson

```

**pnpm**

```shell
pnpm dlx sanity documents validate -y --format ndjson > documentValidations.ndjson

```

**yarn**

```shell
yarn dlx sanity documents validate -y --format ndjson > documentValidations.ndjson

```

**bun**

```shell
bunx sanity documents validate -y --format ndjson > documentValidations.ndjson

```

A pro tip is that you can then use [GROQ CLI](https://github.com/sanity-io/groq-cli) to parse this file. Let’s say you wanted to have a list of Studio URLs of all documents that have a validation error (and not just a `warning`):

```sh
# npm install --global groq-cli
cat documentValidations.ndjson|groq -n "*[level == 'error'].intentUrl"

```

## Working with content migrations in the CLI

The Sanity CLI has tooling for creating and running content migrations against a dataset. Content migrations are described in JavaScript (or TypeScript) as files inside a `migrations` folder in your Sanity Studio project. You can also automate and run content migrations as part of a CI/CD pipeline.

### Creating new content migrations

You can use the CLI command `sanity migrations create` to create a new content migration file. The CLI will prompt you for a human-friendly title, which document types you want to filter on, and a content migration template to start from. You can also go to [the content migration cheat sheet](https://www.sanity.io/docs/content-lake/content-migration-cheatsheet) to find starting points for common migration patterns.

### File and folder structure

The `sanity migrations` CLI command will create and look for migration files in a `migrations` folder, when run from the Studio project root. You can write migration files in JavaScript (`.js`, `.mjs`, `.cjs`) and TypeScript (`.ts`).

You can store migration scripts in two patterns, which can also be combined:

- `studioFolder/migrations/my-content-migration.ts`
- `studioFolder/migrations/my-content-migration/index.ts`

The latter is useful when you have a complex migration and want to split up the code in more files.

### The anatomy of a content migration file

A migration file should export a `defineMigration` (using `default export`) with the following configuration:

- `title`: A reader-friendly description of what the content migration does.
- `documentTypes`: An array of document types to run the content migration on. If you don’t define this, the migration type will target all document types.
- `filter`: A simple GROQ-filter (doesn’t support joins) for documents you want to run the content migration on.
- `migrate`: An object of named helper functions corresponding to the primary schema type of the content you want to migrate. You can also run these functions as async and return the migration instructions as promises if you need to fetch data from elsewhere.

```tsx
// migrations/example-migration/index.ts
import {defineMigration} from 'sanity/migrate'

export default defineMigration({
  title: 'A human-friendly description of what this content migration does',
  documentTypes: ['aDocumentType'],
  migrate: {
    document(doc, context) {
      // this will be called for every document of the matching type
    },
    node(node, path, context) {
      // this will be called for every node in every document of the matching type
    },
    object(node, path, context) {
      // this will be called for every object node in every document of the matching type
    },
    array(node, path, context) {
      // this will be called for every array node in every document of the matching type
    },
    string(node, path, context) {
      // this will be called for every string node in every document of the matching type
    },
    number(node, path, context) {
      // this will be called for every number node in every document of the matching type
    },
    boolean(node, path, context) {
      // this will be called for every boolean node in every document of the matching type
    },
    null(node, path, context) {
      // this will be called for every null node in every document of the matching type
    },
  },
})

```

[Content migrations cheat sheet](https://www.sanity.io/docs/content-lake/content-migration-cheatsheet)

### Understanding `node` in the context of content migrations

We refer to “node” in the code example above and that there is a mutation creator function for `node(node, path, context)`. Here, a “node” refers to any value in a document, including nested ones.

The `object`, `array`, `string`, `number`, `boolean`, and `null` functions are subsets of the node function to make it easier to access content by its JSON data type.

The `node` as the first argument in these functions will be the value for any given node.

The `path` will tell you where the `node` value comes from in the document. This is where you will find any node's key/property/field name and the `_key` value in array data.

> [!TIP]
> Protip
> The migration tooling will run mutations with `autoGenerateArrayKeys` enabled. This means that you don't have to manually set a `_key` value when you work with objects in array data.

Let’s say you log out the values for `node` and `path` (`console.log(node, path)`). The output from a document with a `title` field would then be:

```text
My title [ 'title' ]

```

In the same document, for a slug field:

```text
my-title [ 'slug', 'current' ]

```

And for the first paragraph of a `description` field that is a Portable Text field:

```text
This is some text. 
[
  'description',
  { _key: '77b13c0924cb9fa06505215e8d0c8ee6' },
  'children',
  { _key: 'o9Bcm59c' },
  'text'
]

```

As you will see in the next section, you can change the data without authoring complex patches using these arguments even with more complex data structures.

### Getting the right changes in the right places

The content migration tooling has helper functions for defining the changes you want to make. Under the hood, they translate the content migrations you define into transactions of mutations and patches submitted to the Sanity Content Lake.

For example, if you want to change all occurrences of the single string “acme” to uppercase regardless of where it is in your dataset, then the content migration script could look like this:

```typescript
// migrations/uppercase-acme/index.ts
import {defineMigration, at, set} from 'sanity/migrate'

export default defineMigration({
  title: 'Make sure all strings with “acme” is uppercased to “ACME”',
  migrate: {
    string(node, path, context) {
      if (node === "acme") {
        return set(node.toUpperCase())
      }
    },
  },
})

```

When you use the `document` function or are in a nested field, the `at` function can be useful; you can pass the `path` and the operation to `at` to get the change in the right place. Below is an example of how to change the `_type` in an object field:

```typescript
// migrations/movie-to-film/index.ts
import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'

export default defineMigration({
  title: 'Change the movie object field to film',
  documentTypes: ['screening'],
  migrate: {
    document(doc, context) {
      return [
        at('film', setIfMissing(doc.movie)),
        at('movie', unset())
      ]
    }
  }
})

```

As the example shows, you can return multiple patches as well. When changing a field name, you typically want to remove the value from the old field and add it to the new one.

Do note how the `setIfMissing` patch will only apply to documents without an existing `film` field. Usually, it’s good to be defensive when writing these scripts to avoid making unintended changes to existing data. Of course, you could also use the `set` operation to (over)write data to `film` regardless of whether it existed in a document.

### A note on immutable properties and migrating `_type` and `_id`

Before you go on trying to `at('_type', set('myNewDocumentType'))`, we must address that some fields (or properties) for documents in the Content Lake are *immutable*. In other words, you can’t change the value when it’s set. These immutable built-in properties all begin with an underscore, including:

- `_type` 
- `_id`
- `_createdAt`
- `_updatedAt` (automatically updated on changes to the document)
- `_rev` (automatically updated on changes to the document)

We realize that there are cases where you want to change the value of these. The best strategy for now is to do this by exporting and unpacking your dataset. Make the changes in the NDJSON file that holds all the documents, delete the documents you want to change the `_type` of, and then import it again.

### How to run and execute content migrations

Once you have defined your content migrations as code and made sure they are nicely organized in a `migrations` folder in your Studio project, that is, alongside your `sanity.cli.ts` configuration, then you’re all set up for a test run.

To quickly list out what migrations the tool can access, run `sanity migrations list`.

Now you can copy-paste the content migration ID and run `sanity migrations run <migration-id>`.

The command will *always* run in dry-run mode unless you add the `--no-dry-run` flag. When running in dry-run mode, the CLI will output a list of patches and document IDs from your content migration script. You can review this list to catch obvious mistakes.

Before executing a content migration, ensure you have backed up your dataset (`sanity datasets export` or `sanity datasets copy`).

(Note: Exports are billed against the API quota, though documents are streamed to minimize usage.)

[Important considerations for schema and content migrations](https://www.sanity.io/docs/content-lake/important-considerations-for-schema-and-content-migrations)

When you feel confident that you want to make the changes, you can run the following command:

**npm**

```shell
npx sanity migrations run your-content-migration --no-dry-run

```

**pnpm**

```shell
pnpm dlx sanity migrations run your-content-migration --no-dry-run

```

**yarn**

```shell
yarn dlx sanity migrations run your-content-migration --no-dry-run

```

**bun**

```shell
bunx sanity migrations run your-content-migration --no-dry-run

```

It will output its progress and detail how many documents were processed, mutations were generated, and transactions were committed.

### Rate limits

Migrations adhere to the same [rate limits](https://www.sanity.io/docs/content-lake/technical-limits) as other API interactions with the Sanity Content Lake. If your migrations involve numerous patches, consider regulating the volume of simultaneous mutation requests to manage your API call rate with the `--concurrency` flag. You can run between 1 and 10 transactions in parallel. The default is 6, but you can lower it to avoid rate limits.



# Content migration cheat sheet

Below are content migration code snippets you can copy-paste and fit for your purposes. Requires familiarity with Sanity's schema and content migration tooling.

[Introduction to schema change management](https://www.sanity.io/docs/content-lake/schema-and-content-migrations)

[Important considerations for schema and content migrations](https://www.sanity.io/docs/content-lake/important-considerations-for-schema-and-content-migrations)

[Reference docs for working with content migrations in the CLI](https://www.sanity.io/docs/cli-reference/cli-migrations)

[Reference docs for running validations in the CLI](https://www.sanity.io/docs/cli-reference/documents)

[TypeScript reference for sanity/migrate](https://www.sanity.io/docs/reference/api/sanity/migrate/append)

## Rename a field in a document

```typescript
import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'

export default defineMigration({
  title: 'Rename field from "oldFieldName" to "newFieldName"',
  migrate: {
    document(doc, context) {
      return [
        at('newFieldName', setIfMissing(doc.oldFieldName)),
        at('oldFieldName', unset())
      ]
    }
  }
})

```

## Rename fields to a different naming convention

Use this when a schema authored under different naming rules needs converting. Sanity field names accept only letters, numbers, and underscores, and must start with a letter, so `hero-image` has to become `heroImage` before the schema validates. This migration visits every object in every document, renames each key containing a hyphen, and unsets the old key. Change `NEEDS_RENAME` and `toCamelCase` to target a different convention, such as snake_case.

**index.ts**

```typescript
import {at, defineMigration, type Path, set, unset} from 'sanity/migrate'

// Keys containing a hyphen, such as `hero-image`. Use /_/ for snake_case.
const NEEDS_RENAME = /-/

function toCamelCase(key: string): string {
  return key.replace(/-+(.)/g, (_match, char: string) => char.toUpperCase())
}

// Keys starting with an underscore are system fields, so leave them alone.
function shouldRename(key: string): boolean {
  return !key.startsWith('_') && NEEDS_RENAME.test(key)
}

// The whole subtree moves in one `set`, so rewrite the keys inside it too.
function renameDeep(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(renameDeep)
  if (value === null || typeof value !== 'object') return value
  return Object.fromEntries(
    Object.entries(value as Record<string, unknown>).map(([key, child]) => [
      shouldRename(key) ? toCamelCase(key) : key,
      renameDeep(child),
    ]),
  )
}

// A renamed key's subtree is already handled by that key's own `set`.
function isUnderRenamedKey(path: Path): boolean {
  return path.some(
    (segment) => typeof segment === 'string' && shouldRename(segment),
  )
}

export default defineMigration({
  title: 'Rename hyphenated field names to camelCase',
  // Omit `documentTypes` to run across every document type.
  migrate: {
    object(node, path) {
      if (isUnderRenamedKey(path)) return undefined

      return Object.keys(node)
        .filter(shouldRename)
        .flatMap((key) => [
          // Paths returned from a node handler are relative to that node.
          at([toCamelCase(key)], set(renameDeep(node[key]))),
          at([key], unset()),
        ])
    },
  },
})

```

If a document already has both `hero-image` and `heroImage`, the rename overwrites the existing camelCase value. Run the migration without `--no-dry-run` first and review the patches it reports.

## Add a field with default value to all documents missing the field

Note: This example uses [an async generator pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator) (`*migrate`) to read out the document ID (`_id`) one by one and return the patch. This prevents the script from loading all documents into memory.

```typescript
import {defineMigration, patch, at, setIfMissing} from 'sanity/migrate'

export default defineMigration({
  title: 'Add title field with default value',
  // documentTypes: ['post', 'article'], // only apply to certain document types
  async *migrate(documents, context) {
    for await (const document of documents()) {
      yield patch(document._id, [
        at('title', setIfMissing('Default title')),
      ])
    }
  }
})

```

## Migrate a reference field into an array of references

```typescript
import { defineMigration, at, setIfMissing, append, unset} from 'sanity/migrate'

export default defineMigration({
  title: 'Convert a reference field into an array of references',
  documentTypes: ['product'],
  filter: 'defined(category) && !defined(categories)',
  migrate: {
    document(product) {
      return [
        at('categories', setIfMissing([])),
        // use `prepend()` to insert at the start of the category array
        at('categories', append(product.category)),
        at('category', unset())
      ]
    }
  }
})

```

## Convert a string field into a Portable Text array

```typescript
import {pathsAreEqual, stringToPath} from 'sanity'
import {defineMigration, set} from 'sanity/migrate'

const targetPath = stringToPath('some.path')

export default defineMigration({
  title: 'Convert a string into a Portable Text array',

  migrate: {
    string(node, path, ctx) {
      if (pathsAreEqual(path, targetPath)) {
        return set([
          {
            style: 'normal',
            _type: 'block',
            children: [
              {
                _type: 'span',
                marks: [],
                text: node,
              },
            ],
            markDefs: [],
          },
        ])
      }
    },
  },
})

```

## Convert a Portable Text field into plain text

```typescript
import {pathsAreEqual, stringToPath, type PortableTextBlock} from 'sanity'
import {defineMigration, set} from 'sanity/migrate'

// if the portable text field is nested, specify the full path to it
const targetPath = stringToPath('some.path')

function toPlainText(blocks: PortableTextBlock[]) {
  return (
    blocks
      // loop through each block
      .map((block) => {
        // if it's not a text block with children,
        // return nothing
        if (block._type !== 'block' || !block.children) {
          return ''
        }
        // loop through the children spans, and join the
        // text strings
        return (block.children as {text: string}[]).map((child) => child.text).join('')
      })
      // join the paragraphs leaving split by two linebreaks
      .join('\n\n')
  )
}
export default defineMigration({
  title: 'Convert a Portable Text field into plain text (only supporting top-level blocks)',
  documentTypes: ['pt_allTheBellsAndWhistles'],

  migrate: {
    // eslint-disable-next-line consistent-return
    array(node, path, ctx) {
      if (pathsAreEqual(path, targetPath)) {
        return set(toPlainText(node as PortableTextBlock[]))
      }
    },
  },
})

```

## Migrate inline objects into references

This example shows how to convert an inline object in an array field into a new document and replace the array item with a reference to that new document. 

You can also use this in Portable Text fields and use `.filter(({_type}) => _type == "blockType")` to convert only specific custom blocks.

```typescript
// npm install lodash
import {deburr} from 'lodash'
import {at, createIfNotExists, defineMigration, replace, patch} from 'sanity/migrate'

/**
 * if you want to make sure you don't create many duplicated
 * documents from the same pet, you can generate an ID for it 
 * that will be shared for all pets with the same name
 **/
function getPetId(pet: {name: string}) {
  return `pet-${deburr(pet.name.toLowerCase())}`
}

export default defineMigration({
  title: 'Convert an inline object in an array into a document and reference to it',
  documentTypes: ['human'],
  filter: 'defined(pets) && count(pets[]._ref) > 0',
  migrate: {
    document(human) {
      const currentPets = human.pets
      // migrate any pet object to a new document
      if (Array.isArray(currentPets) && currentPets.length > 0) {
        return currentPets
          // skip pets that have already been converted to a reference
          .filter((pet) => !pet._ref)
          .flatMap((pet) => {
            const petId = getPetId(pet)

            // avoid carrying over the array _key to the pet document
            const {_key, ...petAttributes} = pet

            return [
              createIfNotExists({
                _id: petId,
                _type: 'pet',
                ...petAttributes,
              }),
              patch(human._id, at(['pets'], replace([{_type: 'reference', _ref: petId}], {_key}))),
            ]
          })
      }
    },
  },
})

```

## Delete all documents by type

```typescript
import {defineMigration, del} from 'sanity/migrate'

export default defineMigration({
  title: 'Delete posts and pages',
  documentTypes: ['post', 'page'],
  migrate: {
    document(doc) {
      // Note: If a document has incoming strong references, it can't be deleted by this script.
      return del(doc._id)
    },
  },
})

```

## Migrate a document type

The `_id` and `_type` attributes/fields on *documents* are *immutable*; they can't be changed with a mutation like other fields once they are set. There is no straightforward way to change these using the content migration tooling.

The most controlled way of approaching the migration of a document `_type` and `_id` is to:

1. Export your dataset.
2. Update all target documents with new `_id` and `_type` values.
3. Update references in the dataset to point to the new document `_id` values.
4. Import the modified export file(s).
5. Delete the old documents.

### Export and update

First export the data, update it locally, then import it back to Sanity.

1. Export your dataset (`sanity datasets export <dataset>`, add `--no-assets` if you're not planning to do anything with these).
2. Untar the export file (`tar -xzvf <dataset>.tar.gz`).
3. Open the NDJSON of your dataset (`<dataset>.ndjson`).
4. Use your method of choice to find and replace all the suitable document information in the NDJSON file.
5. Optional: If you plan to import into a clean dataset, you can delete all of the old documents from the NDJSON file. If you're importing back into the same dataset, deleting the documents from the NDJSON file won't delete them from the existing data.
6. Re-import your dataset with the `--replace` flag (`sanity datasets import <dataset>.ndjson --dataset <dataset> --replace`).

> [!NOTE]
> Only changing the type?
> If you're only changing the type and want to keep the `_id` values, you may want to perform the migration described in the next step before importing the modified NDJSON file. Otherwise, you'll run into problems attempting to override the existing `_id` for each document.

### Create a migration to remove old documents

If you're editing an existing dataset, you'll need to remove the old documents with a migration. If you're replacing every document matching a type, you can use the `documentTypes` without a filter. Otherwise, you'll want to filter by additional criteria like a list of `_id` values.

```typescript
import { defineMigration, delete_ } from "sanity/migrate";

export default defineMigration({
  title: "Delete documents",
  documentTypes: ["oldType"],
  // and/or filter just the affected documents.
  // You may wish to import these dynamically.
  filter: "_id in ['id1', 'id2']",

  migrate: {
    document(doc) {
      return delete_(doc._id);
    },
  },
});
```

Always ensure you have a backup of your dataset and triple-check before changing content in production.

## Delete file assets over a certain file size

This migration will attempt to delete any file asset metadata documents over 50MB in size. Deleting the metadata document will also delete the asset from your dataset.

- Update `documentTypes` to include `sanity.imageAsset` to remove images.
- Update `filter` to adjust the maximum file size (in bytes).
- **Note:** Migration filters run against each document in isolation and can't query other documents, so you can't exclude referenced assets in the filter. Check references inside the migrate handler using context.client, or accept that deleting referenced assets will fail at mutation time.

```typescript
import { defineMigration, delete_ } from "sanity/migrate";

export default defineMigration({
  title: "Delete large files",
  documentTypes: ["sanity.fileAsset"],
  // Size is greater than 50MB
  filter: "size > 50000000",

  migrate: {
    document(doc) {
      return delete_(doc._id);
    },
  },
});
```

## Migrate a string to a localized i18n array

This migration migrates string fields to an array of localized fields compatible with the [sanity-plugin-internationalized-array](https://github.com/sanity-io/sanity-plugin-internationalized-array) plugin. Follow the plugin's instructions for installation and setup, then update and run the migration below to match your field and document types.

**index.ts**

```typescript
import {at, set, defineMigration} from 'sanity/migrate'

export default defineMigration({
  title: 'i18n-array',
  documentTypes: ["post"], // update with your document types

  migrate: {
    document(doc, context) {
      // update with your field path instead of 'greeting'
      if (doc.greeting && typeof doc.greeting === 'string') {
        return at('greeting', set([
          {
            _key: 'en',
            _type: 'internationalizedArrayStringValue',
            value: doc.greeting,
          },
          // optionally, automate translation and add additional
          // languages in the same shape as above.
        ]))
      }
    },
  },
})

```

**Shape Before**

```json
{
  "greeting": "Hello"
}
```

**Shape After**

```json
{
  "greeting": [
    {
      "_key": "en",
      "_type": "internationalizedArrayStringValue",
      "value": "Hello"
    }
  ]
}
```

## Backfill missing initial values

This migration fills empty `publishedAt` fields with the `_createdAt` value from the document. Patterns like this are useful for backfilling fields that may have started without an `initialValue` set in the schema, but evolved to need one.

**index.ts**

```typescript
import {at, defineMigration, setIfMissing} from 'sanity/migrate'

export default defineMigration({
  title: 'backfill-initial',
  // update with your target documents and add any filters
  documentTypes: ["post"],

  migrate: {
    document(doc) {
      // update with your field path and value
      return at('publishedAt', setIfMissing(doc._createdAt))
    },
  },
})

```

We don't recommend reading the schema manifest to retrieve initial values, as it is not a stable shape. For often-used initial values that require computation, it may be helpful to export a function and import it where needed, including in the migration file.

## Sort array by reference property

It's generally best to adjust the order of items in an array with [GROQ's sorting abilities](https://www.sanity.io/docs/specifications/groq-pipeline-components). In instances where you want to change the order of the data directly, you can use a migration.

The `document` and `array` methods are both capable of sorting an array. This example uses `document`. Due to the way documents store references, you'll need to follow the references in order to retrieve additional details. This example orders by `name`.

Query the document, then use a sort function to re-order the array. There are many ways to do this, but make sure to keep the `_key` values aligned with the correct `_ref` values.

**index.ts**

```typescript
import {at, defineMigration, set} from 'sanity/migrate'

const AUTHORS_QUERY = `*[_id == $id] { authors[]->{_id, name}}`

interface Author {
  _id: string
  name: string
}

interface AuthorReference {
  _type: 'reference'
  _ref: string
  _key: string
}

function sortReferenceByName(references: AuthorReference[], authors: Author[]): AuthorReference[] {
  // sort the authors by name, match the author _id to the reference _ref, and return the sorted references
  // Build a map for quick lookup of author names by _id
  const authorNameMap = new Map(authors.map(author => [author._id, author.name || '']))
  return references.sort((a, b) => {
    const nameA = authorNameMap.get(a._ref) || ''
    const nameB = authorNameMap.get(b._ref) || ''
    return nameA.localeCompare(nameB)
  })

}

export default defineMigration({
  title: 'order reference array',
  documentTypes: ["post"],

  migrate: {
    async document(doc, context) {
      if (!doc.authors) {
        return
      }
      const response = await context.client.fetch(AUTHORS_QUERY, {id: doc._id})    
      const sortedReferences = sortReferenceByName(doc.authors as AuthorReference[], response[0].authors as Author[])
      return at('authors', set(sortedReferences))
    }
  },
})

```

## Deduplicate arrays

Use the `array` method and an array filtering method to remove duplicates from an array.

Here are two methods for deduping the same array of tags. You can further enhance the check in the second example to accommodate arrays of objects instead of strings.

**Using Set**

```typescript
import {defineMigration, set} from 'sanity/migrate'
const PATH_NAME = 'tags'
export default defineMigration({
  title: 'dedupe arrays',
  documentTypes: ["post"],
  migrate: {
    array(node, path, context) {
      if (path.includes(PATH_NAME)) {
        const cleanArray = [...new Set(node)]
        return set(cleanArray)
      }
    },
  },
})

```

**Using reduce**

```typescript
import {defineMigration, set} from 'sanity/migrate'
const PATH_NAME = 'tags'
export default defineMigration({
  title: 'dedupe arrays',
  documentTypes: ["post"],
  migrate: {
    array(node, path, context) {
      if (path.includes(PATH_NAME)) {
        const cleanArray = node.reduce((acc: string[], item: string) => {
          if (!acc.includes(item)) {
            acc.push(item)
          }
          return acc
        }, [])
        return set(cleanArray)
      }
    },
  },
})

```

You can limit the filtering to specific arrays by validating the path, like in the example above, or omit the condition to apply it to all arrays.

## Convert URLs to reference links

It's common during a migration to end up with many traditional annotation links that could be references to documents in your dataset. This migration is often run after importing documents, as they'll need to exist and have IDs that you can reference.

This example is a simplified version of one we use in our documentation dataset to pick up any stray URLs that should really be references. It looks for `link` annotations in the `content` field of `post` documents.

**index.ts**

```typescript
import {defineMigration, MigrationContext, set} from 'sanity/migrate'

// A helper to take a URL and context, then find an associated document matching it
async function getReference(url: URL, context: MigrationContext): Promise<string | false> {
  // Only look for published documents
  const publishedClient = context.client.withConfig({apiVersion: '2025-09-30', perspective: 'published' })

  const { pathname } = url
  // Clean up the URL over a few steps to pull the slug
  const cleanPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
  const segments = cleanPath.split('/')
  const slug = segments[segments.length - 1]
  // search by slug
  const query = `*[_type == "post" && slug.current == $slug][0]`
  const post = await publishedClient.fetch(query, {slug})
  if (post) {
    // return just the _id to use as a reference
    return post._id as string
  }
  return false

}

export default defineMigration({
  title: 'link to internal',
  documentTypes: ["post"],

  migrate: {
    async object(node, path, context) {
      if (!path.includes('content')) return

      // Confirm the object is the type of link you want to edit.
      // Check type, confirm it's not internal, and confirm it has a URL.
      if (node._type === 'link' && node?.isInternal !== true && node?.url) {

        // Parse the URL. Set a base URL if you ever use relative links.
        const originalUrl = URL.parse(node?.url as string, 'https://your-domain.com')

        // if the URL didn't parse, return
        if (originalUrl === null) return

        // confirm it meets your criteria, such as subdomain, etc.
        // For example, check if it's on your hostname
        if (originalUrl.hostname === 'your-domain.com' || originalUrl.hostname === 'www.your-domain.com') {
          const reference = await getReference(originalUrl, context)
          if (reference) {
            // if the reference is found, replace the existing internal link
            // with a new reference link
            return set({
              // preserve the original _key value.
              _key: node._key,
              _type: 'link',
              isInternal: true,
              reference: {
                _ref: reference,
                _type: 'reference',
              }
            })
          }
        }
      }
    },
  },
})

```

If you anticipate a large quantity of repeated links, you'll want to use a form of caching to avoid making new API calls for links you already have reference data for.

## Migrate using content releases

Running a migration in-place requires coordination between your dataset, Studio, and frontend. You can help remove some of the complexity of this by migrating changes into a content release instead of writing directly to the existing documents.

This example makes a small schema change, but the principle applies to entire document rewrites as well.

First create a new release and obtain the release name. Learn more about release documents in the [content releases API documentation](https://www.sanity.io/docs/content-lake/content-release-document-flow). Then use the release name to create version documents in the release as shown below.

**index.ts**

```typescript
import {defineMigration, createOrReplace} from 'sanity/migrate'
const RELEASE_ID = 'release-id'

export default defineMigration({
  title: 'content release migration',
  documentTypes: ["post"],
  // Only run on published documents. If you use drafts, you may want to omit draft documents as well.
  filter: `!(_id in path('versions.**'))`,
  migrate: {
    async document(doc, context) {
      const newDoc = {
        _type: 'post',
        _id: `versions.${RELEASE_ID}.${doc._id}`,
        title: doc.title,
        content: doc.body,
        // other new, updated, or fields you want to carry over
      }
      return [createOrReplace(newDoc)]
    },
    
  },
})

```

This example uses `createOrReplace`, but you can also use `createIfNotExists` if you don't want to overwrite any existing version documents.

## Shift Portable Text block headings

Sometimes you allow editors to use headings that aren't intended. You can resolve this on the frontend, but you can also edit blocks directly. This example shifts all headings inside a Portable Text block down a level.

**index.ts**

```typescript
import {defineMigration, set} from 'sanity/migrate'

export default defineMigration({
  title: 'shift-headings',
  documentTypes: ["post"],

  migrate: {
    object(node, path, context) {
      if (node._type === 'block') {
        if (node.style === 'h1') {
          return set({
            ...node,
            _type: 'block',
            style: 'h2',
          })
        }
        if (node.style === 'h2') {
          return set({
            ...node,
            _type: 'block',
            style: 'h3',
          })
        }
        if (node.style === 'h3') {
          return set({
            ...node,
            _type: 'block',
            style: 'h4',
          })
        }
        if (node.style === 'h4') {
          return set({
            ...node,
            _type: 'block',
            style: 'h5',
          })
        }
      }
    },
  },
})
```

## Correct incorrect heading nesting

Much like the shifted heading migration above, this example aims to fix mismatched heading hierarchy. By using `array` and checking the path, we can narrow to only include a Portable Text (block) array. Adjust your conditional checks as needed for your schema.

This example ensures that headings cannot skip. For example, an H4 cannot exist without an H3 before it.

**index.ts**

```typescript
import {defineMigration, set} from 'sanity/migrate'

const levelMap = {
  h1: 1,
  h2: 2,
  h3: 3,
  h4: 4,
  h5: 5,
  h6: 6,
}

export default defineMigration({
  title: 'correct-headings',
  documentTypes: ["post"],

  migrate: {
    array(node, path, context) {
      if (path.includes('content') && node.length > 1) {
        // The current level, starting at h1 (1)
        let level = 1
        const newNodes = node.map((item: any, index: number) => {
          // Check if the block is a heading
          if (Object.keys(levelMap).includes(item.style)) {
            // for headings to start at h2, as we want to use a separate field for h1 titles
            if (levelMap[item.style] === 1) {
              level = 2
            // If the block's level is more than 1 greater than the current level,
            // shift it to be one level higher than current level instead.
            } else if (levelMap[item.style] > level + 1) {
              level++
            // If the block's level is less than the current level or the same level,
            // set level to current block's level. We can't know for sure how far back it should go.
            } else {
              level = levelMap[item.style]
            } 
            return {
              ...item,
              style: `h${level}`,
            }
          } else {
            // non-headings are returned as normal
            return item
          }
        })
        return set(newNodes)
      }
    },
  },
})

```

This logic can't infer intent, so it only corrects skipped headings, but doesn't know when to break out of a nested hierarchy.

## Find and update deeply nested objects

Use the `node()` handler to visit every value in a document tree. The handler receives the current node, its path, and a context object. Return a mutation to transform matching nodes:

```typescript
import {defineMigration, set} from 'sanity/migrate'

export default defineMigration({
  title: 'Add tracking field to all link objects',
  migrate: {
    node(node, path, context) {
      if (node._type === 'link') {
        return set({...node, tracking: true})
      }
    },
  },
})
```

The `node()` handler automatically traverses the entire document, including Portable Text blocks, arrays, and nested objects. You can also use `object()` to visit only object nodes, or `array()` for array nodes.

### Advanced: using `extractWithPath` for document context

When you need access to document-level fields while updating nested objects, use `extractWithPath` from `@sanity/mutator` with the `document()` handler. This gives you the full document as context:

```typescript
import {extractWithPath} from '@sanity/mutator'
import {at, defineMigration, set} from 'sanity/migrate'

export default defineMigration({
  title: 'Update all nested link objects using document context',
  migrate: {
    document(doc) {
      const matches = extractWithPath('..[_type=="link"]', doc)
      if (matches.length === 0) return
      return matches.map(({path, value}) =>
        at(path, set({
          ...value,
          source: doc.title,
        })),
      )
    },
  },
})
```

The `..` operator is JSONMatch recursive descent syntax. It matches objects at any depth in the document tree. See the [JSONMatch documentation](https://www.sanity.io/docs/content-lake/json-match) for the full path expression syntax.

> [!TIP]
> Use `node()` when you only need the matched node itself. Use `extractWithPath` with `document()` when you need to reference other fields from the parent document.



# Schema migration principles

Content and schema migrations are potentially high-stakes operations, especially for projects that are in production. At the same time, it can be hard to nail a content model on the first try and anticipate all needs and requirements ahead of time. Our aim at Sanity is to enable you to work with content models and content through APIs early in your projects without being penalized for it when these need to change.

The steps on this page use the Sanity CLI. You also need write access to the dataset you're migrating. For full command syntax, see the [Migrations CLI command reference](https://www.sanity.io/docs/cli-reference/cli-migrations) and the [Documents CLI command reference](https://www.sanity.io/docs/cli-reference/documents).

The considerations you need to take can differ depending on whether your project is in development or has been put into production. Below are some overarching considerations for both scenarios.

> [!WARNING]
> Gotcha
> Keep in mind that editors may be editing in the Studio while the migration is running. It's good to give them a heads-up before running a content migration on a dataset that is being worked on.

## For projects that haven't been put into production yet

Most changes to a content model for projects in development that haven't been put into production are additive; you *add* new document types and fields. Often, you will not have as much content that needs to be changed or updated either. Sometimes, editing documents manually in the Studio might be as efficient as running automated scripts to change them.

That said, there are also cases where you have a lot of content because you have engaged the content team to work in parallel to enhance the design and implementation process or have imported content from another system. You wish to take the opportunity to improve its structure.

In these cases, you should always:

1. Export the dataset before migration.
2. Commit your schema changes to git with updated validation rules and/or deprecated schema types.
3. Run `sanity documents validate` to check what documents give errors against your schema changes.
4. Initialize a migration job with `sanity migrations create` to scaffold a file and boilerplate code.
5. Dry run `sanity migrations run <ID>` and validate that the patches seem correct.
6. Run `sanity migrations run <ID> --no-dry-run` to make the changes.
7. Update the queries and downstream code in the application(s) where the content is used.

> [!TIP]
> Protip
> If you aren't quite ready to change the code that implements your content, you can use [the coalesce function in GROQ](https://www.sanity.io/docs/specifications/groq-functions) to “alias” the new patterns to the old variable/shape:
> `"oldFieldName": coalesce(newFieldName, oldFieldName)`

## For projects in production

Non-additive changes to the content model for projects in production require more diligence, as you might be used to from any database migration, especially if you aim for as little downtime as possible. Migrations like these are easier if you support PR/branch deployments in your CI/CD tooling. We recommend deploying the Studio from a git-based platform if you have more than simple needs.

To prepare a migration for projects in production:

1. Export the dataset before migration (or for enterprise: enable dataset backups).
2. Export and import (or copy) your production dataset into a staging dataset that you can test your migrations and relevant applications against.
3. Make your schema changes, and remember to give easy-to-understand instructions when deprecating fields.
4. Run `sanity documents validate` to check what documents give errors against your new schema changes.
5. Initialize a migration job with `sanity migrations create` to scaffold a file and boilerplate code.
6. Dry run `sanity migrations run <ID>` and validate that the patches seem correct.
7. Run `sanity migrations run <ID> --no-dry-run` to make the changes in your staging dataset.
8. Update queries and downstream code paths in applications that depend on the affected content.
9. The most foolproof way to write “defensive code” that supports both content models.
10. Thoroughly test the changes in the branch/PR deployments.
11. Onboard users/stakeholders of your Sanity Studio to the new changes and let them test out the editorial experience.
12. When you have confidence that everything works, you can merge the applications to production and then run the migration jobs against your production dataset.
13. When you have confirmed that everything works as it should in production, you can clean up the “defensive” code to eliminate the code paths for the old content model.

## Strive for idempotent migrations

An idempotent migration is a migration that can safely be run multiple times. Typically, an idempotent migration will start by checking if a precondition is met before it runs, and if this condition isn't met, the migration will do nothing.

```typescript
import {defineMigration, at, setIfMissing, append, unset} from 'sanity/migrate'

export default defineMigration({
  title: 'Convert product category from string to array of strings',
  documentTypes: ['product'],
  filter: 'defined(category) && !defined(categories)',
  migrate: {
    document(doc) {
      return [
        at('categories', setIfMissing([])),
        at('categories', append(doc.category)),
        at('category', unset()),
      ]
    },
  },
})

```

Example of an idempotent operation:

`at('name', set(person.name.toUpperCase()))`

This will produce the same result no matter how often you run it.

Example of a non-idempotent operation:

`at('members', append({name: 'Some One'}))`

This inserts a new member into the array every time it's run, giving different results every time it's run.

### Providing an idempotence key

If there's no way to make your migrations idempotent, you can instead write an idempotence key to your document along with the migration.

```typescript
import {defineMigration, at, setIfMissing, append} from 'sanity/migrate'

const idempotenceKey = 'xyz' // should be unique for the migration but never change

export default defineMigration({
  title: 'Convert product from reference to array of references',
  filter: 'defined(product) && !defined(products)',
  migrate: {
    document(doc) {
      if ((doc._migrations || []).includes(idempotenceKey)) {
        // Document already migrated, so we can skip
        return
      }
      return [
        // migration
        at('members', append({name: 'Some One'})),
        // …add idempotence key
        at('_migrations', setIfMissing([])),
        at('_migrations', append(idempotenceKey)),
      ]
    },
  },
})

```



# Schema validation

Validation rules you define in your schema run in Sanity Studio. They do not run on the server. When you create or update documents through the HTTP API, a client library like `@sanity/client`, content migrations, or data imports, the Content Lake accepts the write without checking your validation rules.

This is by design. The Content Lake is intentionally schemaless, and that flexibility is what makes several important features possible.

## Why the Content Lake doesn't enforce schemas

A schemaless Content Lake means you can evolve your schema without running database migrations. You can add, rename, or remove fields and the existing data stays intact. Old documents don't break when the schema changes, and new schemas can coexist with old data.

It also means multiple Studios with different schemas can point at the same dataset. This is useful for teams that maintain separate Studio configurations for different roles or workflows.

The trade-off is that schema constraints, including validation rules, are the responsibility of the client application making the write. Sanity Studio handles this automatically. Other clients do not.

## What this means in practice

When an editor saves a document in Sanity Studio, the Studio checks every validation rule before allowing the publish. Required fields, minimum and maximum values, custom validators, and document-level rules all run in the browser.

When your code calls the mutation API or uses `@sanity/client` to create or patch a document, none of those rules run. The Content Lake accepts the document as-is. This applies to all programmatic writes, including the HTTP mutations API, client library methods like `create()`, `createOrReplace()`, and `patch()`, content migrations, and data imports.

There are some schema-aware tools, like [Agent Actions Patch](https://www.sanity.io/docs/agent-actions/patch-quickstart) and some [MCP tools](https://www.sanity.io/docs/ai/mcp-server), but in general most API tools won’t validate your input.

## How to validate data outside the Studio

If you're writing data programmatically and need to ensure it conforms to your schema, you have a few options.

### Validate after writing with the CLI

The Sanity CLI can check all documents in a dataset against your current schema. This is useful as a post-flight check after bulk operations like migrations or imports.

**npm**

```shell
npx sanity documents validate
```

**pnpm**

```shell
pnpm dlx sanity documents validate
```

**yarn**

```shell
yarn dlx sanity documents validate
```

**bun**

```shell
bunx sanity documents validate
```

This runs validation locally against your schema definition. It surfaces the same errors and warnings you would see in the Studio.

#### Validate published documents only

`sanity documents validate` checks every document in the dataset, including drafts and the document versions held in content releases. There is no flag that limits validation to published documents.

To validate published documents only, export the dataset without drafts, then validate the export file:

**npm**

```shell
npx sanity@latest datasets export production published-only.tar.gz --no-drafts
npx sanity@latest documents validate --file published-only.tar.gz
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets export production published-only.tar.gz --no-drafts
pnpm dlx sanity@latest documents validate --file published-only.tar.gz
```

**yarn**

```shell
yarn dlx sanity@latest datasets export production published-only.tar.gz --no-drafts
yarn dlx sanity@latest documents validate --file published-only.tar.gz
```

**bun**

```shell
bunx sanity@latest datasets export production published-only.tar.gz --no-drafts
bunx sanity@latest documents validate --file published-only.tar.gz
```

`--no-drafts` excludes both drafts and content release versions, so the export holds published documents only. Run the validate command from your Studio project directory — it needs your local schema, and it resolves a relative `--file` path from the project root. References to documents that aren't in the export are still checked against the live dataset.

### Validate in your application layer

For applications that write to the Content Lake on an ongoing basis, validate the data in your own code before making the API call. This is standard practice when writing to any data store through an API. Check required fields, value ranges, and any business rules before calling `create()` or `patch()`.

### Use generated types for structural safety

If you're using TypeScript, `sanity typegen` generates types from your schema. This catches structural issues at compile time: wrong field types, missing required fields, and incorrect document shapes. It won't catch value-level constraints like `min(5)` or custom validators, but it covers a significant class of errors.

**npm**

```shell
npx sanity typegen generate
```

**pnpm**

```shell
pnpm dlx sanity typegen generate
```

**yarn**

```shell
yarn dlx sanity typegen generate
```

**bun**

```shell
bunx sanity typegen generate
```



# Perspectives for preview and presentation

By presenting all in-flight changes together within the real experience, content creators, reviewers, and stakeholders can get a realistic view of what the experience will look like when these changes are published. High-fidelity content previews enable valuable and accurate feedback, validation, and approvals before rolling out updates.

Likewise, ensuring that your "production" or "public" experience or application only presents published content is critical to ensuring that what is presented to your consumers is complete, validated, and approved.

Sanity offers a range of tools and [Perspectives](https://www.sanity.io/docs/content-lake/perspectives) for Content Lake to help you build first-class preview experiences.

> [!TIP]
> Protip
> This article focuses on preview environments outside the Studio. To learn more about the various ways of previewing content changes within Sanity Studio, please visit [this article](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing).

A key feature of composable content is that you can shape data from a variety of independent but interconnected documents into whatever shape is required on the consuming end. For content authored in Sanity Studio, all unpublished changes are tracked in *draft documents* that coexist with the published version of the same document. With content spread over multiple documents, each with its own publishing state, creating robust preview tooling can become cumbersome. 

Perspectives is an out-of-the-box solution that does away with the heavy lifting of creating engaging preview experiences.

> [!NOTE]
> Defining "published"
> For simplicity, this article assumes that the publish status on the consuming end matches that of the Content Lake. That is, a document that is published in your studio should be visible in the frontend, while unpublished changes in draft documents are what’s most relevant for previewing purposes.

## Content Lake Perspectives

[Perspectives](https://www.sanity.io/docs/content-lake/perspectives) let you run your queries against an alternate "view" of the documents in your dataset. Use the `drafts` perspective to preview what your content would look like if all drafts were published, or set the `published` perspective in your production environment to make sure no unfinished draft content is accidentally made public.

If you use the [Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration) feature, you can also pass a perspective stack to view a custom perspective containing versions of documents across multiple releases.

The perspective is set as a property of the Sanity client configuration or passed as a parameter to the HTTP API query endpoints, allowing you to reuse queries between preview and production environments.

### Best practice

Web applications often use perspectives to retrieve initial server-side or static content.

- In your production environment, queries use the `published` perspective. 
- In preview environments, queries use the `drafts` perspective or a perspective stack and are then hydrated with live updates via [Loaders](https://www.sanity.io/docs/visual-editing/visual-editing-architecture).

```typescript
// Example JS/TS client configuration
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  useCdn: false, // must be false for 'drafts'
  perspective: 'drafts', // 'raw' | 'published' | 'drafts' 
})
```

```text
// Example using HTTP API
/data/query/production?query=*[]&perspective=drafts
```

### `raw`

The `raw` perspective returns drafts, document versions from releases, and published content for authenticated requests. 

> [!WARNING]
> Gotcha
> Prior to API version `2025-02-19`, `raw` was the default perspective value and returned both published and draft documents.

### `drafts`

When using this perspective, your queries will return as if all draft documents in your dataset were published. This means:

- References to draft documents will be resolved/dereferenced normally, exactly as when dereferencing published documents.
- Query results will not be cached in the CDN, ensuring you always get the latest most up-to-date version of in-flight changes.
- When using [custom permission resources](https://www.sanity.io/docs/content-lake/roles-concepts), draft documents will be presented only when the user’s permissions grant access to the draft document; otherwise, the published version is presented.

### `published`

The default perspective value. When you use this perspective, your queries will operate as if there were no draft or version documents in your dataset.

[Perspectives for Content Lake](https://www.sanity.io/docs/content-lake/perspectives)
Visit the main documentation article on Perspectives for usage and code examples

[JS/TS Client README](https://github.com/sanity-io/client#readme)
Learn more about how to use Perspectives with the Sanity client library for JavaScript and TypeScript

### Release perspectives

The Content Releases feature introduces document versions and allows you to customize the perspective to layer one or more releases in a perspective stack. 

Releases take priority from left to right. For example, in the perspective `a,b,c` you would see changes in `a` take priority over `b` and `c`, and changes in `b` take priority over `c`.

```typescript
// Example JS/TS client configuration
import {createClient} from '@sanity/client'

const client = createClient({
  ...config,
  useCdn: false, // must be false for 'release previews'
  perspective: ['a','b','c']
})
```

The `published` perspective is automatically added to the end, so even if a release only contains changes to one document, the response will include all matching published documents in addition to the release changes. You can also append `drafts` to the list to include draft versions.

## Preview tooling

The Perspectives feature alone can enable running independent preview and production environments, but for truly custom, interactive live-as-you-type preview, Sanity also offers powerful tooling for any frontend framework and offers guides on the most popular ones.

[Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)

[Guide: Visual Editing with Next.js](https://www.sanity.io/guides/nextjs-app-router-live-preview)

[Guide: Visual Editing with Remix](https://www.sanity.io/guides/remix-run-live-preview)



# Live Content API

The Live Content API allows you to deliver live, dynamic experiences to your users without the complexity and scalability challenges that typically come with building real-time functionality. It is available on all plans, including free plans. See the [pricing page for usage details](https://www.sanity.io/pricing#compare-plans).

![Distribute real-time updates to your applications](https://cdn.sanity.io/images/3do82whm/next/053e032a2de083d666a0815b7634dd302490638d-1200x675.png)

With the Live Content API, you can:

- **Subscribe to changes** and receive notifications whenever documents are created, updated, or deleted.
- **Efficiently query** for the exact content you need, and only receive updates for that content.
- **Scale** to handle high volumes of live updates, even during peak traffic periods.

## When to use the Live Content API

The Live Content API is designed to integrate into your existing application. It provides an interface for subscribing to content changes and receiving real-time updates.

Most sites and applications benefit from a mix of live and static content. For example, a news organization may want their homepage to use live content while article pages remain statically generated. In other cases, you may want islands of dynamic content to use the Live Content API in an otherwise statically generated application.

The Live Content API requires API version `v2021-03-25` or later.

> [!NOTE]
> Usage limits and live connections
> Live connections are part of the [Live Content API usage](https://www.sanity.io/pricing#compare-plans), but new requests contribute to your API usage quota.
> Live connections don't request data, but instead listen for targeted updates in your dataset data. Your application relies on these tags to make new requests.
> The Live Content API holds on to old events and can replay them to clients when they reconnect, up to a retention window of 15 minutes on Free and Growth plans. Enterprise plans have custom retention. See [Live Content API pricing](https://www.sanity.io/pricing#compare-plans) for current limits.
> Use site-wide caching techniques to minimize unnecessary requests and prevent unexpected usage spikes. The `next-sanity` library handles this caching for you in Next.js apps.

## How Live Content works

Taking advantage of the Live Content API is a two-stage process, plus an optional third stage when you serve content through a CDN:

1. Clients listen for content identifiers we call sync tags. They map to specific requests, so changes in your dataset only trigger new tags for requests on the new content.
2. Clients query your data, listen for changes, and update content only when it changes.
3. Optional: If you serve content through a CDN, set up a [Sanity Function to invalidate the cache](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) to allow for live updates.

Our client libraries handle this process for you and provide helpers for building on top of the API. In Next.js, `next-sanity` also manages caching. By default it revalidates cached routes in the background, so some visitors keep seeing the previous content until that revalidation finishes. To guarantee that every visitor gets the update, deploy an Invalidate Sync Tags Function and set `waitFor="function"` on `<SanityLive>`. To start building, see [Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide). For more on the underlying API, see the [Live Content API reference](https://www.sanity.io/docs/http-reference/live) and our example implementations.

> [!WARNING]
> Dataset aliases
> The Live Content API does not support dataset aliases. Use the *real dataset name* in all requests.

## Get started

Start implementing the Live Content API by following one of our guides or experimenting with an example project.

[Set up live content in your app](https://www.sanity.io/docs/developer-guides/live-content-guide)
Enable real-time updates and live content in your applications.

[Clean Next.js + Sanity Starter](https://www.sanity.io/templates/nextjs-sanity-clean)
A clean starter project with Next.js 16 and loads of Sanity features including the Live Content API.

[Sanity Learn: Content-driven web application foundations](https://www.sanity.io/learn/course/content-driven-web-application-foundations/)
Learn the latest best practices for modern web applications, including Live Content concepts, with this Sanity Learn course.

## Additional resources

[Live Content API reference](https://www.sanity.io/docs/http-reference/live)
Reference documentation for implementing the API.

[Live content examples on GitHub](https://github.com/sanity-io/lcapi-examples)
A collection of examples for multiple frameworks and the API.



# Listening API

> [!TIP]
> Protip
> Searching for ways to update your sites or apps in real-time as your data changes? [The Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) is a more flexible, efficient choice.
> If you're looking to react to content as it changes, check out [Sanity Functions](https://www.sanity.io/docs/functions) or [GROQ-powered webhooks](https://www.sanity.io/docs/content-lake/webhooks). 

The Sanity data store supports realtime updates, allowing API clients to listen for content changes. This is used for collaborative editing in our content studio, where your view of the document is updated as other people make changes. These updates are available to your own apps as well, and have a wide range of uses, such as:

- Alerting end-users of breaking news stories.
- Updating client state in a multiplayer game.
- Transmitting chat messages between users.
- Controlling IoT (Internet-of-Things) devices such as home automation systems.

Listeners use the [Server-Sent Events protocol](https://www.w3.org/TR/eventsource/), by making an HTTPS request to:

`https://<project-id>.api.sanity.io/v2026-05-21/data/listen/<dataset>?query=<GROQ-query>`

The server will keep the connection open and stream events as they occur for any documents matching the provided [GROQ query](https://www.sanity.io/docs/specifications/groq-syntax). Further parameters and details are listed in the [listeners reference](https://www.sanity.io/docs/http-reference/listen).

> [!WARNING]
> Gotcha
> Listener queries do not support joins, since they operate on individual documents, and will ignore order-clauses and projections.

We recommend using one of our [client libraries](https://www.sanity.io/docs/client-libraries) to listen for updates, which will automatically decode events into native data structures and handle stuff like automatic reconnects. Here's an example using our [JavaScript library](https://www.sanity.io/docs/js-client):

```javascript
const query = '*[_type == "comment" && authorId != $ownerId]'
const params = {ownerId: 'myUserId'}

const subscription = client.listen(query, params)
  .subscribe(update => {
    const comment = update.result
    console.log(`${comment.author} commented: ${comment.text}`)
  })
```

## Events

> [!TIP]
> Protip
> Client libraries may hide or automatically handle certain events, refer to its documentation for details.

### `welcome` 

When the listener is set up and ready to serve mutations, you will receive the `welcome` event. It looks like this:

```text
event: welcome
data: {"listenerName": "Ua6BR3GwQ14cnZXrgwCdsF"}

```

You don't need to process this event, but it could be used to kick off other processing. If you are tracking changes to keep a document in sync on the client side, this is a good time to fetch the initial document using the [doc endpoint](https://www.sanity.io/docs/http-reference/doc). Fetching the document after the listener is ready ensures that you receive every subsequent mutation. If you fetch the initial document before setting up the listener, you may miss one or more mutations in the intervening time.

### `mutation` 

The most common event is the `mutation` event, which looks like this:

```text
event: mutation
id: lqgiok-skp-eja-k6z-9wrng7k5e#38123cba-286c-45a0-a6d1-3cc4dc43748a
data: <JSON-payload on a single line>
```

The payload is a single line of JSON. The [listener reference](https://www.sanity.io/docs/http-reference/listen) has a complete list of fields and descriptions, but some of the most useful fields are summarized below:

- `documentId`: the ID of the modified document
- `transition`: type of event - `update`, `appear`, or `disappear`
- `identity`: the user making the changes
- `mutations`: an array of mutations as submitted to the [mutate endpoint](https://www.sanity.io/docs/http-reference/mutation)
- `result`: the complete document after the mutations are applied
- `previousRev`: the document revision ID before the mutation
- `resultRev`: the document revision ID after the mutation
- `timestamp`: time when the mutation was applied
- `visibility`: whether the change is visible to queries yet (`query`), or only to subsequent transactions (`transaction`).

> [!WARNING]
> Gotcha
> Due to the distributed nature of the Sanity backend, mutation events may be sent out of order. A meticulous client would reassemble mutation events as an unbroken chain by comparing `previousRev` and `resultRev`, or use the most recent `result` document as determined by `timestamp`.

### `channelError` 

Errors during processing will appear as `channelError` events. These are typically caused by syntax errors in the query, and look like this:

```
event: channelError
message: {"message": <the error message>}

```

### `disconnect` 

Normally, if you are disconnected from a listener endpoint you should just immediately reconnect. However, if you receive the `disconnect` event, you should disconnect and stay away. Typically this means you just got a `channelError` that is considered fatal (e.g. a syntax error) and reconnecting will just repeat the ordeal. The event looks like this:

```
event: disconnect
data: {"reason": <a string describing the reason>}

```



## Listeners in Sanity Studio

A single Studio session opens many listeners at once, not one. The preview system, Tasks, Releases, and each open document subscribe separately, so one browser tab produces a steady stream of requests to the listen endpoint.

These requests carry first-party request tags such as `sanity.studio.preview.observe-document-set.listen`, which is often the highest-volume tag in a project's request logs. See [request tags](https://www.sanity.io/docs/platform-management/reference-api-request-tags).

### Reconnect behavior

`@sanity/client` reconnects on its own after a transient connection failure, waiting one second before it retries. Dropped connections, 5xx responses, 408, and 429 all count as transient. A rejection it can identify as permanent, such as an expired token, is surfaced to your code as an error instead, so the client doesn't retry it in a loop.

### Diagnose a spike of 401 responses

A Studio left open with stale credentials shows up in request logs as a large number of 401 responses on the listen endpoint, concentrated on a few IP addresses and often continuing outside working hours. The shape looks like an attack, but it is usually one client that can no longer authenticate, repeated across the many listeners that session holds open.

Before you treat that traffic as hostile, check for:

- Orphaned browser tabs left open on a Studio.
- Development instances still running against the project.
- Studios hosted in a desktop shell such as Electron.

Signing fully out and back in clears the stale credentials. Remember that a request tag is set by the client, so a `sanity.studio` tag on those requests is not evidence that the caller signed in. For what the failed requests mean for your bill, see [plans and payments](https://www.sanity.io/docs/platform-management/plans-and-payments).



# Introduction

## Webhooks at a glance

Webhooks are a way to integrate applications with automated HTTP requests. Typically you use them to connect services by creating a special URL that accepts incoming requests. What happens when the request resolves depends on the application or service.

> [!TIP]
> Have you tried Sanity Functions?
> Webhooks aren’t the only way to react to document changes. [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction) run directly on Sanity’s infrastructure instead of requiring an external service.

Some services only support receiving webhooks; others can both receive and send them. The Content Lake supports both sending sophisticated outgoing webhooks and receiving incoming webhooks to any appropriate API endpoint, provided they have the proper payload and authentication.

## Webhooks in your Content Lake

You can create and manage outgoing webhooks in the API section of your project settings, which you'll find at [sanity.io/manage](https://www.sanity.io/manage). Webhooks can also be managed through the CLI or directly through the project APIs.

![API settings with the webhook overview showing a “Trigger site rebuild” webhook.](https://cdn.sanity.io/images/3do82whm/next/13bdd207a065dbdc51d06151b1a170d40cc26378-1053x624.png)

## Configuration

You can find them all in the webhooks section under API in your project's settings on [sanity.io/manage](https://www.sanity.io/manage).

### Name and description

You can name your webhooks and give them a description. The description field, while optional, is a useful way to add helpful context about your webhook.

### URL

The URL field is where you specify the endpoint to which the webhook request is sent. If you want to test the webhook before entering the production endpoint, you can use services like [webhook.site](https://webhook.site), or [Beeceptor](https://beeceptor.com/). You can also use [ngrok](https://ngrok.com/) or [Localtunnel](https://localtunnel.github.io/www/) to test a hook against your local environment.

### Trigger on

Webhooks can be triggered when a document is **created**, **updated**, **deleted**, or any combination of these.

- **Create**: triggers on the creation of a new document.
- **Update**: triggers on every change to a document once created.
- **Delete**: triggers on the deletion of a document, including when a published document is unpublished.

Between these, you'll be able to react to all major interactions with the documents in question.

Unpublishing a document fires the **delete** trigger. An unpublish deletes the published document, and creates a draft from its contents if no draft already exists. With the drafts setting enabled, that new draft also fires the **create** trigger.

> [!NOTE]
> Pro tip
> By default, your webhooks will not trigger on draft or version events. They will only trigger when changes to the document are published and not for every single occurrence while you edit. Triggering on draft and version events can be enabled, but be careful or you may end up causing huge amounts of traffic to your endpoint!

### Filter

A GROQ filter specifying which documents will, when changed, trigger your webhook. A filter is what you commonly see between the `*[` and `]` in a GROQ query. This field supports all the GROQ functions you'd expect and has additional support for functions in the [delta::](https://www.sanity.io/docs/specifications/groq-functions) namespace, as well as [before()](https://www.sanity.io/docs/specifications/groq-functions) and [after()](https://www.sanity.io/docs/specifications/groq-functions).

If left empty, it will apply to all documents (`*[]`).

The webhook filter does not support the following kinds of queries and will yield `false`:

- Sub-queries, e.g. `_type == "book" && author._ref in *[_type=="author" && name=="John Doe"]._id`
- Cross-dataset references: `_type == "book" && author->featured` where author is a [cross-dataset reference](https://www.sanity.io/docs/studio/cross-dataset-references).

See our [Intro to Filters](https://www.sanity.io/docs/developer-guides/filters-in-groq-powered-webhooks) guide for tips on using filters in webhooks.

### Projection

A GROQ projection defining the payload (or body) of the outgoing webhook request. This field supports GROQ functions in the [delta::](https://www.sanity.io/docs/specifications/groq-functions) namespace, as well as [before()](https://www.sanity.io/docs/specifications/groq-functions) and [after()](https://www.sanity.io/docs/specifications/groq-functions).

If left empty, it will include the whole document *after* the change that triggered it.

> [!WARNING]
> Gotcha
> “Sub-queries” are not supported for webhook projections. For example, the following query will *not* work: `{ "relatedPost": *[^._id in related[]._ref]{_id, title, slug}}`

See our [Intro to Projections](https://www.sanity.io/docs/developer-guides/projections-in-groq-powered-webhooks) guide for tips on using projections in webhooks.

### Status

Enable or disable your webhook.

> [!NOTE]
> Disabling webhooks
> When a webhook is disabled, all pending requests will be canceled.

### HTTP method

This field configures the webhook's [HTTP request method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods). It can be set to POST, PUT, PATCH, DELETE, or GET. Some endpoints require incoming requests to use a specific method to work.

### HTTP headers

Additional HTTP headers. You can add multiple headers. A common example is adding an `Authorization: Bearer <token>` header to authenticate the webhook request.

> [!WARNING]
> Gotcha
> Be mindful when sharing webhooks: the shared link includes your header configuration, which may contain sensitive information. Remove it before sharing the link.

A webhook will always include the following headers and values:

- [connection](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection): close
- [accept-encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding): gzip
- `idempotency-key`: <a unique key>. See documentation below.
- [content-type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): application/json
- [content-length](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length): <the length of the payload in bytes>
- [user-agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent): Sanity.io webhook delivery
- [host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host): <the endpoint URL host>

As well as the following Sanity-specific headers that can be useful for logging and debugging your webhooks:

- `sanity-transaction-id`: ID of transaction.
- `sanity-transaction-time`: Timestamp of transaction.
- `sanity-dataset`: Name of dataset (also available in projection today as `sanity::dataset()`).
- `sanity-document-id`: Document ID being notified about.
- `sanity-project-id`: ID of project (also available in projection today as `sanity::projectId()`).
- `sanity-webhook-id`: ID of webhook.
- `sanity-operation`: Either create, update, or delete.

> [!NOTE]
> Info
> The projection will always be returned as JSON. If you for some reason need it to be another content type, you’ll have to pass it through a serverless function or a custom endpoint and do the transformation there.

### API version

Defaults to `v2021-03-25` of the query API. Can be overridden using the [Webhooks API](https://www.sanity.io/docs/http-reference/webhooks) in cases where you want to create webhooks with old behavior that might have been deprecated.

### Drafts and versions

By default, documents in the `drafts.` and `versions.` ID namespaces will be automatically ignored. Enable the drafts or version setting if you want the triggers and filter to apply to draft or version documents. Note: version support was added in API version `v2025-02-19`.

> [!WARNING]
> Gotcha
> This might cause a lot of webhooks to trigger whenever someone is working inside Sanity Studio, since almost every keystroke represents an `update`. Webhooks are limited to one concurrent request, but you should also make sure that your endpoint is able to handle the incoming events.

### Secret

To let receiving services verify the origin of any outgoing webhook, you may add a secret that will be hashed and included as part of the webhook request's headers. You may find our [webhook toolkit library](https://github.com/sanity-io/webhook-toolkit) helpful for working with secrets. If you want to roll your own, we model the signing and verification of payloads on the same standard as [Stripe](https://stripe.com/docs/webhooks/signatures#verify-manually).

## Idempotency-key

Requests include a header that can be used to de-duplicate deliveries: `idempotency-key`.

This is necessary because webhooks will sometimes be retried, and our system has *at-least-once* delivery. Using the unique idempotency key lets the receiver ignore messages it has already received.

We follow [this draft standard](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) for idempotency.

## Sharing webhooks

Webhook configurations can be shared with a URL. This is practical if you want to quickly repurpose webhooks across projects or share with the community. You can generate a share URL by going to [sanity.io/manage/webhooks/share](https://www.sanity.io/manage/webhooks/share) or by finding the share button in the three-dot menu in the webhooks overview. 

> [!WARNING]
> Gotcha
> Note that all the configuration is stored as part of the URL. Be mindful of any sensitive information that might be part of the configuration and that it will be shared in plain text. It can be wise to replace secret tokens and so on with capitalized placeholder text.

## Debugging webhooks

### Attempts log

> [!TIP]
> Pro tip
> Use the attempts log to determine whether your webhooks are being successfully delivered.

You can find the attempts log if you click the three-dot menu for a given webhook. The log will include information about the response a webhook request got. The attempts log is available as an [API endpoint](https://www.sanity.io/docs/http-reference/webhooks) at:

```text
https://${projectId}.api.sanity.io/v2021-10-04/hooks/projects/${projectId}/${id}/attempts
```

### Message log

> [!TIP]
> Pro tip
> Use the message log if you want to know whether all outstanding messages for a webhook have been delivered. 

The message log is available as an [API endpoint](https://www.sanity.io/docs/http-reference/webhooks) at:

```text
https://${projectId}.api.sanity.io/v2021-10-04/hooks/${id}/messages
```

The log contains a list of messages in the queue and any delivery attempts for each:

- If all the messages returned have the status `queued`, then your processing has fallen behind. This may indicate that your webhook processing is too slow and/or that your webhook filter is too broad and is generating a vast number of messages. 
- If your webhook request handler takes longer to process a message than the rate at which you are generating changes that trigger the webhook, then the queue will never be cleared.

## Technical limits, retry policy, and timeouts

- Webhooks are limited to one concurrent request.
- We will retry sending a webhook request twice, with a 30-second interval between each attempt. This limit is subject to change in the future.
- A webhook request will time out after 30 seconds.

### HTTP status codes

The HTTP status codes are used to determine if delivery is successful:

- 200-range will be treated as a success
- 400-range will be treated as undeliverable, as the server said it was a client error (with one exception, described in the next item)
- 429 will be retried according to the retry policy outlined above
- 500-range will be retried according to the retry policy outlined above

## Webhook origin IP addresses

The full list of IP addresses that Sanity webhook calls originate from can be found in this file:

[https://www.sanity.io/files/webhooks-egress-ips.txt](https://www.sanity.io/files/webhooks-egress-ips.txt)

The IP addresses generally don’t change but they may be updated from time to time, on planned or unplanned/emergency maintenance. For planned changes, we aim to announce upcoming changes seven days in advance on Sanity’s status page feed here: [https://www.sanity-status.com/](https://www.sanity-status.com/). Unplanned maintenance changes will happen without notice, but the URL file will be immediately up to date.

> [!WARNING]
> Gotcha
> If you’re aiming to use these addresses for IP filtering/security purposes, make sure you keep your tooling up to date with the URL above in an automated/unattended way.



# Best practices

This article describes best practices for configuring Sanity webhooks and for handling them in your system.

## Configuration

GROQ webhooks should be configured to trigger on the narrowest possible set of changes. Make sure the filter is as specific as possible and avoid triggering webhooks on draft changes unless absolutely necessary. Drafts can change frequently as content is being edited, which could result in a high volume of webhooks that may be costly or overwhelming for your systems. The same applies to version documents from Content Releases. Documents in the `drafts.` and `versions.` ID namespaces are ignored by default and are controlled by separate settings: enable "Trigger webhook when versions are modified" in sanity.io/manage, or set `includeAllVersions: true` via the Webhooks API, only when your integration genuinely needs unpublished release content. Version support requires webhook API version `v2025-02-19` or later.

## Delays

In rare circumstances there can be delays in the delivery of webhooks. If receiving timely updates is critical to your app, this should be considered in webhook handling. For example, you could check the `sanity-transaction-time` header and compare this to the current date and time — if you see times over a certain age, you might trigger a catch-up using API calls.

Delays could also mean webhooks can potentially be received out of order. Therefore it can be useful to check the `_updatedAt` value on a document to ensure you're using the latest data. It can sometimes be worth considering whether you're best to use the data in a webhook or use the webhook to trigger a query.

## Recovery from downtime

It's important to consider that downtime can occur with any webhook setup, whether it's on the side of your application or the provider itself.

To mitigate the impact of potential downtime, implement a mechanism for recovering missed data through API calls. This ensures your application can stay up to date even if webhooks are temporarily unavailable. Plan for a short retry window: Sanity retries a failed delivery twice at 30-second intervals, then marks the message failed — roughly one minute of tolerance, not hours. Only 429 and 500-range responses are retried; a 400-range response is treated as undeliverable and is never retried. If your queue is saturated, return 429 or a 500-range status rather than a 4xx. Because the retry window is this short, a reconciliation path via API calls is required, not optional, for any outage longer than about a minute.

## Idempotence

Idempotence in the context of webhooks refers to the ability to process the same webhook payload multiple times without adverse effects.

For example, if a webhook is delivered and processed successfully, but the acknowledgment response fails to reach the sender due to a network issue, the sender might retry sending the same payload. In an idempotent system, receiving and processing the same payload again would not result in duplicate data or unintended side effects.

Sanity provides an `idempotency-key` header which you can use to ignore messages that might be in a state of being processed or that have been processed already. By checking the `idempotency-key`, you can ensure that your application processes each unique webhook payload only once, even if it is delivered multiple times.

## Reconciliation

Relying solely on webhooks isn't recommended in any application — delivery can't always be guaranteed due to network issues, application downtime, or other factors.

You might want to run regular sync jobs — at hourly, daily, or other intervals — to make sure everything updated between syncs is reconciled. This sync could filter using the `_updatedAt` field on Sanity documents to find everything which has changed since the last sync.

## Scalability

As the volume of webhooks received by your application increases, it can become challenging to process all of them in real time. Sanity sends webhook requests for a given webhook one at a time — deliveries are limited to one concurrent request, so your endpoint is never hit in parallel. But if your handler takes longer to respond than the interval at which triggering changes occur, the delivery queue falls behind and never clears. Check the message log (`GET https://YOUR_PROJECT_ID.api.sanity.io/v2021-10-04/hooks/WEBHOOK_ID/messages`) — messages stuck at status `queued` mean your processing has fallen behind, or your filter is too broad. To handle this, implement a queuing system for incoming webhooks.

When a webhook is received, instead of processing it immediately, your application should add it to a queue for asynchronous processing. This allows your webhook endpoint to quickly acknowledge receipt of the webhook and return a response within the 30-second timeout window Sanity implements.

It's important to note that the response returned by your webhook endpoint should indicate that the webhook was received successfully, not that it was fully processed. This distinction is crucial because the actual processing of the webhook happens asynchronously through the queue.

By decoupling the receipt and processing of webhooks using a queue, you can ensure that your application remains responsive and can handle a high volume of incoming webhooks without overwhelming your system. The queue acts as a buffer, letting you process webhooks at a pace that your application can handle, while still acknowledging their receipt in a timely manner.

Implementing a robust queuing system for webhook processing is a best practice for building scalable and reliable applications that can handle increasing webhook traffic as your system grows.

## Security

When setting up webhooks, consider the security measures that protect your application and data. Your webhook endpoint is publicly reachable, so anyone who finds its URL can send requests to it. Verify that every payload you receive comes from Sanity.

Here are a few key points to keep in mind:

- **Secrets**: Sanity lets you configure a secret token for your webhooks. This secret should be a unique, random string that is only known to your application and Sanity. Sanity never sends the secret itself. Instead, it uses the secret to sign each payload and sends the result in a `sanity-webhook-signature` header, formatted `t=<timestamp>,v1=<signature>`, where the signature is an HMAC-SHA256 of `<timestamp>.<raw request body>` encoded as base64url. Your application should recompute the signature from the raw, unparsed request body and compare it to the header value. Parsing the body before verifying will produce mismatches.
- **Webhook toolkit**: Sanity offers a [webhook toolkit](https://github.com/sanity-io/webhook-toolkit), which is a set of utilities for handling webhooks in a secure and reliable manner. The toolkit includes features like signature verification, which helps ensure the integrity and authenticity of the webhook payloads you receive. Although the toolkit is written in TypeScript, the concepts and principles it promotes are language-agnostic.
- **IP allowlisting**: Sanity provides a [specific set of IP addresses](https://www.sanity.io/files/webhooks-egress-ips.txt) from which webhooks are sent. You can configure your application to only accept webhook requests originating from these trusted IP addresses. This adds an extra layer of security by preventing unauthorized sources from sending fake webhook payloads to your endpoint.

By implementing these security measures, you can protect your application from potential threats and ensure that the webhooks you receive are genuine and trustworthy.



# Webhooks API reference

The Webhooks API allows you to programmatically interact with and monitor webhooks.

#### Want to get started?

[GROQ-powered webhooks](https://www.sanity.io/docs/content-lake/webhooks)
Send customized HTTP requests when something in your Content Lake has changed.

[Webhook best practices](https://www.sanity.io/docs/content-lake/webhook-best-practices)
Best practices for configuring webhooks and handling them in your system.

In addition to webhooks, you can also react to document changes with [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction).

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).
- Manipulating documents requires read+write access permission for the affected document type. In most cases, this includes the Editor, Developer, or Administrator roles.

## Webhook types

Sanity provides two types of webhooks, transaction and document. Document webhooks are preferred because they are more flexible and powerful.

### Document

A document webhook triggers every time a document is created, updated, or deleted. If a transaction updates 3 documents, 3 webhooks will be executed. Document webhook also allows for more granular filtering and customizable payloads with GROQ.

### Transaction

A transaction webhook triggers once per dataset, meaning if you batch together multiple document mutations in one transaction only one webhook will be executed.



# Introduction

> [!TIP]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. We recommend migrating to the new **Embeddings** feature, now natively available within Sanity datasets.
> The new Embeddings feature offers a more integrated experience with improved performance and full support going forward. No new features or fixes will be made to this package.
> **Migrate today:** [Dataset Embeddings documentation](https://www.sanity.io/docs/content-lake/dataset-embeddings)
> If you have questions or need migration support, please open a discussion or reach out in the [Sanity Community](https://snty.link/community).

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

Embeddings are representations of more complex data. While they simplify the original content, they keep contextual information. Therefore, embeddings can serve use cases that leverage machine learning, prediction, and search.

For example, you can use embeddings to:

- **Implement semantic search**: make semantic search available to your editors or customers so that they can use it to find similar documents. The embeddings index offers a fast lookup that you can use for document similarity searches.
- **Enable related content instructions with AI**: you can enable tools like AI Assist and Agent Actions to work with reference fields for documents as long as they are included in an embeddings index.

Requirements:

Using this feature requires Sanity to send data to OpenAI and Pinecone to store vector interpretations of documents.

> [!WARNING]
> Experimental feature
> Embeddings Index API is currently in **beta**. Features and behavior may change without notice.

## Core concepts

### Embeddings index

An embeddings index is a collection of vector representations of your content that enables semantic search capabilities. When you create an index, Sanity processes your documents and stores the resulting vectors in a database. This allows you to search for documents based on meaning rather than exact text matches. Each index is defined by a name, dataset, filter criteria, and projection to specify which documents and fields to include.

When you first create an index, the process may take some time depending on the number of documents that match your filter criteria. After the initial creation, the index automatically stays up to date through a webhook that Sanity creates for you.

#### Get started

[Create and query an embeddings index (deprecated)](https://www.sanity.io/docs/content-lake/create-query-embeddings-index)
To get started with using the embeddings index API, you first need to create an index.

### Querying embeddings

Querying an embeddings index allows you to find documents based on semantic similarity to a search query. You can use the Embeddings Index HTTP API to send requests with your search terms and receive the most relevant documents in response.

For example, using the `@sanity/client`'s `request` method:

**Example**

```typescript
client.request({
  url: `/embeddings-index/query/${dataset}/${indexName}`,
  method: 'POST',
  body: {
    query: 'Your search query',
    maxResults: 15,
  }
})
```

The API supports filtering results by document type and limiting the number of results returned. It returns basic identifying details about the document, like the ID and type, as well as a score indicating how "close" the match is.

### Embeddings CLI

The [Embeddings Index CLI](https://www.sanity.io/docs/libraries/embeddings-index-cli-reference) is a command-line tool that helps developers create and manage embeddings indexes. It provides commands for creating indexes, checking their status, and defining index configurations through arguments or JSON manifest files. The CLI simplifies the process of setting up embeddings for your content and integrating semantic search capabilities into your applications. You can use it to create indexes with specific filters and projections, monitor index creation progress, and manage multiple indexes across your Sanity projects.

**npm**

```shell
npx @sanity/embeddings-index-cli create --indexName "example" --dataset "production" --filter "_type=='post'" --projection "{_id, title}"
```

**pnpm**

```shell
pnpm dlx @sanity/embeddings-index-cli create --indexName "example" --dataset "production" --filter "_type=='post'" --projection "{_id, title}"
```

**yarn**

```shell
yarn dlx @sanity/embeddings-index-cli create --indexName "example" --dataset "production" --filter "_type=='post'" --projection "{_id, title}"
```

**bun**

```shell
bunx @sanity/embeddings-index-cli create --indexName "example" --dataset "production" --filter "_type=='post'" --projection "{_id, title}"
```

### Embeddings UI tool

The Embeddings Index UI is a component for Sanity Studio that provides a visual interface for working with embeddings. It allows editors to create, manage, and query embeddings indexes directly from the Studio interface without writing code. The UI tool makes it easy to find similar documents or content related to specific phrases, enabling editors to discover connections between content and implement semantic search capabilities within their workflow. 

![a screenshot of the embeddings indexes page](https://cdn.sanity.io/images/3do82whm/next/27c1d95a237f410a4456545489c5657b402a11ed-2116x1700.png)

Enable the embeddings dashboard in your Studio by installing and adding it to your configuration's `plugins`.

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { embeddingsIndexDashboard } from '@sanity/embeddings-index-ui'

export default defineConfig({
  // ...
  plugins: [ embeddingsIndexDashboard()]
  // ...
})
```

The Studio tool also allows you to configure reference fields to support semantic search, which makes finding similar documents easier. 

#### Learn more

[@sanity/embeddings-index-ui](https://www.npmjs.com/package/@sanity/embeddings-index-ui)
Learn more about the embeddings index Studio tool

### Connect to AI Assist and Agent Actions

Configuring an embeddings index enables the AI Assist plugin and Agent Actions to interact with references. 

#### Integrate with other AI features

[Install and configure Sanity AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)
How to install and configure the AI Assist plugin for Sanity Studio.

[Enable references in Generate](https://www.sanity.io/docs/agent-actions/generate-add-references)
Use references in Generate to populate fields and connect documents based on your instructions.

### Getting better comparisons

Without a projection in your index configuration, the system will process and embed your entire document, automatically chunking it to fit the embedding model's limits.

If you compare your documents with excerpts from other documents, this may work fine. Occasionally, you might need to reshape your documents into something that looks more like your query string.

**For example:** If you want to improve document search accuracy for short user queries, consider this approach:

1. Use an LLM to generate concise summaries of each document in your collection.
2. Create an embeddings index that includes only these summaries.
3. When a user searches, have the LLM transform their search query into a similar summary format.

This creates a more accurate comparison between your indexed content (document summaries) and search queries (transformed into the same summary format), resulting in better semantic matches than comparing raw documents to simple search strings.

In this example, you would be comparing apples to apples: summaries of actual documents and the summary of a document that could represent the search string. Just using entire documents and search strings will still produce results, but the quality may be lower.

## Limitations

- Embeddings Index API is currently in **beta**. Features and behavior may change without notice.
- The Embeddings Index API does not support dataset aliases. This means that you have to use the **real dataset name** in all requests.



# Create and query an embeddings index (deprecated)

> [!TIP]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. We recommend migrating to the new **Embeddings** feature, now natively available within Sanity datasets.
> The new Embeddings feature offers a more integrated experience with improved performance and full support going forward. No new features or fixes will be made to this package.
> **Migrate today:** [Dataset Embeddings documentation](https://www.sanity.io/docs/content-lake/dataset-embeddings)
> If you have questions or need migration support, please open a discussion or reach out in the [Sanity Community](https://snty.link/community).

You can create an embeddings index in one of the following ways:

- With the [Embeddings Index CLI](https://www.npmjs.com/package/@sanity/embeddings-index-cli).
- With the [Embeddings Index UI](https://www.npmjs.com/package/@sanity/embeddings-index-ui) for Sanity Studio.
- With the [Embeddings Index HTTP API](https://www.sanity.io/docs/http-reference/embeddings-index).

This guide walks you through configuring an embeddings index for a Sanity project using the Embeddings Index CLI.

## Prerequisites

- The Sanity CLI. The CLI ships with the [main Sanity package](https://www.npmjs.com/package/sanity).
You need it to log in to Sanity, which enables consuming the Embeddings Index CLI.
- The example assumes that the CLI is run from within a local Sanity project.

> [!WARNING]
> Gotcha
> In its current state, the embeddings-index API does not support dataset aliases. This means that you have to use the **real dataset name** in all requests.

## Creating an embeddings index

To create an embeddings index, open a terminal session, and then the command that matches how you plan to create an index:

**npm**

```shell
# Create an embeddings index by passing arguments
npx @sanity/embeddings-index-cli create --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"

# Alternatively, create an embeddings index by passing a JSON manifest
npx @sanity/embeddings-index-cli create --manifest <manifest-file-name>.json
```

**pnpm**

```shell
# Create an embeddings index by passing arguments
pnpm dlx @sanity/embeddings-index-cli create --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"

# Alternatively, create an embeddings index by passing a JSON manifest
pnpm dlx @sanity/embeddings-index-cli create --manifest <manifest-file-name>.json
```

**yarn**

```shell
# Create an embeddings index by passing arguments
yarn dlx @sanity/embeddings-index-cli create --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"

# Alternatively, create an embeddings index by passing a JSON manifest
yarn dlx @sanity/embeddings-index-cli create --manifest <manifest-file-name>.json
```

**bun**

```shell
# Create an embeddings index by passing arguments
bunx @sanity/embeddings-index-cli create --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"

# Alternatively, create an embeddings index by passing a JSON manifest
bunx @sanity/embeddings-index-cli create --manifest <manifest-file-name>.json
```

Creating an index can take time, depending on the number of existing documents and the indexer load.

> [!TIP]
> The commands in this guide use `npx` to run the library, but you can also install the CLI globally and use the `embeddings-index` command as shown in the [CLI readme](https://www.npmjs.com/package/@sanity/embeddings-index-cli).

You can define the configuration of an embeddings index in one of the following ways:

- By passing configuration arguments when you create the index in the CLI.
- By storing the configuration details in a JSON manifest file.

### Defining the index in the CLI

To define a new embeddings index in the root directory of a Sanity project, pass the following required arguments with the `embeddings-index create` command:

- `--indexName`: assign a descriptive name to the index.
- `--dataset`: specify the name of an existing dataset. This is the target dataset to index.
- `--filter`: specify the filtering criteria to include in the index only the selected subset of documents from the database.
The filter must be a valid [GROQ filter](https://www.sanity.io/docs/content-lake/how-queries-work) *without the square brackets* that wrap the value assigned to `_type`.
Example: `_type=='tutorial'`
- `--projection`: specify the projection criteria to include in the index only the selected subset of properties from the filtered documents.
The projection must be a valid [GROQ projection](https://www.sanity.io/docs/content-lake/query-cheat-sheet), including curly brackets.
Example: `{title, author}`

Alternatively, you can create an embeddings index by passing a JSON manifest file with the `--manifest` argument:

- `--manifest <manifest-file-name>.json`

**Example**

**npm**

```shell
# Create embeddings index with arguments
# 'filter' has no '[]' square brackets
# 'projection' keeps '{}' curly brackets
npx @sanity/embeddings-index-cli create --indexName "my-embeddings-index" --dataset "production" --filter "_type=='myDocumentType'" --projection "{...}"

# Create embeddings index with JSON manifest
# The JSON manifest is in the project root directory
npx @sanity/embeddings-index-cli create --manifest embeddings-index-manifest.json
```

**pnpm**

```shell
# Create embeddings index with arguments
# 'filter' has no '[]' square brackets
# 'projection' keeps '{}' curly brackets
pnpm dlx @sanity/embeddings-index-cli create --indexName "my-embeddings-index" --dataset "production" --filter "_type=='myDocumentType'" --projection "{...}"

# Create embeddings index with JSON manifest
# The JSON manifest is in the project root directory
pnpm dlx @sanity/embeddings-index-cli create --manifest embeddings-index-manifest.json
```

**yarn**

```shell
# Create embeddings index with arguments
# 'filter' has no '[]' square brackets
# 'projection' keeps '{}' curly brackets
yarn dlx @sanity/embeddings-index-cli create --indexName "my-embeddings-index" --dataset "production" --filter "_type=='myDocumentType'" --projection "{...}"

# Create embeddings index with JSON manifest
# The JSON manifest is in the project root directory
yarn dlx @sanity/embeddings-index-cli create --manifest embeddings-index-manifest.json
```

**bun**

```shell
# Create embeddings index with arguments
# 'filter' has no '[]' square brackets
# 'projection' keeps '{}' curly brackets
bunx @sanity/embeddings-index-cli create --indexName "my-embeddings-index" --dataset "production" --filter "_type=='myDocumentType'" --projection "{...}"

# Create embeddings index with JSON manifest
# The JSON manifest is in the project root directory
bunx @sanity/embeddings-index-cli create --manifest embeddings-index-manifest.json
```

### Defining the index in a JSON manifest

To store, reuse, and manage embeddings indexes with source code control and versioning, define their configuration in a JSON manifest file. Save the embeddings indexes `manifest.json` file to the root directory of a Sanity project. 

A JSON manifest file defining an embeddings index must contain the following required fields:

```json
{
  indexName: string,
  dataset: string,
  filter: string,
  projection: string
}
```

**Example**

```json
{
  "indexName": "my-embeddings-index",
  "dataset": "production",
  "filter": "_type=='myType'", // No '[]' square brackets
  "projection": "{...}" // Keeps '{}' square brackets
}
```

To create a JSON manifest file, invoke the [manifest command](https://www.sanity.io/docs/libraries/embeddings-index-cli-reference):

**npm**

```shell
npx @sanity/embeddings-index-cli manifest --out manifest.json --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"
```

**pnpm**

```shell
pnpm dlx @sanity/embeddings-index-cli manifest --out manifest.json --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"
```

**yarn**

```shell
yarn dlx @sanity/embeddings-index-cli manifest --out manifest.json --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"
```

**bun**

```shell
bunx @sanity/embeddings-index-cli manifest --out manifest.json --indexName "<name-of-the-index>" --dataset "<name-of-the-dataset>" --filter "<GROQ-filter>" --projection "<GROQ-projection>"
```

To replace/update an existing index configuration, you'll need to first run the `delete` command, followed by the `create` process again.

### Checking an embeddings index status

You can check the status of your embeddings indices to monitor the creation progress or the completeness of the indexes.

To check the status of all embeddings indexes in a Sanity project, run:

**npm**

```shell
npx @sanity/embeddings-index-cli list
```

**pnpm**

```shell
pnpm dlx @sanity/embeddings-index-cli list
```

**yarn**

```shell
yarn dlx @sanity/embeddings-index-cli list
```

**bun**

```shell
bunx @sanity/embeddings-index-cli list
```

To check the status of a specific embeddings index in a Sanity project, run:

**npm**

```shell
npx @sanity/embeddings-index-cli get --indexName "<name-of-the-index>"
```

**pnpm**

```shell
pnpm dlx @sanity/embeddings-index-cli get --indexName "<name-of-the-index>"
```

**yarn**

```shell
yarn dlx @sanity/embeddings-index-cli get --indexName "<name-of-the-index>"
```

**bun**

```shell
bunx @sanity/embeddings-index-cli get --indexName "<name-of-the-index>"
```

## Query an index

To query an index, make a request with the [Embeddings Index HTTP API](https://www.sanity.io/docs/http-reference/embeddings-index). 

**JS client**

```
import { createClient } from '@sanity/client'
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: '<dataset-name>',
  apiVersion: 'vX', // vX is required for embeddings API calls
  token: process.env.SANITY_API_TOKEN,
});
const dataset = '<dataset-name>'
const indexName = '<index-name>'

await response = client.request({
  url: `/embeddings-index/query/${dataset}/${indexName}`,
  method: 'POST',
  body: {
    query: 'your search query',
    maxResults: 15,
  }
})
```

**CURL**

```sh
curl --request POST 'https://<project-id>.api.sanity.io/<api-version>/embeddings-index/query/<dataset>/<index-name>' \
     --header 'Authorization: Bearer <bearer-token>' \
     --header 'Content-Type: application/json' \
     --header 'Accept: application/json' \
     --data '{  
                "query": "sci-fi adventure with cowboys and aliens",
                "maxResults": 10,
                "filter": {
                  "type": ["summary", "synopsis", "userReview"]
                }
             }'
```

This example uses the query endpoint to search against an index and filter by document type. [Learn more about querying the API](https://www.sanity.io/docs/embeddings-index-http-api-reference#ce88034da6ac).



# Migrate to dataset embeddings

The Embeddings Index API is deprecated, along with the `@sanity/embeddings-index-cli` package and the `@sanity/embeddings-index-ui` Studio plugin. They receive no new features and no fixes. This guide walks through replacing them with Dataset Embeddings and GROQ, from exporting your current index configuration to deleting it once the new path is live.

> [!NOTE]
> Does this guide cover you?
> It covers search: your application sends a query and renders the results. It does not cover using the index to populate reference fields through AI Assist or Agent Actions, or similar-document search in the Studio plugin. **At this time, we do not have a replacement solution available for using references with Agent Actions.**

## Before you start

You'll need:

- A project role with permission to manage the legacy embeddings indexes, for the export in [Step 1](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api) and the delete in [Step 5](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api). See [roles and permissions](https://www.sanity.io/docs/user-guides/roles).
- The Sanity CLI, `sanity` 5.18.0 or later. The `sanity datasets embeddings` commands ship in `@sanity/cli` 6.2.0, which `sanity` 5.18.0 is the first release to require. Run commands as `npx sanity@latest` to stay on the current version. To call the legacy endpoints over HTTP instead, you need an API token.
- A server-side application using [@sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) and a [read token](https://www.sanity.io/docs/content-lake/http-auth). A read token is all your application needs to *query*.
- Your project ID, dataset name, and the name of every legacy index you're replacing.
- A set of real search queries to test against. Pull these from your application's search logs or analytics.

Also pull your [request logs](https://www.sanity.io/docs/platform-management/request-logs) before you start. They show which indexes are still being called, how often, and from where, which is how you catch a caller you forgot about. Self-serve plans can export the last seven days from the Usage section of [project settings](https://www.sanity.io/manage). That export is capped at 1 GB: a project producing more log data than that within the window gets a truncated file covering less than seven days, so check the range it actually covers before you draw conclusions from it. Enterprise projects can have logs delivered to a storage bucket. Note that the search strings themselves are sent in a POST body and aren't recorded, so your test queries have to come from your own logs.

## What is actually changing

Today you create a named index, POST a search string to a query endpoint, and get back document IDs and scores. Then you query GROQ a second time to fetch the documents.

Dataset Embeddings replace both requests with one. `text::semanticSimilarity()` is a GROQ function you call inside `score()`, so filtering, keyword matching, boosting, ordering, slicing, and your projection all happen in the same query, and you get documents back. There is no index to create, poll, or keep current.

This is not a drop-in swap. Results will not rank identically, because the embedding model, the chunking, and the score scale all differ. Named indexes no longer exist. Reference expansion stops working inside the embedding projection, though `->` still works everywhere else in your queries. Check [what ports cleanly and what doesn't](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api) before you start.

### Concept mapping

| Embeddings Index API | Dataset Embeddings |
| --- | --- |
| One or more named indexes per dataset | One embeddings configuration per dataset |
| Index `filter` selects which documents get embedded | Projection selects which document types get embedded. Value-level filtering moves to query time |
| Index `projection` selects which fields get embedded, and supports reference expansion | Dataset projection selects which fields get embedded. No reference expansion |
| `POST /embeddings-index/query/:dataset/:indexName` | `text::semanticSimilarity()` inside `score()` in any GROQ query |
| `maxResults` in the request body | A GROQ slice, for example `[0...10]` |
| `filter.type` in the request body | `_type == $type` or `_type in $types` in the GROQ filter |
| Response: `[{score, value: {documentId, type}}]` | Documents, with `_score` and `_embeddings` fields |
| `score` is a per-result similarity value on a fixed scale | `_score` is opaque and only ranks results within a single query |
| Sanity creates a webhook to keep the index current | Updates are automatic, asynchronous, and debounced. No webhook involved |
| Create, poll, and delete an index | Enable embeddings once per dataset |

### The endpoints you are replacing

| Legacy endpoint | Replacement |
| --- | --- |
| `GET /vX/embeddings-index/:dataset` | `GET /projects/:projectId/datasets/:name/settings/embeddings`, or `sanity datasets embeddings status <name>` |
| `POST /vX/embeddings-index/:dataset` | `PUT /projects/:projectId/datasets/:name/settings/embeddings`, or `sanity datasets embeddings enable <name>` |
| `GET /vX/embeddings-index/:dataset/:indexName` | `GET /projects/:projectId/datasets/:name/settings/embeddings`. There is one configuration per dataset, so there is no index name |
| `DELETE /vX/embeddings-index/:dataset/:indexName` | Nothing. See the warning about `sanity datasets embeddings disable` in step 5 |
| `POST /vX/embeddings-index/query/:dataset/:indexName` | A GROQ query using `text::semanticSimilarity()` |

### What ports cleanly and what doesn't

Each legacy behavior falls into one of three groups: it ports directly, it ports with a code or content change, or it doesn't port at all. Check where yours lands before you plan the work.

#### Ports directly

| Legacy behavior | What to do |
| --- | --- |
| A projection of fields on the document itself | Add the same fields to the dataset projection |
| `filter.type` in the query request | Use `_type == $type` or `_type in $types` in the GROQ filter |
| `maxResults` | Use a GROQ slice, for example `[0...10]` |
| Returning document IDs and types | Project `_id` and `_type`, or project the fields you render and skip the second query entirely |
| Fixed RAG retrieval | Same as site search. Query with GROQ and pass the results to your model |

#### Ports with a code or content change

| Legacy behavior | What to do |
| --- | --- |
| The index `filter` | Copy it into every replacement query. Enabling embeddings does not carry it over |
| A named index | Replace each `indexName` with a query function in your application |
| Several indexes over different document types | Merge the fields into one type-conditional projection. Keep a separate query function per index |
| Reference expansion in the projection, like `category->title` | Expanding references does not work in embedding projections. Only what's in the document. To keep a referenced value in the embedding, materialize it into a real field first (see Step 2) |
| Webhook-driven index updates | Delete the webhook after cutover. Sanity handles updates |
| Passing a JSON document as the query input, as the deprecated CLI's `query --text` argument allowed | `text::semanticSimilarity()` takes a string. Serialize or summarize structured input in your application before you query |

#### Doesn't port

| Legacy behavior | What to do |
| --- | --- |
| Several indexes with different projections over the *same* documents | A dataset holds one embeddings configuration, so each document has a single projected representation. Embed the union of fields and separate the behaviors with query-time filters and scoring, or model separate searchable documents |
| Numeric score thresholds, like `score > 0.8` | Remove them. `_score` is opaque and only meaningful within one query. Bound results with a slice instead |
| Identical result ranking | Different model, different chunking, different scoring. Expect the ordering to shift and test accordingly |

> [!WARNING]
> Copy the legacy filter into every replacement query
> Enabling embeddings does not carry your index filter forward. If that filter excluded unpublished, private, market-specific, or expired content, leaving it out changes the result set and can surface content you meant to hide.

## Steps to execute the migration

The examples in this guide all use the same index: `public-articles`, filtered to `_type == "article" && searchable == true`, projecting `{title, body, "categoryTitle": category->title}`.

### Step 1: Export your index configuration

Fetch every index on the dataset and save the `filter`, `projection`, and `indexName` for each one. You need all three to rebuild the behavior. Store the response as JSON in version control or wherever your team keeps infrastructure config, not just in a scratch file. You'll want it again in [step 5](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api). Two things about the response: Sanity stores your projection with `_type` auto-prepended, so the saved config won't be byte-identical to what you created; and the projection string may contain an unescaped newline that trips strict JSON parsers. Save the raw text, or parse tolerantly.

**CLI**

```bash
curl https://YOUR_PROJECT_ID.api.sanity.io/vX/embeddings-index/production \
  -H "Authorization: Bearer $SANITY_API_TOKEN"
```

Then search your codebase for the query URL, `/embeddings-index/query/`, and note every caller and everything downstream that reads the response. Anything reading `value.documentId` or comparing `score` to a number needs to change. Cross-check the list against your request logs: an index that's taking traffic but doesn't appear in your codebase means there's a caller somewhere you haven't accounted for.

### Step 2: Translate the projections into one dataset projection

You get one projection per dataset. The index in the running example covers one document type, so the projection does too. If you're replacing several indexes, merge their fields into a single conditional projection. See [type-specific projections](https://www.sanity.io/docs/content-lake/dataset-embeddings).

Type-level scoping from your old filters can move into the projection, because document types you don't list are not embedded. Value-level conditions, like `searchable == true`, cannot. Those stay at query time.

**Projection**

```groq
{
  _type == "article" => {
    title,
    body
  }
}
```

> [!WARNING]
> Do not project a reference alias like categoryTitle
> In the legacy index, `categoryTitle` was `"categoryTitle": category->title` — reference expansion. Dataset embeddings cannot dereference, and there is no `categoryTitle` field on the document, so projecting `categoryTitle` here embeds nothing at all, with no error. If you need the referenced value in the embedding, first materialize it into a real string field on the document (for example, an `article.categoryTitle` field kept in sync by a [content migration](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) or a [Sanity Function](https://www.sanity.io/functions)), then project that real field. It's a weak signal either way, so weigh whether it earns the extra field.

Keep the projection tight. Every field you add grows each document's embedding, slows generation and recomputation, and adds noise that competes with the signal your users are searching for. Leave out fields that change often but carry no meaning for search, because each change triggers a recomputation.

Field names carry semantic weight. `{"musicalGenre": category}` tells the model to read "classical" as music rather than engineering.

Documents are chunked before embedding, and there's a cap of 10 chunks per document (subject to change). Content past the cap is dropped. If you're embedding long body fields, scope the projection.

Expanding references does not work in embedding projections. Only what's in the document. In testing, the legacy index did embed `category->title`, but as a **weak** signal—a few hundredths of cosine similarity, outweighed by the document's own `title` and `body`. Losing it mostly reshuffles the tail of your results, not the top hit. Materialize the field only if that referenced value is genuinely important to how users search.

### Step 3: Enable embeddings on the dataset

**npm**

```shell
npx sanity@latest datasets embeddings enable production \
  --projection '{_type == "article" => {title, body}}' \
  --wait
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets embeddings enable production \
  --projection '{_type == "article" => {title, body}}' \
  --wait
```

**yarn**

```shell
yarn dlx sanity@latest datasets embeddings enable production \
  --projection '{_type == "article" => {title, body}}' \
  --wait
```

**bun**

```shell
bunx sanity@latest datasets embeddings enable production \
  --projection '{_type == "article" => {title, body}}' \
  --wait
```

`--wait` blocks until the initial generation finishes. Without it, the command returns immediately and generation continues in the background. On a large dataset this takes a while (for a few dozen documents it's under a minute; budget much more for large datasets).

Check the status at any point:

**npm**

```shell
npx sanity@latest datasets embeddings status production
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets embeddings status production
```

**yarn**

```shell
yarn dlx sanity@latest datasets embeddings status production
```

**bun**

```shell
bunx sanity@latest datasets embeddings status production
```

The status is `updating`, `ready`, or `error`. Don't send production traffic until it reads `ready`. Querying a dataset without embeddings enabled returns an error.

To do this over HTTP instead:

**HTTP**

```http
PUT /projects/:projectId/datasets/:name/settings/embeddings HTTP/1.1
Content-Type: application/json

{
  "enabled": true,
  "projection": "{_type == \"article\" => {title, body}}"
}
```

The endpoint returns `202 Accepted` and generates asynchronously.

> [!NOTE]
> Write performance
> Depending on system load, write speeds may be slower on datasets with embeddings enabled, and Sanity may apply rate limits to manage resource usage. These behaviors are subject to change. If your dataset takes heavy write traffic, watch it during your test window. See [performance considerations](https://www.sanity.io/docs/content-lake/dataset-embeddings).

### Step 4: Replace the query call

The POST to the index and the follow-up query that fetched the documents collapse into a single GROQ query.

Before:

**Before**

```typescript
const results = await client.request({
  url: '/embeddings-index/query/production/public-articles',
  method: 'POST',
  body: {query: searchText, maxResults: 10, filter: {type: ['article']}},
})

const ids = results.map((result) => result.value.documentId)
const documents = await client.fetch(`*[_id in $ids]{_id, title, slug}`, {ids})
```

After:

**After**

```groq
*[_type == "article" && searchable == true]
  | score(text::semanticSimilarity($searchText))
  [0...10] {
    _id, _type, title, slug, _score
  }
```

The index filter becomes the GROQ filter, `filter.type` becomes `_type`, and `maxResults` becomes the slice.

[Query changes](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api) has the full client code, the new response shape, a compatibility adapter for callers that still expect the old format, and when to add keyword matching.

### Step 5: Delete the legacy index

> [!WARNING]
> Destructive operations
> Deleting an index is permanent, and the legacy API is deprecated, so recreating one is not a path you want to depend on. Delete only after you have cut over and run at full traffic long enough to notice a problem. Until then, keep the index so a rollback stays available. Keep the JSON you saved in [step 1](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api) as well: it holds the `filter`, `projection`, and `indexName` you would need to rebuild. And don't reach for `sanity datasets embeddings disable` as cleanup: that command turns off your new dataset embeddings, not your old index. Disabling is destructive too. The computed embedding data may be deleted immediately, and re-enabling triggers a full recompute of every document.

**CLI**

```bash
curl -X DELETE https://YOUR_PROJECT_ID.api.sanity.io/vX/embeddings-index/production/public-articles \
  -H "Authorization: Bearer $SANITY_API_TOKEN"
```

Sanity removes the webhook it created for the index automatically when you delete the index. If *you* added any webhooks of your own to keep the index current, remove those.

## Query changes

### Before

Two requests: one to the embeddings index, one to fetch the documents.

**Before**

```typescript
const results = await client.request({
  url: '/embeddings-index/query/production/public-articles',
  method: 'POST',
  body: {
    query: searchText,
    maxResults: 10,
    filter: {type: ['article']},
  },
})

// [{score: 0.83, value: {documentId: 'abc123', type: 'article'}}]

const ids = results.map((result) => result.value.documentId)
const documents = await client.fetch(`*[_id in $ids]{_id, title, slug}`, {ids})
```

### After

One request. The filter, the scoring, the ordering, the slice, and the projection all live in the same query.

**searchArticles.ts**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID,
  dataset: 'production',
  apiVersion: '2026-08-21',
  token: process.env.SANITY_API_READ_TOKEN,
  useCdn: false,
  perspective: 'published',
})

export async function searchArticles(searchText: string, maxResults = 10) {
  const limit = Math.min(Math.max(Math.trunc(maxResults) || 10, 1), 50)

  return client.fetch(
    `*[_type == "article" && searchable == true]
      | score(text::semanticSimilarity($searchText))
      [0...$limit] {
        _id, _type, title, slug, _score
      }`,
    {searchText, limit},
  )
}
```

The old index filter, `searchable == true`, is now in the GROQ filter. Nothing carries it over for you.

`text::semanticSimilarity()` is only valid as an argument to `score()`. Using it anywhere else returns an error. `score()` already sorts results by `_score` descending, so you don't need an explicit `order()`.

`maxResults` becomes the upper bound of a [slice](https://www.sanity.io/docs/content-lake/query-cheat-sheet). There is no default limit in GROQ, so always bound your results. Note that `..` is inclusive and `...` is exclusive, which matters when you convert a `maxResults` integer into a slice bound.

### What the response looks like now

You get documents back, not pointers to documents:

**Response**

```json
[
  {
    "_id": "article-auth-guide",
    "_type": "article",
    "title": "Authentication guide",
    "slug": {"current": "authentication-guide"},
    "_score": 8.341205
  }
]
```

`_score` is a unitless, opaque ranking value. It orders results within one query and nothing more. It is not comparable across queries, and it is not on the same scale as the score the old API returned. If your code has a line like `results.filter(r => r.score > 0.75)`, delete it and control the result count with the slice instead.

Semantic queries automatically include an `_embeddings` field on each result, carrying the text fragments that drove the match along with their source fields and character offsets. If your query uses an explicit projection, add `_embeddings` to it to keep the field. Good for highlighting search results, and good for working out why something ranked where it did:

**Projection**

```groq
*[_type == "article" && searchable == true]
  | score(text::semanticSimilarity($searchText))
  [0...$limit] {
    _id, _type, title, slug, _score, _embeddings
  }
```

**_embeddings**

```json
{
  "_embeddings": [
    {
      "fragments": ["OAuth 2.0 provides a secure delegation protocol..."],
      "fields": ["body"],
      "startPositions": [0],
      "endPositions": [74],
      "score": 8.341205
    }
  ]
}
```

### Keeping the old response shape temporarily

If several callers read the legacy format and you'd rather not change them all at once, rebuild the old shape in the projection. No adapter code needed:

**GROQ**

```groq
*[_type == "article" && searchable == true]
  | score(text::semanticSimilarity($searchText))
  [0...$limit] {
    "score": _score,
    "value": {
      "documentId": _id,
      "type": _type
    }
  }
```

The shape matches, but `score` no longer means what it did. Treat this as a stepping stone, not a destination, and make sure nothing downstream is thresholding on that number.

### Add keyword matching when exact terms matter

Semantic search alone handles conceptual queries well. It's weaker on proper nouns, product codes, brand names, and part numbers, where the exact string is the point. Add a `match` expression alongside the semantic one inside `score()`, wrapped in `boost()` to set how much weight the keyword signal carries:

**GROQ**

```groq
*[_type == "article" && searchable == true]
  | score(
      boost([title, body] match text::query($searchText), 0.5),
      text::semanticSimilarity($searchText)
    )
  [0...10] {
      _id, _type, title, slug, _score
    }
```

Each expression contributes to `_score` independently, and a document doesn't need to match both to appear. `boost()` sets the balance. Keyword matches on short fields like `title` can outweigh strong semantic matches on long fields like `body`, because each matching term is a bigger share of a short field, so start the keyword weight low and tune from there.

Start semantic-only. Add keyword matching when you can point at queries it fixes.

> [!NOTE]
> Building an agent rather than a search feature?
> If a model decides what to look up, inspects your schema, and writes its own queries, [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) is likely a better fit than querying GROQ directly. It reads from the same dataset embeddings, so enable them either way. Passing search results to a model does not by itself mean you need Context.

## Validation and launch

Your new results will not match the old ones exactly. The goal is to confirm they're as good or better, not that they're identical.

### Compare before you switch

Use the queries you gathered in [before you start](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api). Twenty to thirty is enough. Include the high-volume ones, the long-tail ones, queries that should return nothing, and any query containing an exact name, code, or brand. Those last ones are where semantic-only search tends to fall short, and where keyword matching helps most.

Run each query through both paths and look at the top few results side by side. You're checking for two things: results that got worse, and results that appeared out of nowhere. The second one usually means a filter didn't make it across.

### Watch for the known traps

- **Content appearing that the old index excluded.** The legacy filter is missing from your GROQ query. Check your [perspective](https://www.sanity.io/docs/content-lake/perspectives) too. On API versions from 2025-02-19 onward the default is `published`, but on older versions it's `raw`, which returns drafts alongside published documents. Set `perspective: 'published'` explicitly if the old index only covered published documents.
- **Exact names and codes ranking poorly.** Add keyword matching.
- **Recently edited content returning stale matches.** Embedding updates are asynchronous and debounced. The lag is usually under a minute but can be longer on large or busy datasets. If your product needs fresher results than that, measure the real lag before you commit.
- **Everything returning an error.** Check that the embeddings status is `ready`, and that you're calling `text::semanticSimilarity()` inside `score()`.

### Cut over

Run both paths against production traffic for a short period if you can, comparing outputs without acting on the new one. Then switch, keeping the old index in place so a rollback is a config change rather than a rebuild. On a large or business-critical search integration, talk to your account team before you cut over.

After the switch, watch error rate, latency, and your zero-result rate. Give it a week at full traffic, then confirm in your request logs that the legacy index is taking no traffic before you [delete it](https://www.sanity.io/docs/content-lake/migrate-from-embeddings-index-api). Check that your log export actually covers the whole week before you treat quiet logs as evidence: a truncated export looks exactly like an idle index.

## FAQs

### Do I have to migrate?

Yes, if you want your search to keep working. The Embeddings Index API is deprecated and should be replaced with Dataset Embeddings. It receives no fixes or new features.

### What replaces the `@sanity/embeddings-index-cli` package?

The Sanity CLI. See [sanity datasets embeddings](https://www.sanity.io/docs/cli-reference/cli-datasets).

### What about the Studio plugin, AI Assist, and Agent Actions references?

There's no replacement for reference population in AI Assist and Agent Actions yet, and none for the similar-document search in the Studio plugin. If you depend on either, [contact us](https://www.sanity.io/contact) or post in the [Sanity Community](https://snty.link/community) so we can factor your use case into what comes next.

### Does this cost more?

Embeddings generation is included on all plans. Embeddings queries are metered, with a monthly allowance that varies by plan and overage pricing above it. Semantic search itself is included. See [pricing](https://www.sanity.io/pricing) for current allowances, and if you're on an enterprise plan, ask your account team for a forecast based on your actual query volume.

## Next steps

- [Dataset Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings). Enabling, projections, chunking, querying, and result metadata.
- [Search text content with GROQ](https://www.sanity.io/docs/content-lake/search-content-with-groq). Filtering, scoring, BM25, boosts, and pagination.
- [Datasets CLI reference](https://www.sanity.io/docs/cli-reference/cli-datasets). The `sanity datasets embeddings` commands.
- [Embeddings Index API reference](https://www.sanity.io/docs/http-reference/embeddings-index). The deprecated API, for reference while you migrate.



# Embeddings Index CLI reference (deprecated)

> [!TIP]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. We recommend migrating to the new **Embeddings** feature, now natively available within Sanity datasets.
> The new Embeddings feature offers a more integrated experience with improved performance and full support going forward. No new features or fixes will be made to this package.
> **Migrate today:** [Dataset Embeddings documentation](https://www.sanity.io/docs/content-lake/dataset-embeddings)
> If you have questions or need migration support, please open a discussion or reach out in the [Sanity Community](https://snty.link/community).

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

> Using this feature requires Sanity to send data to OpenAI and Pinecone to store vector interpretations of documents.

> [!WARNING]
> Gotcha
> Embeddings Index API is currently in **beta**. Features and behavior may change without notice.
> Embeddings Index API is available to users on the [Team plan and above](https://www.sanity.io/docs/platform-management/plans-and-payments).

> [!NOTE]
> [Embeddings Index API](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview) functionality is available through the [Embeddings Index CLI](https://www.npmjs.com/package/@sanity/embeddings-index-cli), the [Embeddings Index UI](https://www.npmjs.com/package/@sanity/embeddings-index-ui) for Sanity Studio, and the [Embeddings Index HTTP API](https://www.sanity.io/docs/http-reference/embeddings-index).

The Sanity Embeddings Index CLI offers commands to create, delete, fetch, and query embeddings indexes in a Sanity project.

You can install the Embeddings Index CLI:

- Globally, to make its commands available in the terminal regardless of the current directory path.
- Locally, on a per-project basis.

To execute the commands without installing the Embeddings Index CLI, invoke them through the [npx](https://www.npmjs.com/package/npx) package runner.

The Embeddings Index CLI commands work only in the context of a local Sanity project:

**npm**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
npx @sanity/embeddings-index-cli <command> [<arguments>]
```

**pnpm**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
pnpm dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**yarn**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
yarn dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**bun**

```shell
# Go to the root directory of a Sanity project
cd path-to/my-sanity-project/root-dir

# Invoke the embeddings-index CLI commands
embeddings-index-cli <command> [<arguments>]

# Alternatively: invoke the commands without installing
bunx @sanity/embeddings-index-cli <command> [<arguments>]
```

## Prerequisites

- The Sanity CLI. The CLI ships with the [main Sanity package](https://www.npmjs.com/package/sanity).
You need it to log in to Sanity, which enables consuming the Embeddings Index CLI.
- The [Embeddings Index CLI](https://www.npmjs.com/package/@sanity/embeddings-index-cli). 

## Installing the Embeddings Index CLI

**npm**

```shell
# Installing the Embeddings Index CLI globally
npm install --save-dev --global @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
npm install --save-dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
npx @sanity/embeddings-index-cli <command> [<arguments>]
```

**pnpm**

```shell
# Installing the Embeddings Index CLI globally
pnpm add --save-dev --global @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
pnpm add --save-dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
pnpm dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**yarn**

```shell
# Installing the Embeddings Index CLI globally
yarn global add --dev @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
yarn add --dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
yarn dlx @sanity/embeddings-index-cli <command> [<arguments>]
```

**bun**

```shell
# Installing the Embeddings Index CLI globally
bun add --dev -g @sanity/embeddings-index-cli

# Installing the Embeddings Index CLI for a specific Sanity project
cd path-to/my-sanity-project/root-dir
bun add --dev @sanity/embeddings-index-cli

# Running the Embeddings Index CLI commands without installation
bunx @sanity/embeddings-index-cli <command> [<arguments>]
```

## Embeddings Index CLI commands

To view the built-in help, run:

**npm**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
npx @sanity/embeddings-index-cli --help
```

**pnpm**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
pnpm dlx @sanity/embeddings-index-cli --help
```

**yarn**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
yarn dlx @sanity/embeddings-index-cli --help
```

**bun**

```shell
# Prints the help for the available commands and arguments
embeddings-index-cli --help

# Alternatively, without installing the CLI
bunx @sanity/embeddings-index-cli --help
```

### Commands

#### Properties

**create**

Creates a new embeddings index in the current Sanity project.
It requires the following arguments:

--indexName: assign a descriptive name to the index.

--dataset: specify the name of an existing dataset. This is the target dataset to index. Note that the embeddings index API does not support dataset aliases.

--filter: specify the filtering criteria to include in the index only the selected subset of documents from the database.
The filter must be a valid GROQ filter without the square brackets that wrap the value assigned to _type.
Example: _type=='tutorial'

--projection: specify the projection criteria to include in the index only the selected subset of properties from the filtered documents.
The projection must be a valid GROQ projection, including curly brackets.
Example: {title, author}

Alternatively, you can create an embeddings index by passing a JSON manifest file with the --manifest argument:

--manifest <manifest-file-name>.json

For more information on creating a JSON manifest file, see the CLI manifest command in this reference.

**delete**

Deletes an existing embeddings index in the current Sanity project.
It requires the following argument:

--indexName: the name of the index to delete.

Alternatively, you can specify an existing JSON manifest file instead of indexName:

--manifest <manifest-file-name>.json

**get**

Retrieves status information about a specific embeddings index in the current Sanity project.
It requires the following argument:

--indexName: the name of the index whose status you want to retrieve.

Alternatively, you can specify an existing JSON manifest file instead of indexName:

--manifest <manifest-file-name>.json

**list**

Gets the status of all existing embeddings indexes in a Sanity project.

**manifest**

Creates a JSON manifest file with the configuration of an embeddings index, and saves the file to the specified location.

It requires the following arguments:

--out: specify the name of the JSON manifest file and, if necessary, the path to the directory to save it to.
If you don't specify a path, the JSON manifest file is saved to the current location in the Sanity project.
Example: <manifest-file-name>.json

--indexName: see the same argument under create.

--dataset: see the same argument under create.

--filter: see the same argument under create.

--projection: see the same argument under create.

**query**

Queries an embeddings index.
Returns an array of document IDs with their relevance score, based on the queried input string.

It requires the following arguments:

--indexName: the name of the index you want to query

--text: enter the content that you want to retrieve from the database using the embeddings index.
The content can be a string of text or a valid JSON-formatted document.

Examples

Query the embeddings index to retrieve relevant documents whose content matches the following text string:

"This is a song about vegetables."

Query the embeddings index to retrieve relevant documents whose content matches the following JSON document:

'{"_type": "lyrics", "title": "Call Any Vegetable"}'

### Options

#### Properties

**--debug**

Prints the stack trace. Useful to inspect errors.

**--help**

Prints the CLI built-in help.

**--silent**

Doesn't print any information or warning messages.
Use either --silent or --verbose. Don't specify both options.

**--verbose**

Logs extensive information and warning messages.
Use either --silent or --verbose. Don't specify both options.

**--version**

Prints the version number of the currently installed embeddings index CLI.

## Further reading

[embeddings-index-cli package on the npm registry](https://www.npmjs.com/package/@sanity/embeddings-index-cli)





# Embeddings index API reference

The Embeddings Index API allows you to create, manage, and query embeddings indexes for semantic search in your Sanity project.

> [!TIP]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. We recommend migrating to the new **Embeddings** feature, now natively available within Sanity datasets.
> The new Embeddings feature offers a more integrated experience with improved performance and full support going forward. No new features or fixes will be made to this package.
> **Migrate today:** [Dataset Embeddings documentation](https://www.sanity.io/docs/content-lake/dataset-embeddings)
> If you have questions or need migration support, please open a discussion or reach out in the [Sanity Community](https://snty.link/community).

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

Note: Using this feature requires Sanity to send data to OpenAI and Pinecone to store vector interpretations of documents.

#### Want to get started?

[Embeddings index introduction (deprecated)](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview)
Embeddings allow you to search for what your documents are about. Use the Embeddings Index API to build LLM agents or to enable semantic search.

## Authentication

- All requests must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth).

## Known limitations

- Creating an embeddings index for very large datasets can be slow.
- The Embeddings Index HTTP API rate limit depends on the OpenAI rate limit, which sets a cap for the HTTP API at about 8,000 tokens per minute.
- The embeddings-index API does not support dataset aliases—you must use the real dataset name in all requests.



# Access your data (CORS)

For security reasons, your project defaults to only allow requests from `localhost:3333` (the default local development server for Sanity Studio) and the hostname you used when deploying (if you used [sanity deploy](https://www.sanity.io/docs/cli-reference/deploy)).

If you want to open up your project to another website, you need to add its URL to your allowed CORS origins (you can read more on [browser security & CORS](https://www.sanity.io/docs/content-lake/browser-security-and-cors) or [the technicalities of CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)).

Typical reasons you'd want to add a new CORS origin include:

- You are using a non-default port when developing, so you'd open up to `http://localhost:<your port>`.
- You are [deploying a studio outside the Sanity infrastructure](https://www.sanity.io/docs/studio/deployment) (not using the `sanity deploy` command).
- You want to make it possible for a frontend to read contents from your public dataset.

## Defining a CORS origin

A CORS origin will be defined using the following format:

```text
protocol://hostname[:port]
```

The protocol and hostname are required while the port is optional when it is the default HTTP port 80.

Some valid examples include:

- `https://your-domain.org`
- `http://localhost:3333`
- `http://localhost:*`

Shared

### Allowing credentials

When adding a CORS origin, you will also need to decide whether or not to allow credentials. If you allow credentials, the website hosted at a matching origin will be allowed to send authenticated requests using the token or session of any logged-in visitor.

If this origin hosts a studio or otherwise needs to make authenticated requests, you will need to allow credentials. Otherwise, you should probably select **not** to allow credentials.

> [!TIP]
> Common browser errors
> Are you getting one of these errors in your browser console when trying to access your studio?
> **Firefox:** `Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://PROJECT_ID.api.sanity.io/v1/users/me. (Reason: expected ‘true’ in CORS header ‘Access-Control-Allow-Credentials’)`
> **Chrome:** `Access to XMLHttpRequest at 'https://PROJECT_ID.api.sanity.io/v1/auth/providers' from origin '<STUDIO_URL>' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'`
> **Safari:** `XMLHttpRequest cannot load https://PROJECT_ID.api.sanity.io/v1/users/me due to access control checks. Credentials flag is true, but Access-Control-Allow-Credentials is not "true".`
> Try allowing credentials on your CORS origin.

### Preview deployments

Branch and pull request deploys on platforms like Vercel and Netlify get a new URL for each branch or commit, so you can't add the origin ahead of time.

If you deploy your Studio with `sanity deploy`, it runs on `your-project.sanity.studio`, which does not need any additional CORS entries. This section is for Studios you host yourself on a platform like Vercel or Netlify, and for frontend apps that query your project from the browser.

Never add a platform-wide wildcard with credentials. `https://*.netlify.app` or `https://*.vercel.app` would let any site on that platform, including ones you don't control, make authenticated requests as your logged-in users.

Scope the wildcard to a namespace you *own*. There’s two ways to do this, the best way first:

- **Route previews to your own domain.** Netlify's automatic deploy subdomains and Vercel's preview deployment suffix let you serve previews from a custom domain, like `branch.previews.yourcompany.com`. Then add `https://*.previews.yourcompany.com`. Only your deploys live under your domain, so no one else can produce a matching origin. This is the safest option, and it keeps preview auth flows behaving like production.
- **Scope to your site or team on the platform's domain.** If you stay on the default URLs, anchor the wildcard to the part of the URL only you control. On Netlify that's your site name, so `https://*--my-studio.netlify.app` matches your site's branch and preview deploys and nothing else, since the part after `--` is your unique site name. On Vercel, every preview URL ends with your account or team scope slug, so anchor to that: `https://*-my-team.vercel.app`, where `my-team` is your scope slug (the slug, not the display name).

Turn on Allow credentials for these origins, since the Studio performs requests as the logged-in user. Using credentials with a wildcard that only matches domains you own and trust is fine.

A few things to keep in mind:

- On a public repo, a deploy preview built from a forked pull request still matches your site-scoped wildcard, and with credentials on it could act as a logged-in user who opens it. For public repos, disable fork deploy previews or point previews at a separate, non-production project.
- A frontend app preview that only reads public data in the browser should keep Allow credentials off, and the same scoping rules apply. If it fetches on the server instead (during SSR or build), it doesn't need a CORS origin at all.
- Remove preview origins you no longer use, the same as any other origin.

### Adding code sandboxes

Online sandboxes like CodeSandbox, StackBlitz, CodePen, and JSFiddle run your code in the browser. That means requests to your project come from the sandbox's domain, not your own site.

To let a sandbox read your project data, add its domain as a CORS origin. That domain is the sandbox's preview URL, which is usually different from the sandbox/code editor URL. For example, you write your code at `codesandbox.io`, but the sandbox runs on a `csb.app` subdomain, and the `csb.app` address is the one to add. To find it, open the sandbox with your browser dev tools open and look at the `Origin` of the blocked request, or the origin named in the CORS error, then add that exact value.

What you add depends on the platform. JSFiddle runs every fiddle on the same host, so you add that one host. CodeSandbox gives each sandbox its own subdomain, so you add the exact subdomain your sandbox runs on:

- `https://fiddle.jshell.net` (JSFiddle, one fixed host)
- `https://abc123-3000.csb.app` (one CodeSandbox sandbox; yours will have a different ID)

Add the exact origin, not a wildcard like `https://*.csb.app`. A CodeSandbox subdomain changes when you fork or re-create the sandbox, so the new copy won't be allowed to fetch until you add its origin too.

#### Considerations

Sandbox domains are shared and public. A wildcard like `https://*.csb.app` lets every sandbox on that platform reach your project, including ones built by people you don't know. So treat anything a sandbox can reach as public.

With that in mind:

- **Don't allow credentials.** If you allow credentials, any sandbox on that shared domain can make requests as anyone who is logged in to your Studio. Always leave credentials off for sandbox origins.
- **Only expose public data.** Have the sandbox read a public dataset. Public reads don't need a token. Don't open a private or production dataset to a shared playground.
- **Keep tokens out of sandbox code.** Anyone who opens the sandbox can read its code, so any token in it (read, write, or admin) is visible and usable by anyone who finds it. A read-only token does not keep data private. It's no safer than making the dataset public, and worse if the token can also read data you didn't mean to share.
- **Use a proxy for private data.** If a sandbox needs data from a private dataset, don't let it call your project directly. Put a small server in between that holds the token and returns only the data that should be public, then point the sandbox at that server. The private data and the token stay out of the browser.
- **Remove the origin when you're done.** Sandboxes are temporary, but the CORS rule stays until you delete it. Remove origins you added for experiments, so an origin you no longer use can't keep reaching your project.

### Wildcards

Wildcards (`*`) are supported. A `*` fills in a single segment: one subdomain level or the port. For example, `https://*.your-domain.org` matches `https://foobar.your-domain.org` but not `https://foo.bar.your-domain.org`. The same applies to ports: `http://localhost:*` matches `http://localhost:3000`, `http://localhost:8080`, or any other port.

> [!WARNING]
> Wildcards and credentials
> Allowing credentials from wildcard origins is **dangerous**. Any website that matches the given pattern will be able to send requests **on the user's behalf** if they are logged in to your studio. Take extra care and make sure the wildcard only matches domains you trust.

## How to add a CORS origin

You can add a CORS origin from your management console, using the [command line interface (CLI)](https://www.sanity.io/docs/apis-and-sdks/cli), or with [Blueprints](https://www.sanity.io/docs/blueprints).

> [!NOTE]
> Permission required
> To add a CORS origin, you will need the [proper permissions](https://www.sanity.io/docs/user-guides/roles). If you are unable to add a CORS origin, please speak to your project Administrator.

### With the management console

To add a CORS origin from your management console:

1. Go to [https://www.sanity.io/manage](https://www.sanity.io/manage).
2. Pick your project from the list.
3. Go to **Settings**, and then to **API settings**.
4. Under **CORS Origins**, click the **Add CORS origin** button.
5. Enter your **Origin**, select whether or not to **Allow credentials**, and click **Save**. If your origin was added successfully, it will appear at the top of your CORS origins list.

### With the command line interface (CLI)

To add a CORS origin from the CLI:

1. Navigate to your project's folder in your terminal.
2. Run the command `sanity cors add [ORIGIN]`, where `[ORIGIN]` meets the requirements listed above.
3. When prompted, select whether or not to allow credentials.

You can confirm your origin was added with the statement `CORS origin added successfully` or by consulting the list returned by the command `sanity cors list`.

### With Blueprints

You can add CORS origins as resources using Blueprints and the `defineCorsOrigin` helper. Follow the [Define a CORS origin guide](https://www.sanity.io/docs/blueprints/blueprints-cors) for details.



# CORS and browser security

> [!TIP]
> Protip
> [Read this article](https://www.sanity.io/docs/content-lake/keeping-your-data-safe) to learn more about the use of tokens in your API client.

CORS is a technique to relax the strict browser security model for websites hosted on certain trusted domains.

CORS makes it possible to specify which origins (i.e. webpages) that may issue requests and read response data from the API of your project. Let's say you have the Sanity Content Studio open in a browser tab and you're logged in. Behind the scenes, you now have an active session cookie at `https://yourproject.api.sanity.io`. Blissfully unaware, you click a link you receive in a mail, that opens evil-site.com in another browser tab. Now, if browser vendors did not care about security at all, the JavaScript running on `evil-site.com` would be able to make requests to `https://yourproject.api.sanity.io`. These requests would even be authenticated, passing whatever cookies the browser had previously set for yourproject.api.sanity.io, meaning that nothing would prevent the maintainer of `evil-site.com` from including a script that deletes all the contents in your projects dataset.

Luckily, browser vendors are extremely concerned about security and have all implemented something called the Same-origin policy, which denies all requests from a web page to another web page that does not share the same origin (origin is the combination of URI scheme, hostname, and port number). The Same-origin policy is what prevents your bank account from becoming emptied when you're logged in to your online bank while also browsing random web pages at the same time. If you're unlucky and enter `evil-site.com`, there's no way any browser will allow scripts running on `evil-site.com` to send a request to `your-bank.com/transfer` with instructions to transfer all your money to the maintainer of evil site.

However, the Same-origin policy is bad news for you as a Sanity user, since your Content Studio (and possibly your single page app too) doesn't run on the same origin as the Sanity API. Typically the Content Studio runs on either `http://localhost:3333` or `https://yourproject.sanity.studio`, while your content is located on `https://yourproject.api.sanity.io`. Due to the Same-origin policy, any script running on `http://localhost:3333` is denied from making requests to yourdataset.api.sanity.io. So how can you still run your studio and edit your data at `https://yourproject.sanity.studio`? This is where CORS comes to the rescue. CORS stands for Cross-Origin Resource Sharing and is a way to bypass the Same-origin policy for trusted pages. 

CORS provides a way for the browser to first check with the server whether the origin of a page is allowed to perform a specific request (e.g. a request to delete some content). Explained simplified: if a script loaded on a web page tries to request `https://yourproject.api.sanity.io`, the browser will issue a *preflight* request to the Sanity API, providing information about the origin of the web page (along with some additional metadata about the request it is about to perform). If the origin is in the list of trusted origins for your project, then the Sanity API will respond with a “YAY”, and the browser will continue with the actual request. If the origin is *not* found in the list of trusted origins, the Sanity API will respond with a “NOPE” and the browser will *not* perform the request at all. To add or remove a trusted origin, go to the [management console](https://manage.sanity.io), select a project, and find the list under the settings tab.

In addition to simply allowing or *not* allowing an origin access to your dataset, there's also an additional setting for whether or not to allow *credentials* to be sent. In this context, credentials means the session cookie. If you are creating a single-page application that reads content from the Sanity API directly, you usually want to *disallow* sending credentials. 

> [!NOTE]
> Worth remembering
> CORS is only about browser security, and does not apply when requesting from e.g. Node.js or `curl`. Think of CORS as a way to relax the browser’s strict same-origin policy for domains you trust.



# Keeping your data safe

## Take good care of your access tokens

An access token (also known as a robot token) is a credential that can give access to read or write data to a Sanity project. You can read more about access control and tokens in the [authentication docs](https://www.sanity.io/docs/content-lake/http-auth).

Access tokens are project-specific and you can create them from the project settings in the [management console](https://manage.sanity.io).

> [!WARNING]
> Gotcha
> Access tokens should not be confused with *user tokens*, which is a *personal* token that identifies a logged in user and is generated at the time the user logs in.

The single most important thing you can do to keep your data safe is to make sure never to disclose access tokens to unauthorized users. There are several ways to accidentally leak an access token, the most common being that it is gets bundled together with a frontend JavaScript bundle.

As a rule of thumb, you should:

- **Never** add an access token to JavaScript that is bundled for client-side use and served publicly unless you take extra precautions (described below).
- **Never** commit access tokens to public code repositories or open source projects.
- **Never** share access tokens through unsecured or public channels.



> [!WARNING]
> Gotcha
> Be extra careful with access tokens that grant write access to your data. Everyone with access to that token can delete all of your data.

### What to do if an access token gets compromised?

If you find that your token has been leaked or accidentally made public, you should consider it forever lost and delete it immediately, no matter how quickly you manage to make it private again. 

To delete a token, go to the management console at [https://manage.sanity.io](https://manage.sanity.io), select your project, and navigate to project settings. From there, select the API settings and delete the token in question.

## Submitting data from a frontend

If you want users of your website or app to be able to submit data, we recommend creating a small proxy server or cloud function that validates the received data, transforms it to a Sanity document and submits it using a [sanity client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) that is configured with a token that has write access to the dataset. 

## Dataset visibility

When creating datasets, you may choose whether it should be:

- **Public** - everyone can query for content in the dataset without being authorized - great for single page applications
- **Private** - only authenticated users or requests with authorization tokens can read from the dataset

You may change the visibility mode for your dataset either by running `sanity dataset visibility set <datasetName> <public/private>` or by using the management console at [https://manage.sanity.io/](https://manage.sanity.io/)

> [!WARNING]
> Gotcha
> Asset files are not private, so even images uploaded to a private dataset can be viewed by unauthenticated users.

> [!TIP]
> Protip
> Private datasets can be cached in our API Content Delivery Network (API CDN), it is cached with your access token as the key. 
> See [API CDN documention](https://www.sanity.io/docs/content-lake/api-cdn) for details.

Customers with the custom access control feature can specify fine-grained rules for configuring which users can create, delete and update documents. See the [access control](https://www.sanity.io/docs/content-lake/roles-concepts) documentation for details.

## Tokens in browser-side JavaScript

Configuring the Sanity client with an access token should generally be avoided for browser-side JavaScript. Usually, JavaScript for browsers are served publicly, and if it includes an access token, then that token will be available in plain text to everyone.

A common case is wanting to fetch data from a private dataset in a public frontend. If this is done by including an access token in JavaScript code that is shipped to the browser of the visitors of the site, the whole dataset will in effect be made public, since it takes little technical insight to inspect the JavaScript source code and find the token there. Even worse, if the access token grants write permission, you have in effect made your data writeable by everyone.

To avoid this, you could consider:

- Making the JavaScript private by serving it only to authorized users.
- Making the backend fetch the data from the Sanity APIs, filtering out only the data that should be available to the general public.

If you are making a frontend for a private intranet, make sure that also static assets are served only to authorized users as long as it includes an access token.



# Activity Feed

The Activity Feed lets you investigate what happened in your Sanity projects. If you are uncertain how a scenario took place, you can use the Activity Feed to investigate what actually happened.

*Screenshot of the project activity from sanity.io/manage*

## What is an event?

An event is created when various actions are performed in the system. This can be by a user, by Sanity, or even by a robot token. An event contains information about what happened and when. Events differ by action, and each contains a unique ID.

### List of team events

- user creates team
- user changes team’s name
- user changes billing address
- user changes payment method
- user changes EU Representative
- user changes Data Protection Officer
- user changes user’s role
- user removes user
- user invite user(s)
- user joins team
- user revoked invitation

### List of project events

- user creates project
- user changes project’s name
- user changes project’s custom studio URL
- user changes project’s plan
- user adds CORS origin
- user removes CORS origin
- user adds webhook
- user removes webhook
- user adds API token
- user removes API token
- user changes user’s permissions
- user removes user
- user invites user(s)
- user joins project
- user revokes invitation
- user creates dataset
- user deletes dataset
- user edits dataset
- user duplicates dataset

## Exporting

Actions for projects and teams are available as a CSV export from the [manage dashboard](https://sanity.io/manage) for each project. The export can be customized by the date when created.

### Data provided in the export

- action
- actorEmail
- actorId
- actorName
- correlationId
- datasetName
- description
- documentId
- id
- metadata.email
- metadata.invitedBy
- metadata.role
- organizationDisplayName
- organizationId
- projectDisplayName
- projectId
- timestamp
- transactionId
- userEmail
- userId
- userName
- version



# Roles and permissions

The Sanity [Roles system](https://www.sanity.io/docs/user-guides/roles) is a granular way of attaching specific capabilities to specific groups of users. It is designed to function in a structured and flexible way. The goal of the Roles system is to provide a set of strong default permissions groups with an API for creating, managing, and using [custom roles](https://www.sanity.io/docs/content-lake/roles-concepts) built the way your organization works with content.

> [!NOTE]
> Use the Access API
> The preferred method for interacting with roles is the [Access API](https://www.sanity.io/docs/http-reference/access-api). The project-based [Roles API](https://www.sanity.io/docs/http-reference/roles) is still available, but the documentation below focuses on the Access API.

## Role concepts

The Roles system consists primarily of:

- Resources
- Permissions
- Roles
- Members (users)

### Resources

A resource defines an element of a Sanity **project** or **organization** on which a user can have special grants. 

Today, a resource is either an `organization` or a `project`. Some predefined permissions also target additional resources like Media Library, Canvas, Dashboard, and View; the API treats these as resource types in their own right.

### Permissions

Every resource has a list of permissions. These permissions represent actions that can be performed on the resource. A user or robot must be granted a permission (through a role) in order to perform the action.

The permission typically takes the form of `{company}.{resourceType}.{objectName}.{action}`, but this is not always the case due to legacy terms.

There are both predefined and custom permissions. Predefined permissions are included with the product and are not editable.

#### Permission names: dotted vs. hyphenated

Permissions are identified two ways depending on the field or endpoint:

- **The dotted form** (`sanity.project.members`, `sanity.document.filter.mode`) identifies the permission's *type*. Use it in the `type` field of a custom permission body and in `/user-permissions/me/check?permissions=...` queries (where you collapse type and action into a single dotted string, for example, `sanity.project.members.read`).
- **The hyphenated form** (`sanity-project-members`, `sanity-document-filter-drafts`) identifies a *specific predefined permission resource* by name. Use it in role-body `permissions[].name` fields and in any other endpoint that takes a permission's `name` as a parameter, including path parameters like `/permissions/{permissionName}`.

Both forms refer to the same underlying permission. Role bodies use the hyphenated form because `permissions[].name` takes a permission's identifier name. Custom permissions you create use a name you choose; they aren't predefined and don't have a dotted-vs-hyphenated equivalence.

### Roles

Roles define a set of grants that project members can have assigned to them. A project member can have many roles and grants, even within the same organization. 

A role is a named bundle of permissions plus two flags that control who the role can be assigned to:

```json
{
  "name": "article-editor",
  "title": "Article editor",
  "appliesToUsers": true,
  "appliesToRobots": false,
  "permissions": [
    { "name": "sanity-project", "action": "read" },
    { "name": "articles-only",  "action": "update" },
    { "name": "articles-only",  "action": "create" }
  ]
}
```

Each entry in `permissions` is a `{name, action, params?}` object. `name` references either a predefined permission or a custom permission you've created in the same resource. `action` is the action you're granting. `params` is optional and used for permission types that take action parameters, like mode and dataset scoping on `sanity.document.filter.mode`.

The `appliesToUsers` and `appliesToRobots` flags control which kinds of subjects can hold the role. A role intended for CI pipelines should set `appliesToUsers: false` and `appliesToRobots: true` so it can't be assigned to a person by mistake.

#### Default roles

By default, there are specifically defined roles available for each plan type. Custom roles are available for Enterprise customers.

- All plans- Administrator: Read and write access to all datasets, with full access to all project settings. 
- API Tokens- Editor Token (read+write)
- Viewer Token (read-only)




- Free- Administrator
- Viewer: Can view all documents in all datasets within the project.


- Growth- Administrator
- Editor
- Viewer
- Developer
- Contributor: Read and write access to draft content within all datasets, with no access to project settings.



The table below summarizes what each built-in role grants. You can use these as-is, assign multiple to the same user, or use them as a baseline to compare custom roles against.

| Role | Publish content? | Manage content? | Manage project infrastructure? | Manage members and roles? |
| --- | --- | --- | --- | --- |
| Administrator | Yes | Yes | Full (datasets, tokens, CORS, webhooks, GraphQL, deploy) | Full, including adding administrators |
| Editor | Yes | Yes | No | Read-only |
| Viewer | No (read-only) | No | No | Read-only |
| Contributor | No (drafts only) | Drafts only | No | Read-only |
| Developer | Yes | Yes | Full (datasets, tokens, CORS, webhooks, GraphQL, deploy) | Invite, read, and update (cannot delete members) |

The five built-in roles also carry implied organization-level roles for Media Library, Canvas, and Dashboard (where those apps are available on your plan). This is automatic, and worth knowing about: assigning a project Editor role also grants organization-level editor access to those apps. If you're designing a custom role to restrict a user to a single project surface, factor in the implied organization-level access from any roles they already hold.

### Members (users)

A user is a person who has one or more roles assigned to them.

A user is initially added to a resource via invitation or access request. A user who already has one role can be assigned roles in another project within the same organization or at the organization level without requiring a separate invite.

As an organization owns multiple resources, such as projects, any users with roles on these resources are also returned when reading the users of an organization.

If a user has roles in multiple projects, they are considered a single user and can be referenced by their `sanityUserId`. For example, inviting user A to project B and project C in the same organization will result in a single user with two memberships.

## Custom roles

When the built-in roles don't fit, you can author your own with the Access API. Custom roles are an Enterprise plan feature.

### When to reach for a custom role

The built-in roles are the right answer for most projects. Reach for a custom role when you need one of these patterns:

- A CI/CD pipeline that deploys the Studio, but must not be able to mutate content or manage users.
- An editor scoped to a single dataset (for example, `staging` only) or a single document type (for example, `article` only).
- A release reviewer who can schedule releases but not publish them.
- A read-only role for a business intelligence tool that queries production data via the API.
- An attribute-based role where the permission depends on a runtime check, like "the user is listed in the document's `assignees` array."

Each of these maps to a recipe in [Build a custom role with the Access API](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

### What custom roles can express

Custom roles are powerful but not unlimited. Knowing the boundaries up front saves design time.

**You can express:**

- Document-type matching with GROQ filters (for example, `_type == "article"`).
- Attribute comparisons (`status == "published"`, `priority > 3`).
- Identity-based filtering with `identity()` (the calling user's Sanity user ID).
- User-attribute templating with `user::attributes()` for attribute-based access control.
- Dataset scoping via `params.dataset` on `sanity.document.filter.mode` permissions.
- Mode scoping (`read`, `create`, `publish`) for read-only roles, draft-only contributors, and full-publish editors.

**You can't express:**

- Joins or reference traversal in filter GROQ. A filter like `*[_type == "user" && _id == identity()].assignedDocs[]._ref` is rejected.
- Subqueries in filter GROQ.
- Filters that use `user::` functions (like `user::attributes()`) unless the user-attributes feature is enabled for your project; permission creation rejects them otherwise.
- A "review-but-not-comment" role. Comments and mentions check read permission only; there's no separate write check for leaving a comment, so you can't grant read while denying comment.

If your role needs something filter GROQ can't express, the workaround is usually to denormalize the data (put the value you need to check directly into the document) or to gate the feature higher up in your application.

### Creating a custom role

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You create custom roles with the Access API: define any custom permissions, bundle them into a role, and assign the role to users or robots. For the four-step flow, six copy-paste recipes, and verification patterns, see [Build a custom role with the Access API](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

### How custom roles take effect

When you create or modify a role via the Access API, Sanity propagates the change to the Content Lake, typically within seconds. After propagation, the new permissions apply to the user's or robot's next request.

Two things to know about propagation in practice:

- **Studio sessions cache permissions:** A running Studio app may show stale permission state until the user reloads. After you assign a new role, ask the user to refresh the browser to pick up the change.
- **For automated checks:** Call `GET /v2025-07-11/access/{resourceType}/{resourceId}/user-permissions/me/check` with the user's session token to verify a role has applied. See the [verification section in Build a custom role](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) for the request shape and a CI-friendly testing pattern.

## Administrator default permissions

The following are the permissions for the default "Administrator" role available for various plans. You can view your default permission resources with the [Access API](https://www.sanity.io/docs/http-reference/access-api).

### Project administrator default permissions

**Response**

```json
{
  "name": "administrator",
  "title": "Administrator",
  "description": "Read and write access to all datasets, with full access to all project settings.",
  "isCustom": false,
  "resourceId": "3do82whm",
  "resourceType": "project",
  "appliesToUsers": true,
  "appliesToRobots": false,
  "permissions": [
    {
      "name": "sanity-project",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "invite",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-roles",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-project-roles",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-roles",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project",
      "action": "deployStudio",
      "params": {}
    },
    {
      "name": "sanity-project",
      "action": "createSession",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-roles",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-datasets",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-project-datasets",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-datasets",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project-tags",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-project-tags",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-tags",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project-tokens",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-project-tokens",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-tokens",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-datasets",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-tags",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-cors",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-project-cors",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-cors",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-webhooks",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-project-webhooks",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-webhooks",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project-graphql",
      "action": "manage",
      "params": {}
    },
    {
      "name": "sanity-project-usage",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-webhooks",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-all-documents",
      "action": "mode",
      "params": {
        "mode": "publish",
        "history": true
      }
    }
  ]
}
```

**Endpoint**

```text
https://api.sanity.io/v2025-07-11/access/project/{projectId}/roles/administrator
```

### Organization administrator default permissions

**Response**

```json
{
  "name": "administrator",
  "title": "Administrator",
  "description": "Administrators can manage billing details, legal contacts, organization members and manage project ownership",
  "isCustom": false,
  "resourceId": "oSyH1iET5",
  "resourceType": "organization",
  "appliesToUsers": true,
  "appliesToRobots": false,
  "permissions": [
    {
      "name": "sanity-organization",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-organization",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-organization",
      "action": "billing",
      "params": {}
    },
    {
      "name": "sanity-organization-projects",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-projects",
      "action": "attach",
      "params": {}
    },
    {
      "name": "sanity-organization-projects",
      "action": "detach",
      "params": {}
    },
    {
      "name": "sanity-organization-legal",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-legal",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-organization-members",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-members",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-organization-members",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-organization-members",
      "action": "invite",
      "params": {}
    },
    {
      "name": "sanity-organization-roles",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-organization-roles",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-roles",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-organization-roles",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-organization-tokens",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-tokens",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-organization-tokens",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-project-members",
      "action": "invite",
      "params": {}
    },
    {
      "name": "sanity-media-library",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-media-library-members",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-media-library-members",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-media-library-members",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-media-library-members",
      "action": "invite",
      "params": {}
    },
    {
      "name": "sanity-sdk-applications",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-sdk-applications",
      "action": "deploy",
      "params": {}
    },
    {
      "name": "sanity-sdk-applications",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-project",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-project",
      "action": "deployStudio",
      "params": {}
    },
    {
      "name": "sanity-dashboard-configuration-organization",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-dashboard-configuration-organization",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-dashboard-configuration-organization",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-view",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-view",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-view",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-view",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-organization-views",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-views",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-organization-views",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-organization-views",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-dashboard-intents",
      "action": "create",
      "params": {}
    },
    {
      "name": "sanity-dashboard-intents",
      "action": "update",
      "params": {}
    },
    {
      "name": "sanity-dashboard-intents",
      "action": "delete",
      "params": {}
    },
    {
      "name": "sanity-view",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-sessions",
      "action": "read",
      "params": {}
    },
    {
      "name": "sanity-organization-sessions",
      "action": "delete",
      "params": {}
    }
  ]
}
```

**Endpoint**

```text
https://api.sanity.io/v2025-07-11/access/organization/{organizationId}/roles/administrator
```

## Where to go next

- **Build a custom role with the Access API:** the four-step flow, six recipes covering the most common scenarios, and a reference table mapping intents to permissions.
- **Access API HTTP reference:** the full endpoint inventory.



# Create custom roles

This page covers the patterns you'll use to build, assign, and verify custom roles via the Access API. If you're new to roles, read [the concepts page](https://www.sanity.io/docs/content-lake/roles-concepts) first.

## Before you start

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You'll need:

- A Sanity project and a token that can call the Access API. An admin user token (from `sanity login`) works; a project- or organization-scoped robot token also works for most operations.
- Comfort making authenticated HTTP requests with curl or a similar client, and reading and writing JSON request bodies.

The examples on this page use [API version](https://www.sanity.io/docs/content-lake/api-versioning) `v2026-07-11`. Pin to a version that suits your project's needs.

## The `action` and `params.mode` pattern

Every recipe on this page relies on one pattern that surprises most authors the first time they see it. Read this section before you write any role JSON.

Both `sanity.document.filter` and `sanity.document.filter.mode` are GROQ-filter-backed types: they scope a permission to documents matching a `config.filter` predicate. They differ in how the action is expressed. For `sanity.document.filter.mode`, the outer `action` field is always the literal string `"mode"`. The semantic action you actually care about (`read`, `create`, or `publish`) goes inside `params.mode`.

```json
{
  "name": "all-docs",
  "action": "mode",
  "params": { "mode": "publish" }
}
```

This shape is what enables dataset and history scoping via additional `params` fields:

```json
{
  "name": "all-docs",
  "action": "mode",
  "params": { "mode": "publish", "dataset": "staging" }
}
```

For permissions of type `sanity.document.filter` (the non-`.mode` variant) and for project-level types like `sanity.project.datasets`, the `action` field carries the semantic action directly: `read`, `create`, `update`. Available actions vary by type: project-level types like `sanity.project.datasets` also include `delete`, while `sanity.document.filter` has no `delete` action (its `update` grant covers delete). No `params` block needed.

```json
{ "name": "sanity-project-datasets", "action": "read" }
```

When you read role JSON, ask yourself: is this a `.mode` permission? If yes, the `action` is `"mode"` and the real action is in `params.mode`. If no, the `action` is the action.

### Choosing between the two types

The recipes on this page use both. Pick based on what scoping you need:

- **sanity.document.filter** when a GROQ filter alone is enough to scope the permission. Best for simple type-matching filters (Recipe: [Content-type-restricted editor](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api): "edit articles only"), release-action filtering (Recipe: [Release reviewer](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api): "schedule but not publish"), and any case where you don't need dataset or history scoping.
- **sanity.document.filter.mode** when you need first-class `params.dataset` for dataset scoping (Recipes: [Read-only analyst](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api), [Single-dataset editor](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api)), `params.history` for history scoping, or the canonical `mode: "publish"` shape for expressing publish authority (Recipe: [Attribute-based editing](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api)).

If you're not sure, start from the closest recipe match above.

**Avoid mixing them in the same role without understanding the dataset-bypass gotcha:** the `params.dataset` scope on a `sanity.document.filter.mode` grant doesn't constrain sibling `sanity.document.filter` grants in the same role; those escape dataset scope and apply to all datasets. If you need dataset scoping, use `sanity.document.filter.mode` for all the document-plane permissions in that role.

## The four-step flow

Building a custom role follows four steps in order. The example below creates an "article-editor" role: a role that can update and create documents of type `article`, and read articles.

### Step 1: Create a custom permission

Custom permissions are scoped to a resource (organization or project) and addressed by their `name`. The `name` is the unique identifier within the resource scope.

```bash
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "articles-only",
    "title": "Articles only",
    "description": "Only allow access to articles",
    "type": "sanity.document.filter",
    "config": { "filter": "_type == \"article\"" }
  }'
```

The `type` field references the predefined permission type that this custom permission specializes. The `config.filter` field holds a GROQ predicate that narrows the permission to documents matching the filter.

### Step 2: Create a role that uses the permission

A role bundles permissions with two flags (`appliesToUsers` and `appliesToRobots`) controlling who the role can be assigned to.

```bash
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "article-editor",
    "title": "Article editor",
    "description": "Can edit and manage articles; reads articles",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      { "name": "articles-only",          "action": "update" },
      { "name": "articles-only",          "action": "create" },
      { "name": "articles-only",          "action": "read" }
    ]
  }'
```

The first three entries (`sanity-project`, `sanity-project-members`, `sanity-project-roles`) are predefined permissions that almost every role needs: the user has to be able to read basic project metadata for the API client to initialize. The last three entries grant `update`, `create`, and `read` actions on the custom `articles-only` permission you defined in Step 1. (Note that on `sanity.document.filter`, the `update` action allows both update and delete on matching documents; see [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).)

### Step 3: Assign the role to a user

```bash
curl -X PUT "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/users/$SANITY_USER_ID/roles/article-editor" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
```

`$SANITY_USER_ID` is the target user's Sanity user ID (not their email). You can look it up with `GET /v2026-07-11/access/project/$PROJECT_ID/users`.

### Step 4: Teardown (in reverse order)

When you tear down a role, the API enforces one part of the cleanup order and silently rewrites the rest. Remove the assignments before deleting the role; clean up the permissions last to avoid silently degrading other roles.

```bash
# 1. Unassign the role from each user
curl -X DELETE "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/users/$SANITY_USER_ID/roles/article-editor" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# 2. Delete the role
curl -X DELETE "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles/article-editor" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# 3. Delete the custom permission (only after confirming no other roles depend on it; see warning below)
curl -X DELETE "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions/articles-only" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
```

### Cleanup behavior: what the API enforces vs. what it allows

- **Required:** Remove role assignments before deleting a role. The API rejects role deletion while the role is assigned to any user; the role needs to be removed from users first.
- **Allowed but discouraged:** The API permits permission deletion even when roles still reference the permission. When this happens, the API silently strips the dangling entries from each affected role's `permissions[]` array. No error and no notification; the activity log records the permission deletion itself, but not which roles lost grants. The role and its user assignments remain, but the role's effective permission set narrows.

**Why this matters:** if a role's only document-scoping grant comes from a custom permission that gets deleted, users assigned to that role can lose document-plane access without any explicit signal. The only way to detect it after the fact is to `GET` the role and compare its `permissions[]` array to what you expected.

**Recommended teardown order:**

1. Remove the role's assignments from each user (required).
2. Delete the role (now allowed; will reject if any assignment remains).
3. Delete the custom permission only after confirming no other roles reference it. If other roles do reference it, decide whether to update those roles first (replace the reference) or accept the silent narrowing.

If you're deleting a permission as part of a refactor rather than a teardown (the permission stays in use but you're restructuring), follow the same step 3: update affected roles before deleting the permission.

### Verify the role applied

After Step 3, confirm the role took effect by calling `/user-permissions/me/check` as the assigned user:

```bash
curl "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/user-permissions/me/check?permissions=sanity.document.filter.update&permissions=sanity.project.members.invite" \
  -H "Authorization: Bearer $USER_TOKEN"
```

```json
{
  "data": {
    "sanity.document.filter.update": true,
    "sanity.project.members.invite": false
  }
}
```

For deeper testing patterns, including a CI integration sketch, see the [verification section](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) below.

## Recipes

Each recipe below follows the same shape: persona, mechanism, payloads, verification, caveats. Every recipe targets `resourceType=project` unless noted; adapt the `resourceId` and the URL path for organization-scoped roles.

### CI/CD robot: minimum-permission deploy token

**Persona:** A CI pipeline that deploys the Studio on every main-branch merge. It must not be able to mutate content or manage users.

> [!NOTE]
> **Robot tokens work on Access API operations.** Most Access API endpoints accept a project- or organization-scoped robot token, including the four-step flow above and `/user-permissions/me*` (which returns the robot's effective permissions when queried with a robot token). The exception is `/users/me`, which surfaces user-identity state and requires a user session. If you've seen a "user session required" 401 from a robot-token call, it's coming from `/users/me`, not from the Access API in general. Robot tokens authenticate the CI flow described in this recipe end to end.

**Capabilities needed:**

- `sanity deploy` (the Studio) needs `sanity.project:deployStudio`.
- `sanity dataset copy` (optional, for snapshot-based deploys) needs `sanity.project.datasets:{read, create}`.

**Mechanism:** Define a robot-only role (`appliesToUsers: false`, `appliesToRobots: true`) with only deploy-relevant permissions. Create a robot under the role and use its token for CI.

**Anti-pattern to avoid:** Assigning the built-in `administrator` role to a CI robot because it's faster to set up. Any pipeline compromise becomes a full project takeover, including members and tokens. Custom roles exist for exactly this reason.

**Payloads:**

```bash
# Step 1: Create the role (no custom permissions needed; all are predefined).
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "ci-deploy",
    "title": "CI/CD deploy",
    "description": "Minimum permissions for Studio deploy pipelines",
    "appliesToUsers": false,
    "appliesToRobots": true,
    "permissions": [
      { "name": "sanity-project",          "action": "deployStudio" },
      { "name": "sanity-project-datasets", "action": "read" },
      { "name": "sanity-project-datasets", "action": "create" }
    ]
  }'

# Step 2: Create a robot under this role.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/robots" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "GitHub Actions deploy",
    "memberships": [
      {
        "resourceType": "project",
        "resourceId": "'"$PROJECT_ID"'",
        "roleNames": ["ci-deploy"]
      }
    ]
  }'
# The response includes the robot's bearer token. Store it as a repository secret.
# To set an explicit token expiry (recommended for CI), include "expiresAt": "<ISO-8601 timestamp>".
```

**Verification:**

```bash
# Verify the robot's permissions directly with /user-permissions/me/check
# (robot tokens work fine on this endpoint), or attempt the gated action.
curl "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/user-permissions/me/check?permissions=sanity.project.deployStudio" \
  -H "Authorization: Bearer $ROBOT_TOKEN"
# Expected: { "data": { "sanity.project.deployStudio": true } }
```

**Caveats:**

- `sanity deploy` from the CLI authenticates with a user token. Set the `SANITY_AUTH_TOKEN` environment variable to override the user token.
- Granting `create` on `sanity-project-datasets` is broader than "copy into an existing dataset." If snapshotting isn't needed, drop it.

### Release reviewer: schedule but not publish

**Persona:** A content-team member who prepares releases and schedules go-live times, but requires a senior editor's approval before the actual publication.

**Mechanism:** Release actions decompose into permission checks against synthetic document IDs of the form `_.releases.<releaseId>.actions.<action>`. Granting `update` on a filter that matches a subset of these IDs authorizes specific release actions. Withhold the filter match for `_.releases.*.actions.publish` and the user can't publish. (The `update` action here authorizes release actions like schedule and unschedule; it does not interact with the data-plane document mutation gate where the "update grants delete" mechanic applies. See [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).)

**Permissions granted:** schedule, unschedule, edit, archive, unarchive. **Permissions withheld:** publish, delete.

**Payloads:**

```bash
# Step 1: Create the filter permission.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "release-reviewer-scope",
    "title": "Release reviewer scope",
    "description": "Allows schedule, unschedule, edit, archive, and unarchive on releases; withholds publish and delete",
    "type": "sanity.document.filter",
    "config": {
      "filter": "_id in path(\"_.releases.*.actions.schedule\") || _id in path(\"_.releases.*.actions.unschedule\") || _id in path(\"_.releases.*.actions.edit\") || _id in path(\"_.releases.*.actions.archive\") || _id in path(\"_.releases.*.actions.unarchive\")"
    }
  }'

# Step 2: Create the role bundling release scoping with baseline read access.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "release-reviewer",
    "title": "Release reviewer",
    "description": "Can schedule releases but not publish them",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      { "name": "release-reviewer-scope", "action": "update" }
    ]
  }'

# Step 3: Assign to the user as in the four-step flow above.
```

**Verification:** This role is best validated end to end. Assign the role to a test user, then attempt both `schedule` and `publish` actions: schedule should succeed; publish should return a 403 from the release dry-run check.

**Caveats:**

- The filter string exposes synthetic document ID paths. These are stable today but may be replaced by a higher-level abstraction in a future release.
- This role grants only release-action authority. It doesn't include the document-editing permissions needed to change release contents. Combine it with an editor role if the reviewer should also edit release drafts.

### Read-only analyst: dataset-scoped

**Persona:** A BI analyst who queries production data via the API from an external tool. The role must never mutate.

**Mechanism:** Grant read-only access on documents plus the minimum project metadata. Deny everything else by omission. The role applies to robots so the analyst's tool can use a scoped token.

**Payloads:**

```bash
# Step 1: Create the read-scope filter (optional; narrows read to the production dataset).
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "analyst-read-scope",
    "title": "Analyst read scope",
    "description": "Read-only permission for analysts",
    "type": "sanity.document.filter.mode",
    "config": { "filter": "true" }
  }'

# Step 2: Create the role.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "read-only-analyst",
    "title": "Read-only analyst",
    "description": "Query production data; no mutations",
    "appliesToUsers": true,
    "appliesToRobots": true,
    "permissions": [
      { "name": "sanity-project",          "action": "read" },
      { "name": "sanity-project-datasets", "action": "read" },
      {
        "name": "analyst-read-scope",
        "action": "mode",
        "params": { "mode": "read", "dataset": "production" }
      }
    ]
  }'
```

**Verification:**

```bash
# A query should succeed.
curl "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/query/production?query=*\[_type==%22article%22\]\[0...5\]" \
  -H "Authorization: Bearer $ROBOT_TOKEN"

# A mutation should return 403.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $ROBOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"nope"}}]}'
# Expected: 403 with an insufficient-permissions error.
```

**Caveats:**

- Most uses of `@sanity/client` need `sanity.project:read` to fetch project metadata. Pure GROQ queries with an explicit dataset don't need it, but most other operations do; if you omit the grant, some operations will return a misleading error.
- The Content Lake `POST /actions` endpoint is a mutation surface; read-only roles will (correctly) return 403 there. Some teams conflate "doesn't mutate" with "can call any endpoint that returns data," so be explicit when documenting the role for your stakeholders.
- If the analyst needs to enumerate all datasets (not only production), drop the `params.dataset` scoping.

### Single-dataset editor

**Persona:** A content contributor who edits in `staging` but must never touch `production`.

**Mechanism:** Native `params.dataset` scoping. The `sanity.document.filter.mode` permission's `params.dataset` field is first-class. The Access API gates the grant to that dataset without filter-string escape hatches.

**Payloads:**

```bash
# Step 1: Create an unscoped filter permission (the "everything" filter).
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "all-docs",
    "title": "All documents",
    "description": "Access all documents",
    "type": "sanity.document.filter.mode",
    "config": { "filter": "true" }
  }'

# Step 2: Role granting publish-mode scoped to the staging dataset.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "staging-editor",
    "title": "Staging editor",
    "description": "Full editing rights in staging dataset only",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",          "action": "read" },
      { "name": "sanity-project-members",  "action": "read" },
      { "name": "sanity-project-roles",    "action": "read" },
      { "name": "sanity-project-datasets", "action": "read" },
      {
        "name": "all-docs",
        "action": "mode",
        "params": { "mode": "publish", "dataset": "staging" }
      }
    ]
  }'
```

**Verification:**

```bash
# Staging mutation succeeds.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/staging" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"test"}}]}'

# Production mutation returns 403.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"nope"}}]}'
```

**Caveats:**

- The `all-docs` permission can be reused across many roles; it's a packaging of "filter = true." Consider naming it `all-docs-filter` if you'll stack it into other recipes.
- If you scope to a dataset that doesn't exist yet, the grant is inert but valid. Create the dataset first.

### Content-type-restricted editor

**Persona:** A marketing editor who should edit and manage `article` documents only, not `product` or `config`.

**Mechanism:** `sanity.document.filter` with a type-matching filter, granted at `update` and `create` actions. Reads are scoped to articles by the same filter: deny-by-omission means the role reads articles only. See the caveat about reference integrity below.

**Payloads:**

```bash
# Step 1: Article-only filter.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "articles-only",
    "title": "Articles only",
    "description": "Access articles only",
    "type": "sanity.document.filter",
    "config": { "filter": "_type == \"article\"" }
  }'

# Step 2: Role with update and create on articles, read on everything.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "article-editor",
    "title": "Article editor",
    "description": "Edits and manages articles only; reads references to other types",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      { "name": "articles-only",          "action": "update" },
      { "name": "articles-only",          "action": "create" },
      { "name": "articles-only",          "action": "read"   }
    ]
  }'
```

**Verification:**

```bash
# Creating an article succeeds.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"article","title":"ok"}}]}'

# Creating a product returns 403.
curl -X POST "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/mutate/production" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mutations":[{"create":{"_type":"product","sku":"nope"}}]}'
```

**Caveats:**

- **Reference leakage.** An `article` can point to a `product` via a reference field. Because reads are scoped to articles, the editor can't see products in the reference picker. Consider situations like this when creating narrow permissions.
- After a permission change, the Studio's permission cache may briefly show the pre-role state. Buttons might appear available even though the action will return 403 at submit time. Ask the user to reload after a role change.
- **The update grant covers delete.** Granting `update` on `articles-only` allows the editor to delete matching `article` documents at the data-mutate gate, in addition to updating them. Filter scope is enforced (non-`article` documents remain protected), but if your intent was edit-only, gate deletion higher up in your application. See [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

### Attribute-based editing: "edit what's assigned to me"

**Persona:** An editor who can edit only documents where they're listed in an `assignees` array.

**Mechanism:** `sanity.document.filter.mode` with a user-attribute template. The filter resolves at request time using the calling user's identity or stored attributes. For the simple "is the caller listed in this document's assignees?" pattern, use `identity()` directly.

**Payloads:**

```bash
# Step 1: Filter based on identity().
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/permissions" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "assignee-editor-filter",
    "title": "Documents I am assigned to",
    "description": "User-assigned document access",
    "type": "sanity.document.filter.mode",
    "config": {
      "filter": "identity() in assignees[]._ref"
    }
  }'

# Step 2: Role.
curl -X POST "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/roles" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "assignee-editor",
    "title": "Assignee editor",
    "description": "Edit documents where the user is in the assignees array",
    "appliesToUsers": true,
    "appliesToRobots": false,
    "permissions": [
      { "name": "sanity-project",         "action": "read" },
      { "name": "sanity-project-members", "action": "read" },
      { "name": "sanity-project-roles",   "action": "read" },
      {
        "name": "assignee-editor-filter",
        "action": "mode",
        "params": { "mode": "publish" }
      }
    ]
  }'
```

For attribute-based variants (for example, team membership set admin-side), reference `user::attributes()` in the filter:

```json
"config": {
  "filter": "team == user::attributes().teamId"
}
```

At request time, `user::attributes().teamId` resolves to the calling user's stored `teamId` attribute.

**Verification:** Filters are evaluated per document per caller, so verification is easiest as the affected user:

```bash
curl "https://$PROJECT_ID.api.sanity.io/v2026-07-11/data/query/production?query=*\[_type==%22article%22\]" \
  -H "Authorization: Bearer $USER_TOKEN"
# Returns only articles where the user is in assignees.
```

**Caveats:**

- **Filters must be simple.** No joins, no subqueries, no custom functions. A filter like `*[_type == "user" && _id == identity()].assignedDocs[]._ref` is rejected at permission-create time. Denormalize the data (put the assignee list in the article document itself) or gate the feature higher up in your application.
- **Attribute propagation:** User-attribute changes push to the permission layer immediately on write, typically landing within a second; a polling fallback catches any missed updates within roughly 10-30 seconds.
- **identity() is the user's Sanity user ID**, not an email or profile ID. When you denormalize, store the `sanityUserId` value.
- **The mode grant covers delete on matching documents.** `mode` on `sanity.document.filter.mode` grants delete capability for documents matching the filter (the same data-mutate gate semantics as `update` on `sanity.document.filter`; see [two common traps when verifying denials](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api)). Behavior on documents that do NOT match the filter is not currently documented as a contract; for security-critical denials on this recipe, verify end to end against documents your role both should and should not match.

## Verifying a custom role

After you've assigned a role, two patterns are useful for verification.

### Single-call check

`/user-permissions/me/check` reads from the authoritative permission store, so it's consistent within milliseconds of a role edit.

```bash
curl "https://api.sanity.io/v2026-07-11/access/project/$PROJECT_ID/user-permissions/me/check?permissions=sanity.document.filter.update&permissions=sanity.project.datasets.create" \
  -H "Authorization: Bearer $USER_TOKEN"
```

```json
{
  "data": {
    "sanity.document.filter.update": true,
    "sanity.project.datasets.create": false
  }
}
```

Each `permissions` query parameter is a dot-delimited `<type>.<action>` string. The endpoint checks the coarse `(resource, action)` pair only; it doesn't evaluate `params.mode` or `params.dataset`. For mode-scoped or dataset-scoped permissions, supplement the check with a real action against a test environment.

### CI integration pattern

For end-to-end validation in CI, set up a known test user, assign the role, run permission checks plus real actions, and tear down.

```typescript
import {createClient} from '@sanity/client'

const admin = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET_NAME',
  token: 'ADMIN_TOKEN',
  apiVersion: '2026-07-11',
  useCdn: false,
})

const testUser = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET_NAME',
  token: 'TEST_USER_TOKEN',
  apiVersion: '2026-07-11',
  useCdn: false,
})

beforeAll(async () => {
  await admin.request({method: 'POST', uri: `/access/project/${PROJECT_ID}/permissions`, body: {/* permission */}})
  await admin.request({method: 'POST', uri: `/access/project/${PROJECT_ID}/roles`, body: {/* role */}})
  await admin.request({method: 'PUT',  uri: `/access/project/${PROJECT_ID}/users/${SANITY_USER_ID}/roles/article-editor`})
})

afterAll(async () => {
  await admin.request({method: 'DELETE', uri: `/access/project/${PROJECT_ID}/users/${SANITY_USER_ID}/roles/article-editor`})
  await admin.request({method: 'DELETE', uri: `/access/project/${PROJECT_ID}/roles/article-editor`})
  await admin.request({method: 'DELETE', uri: `/access/project/${PROJECT_ID}/permissions/articles-only`})
})

test('article-editor can update articles but not products', async () => {
  const res = await testUser.request({
    method: 'GET',
    uri: `/access/project/${PROJECT_ID}/user-permissions/me/check?permissions=sanity.document.filter.update`,
  })
  expect(res.data['sanity.document.filter.update']).toBe(true)

  // Mutations are gated at request time by the Content Lake.
  await expect(testUser.create({_type: 'product', sku: 'nope'})).rejects.toThrow(/403|insufficient/i)
})
```

**Note on robot tokens:** The `/user-permissions/me/check` endpoint accepts robot tokens, so a robot can verify its own granted permissions directly with the same query pattern shown above. (`/users/me` is the one `/me` endpoint that requires a user session, but the permission-check path doesn't.)

**Re-verify after permission changes.** If a custom permission referenced by this role has been deleted, the role's `permissions[]` array is silently rewritten (see [Cleanup behavior](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) above). After any permission deletion, re-fetch any role that referenced it and confirm its `permissions[]` matches your expectations.

**Note on role propagation.** Role and permission changes propagate to the Content Lake mutation gate asynchronously: the role API records the change immediately, then rebuilds the affected dataset ACLs and pushes them to the Content Lake, which applies them in real time as they arrive. A single change typically lands within seconds; under churn (many roles created or modified in close succession), the throttled rebuild step can take noticeably longer. The `/access/...` endpoints answer from the authoritative store and aren't affected. For CI pipelines that create roles and immediately attempt mutations, build in a retry window.

### Two common traps when verifying denials

When a custom role is supposed to deny an action, the cleanest test is to attempt the action and observe a 403. Two traps can make a denial look like it works when it doesn't.

**Confirm the identity you're testing as.** If the test identity is also a member of another role that grants the action (for example, an administrator account in the same project), the action will succeed and the denial test silently passes. Before concluding a denial works, call `/user-permissions/me/check` with the test identity's token and confirm the relevant `(type, action)` returns `false`. The check returns the authoritative grant set for whatever identity the token represents, so a `false` there guarantees no other role on that identity is granting the action.

**update on sanity.document.filter grants delete capability.** On `sanity.document.filter` permissions, granting `update` allows both update and delete mutations at the data-mutate gate. There's no separate `delete` action on this permission type, and no update-without-delete grant available. A role with `{name: <filter-perm>, action: update}` can delete documents matching the filter, even though the role body contains no `delete` entry. To deny deletion while allowing edits, gate deletion at a different layer (a workflow tool, a higher-tier role required for destructive actions, or a server-side check before the mutation hits the Content Lake).

These traps share a shape: a role looks restrictive on paper, but the actual capability surface is broader than the role body suggests. When designing a custom role for a security-sensitive surface, verify denials with a real action attempt against a known-isolated test identity, rather than by reading the role body alone.

## Reference: intent to permission

This section maps user-visible capabilities to the Access API permissions you'd grant in a custom role to enable or restrict each capability.

The tables answer the question: "I want a custom role that can do X. Which permission do I grant?"

> [!NOTE]
> The tables use `<type>:<action>` capability notation to describe permissions. To use these in a role body, build a custom permission with the `type` field set to the dotted type (`sanity.document.filter`, `sanity.document.filter.mode`) and reference it by your custom name in the role's `permissions[].name`. See [Permission names: dotted vs. hyphenated](https://www.sanity.io/docs/content-lake/roles-concepts) in the concepts page for the format reference.

### Document editing

These permissions gate the surfaces where users edit content: creating, publishing, deleting, discarding, and duplicating documents, plus the read-only banner on the form itself.

| Capability | Permission to grant | Notes |
| --- | --- | --- |
| Create new documents | `sanity.document.filter:create` | Scope with a filter (for example, `_type == "article"`) to restrict creation to specific document types. |
| Publish a document | `sanity.document.filter.mode` with `params.mode: "publish"` | The publish mode grants read, create, and update on matching documents, and decomposes internally into update-published, create-published, and delete-draft. A `sanity.document.filter:update` grant also works, but `mode: "publish"` is the canonical way to express "publish authority." |
| Unpublish a document | `sanity.document.filter:update` (or `mode: "publish"`) | Decomposes into delete-published and update-draft. |
| Delete a document | `sanity.document.filter:update` | The same `update` grant covers delete; the document plane doesn't have a separate `delete` action. |
| Discard draft changes | `sanity.document.filter:update` |  |
| Duplicate a document | `sanity.document.filter:create` | Same as create; scope with a filter to restrict duplication targets. |
| Edit a field (form editability and read-only banner) | `sanity.document.filter:update` | Without this, the Studio renders the form with a read-only banner. Combine with `mode: "create"` for draft-only authoring (gating on `drafts.*` document IDs; matches the contributor starter role). |
| Field-level revert in the diff viewer | `sanity.document.filter:update` |  |

> [!WARNING]
> The `params.dataset` scope on a `sanity.document.filter.mode` permission applies only to that permission. 
> If the same role also grants `sanity.document.filter:create` or `:update` actions (the non-mode type), those non-mode grants are not constrained by the sibling mode grant's `params.dataset`: they apply to all datasets. To scope a role to a single dataset for both mode-style and non-mode-style grants, scope each one explicitly.
> For `sanity.document.filter.mode` grants, set `params.dataset` on the role's grant.
> For `sanity.document.filter` (non-mode) grants, either use a filter string that includes a dataset check, or don't combine the two patterns in one role.
> The [single-dataset editor recipe](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api) uses the mode-only pattern specifically to avoid this trap.

**Patterns seen in the wild:**

- **Edit but not publish:** Grant `mode: "create"` but not `mode: "publish"`. This is exactly how the built-in contributor role is composed.
- **Type-restricted editor:** Grant `update` and `create` with a filter like `_type == "article"`.
- **Dataset-scoped editor:** Grant `mode: "publish"` with `params.dataset: "staging"`.

### Content releases

Releases are bundles of versioned documents that publish atomically. Each release action (create, publish, schedule, archive, edit metadata) is gated by a grant on a synthetic document ID of the form `_.releases.<releaseId>.actions.<action>`. You grant `sanity.document.filter:update` with a filter that matches the action IDs you want to authorize.

| Capability | Permission to grant | Filter to match |
| --- | --- | --- |
| Create a new release | `sanity.document.filter:update` | `_.releases.*.actions.create` |
| Publish a release | `sanity.document.filter:update` | `_.releases.*.actions.publish` |
| Schedule a release | `sanity.document.filter:update` | `_.releases.*.actions.schedule` |
| Unschedule a release | `sanity.document.filter:update` | `_.releases.*.actions.unschedule` |
| Archive a release | `sanity.document.filter:update` | `_.releases.*.actions.archive` |
| Unarchive a release | `sanity.document.filter:update` | `_.releases.*.actions.unarchive` |
| Delete a release | `sanity.document.filter:update` | `_.releases.*.actions.delete` |
| Edit release metadata (title, description, schedule) | `sanity.document.filter:update` | `_.releases.*.actions.edit` |
| Revert a release (creates a new reverting release) | `sanity.document.filter:update` | `_.releases.*.actions.create` |
| Discard a version document inside a release | `sanity.document.filter:update` | Plain document filter; no synthetic ID |
| Unpublish a version document inside a release | `sanity.document.filter:update` | Plain document filter; no synthetic ID |

The Manage UI expresses the same grants more coarsely. A content resource with the filter `_id in path("_.releases.**")` matches the release document and all of its action IDs at once, so it takes a single access level rather than per-action grants: **Publish** to create, schedule, publish, and archive releases. Use the per-action filters in this table when you need to allow one release action while withholding another. For the Manage-UI equivalents of the drafts, versions, and releases resources, see [Permissions for Studio features](https://www.sanity.io/docs/user-guides/roles).

### Comments, mentions, and tasks

> [!TIP]
> Worth knowing up front
> Comments and mentions check read permission only on the target document. There's no separate write check for leaving a comment or @-mentioning someone. A "reviewer-cannot-comment" role isn't expressible today; revoking read also hides the document.

| Capability | Permission to grant |
| --- | --- |
| See and leave comments on a document | `sanity.document.filter:read` on the target document |
| Appear as a mention candidate to other users | `sanity.document.filter:read` on the target document (per-user check) |
| Be assignable as a task assignee | `sanity.document.filter:read` on the target document (per-user check) |

### Project membership and access requests

Two capabilities live at the project-management level rather than the document plane.

| Capability | Permission to grant | Notes |
| --- | --- | --- |
| Invite new members to the project | `sanity.project.members:invite` | Covers the "Invite members" button in the Studio's navbar. |
| Submit a request for access to a resource | No grant required | The endpoint is self-request: `POST /v2024-07-01/access/{resourceType}/{resourceId}/requests`. |
| Approve or decline an access request | `sanity.project.members:invite` | `PUT .../requests/{id}/accept` or `PUT .../requests/{id}/decline`. |

The full request-access flow is pinned to `v2024-07-01` and covers five endpoints. See the [Access API HTTP reference](https://www.sanity.io/docs/http-reference/access-api) for the complete request schemas.

## Troubleshooting

Custom roles can't express everything. If you're trying to build a role and the filter mechanism rejects your design, three patterns usually cover the gap:

- **Denormalize the data.** If your filter needs to follow a reference (for example, looking up a user document to read its `assignedDocs` array), copy the data you're filtering on into the document itself. Filters can read fields on the document being checked; they can't follow references to other documents.
- **Gate higher up.** If the access rule requires logic the filter system can't express (subqueries, custom functions, complex branching), enforce it in your application layer or a webhook rather than in the role.
- **Test in a non-production environment first.** Assign the role to a test user account in a staging dataset (or a separate test project) and attempt the actions you expect to allow and deny before rolling out to production users.

The [concepts page](https://www.sanity.io/docs/content-lake/roles-concepts) lists the full set of capabilities you can and can't express in custom roles today.



# Roles user guide

You can manage access to content and settings in your Sanity Content Lake by setting roles and permissions for project members. Each member may have different roles for granular access control to your Content Lake. All projects have default roles available, but you can also create **custom roles** that define granular access to datasets and project settings. You can also use GROQ to define custom **content resources**. Content permissions are typically set to either all or individual datasets, but you can use **Tags** to group datasets that should share permissions.

Roles and permissions can be [configured through the API](https://www.sanity.io/docs/http-reference/roles), or through the project settings available at [sanity.io/manage](https://sanity.io/manage). This article will focus mainly on the latter option.

## Default roles per plan

Each plan type has access to specifically defined roles. Custom roles are available for Enterprise customers.

#### Properties

**Administrator** (All plans)

Read and write access to all datasets, with full access to all project settings.

**Viewer** (All plans)

Read-only access to all datasets, with no access to project settings. Note: viewers can comment in projects where comments are enabled.

**Editor** (Growth and Enterprise)

Read and write access to all datasets, with limited access to project settings.

Editors can modify existing datasets, but cannot create new ones.

**Developer** (Growth and Enterprise)

Read and write access to all datasets, with access to project settings for developers.

**Contributor** (Growth and Enterprise)

Read and write access to draft content within all datasets, with no access to project settings. Can write but not publish documents.

**Custom** (Enterprise)

Fully custom roles and permissions, with custom access to project settings.

## Assigning roles to members

To assign roles to users, navigate to the Member section in your project settings at [sanity.io/manage](https://sanity.io/manage). You'll see each project member's roles listed by their name and login info.

The login info shows which sign-in method each member's account uses. A Sanity account belongs to a sign-in method rather than to an email address, so a member who signs in with a different method than the one they were invited under arrives as a separate account with no membership and no roles. To hand membership over to that account, remove the old one and send a new invitation, as described in [Account recovery](https://www.sanity.io/docs/help/account-recovery).

![Overview of project members in manage](https://cdn.sanity.io/images/3do82whm/next/554f0afe514787948ac29011ff22443a72e91450-796x385.png)

When using Single Sign-On (SSO), roles can be [automatically assigned](https://www.sanity.io/docs/developer-guides/sso-saml) to users using rules that evaluate each user’s group membership in your identity provider. Role assignment can be restricted to be set only through mapping rules, or allow for manual modification. If role assignment is restricted to be set only through mapping rules, you cannot manually change the role of a user in this screen.



![Shgows a popover alerting the user that roles are handled by identity provider and cannot be manually updated](https://cdn.sanity.io/images/3do82whm/next/9a7a942da4941567856316ac1613ac4a72ca57f9-1605x1365.png)

## Creating custom roles

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

To define custom roles, navigate to the **Access** tab in your project settings. You will see a list of your currently defined roles with a summary of each role's access privileges. To create a new custom role, click the button in the upper right corner.

![Shows the button indicated above](https://cdn.sanity.io/images/3do82whm/next/ee47a8e618a0fbcae78c8dd451ba40f27eded47e-1041x648.png)

You will be asked to provide some basic details for your role.

![Shows dialog for creating new role](https://cdn.sanity.io/images/3do82whm/next/787e50a9c2ed123cabbc98ed5d57ef0feedb799f-648x516.png)

Once created you'll have the option of adding members to the role or proceeding to define permissions and restrictions. These are divided into two main categories: **Content Permissions** and **Management Permissions**.

### About viewer roles

Users with the "Viewer" role are free and don't count toward a plan's available seats. If a user with the viewer role is assigned an additional role, that user will count as a billable user. 

> [!WARNING]
> Gotcha
> Only the built-in viewer role is considered a "free viewer." Any custom role, even if it only grants read-only access, is billed as a regular user.

## Management Permissions

These settings grant a role access to your project's settings which are typically accessed in the project management console at [sanity.io/manage](https://sanity.io/manage). Access to a project's details and usage statistics, members and roles, API settings, and datasets and tags are currently available for configuration.



> [!WARNING]
> Gotcha
> In order for a role to have access to the project, **Project Details** should be set to **read**. For content editor roles, also setting **Project Members** to **read** will ensure they get the best studio experience with the full advantage of [Presence](https://www.sanity.io/blog/introducing-presence) features.
> Custom roles that work with datasets also need **Project datasets** set to **Read**, in the **Datasets and tags** section of **Management Permissions**. Every built-in role that grants project access includes this permission, so it's easy to miss when you build a custom role from scratch. Without it, requests that read the project's datasets fail with `Unauthorized - User is missing required grant sanity.project.datasets/read to perform this operation`.

## Content permissions

This is where you define the role's access to your Content Lake. You can grant any role wide-reaching privileges that extend to all your datasets or use GROQ filters to set up granular access to only certain content types.

Once you've navigated to the role you want to configure you'll be presented with a list of your datasets that can be individually configured, as well as the opportunity to set some base permissions for *all datasets*.

![Shows overview of permissions for role](https://cdn.sanity.io/images/3do82whm/next/1934bbd28a3ca4b6cc182beed7d3a04b9b9035e3-910x780.png)

By default, all permissions are set to **No access**. Permissions cascade down from more general contexts to more specific ones, so it's generally better to start restrictive and grant privileges on each dataset separately as any permission granted on **All datasets** will override more restrictive settings in the individual datasets.

![Shows default permissions for all datasets set to "No access"](https://cdn.sanity.io/images/3do82whm/next/8d2232e47247037206b615fbac27a3b0bbebcc2a-677x313.png)

> [!WARNING]
> Gotcha
> Permissions are additive in nature. 
> That means you cannot remove a permission that has been granted to a role elsewhere.
> **Example**: If you defined that a role has `publish` rights for all documents in all datasets, it is impossible to define a resource (via GROQ filter) which only grants `read` access to a specific subset of documents.

This hereditary characteristic of permissions is visualized when you go to edit the permission for a single dataset. The dialog shown below demonstrates how the final permissions for the dataset are derived from both the privileges set generally for all datasets and from the privileges set specifically for this dataset.

![Shows permissions on several levels of specificity](https://cdn.sanity.io/images/3do82whm/next/7e324ed31d419d7136c61cdbe4e9b0830d77db16-826x735.png)

The base set of content resources available for access control are general in nature but powerful enough to cover many use-cases. You may grant privileges to read, create and update, and publish each of the widely encompassing options; **All documents**, **Image assets**, and **File assets**.

> [!WARNING]
> Gotcha
> If your dataset is **public** all project members will have read access to your **published content** *even if their role is set to no access*!

## Content resources

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

In addition to the basic set of permission scopes that lets you configure access to **All documents**, **Image assets**, and **File assets,** you may also create custom content resources to control access to particular content types, which you may then control the access to with your custom roles. To create a new content resource, find the **Resources** section in the left column menu, under the **Access** tab.

> [!WARNING]
> Gotcha
> The filter does not support dereferencing! This will **not** work: `referenceField->`! Instead, check against the `_ref` property when creating custom resources: `referenceField._ref == "my-referenced-doc-id"`.

![Shows the button described below](https://cdn.sanity.io/images/3do82whm/next/4f324593a0373521a2486807bf47e5afa91483b6-683x470.png)

In our example, we'll be working with the default starter template called *Movie Project*. This gives us a prefilled dataset with content types like `movie`, `person`, and *screening*. Click the button in the top right of the section to create a new content resource.

Name the content resource “Movies” and select the `movie` document type.

![A "Create new content resource" dialog showing "Document types" step with "Movie" selected.](https://cdn.sanity.io/images/3do82whm/next/b48cc6f3767b8b707c5a4c79888d5f907d3caae2-964x326.png)

No additional filter conditions are needed right now. Content resources leverage the power of [GROQ](https://www.sanity.io/docs/content-lake/how-queries-work) to filter which content types are affected by the privileges you choose to grant. In this example, we're using a simple but powerful GROQ expression to return only documents of type movie.

![A "Create new content resource" screen showing the active "Filter" step with a GROQ query `_type == "movie"`.](https://cdn.sanity.io/images/3do82whm/next/c928d4ad5e3a6e169c3b2f2e7630dbacafb74fdc-966x327.png)

> [!TIP]
> Protip
> With a deployed studio, the visual builder lets you build conditions using your schema definitions.

If we’re working with a studio without a deployed schema, we’ll need to provide the GROQ filter manually.

![Content resource creation screen showing the 'Filter' step with `_type == "movie"`.](https://cdn.sanity.io/images/3do82whm/next/e76344c99672de90f9783629033acc455fa1493a-998x331.png)

Once you hit save, you should see your new content resource added to the list.

![Shows the new content resource in the list of resource definitions](https://cdn.sanity.io/images/3do82whm/next/0707eba5207d5c510e085be4984a7dd6111e73cb-1766x576.png)

Revisiting the **Roles** section in the left column menu, we can now set `movie`-specific privileges on our custom role.

![Shows dialog for specifying permissions on content resource](https://cdn.sanity.io/images/3do82whm/next/926857104bb65dbd336033dadbaf08ef442f51f2-649x728.png)

To test your role, make sure you have actually set the role on a member account and then proceed to log into the studio with the account in question.

![Shows list of project members, one with new role specified](https://cdn.sanity.io/images/3do82whm/next/dd43be021181aa698dcc53d7ed502f17534dd9ee-667x189.png)

Your account should be able to view, create, update and publish any document of the `movie` type, but should be unable to edit documents of any other type.

![Shows a notification stating that current user does not have permissions to update document](https://cdn.sanity.io/images/3do82whm/next/850bcc2e259960e29685bd8742b20f7aab09eaae-640x357.png)

![Shows a notification stating that current user does not have permissions to update document](https://cdn.sanity.io/images/3do82whm/next/2cdd6060d774cc8654bd556f6ef2c79eab88d276-641x287.png)

## Permissions for Studio features

Some features, like drafts, Content Releases, and Scheduled Drafts read and write system documents that don't come from your schema. Those documents are addressed by a fixed document-ID path rather than by a type: drafts are stored as `drafts.<documentId>`, the copy of a document inside a release is stored as `versions.<releaseId>.<documentId>`, and the release itself is stored as `_.releases.<releaseId>`. A custom role reaches them through content resources that filter on the document ID, so there is no schema type or `sanity.*` wildcard to grant instead. A role scoped only to your own document types blocks these features, even when the same user can edit the underlying content.

Create one content resource per filter, then set the role's access level for that resource. Access levels are cumulative: **Read** grants read only, **Update and create** adds creating and editing, and **Publish** adds deleting and publishing.

The following table lists the content resource each feature needs:

##### Permissions for Studio features

| Studio feature | Content resource filter | Access level |
| --- | --- | --- |
| Work with drafts | `_id in path("drafts.**")` | Update and create |
| Edit documents inside a content release | `_id in path("versions.**")` | Update and create |
| Create, schedule, publish, and archive releases | `_id in path("_.releases.**")` | Publish |
| Upload images and files | The built-in image assets and file assets resources | Update and create |

### Grant Schedule drafts on a single document

[Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) on a single document is a scheduled release with one document in it. The Studio creates a release document, copies the draft into the release as a version document, then schedules the release. A role that can use it therefore needs all three of the document-path resources in the table, not just the one for the document being scheduled.

### Troubleshoot a role that can't schedule or publish

- `_id in path("_.draft.**")` matches no documents: drafts use the `drafts.` prefix, so the filter you want is `_id in path("drafts.**")`.
- Scheduling fails with `Insufficient permissions; permission "update" required`: the `_.releases.**` resource is set below **Publish**. Scheduling a release is a deferred publish, so **Update and create** isn't enough on its own.

The Access API expresses release permissions at a finer grain. Each release action is gated by a synthetic document ID of the form `_.releases.<releaseId>.actions.<action>`, so a filter such as `_.releases.*.actions.schedule` authorizes one action at a time and lets you allow scheduling while withholding publishing. The `_id in path("_.releases.**")` filter matches the release document and every one of its action IDs at once, which is why it takes a single access level. To grant release actions individually, see [Build a custom role with the Access API](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

## User attributes

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

[User attributes](https://www.sanity.io/docs/http-reference/user-attributes) are key-value pairs that describe a user within your organization; things like `location="torrevieja"`, `department="front_desk"`, or `year_joined=2019`. You can reference these attributes in content resource filters to create **parameterized roles** that adapt to each user automatically, rather than creating separate roles for every location, department, or team.

### Where attributes come from

Attributes can come from two sources:

- **SAML**: When users authenticate via SSO (Okta, Azure AD, Auth0, etc.), Sanity automatically captures all attributes included in the SAML assertion. These are refreshed on every login. No pre-configuration is needed. If the identity provider sends it, Sanity stores it.
- **Sanity (manual)**: Administrators can define additional attributes and set values directly in Manage or through the API. You can also use manual attributes to override a SAML-provided value for a specific user.

When both SAML and Sanity provide a value for the same attribute key, the Sanity value takes precedence. Removing the Sanity override reveals the SAML value again.

### Defining attributes

To define and manage attributes, navigate to the **Members** tab in your organization settings and find the **Attributes** section. Any attributes that have been captured from SAML logins will appear here automatically.

> [!WARNING]
> Gotcha
> User attributes are defined and managed at the organization level, not on individual projects. If you don't see the **Attributes** section, make sure you're viewing your organization's settings in Manage rather than a project's settings.

To create a new attribute, click the button in the upper right corner. You'll be asked to provide an attribute key (the name) and a type. Supported types are `string`, `integer`, `number`, `boolean`, and array variants of each (except boolean).

![The "Attributes" page of a web application, showing a table of custom member attributes including "brand," "department," and "email," with a search bar and "Create attribute" button.](https://cdn.sanity.io/images/3do82whm/next/ca3a656f935250d83701c20968da31f08ce05a84-1031x366.png)

> [!WARNING]
> Gotcha
> You cannot create a Sanity attribute definition when a SAML definition already exists for that key. If your identity provider sends an attribute like `location`, it will appear automatically—you don't need to define it again. You can set Sanity override values directly on individual users.

### Setting attribute values on users

To view and manage a user's attributes, navigate to that user within the Members section. You'll see all of their current attribute values, including which source each value comes from (SAML or Sanity) and which value is currently active.

From here you can:

- **Set a Sanity value** to override a SAML-provided attribute for this user.
- **Add a value** for an attribute that the user doesn't have from SAML.
- **Remove a Sanity override** to revert to the SAML value.

![A user role inspection panel, showing editable attributes and an 'Add attribute' dropdown open with options like 'brand' and 'department'.](https://cdn.sanity.io/images/3do82whm/next/0c2af3fa87daf1e83ef00f0eaa03259be0a53e26-708x842.png)

> [!TIP]
> Protip
> Overriding attributes is useful for temporary changes, like reassigning a user to a different location for a project, without modifying your identity provider. When you're done, remove the override and the SAML value takes effect again on the user's next login.

### Using attributes in content resources

Attributes become powerful when referenced in GROQ filters for content resources. Instead of hardcoding a value like `_type == "post" && branch == "london"`, you can use the `user::attributes()` function to make the filter dynamic:

```groq
_type == "post" && branch == user::attributes().branch
```

When a user with `branch="london"` accesses content through a role using this resource, the filter resolves to `branch == "london"`. A user with `branch="tokyo"` sees only Tokyo content. One role covers both users.

To create a parameterized content resource, follow the same steps as creating any content resource, but reference `user::attributes()` in your GROQ filter. The visual builder gives you an option to add user attribute conditions directly from your schema.

!["Create new content resource" screen with the "Filter" step selected, showing a GROQ filter configured to match documents where the user's "brand" attribute equals "brand".](https://cdn.sanity.io/images/3do82whm/next/c325a3c7921339ff819aa94d901c0d85dc3cd862-966x470.png)

#### Filter best practices

When you use `user::attributes()` in a content resource filter, be aware that if the referenced attribute is missing (due to a typo, a migration, or the user simply not having that attribute set), the expression evaluates to `null`. If the document field you're comparing against is also missing, the filter could simplify to `null == null`, which evaluates to `true`, granting access to all matching documents.

This means a misconfigured filter can silently escalate privileges rather than deny access.

To prevent this, either wrap `user::attributes()` in a `coalesce()` function or add an explicit `!= null` check:

**filters.groq**

```groq
// Unsafe: fails open when both sides are null
_type == "hotel" && location == user::attributes().userLocation

// Safe with coalesce: fails closed when the attribute is missing
_type == "hotel" && coalesce(user::attributes().userLocation, "__no_value__") == location

// Safe with null check: fails closed when the attribute is missing
_type == "hotel" && user::attributes().userLocation != null && location == user::attributes().userLocation
```

**Common scenarios where this matters:**

- The attribute name in the filter has a typo. For example, `userLocaton` instead of `userLocation`.
- An attribute migration removes or renames the attribute, but existing filters still reference the old name.
- A user has not been assigned the attribute referenced in the filter.
- The document field being compared against does not exist on some documents.

If any of these occur without a null guard, the filter will match all documents of the specified type, effectively disabling the access restriction.

### Example: genre-based editing

Continuing with our *Movie Project* example, imagine your team has editors who specialize in different genres. One group handles horror films, another covers documentaries, and so on. Without user attributes, you'd need a separate role for each genre: "Editor - Horror", "Editor - Documentary", "Editor - Comedy", and so on.

With user attributes, you define a single content resource with the filter:

```groq
_type == "movie" && genre == user::attributes().genre
```

Then create one role, such as "Genre Editor", that uses this content resource with read, create, update, and publish permissions. Assign the role to all editors. Each editor sees only the movies matching their genre, based on the `genre` attribute from their identity provider or set in Manage.

If an editor needs to temporarily cover a different genre, an administrator can set a Sanity override for that user's `genre` attribute without changing anything in the identity provider.

### Gotcha: SAML attribute types are inferred, and they can change to a list

SAML has no single-value type. Your identity provider always sends a claim as a list of values. Sanity infers the type of the attribute from the number of values that it receives:

- One value becomes a single value.
- Two or more values become a list.

A single value is not a final type. It only shows that no member of your organization has sent two values for that claim yet. When the first member signs in with two values, Sanity changes the attribute to a list for all members that hold that attribute. The stored values stay the same, but they become one-item lists.

Example, for a `department` claim:

```batchfile
Before   Alice   department = "Engineering"
         Bob     department = "Support"

Carol signs in, and her identity provider sends two departments.

After    Alice   department = ["Engineering"]
         Bob     department = ["Support"]
         Carol   department = ["Sales", "Ops"]
```

Alice and Bob did not sign in, and their department did not change. Only the shape of the value changed.

**Effect on filters:** Write your filters so that they work with a list. A filter that compares the attribute to an exact value stops to match after the change:

```typescript
// Fragile: fails after the attribute becomes a list.
user::attributes().department == "Engineering"

// Recommended: works for a single value and for a list.
"Engineering" in coalesce(
  user::attributes().department[],
  [user::attributes().department]
)
```

Because a filter controls access, a filter that is not updated can deny access to members who had access before.

The change to a list is permanent for that attribute. A later sign-in with one value does not change the attribute back to a single value.

## Tags

Tags are a useful feature that lets you group datasets with similar characteristics together so that roles and permissions can be conveniently set on multiple datasets in a single operation. You might create tags for different environments, such as `production` and `staging`, or combine tags for different publications and locales, E.g. `elle` `us` or `vogue` `jp`.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

To create a new tag, navigate to the **Datasets** tab in your project settings and find the **Tags** section in the left column menu.

![Shows the button described above](https://cdn.sanity.io/images/3do82whm/next/55c7e0536da439f29d08e05ec42a4f53e1d56042-1540x466.png)

In the example shown below we'll be creating a tag for staging and production datasets for our movie blog, and then assigning editing privileges in both for our `movie-critic` role.

![Shows setup dialog for new dataset tag](https://cdn.sanity.io/images/3do82whm/next/80c860d3162574acd4c512d218d997eefdafec7b-645x639.png)

Once created, we can add datasets to the tag and define permissions to content resources for our custom roles.

![Shows content permissions for dataset tag](https://cdn.sanity.io/images/3do82whm/next/17b14929387db6f537a1b496a87029d25490f0f7-1546x988.png)

The change is reflected and can be edited in the content permissions for the custom role.

![Shows the permission setting as described above](https://cdn.sanity.io/images/3do82whm/next/552ddfb71be659d716500c07b44cfd7ed3d7e64f-1554x1280.png)





# Common Sanity document types

The example document shapes in this article are presented in JSON as reference. Keep in mind that you can adjust the shape of query responses using GROQ projections.

## Project datasets

The following document types are found when querying a project's dataset.

### Schema-based Sanity documents

Your documents differ based on your schema. In addition to the schema you define, the basic Sanity document has the following shape.

```json
{
  "_createdAt": "2022-11-23T20:00:13Z",
  "_id": "004fecc4-d324-49b5-b78c-7216f539b3d5",
  "_originalId": "004fecc4-d324-49b5-b78c-7216f539b3d5",
  "_rev": "O2LflXWjWDt48mytHz2LFT",
  "_type": "your-document-type",
  "_updatedAt": "2025-05-15T14:31:46Z",
  // ...rest of schema
}
```

### Assets

Learn more about [assets in Content Lake](https://www.sanity.io/docs/content-lake/assets), or how to [query them in your front end](https://www.sanity.io/docs/apis-and-sdks/presenting-images). Asset type is detected at upload and will be either `sanity.imageAsset` or `sanity.fileAsset`.

**sanity.imageAsset**

```json
{
  "_createdAt": "2022-06-14T13:21:36Z",
  "_id": "image-000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876-png",
  "_originalId": "image-000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876-png",
  "_rev": "6uwRxsZd4dJaf44JVxaEuT",
  "_type": "sanity.imageAsset",
  "_updatedAt": "2022-06-14T13:21:36Z",
  "assetId": "000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1",
  "extension": "png",
  "metadata": {
    "_type": "sanity.imageMetadata",
    "blurHash": "e03SL--?vJRNR1?dj^Mwj=fkvcMwIux^S7abk8o#WEe:MHt6NNIVRj",
    "dimensions": {
      "_type": "sanity.imageDimensions",
      "aspectRatio": 1.1364605543710022,
      "height": 1876,
      "width": 2132
    },
    "hasAlpha": true,
    "isOpaque": false,
    "lqip": "data:image/png;base64...",
    "palette": {
      "_type": "sanity.imagePalette",
      "darkMuted": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#3a385e",
        "foreground": "#fff",
        "population": 0.09,
        "title": "#fff"
      },
      "darkVibrant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#14245c",
        "foreground": "#fff",
        "population": 0,
        "title": "#fff"
      },
      "dominant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#bfc9d1",
        "foreground": "#000",
        "population": 1.36,
        "title": "#fff"
      },
      "lightMuted": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#bfc9d1",
        "foreground": "#000",
        "population": 1.36,
        "title": "#fff"
      },
      "lightVibrant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#92a5e7",
        "foreground": "#000",
        "population": 0,
        "title": "#fff"
      },
      "muted": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#50698f",
        "foreground": "#fff",
        "population": 0.16,
        "title": "#fff"
      },
      "vibrant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#2d51d1",
        "foreground": "#fff",
        "population": 0,
        "title": "#fff"
      }
    }
  },
  "mimeType": "image/png",
  "originalFilename": "example.png",
  "path": "images/3do82whm/next/000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876.png",
  "sha1hash": "000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1",
  "size": 458453,
  "uploadId": "ewspz9k52x2uIex0RXIdqUwctLPio2Rf",
  "url": "https://cdn.sanity.io/images/3do82whm/next/000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876.png"
}
```

**sanity.fileAsset**

```json
{
  "_createdAt": "2024-01-22T12:27:32Z",
  "_id": "file-050de1efeee92e61ed7d8210a6ed9c598128e59e-csv",
  "_originalId": "file-050de1efeee92e61ed7d8210a6ed9c598128e59e-csv",
  "_rev": "YgFRxBViy44CfW0H4Ry4Qp",
  "_type": "sanity.fileAsset",
  "_updatedAt": "2024-01-22T12:27:33Z",
  "assetId": "050de1efeee92e61ed7d8210a6ed9c598128e59e",
  "extension": "csv",
  "mimeType": "text/csv",
  "originalFilename": "050de1efeee92e61ed7d8210a6ed9c598128e59e.csv",
  "path": "files/3do82whm/next/050de1efeee92e61ed7d8210a6ed9c598128e59e.csv",
  "sha1hash": "050de1efeee92e61ed7d8210a6ed9c598128e59e",
  "size": 18193,
  "uploadId": "SjqHLO18Iz5bAIF9vDtsufIFFKcz1aoO",
  "url": "https://cdn.sanity.io/files/3do82whm/next/050de1efeee92e61ed7d8210a6ed9c598128e59e.csv"
}
```

An asset linked from Media Library is also a `sanity.imageAsset` document in your project's dataset. It carries two fields an uploaded asset doesn't have: a `media` reference back to the library, and a `source` object naming Media Library as the origin. The `media._ref` points to the asset version in the library, while `source.id` holds the ID of the `sanity.asset` container.

**sanity.imageAsset (linked from Media Library)**

```json
{
  "_createdAt": "2026-06-14T13:21:36Z",
  "_id": "image-000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876-png",
  "_rev": "6uwRxsZd4dJaf44JVxaEuT",
  "_type": "sanity.imageAsset",
  "_updatedAt": "2026-06-14T13:21:36Z",
  "assetId": "000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1",
  "extension": "png",
  "media": {
    "_ref": "media-library:mlNBkjZ8wqSZ:image-000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876-png",
    "_type": "globalDocumentReference",
    "_weak": true
  },
  "metadata": {
    // ...same shape as an uploaded image asset
  },
  "mimeType": "image/png",
  "originalFilename": "example.png",
  "path": "images/3do82whm/next/000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876.png",
  "sha1hash": "000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1",
  "size": 458453,
  "source": {
    "id": "34fMJaofTI5ptNBZFOYoBNfy6NM",
    "name": "sanity-media-library"
  },
  "uploadId": "ml-link-Cd5sOgKhAgs8WHdlNg1tGgRnYial74Ue",
  "url": "https://cdn.sanity.io/images/3do82whm/next/000d3a19d7bb022f39de0b0b7baff6ed95c2b7f1-2132x1876.png"
}
```

> [!NOTE]
> The media reference is the reliable signal
> The `source` field is optional and some Media Library links don't carry it, so an asset without a `source.name` of `sanity-media-library` isn't necessarily stored in your dataset. Check `media._ref` for the `media-library:` prefix as well.

### Content release system document

The `system.release` document type is used for [Content Releases](https://www.sanity.io/docs/content-lake/content-release-document-flow) and [scheduled drafts](https://www.sanity.io/docs/studio/scheduled-drafts). This contains information about the release itself. After publishing, the document also contains the `_id`s of affected documents.

**Active (undecided type)**

```json
{
  "_createdAt": "2025-11-05T21:29:26Z",
  "_id": "_.releases.rD1Xd4ncd",
  "_rev": "dr42QZx7KPEhsymkMySD7L",
  "_type": "system.release",
  "_updatedAt": "2025-11-06T15:17:25Z",
  "metadata": {
    "description": "",
    "releaseType": "undecided",
    "title": "📝 docs: Document shape reference"
  },
  "name": "rD1Xd4ncd",
  "state": "active"
}
```

**Active (at time type)**

```json
{
  "_createdAt": "2025-11-05T21:29:26Z",
  "_id": "_.releases.rD1Xd4ncd",
  "_rev": "dr42QZx7KPEhsymkMySD7L",
  "_type": "system.release",
  "_updatedAt": "2025-11-06T15:17:25Z",
  "metadata": {
    "description": "",
    "releaseType": "scheduled",
    "intendedPublishAt": "2025-12-19T13:04:00.000Z",
    "title": "📝 docs: Document shape reference"
  },
  "name": "rD1Xd4ncd",
  "state": "active"
}
```

**Published**

```json
{
  "_createdAt": "2025-10-27T17:04:11Z",
  "_id": "_.releases.r0NTEBzIk",
  "_rev": "3yqUckHTy25h67wlKTbN4l",
  "_type": "system.release",
  "_updatedAt": "2025-10-27T17:33:46Z",
  "finalDocumentStates": [
    {
      "id": "versions.r0NTEBzIk.237c16b0-a9f4-4e82-b595-49c16488987b"
    }
  ],
  "metadata": {
    "description": "",
    "releaseType": "asap",
    "title": "docs: custom auth updates"
  },
  "name": "r0NTEBzIk",
  "publishAt": "2025-10-27T17:33:43.286273143Z",
  "publishedAt": "2025-10-27T17:33:46.857738394Z",
  "state": "published",
  "userId": "pplmUjjS1"
}
```

**Archived**

```json
{
  "_createdAt": "2025-02-07T11:59:48Z",
  "_id": "_.releases.r0bFlTPnM",
  "_rev": "CTPe2v8JIKtoV0Y6xK47fi",
  "_type": "system.release",
  "_updatedAt": "2025-02-07T12:00:38Z",
  "metadata": {
    "description": "",
    "releaseType": "asap",
    "title": "Archived release title"
  },
  "name": "r0bFlTPnM",
  "state": "archived",
  "userId": "p70uuxnEh"
}
```

**Scheduled draft**

```json
{
  "_createdAt": "2025-11-18T21:50:09Z",
  "_id": "_.releases.rl1x5tuDA",
  "_originalId": "_.releases.rl1x5tuDA",
  "_rev": "5qJ56lhgtn32Bpl4tfE4FQ",
  "_type": "system.release",
  "_updatedAt": "2025-11-18T21:50:12Z",
  "metadata": {
    "cardinality": "one",
    "description": "",
    "intendedPublishAt": "2025-11-29T21:50:00.000Z",
    "releaseType": "scheduled",
    "title": "Scheduled publish"
  },
  "name": "rl1x5tuDA",
  "publishAt": "2025-11-29T21:50:00Z",
  "state": "scheduled",
  "userId": "pplmUjjS1",
  "workflowId": "release-rl1x5tuDA-K76KLi6SbRI9Qep6KPCyRr"
}
```

## Media Library

The following document types are found when querying your organization's [Media Library](https://www.sanity.io/docs/media-library/introduction).

These documents live in the Media Library's own dataset, not in a project dataset. The `source` field on a Media Library asset records how that asset entered the library, so it names the upload source rather than the library itself. The copy created in a project dataset when you link the asset is a different document, with a `source.name` of `sanity-media-library`, shown in the "Project datasets" section.

### Assets

- `sanity.asset`: The container for Media Library assets, this contains details about the asset, versions, and aspects.
- `sanity.imageAsset`: The document for an individual image asset version. It contains metadata from the image file as well as the URL.
- `sanity.fileAsset`: The document for an individual file asset version.

**sanity.asset**

```json
{
  "_createdAt": "2025-10-27T21:21:41Z",
  "_id": "34fMJaofTI5ptNBZFOYoBNfy6NM",
  "_rev": "eefe4de2-7ec8-4307-aecc-1b0e890fa4e6",
  "_system": {
    "createdBy": "gvRshKueQ"
  },
  "_type": "sanity.asset",
  "_updatedAt": "2025-11-05T19:19:24Z",
  "aspects": {
    "photographer": {
      "_ref": "dataset:y856rro4.production:200e44f2-14a9-4c7a-a621-a4ca4d9b559c",
      "_type": "globalDocumentReference",
      "_weak": true
    }
  },
  "assetType": "sanity.imageAsset",
  "cdnAccessPolicy": "public",
  "currentVersion": {
    "_ref": "image-5e510a718d122013621f6f2ac5c8fefda767181b-2410x1940-png",
    "_type": "reference"
  },
  "title": "agent-context.png",
  "versions": [
    {
      "_key": "34fMJa5D3oyeodCbUK85oqzmRCh",
      "_type": "sanity.asset.version",
      "instance": {
        "_ref": "image-5e510a718d122013621f6f2ac5c8fefda767181b-2410x1940-png",
        "_type": "reference"
      },
      "title": "agent-context.png"
    }
  ]
}
```

**sanity.imageAsset**

```json
{
  "_createdAt": "2025-05-05T16:17:18Z",
  "_id": "image-049cdf41569df83dae668a4578e5d768ab0e3af7-4000x6000-jpg",
  "_rev": "jvG3hn4E5wAByM2zqNN5yk",
  "_system": {
    "createdBy": "gvRshKueQ"
  },
  "_type": "sanity.imageAsset",
  "_updatedAt": "2025-05-05T16:17:23Z",
  "extension": "jpg",
  "metadata": {
    "_type": "sanity.imageMetadata",
    "blurHash": "dfCZ^dtlRPj[?wtSaejYWCt7t7aeIUaeozfQV@RjWBkC",
    "dimensions": {
      "_type": "sanity.imageDimensions",
      "aspectRatio": 0.6666666666666666,
      "height": 6000,
      "width": 4000
    },
    "exif": {
      "ApertureValue": 2.970854,
      "ColorSpace": 65535,
      "Contrast": 2,
      "CustomRendered": 1,
      "DateTimeDigitized": "2025-01-23T22:36:48.000Z",
      "DateTimeOriginal": "2025-01-23T22:36:48.000Z",
      "ExposureBiasValue": 0,
      "ExposureMode": 0,
      "ExposureProgram": 3,
      "ExposureTime": 0.00125,
      "FNumber": 2.8,
      "Flash": 16,
      "FocalLength": 26,
      "FocalLengthIn35mmFormat": 40,
      "FocalPlaneResolutionUnit": 3,
      "FocalPlaneXResolution": 2556.533905029297,
      "FocalPlaneYResolution": 2556.533905029297,
      "ISO": 100,
      "LensModel": "GR LENS 26mm F2.8",
      "MeteringMode": 255,
      "Saturation": 0,
      "SceneCaptureType": 0,
      "SensingMethod": 2,
      "SensitivityType": 1,
      "Sharpness": 0,
      "ShutterSpeedValue": 9.643856,
      "StandardOutputSensitivity": 100,
      "SubjectDistanceRange": 3,
      "WhiteBalance": 1,
      "_type": "sanity.imageExifMetadata"
    },
    "hasAlpha": false,
    "image": {
      "Copyright": "MARK MICHON",
      "ExifOffset": 256,
      "GPSInfo": 784,
      "Make": "RICOH IMAGING COMPANY, LTD.",
      "Model": "RICOH GR IIIx",
      "ModifyDate": "2025-02-21T18:37:52.000Z",
      "ResolutionUnit": 2,
      "Software": "Adobe Lightroom 8.2 (Macintosh)",
      "XResolution": 240,
      "YResolution": 240,
      "_type": "sanity.imageExifTags"
    },
    "isOpaque": true,
    "keywords": [
      "landscape",
      "hillside",
      "trees",
      "foliage",
      "bare branches",
      "clear sky",
      "architecture",
      "building",
      "outdoor",
      "daytime",
      "natural light",
      "scenic",
      "rural",
      "countryside",
      "hill",
      "vegetation",
      "nature",
      "exterior",
      "structure",
      "view",
      "scenery"
    ],
    "location": {
      "_type": "geopoint",
      "alt": 0,
      "lat": 35.188269,
      "lng": 139.135506
    },
    "lqip": "data:image/jpeg;base64...",
    "palette": {
      "_type": "sanity.imagePalette",
      "darkMuted": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#604c32",
        "foreground": "#fff",
        "population": 3.89,
        "title": "#fff"
      },
      "darkVibrant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#261b04",
        "foreground": "#fff",
        "population": 1.13,
        "title": "#fff"
      },
      "dominant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#604c32",
        "foreground": "#fff",
        "population": 3.89,
        "title": "#fff"
      },
      "lightMuted": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#abccd2",
        "foreground": "#000",
        "population": 3.5,
        "title": "#fff"
      },
      "lightVibrant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#aad1e5",
        "foreground": "#000",
        "population": 0.04,
        "title": "#fff"
      },
      "muted": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#8a7f74",
        "foreground": "#fff",
        "population": 2.6,
        "title": "#fff"
      },
      "vibrant": {
        "_type": "sanity.imagePaletteSwatch",
        "background": "#706532",
        "foreground": "#fff",
        "population": 0.01,
        "title": "#fff"
      }
    }
  },
  "mimeType": "image/jpeg",
  "originalFilename": "japan-2025-08.JPG",
  "path": "media-libraries/mlNBkjZ8wqSZ/images/049cdf41569df83dae668a4578e5d768ab0e3af7-4000x6000.jpg",
  "sha1hash": "049cdf41569df83dae668a4578e5d768ab0e3af7",
  "size": 16096156,
  "source": {
    "id": "sanity-image-image-049cdf41569df83dae668a4578e5d768ab0e3af7-4000x6000-jpg",
    "name": "sanity-image",
    "url": "https://cdn.sanity.io/media-libraries/mlNBkjZ8wqSZ/images/049cdf41569df83dae668a4578e5d768ab0e3af7-4000x6000.jpg"
  },
  "state": "ready",
  "url": "https://cdn.sanity.io/media-libraries/mlNBkjZ8wqSZ/images/049cdf41569df83dae668a4578e5d768ab0e3af7-4000x6000.jpg"
}
```

**sanity.fileAsset**

```json
{
  "_createdAt": "2025-06-17T15:22:03Z",
  "_id": "file-6476df3aac780622368173fe6e768a2edc3932c8-txt",
  "_rev": "zOr4hk5Ojjq0FM4nUeCABF",
  "_system": {
    "createdBy": "gvRshKueQ"
  },
  "_type": "sanity.fileAsset",
  "_updatedAt": "2025-06-17T15:22:03Z",
  "extension": "txt",
  "metadata": {
    "_type": "sanity.fileMetadata"
  },
  "mimeType": "text/plain; charset=utf-8",
  "originalFilename": "text-file.txt",
  "path": "media-libraries/mlNBkjZ8wqSZ/files/6476df3aac780622368173fe6e768a2edc3932c8.txt",
  "sha1hash": "6476df3aac780622368173fe6e768a2edc3932c8",
  "size": 15,
  "source": {
    "id": "sanity-file-file-6476df3aac780622368173fe6e768a2edc3932c8-txt",
    "name": "sanity-file",
    "url": "https://cdn.sanity.io/media-libraries/mlNBkjZ8wqSZ/files/6476df3aac780622368173fe6e768a2edc3932c8.txt"
  },
  "state": "ready",
  "url": "https://cdn.sanity.io/media-libraries/mlNBkjZ8wqSZ/files/6476df3aac780622368173fe6e768a2edc3932c8.txt"
}
```

### Aspects

The following is the shape of an aspect document (`sanity.asset.aspect`), along with the aspect schema used to create it.

**sanity.asset.aspect**

```json
{
  "_createdAt": "2025-04-25T16:18:31Z",
  "_id": "copyright",
  "_rev": "jvG3hn4E5wAByM2zqNPrvO",
  "_system": {
    "createdBy": "gvRshKueQ"
  },
  "_type": "sanity.asset.aspect",
  "_updatedAt": "2025-05-05T16:21:56Z",
  "definition": {
    "fields": [
      {
        "name": "copyrightHolder",
        "title": "Copyright Holder",
        "type": "string"
      },
      {
        "name": "copyrightDate",
        "title": "Date",
        "type": "date"
      }
    ],
    "name": "copyright",
    "title": "copyright",
    "type": "object"
  }
},
```

**Source aspect schema**

```
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
})
```

## Hierarchy

The following document types are part of the [hierarchy primitive](https://www.sanity.io/docs/content-lake/hierarchy), and are used for folders in [Media Library](https://www.sanity.io/docs/media-library/folders).

- `sanity.tree`: The root of a hierarchy. One per logical grouping (in Media Library, one per library).
- `sanity.directory`: A folder. Has a `name` and a `parent` reference to a `sanity.tree` or another `sanity.directory`.
- `sanity.symlink`: A pointer document. Used by Media Library to implement shortcuts that make a single asset appear in more than one folder.

**sanity.tree**

```json
{
  "_createdAt": "2026-04-15T10:00:00Z",
  "_id": "tree.ml123",
  "_rev": "abc123",
  "_type": "sanity.tree",
  "_updatedAt": "2026-04-15T10:00:00Z",
  "name": "folders"
}
```

**sanity.directory**

```
{
  "_createdAt": "2026-04-15T10:01:00Z",
  "_id": "directory-marketing",
  "_rev": "def456",
  "_type": "sanity.directory",
  "_updatedAt": "2026-04-15T10:01:00Z",
  "name": "Marketing",
  "parent": {
    "_ref": "root-tree",
    "_type": "reference"
  }
}
```

**sanity.symlink**

```
{
  "_createdAt": "2026-04-15T10:05:00Z",
  "_id": "symlink-abc",
  "_rev": "ghi789",
  "_type": "sanity.symlink",
  "_updatedAt": "2026-04-15T10:05:00Z",
  "parent": {
    "_ref": "directory-campaigns",
    "_type": "reference"
  },
  "target": {
    "_ref": "34fMJaofTI5ptNBZFOYoBNfy6NM",
    "_type": "reference"
  }
}
```

For full validation rules and the error reference, see [Hierarchy](https://www.sanity.io/docs/content-lake/hierarchy).



# URL format

Take this generic document query URL: 

```text
https://zp7mbokg.api.sanity.io/v2026-06-09/data/query/production?query=*[0]
```

Each project has its own private hostname, which is always `<projectId>.api.sanity.io` for requests and `<projectId>.apicdn.sanity.io` for the [API CDN (cache) endpoint](https://www.sanity.io/docs/content-lake/api-cdn). The path (what comes after the hostname) is always preceded by the [API version](https://www.sanity.io/docs/content-lake/api-versioning) (you can set the present ISO date, `YYYY-MM-DD`, for the latest version) then the path to the API endpoint. So, to sum it up, these are the URL prefixes:

- API: `https://<projectId>.api.sanity.io/v<YYYY-MM-DD>/<path>`
- API CDN: `https://<projectId>.apicdn.sanity.io/v<YYYY-MM-DD>/<path>`

In the rest of this document we will generally only refer to the `<path>` part of the URL.

> [!NOTE]
> Note about the API CDN
> The API CDN only supports the `/data/query` path—its purpose being to cache query results across the globe for the benefit of your end users.

## How do I find my project ID?

In a configured studio, you find the project ID in the `sanity.json` file at the root of your project. Otherwise, you can always find it by locating your project on [sanity.io/manage](https://www.sanity.io/manage) or running [sanity debug](https://www.sanity.io/docs/cli-reference/manage) in the terminal in your studio folder.

## URL encoding

For clarity, we have opted to write URLs with their component in cleartext. In actual use they will all have to be encoded (using [encodeURIComponent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) or equivalent) so that this:

```text
https://zp7mbokg.api.sanity.io/v2026-06-09/data/query/production?query=*[_id == $id]&$id="myId"
```

Becomes this:

```text
https://zp7mbokg.api.sanity.io/v2026-06-09/data/query/production?query=*%5B_id%20%3D%3D%20%24id%5D&%24id=%22myId%22
```

> [!WARNING]
> Gotcha
> If you encode a URL that contains more than just a `query` string (i.e., it includes params as well), [encodeURIComponent()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) will encode the `&` between `query` and your params, which is probably not what you want. Consider encoding the query and parameter strings separately or using [encodeURI()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI) instead.



# Authentication and tokens

By default, unauthenticated users have read access to published documents (with some exceptions like private datasets). However, if you want to access draft documents or make modifications you will need to authenticate yourself as a project member with write access. 

## Personal tokens

Sanity uses tokens for authentication, which are generated when you log in and then attached to all API requests in the HTTP `Authorization` header, for example:

```text
Authorization: Bearer skE5UXUmBEy7U50jcG4In4v4xoHZTlduDxQYet8Y84tsTqAZxp2reIPJsA1JzqXJno2qcpauGwPfjHpU
```

The content studio handles this for you automatically when you log in, and the command-line tool will generate and store a personal token when you run [sanity login](https://www.sanity.io/docs/cli-reference/manage).

> [!WARNING]
> Gotcha
> Without intervention, personal tokens last for one year (if using SAML SSO, the lifetime of the token is shorter). After logging out of the Sanity CLI, the subsequent login generates a new personal token and invalidates the old one.
> Child tokens issued to MCP clients are tied to your active CLI session. Logging out invalidates them along with the parent personal token.

If you want to run authenticated API requests manually with e.g. `curl`, you can find your personal API token by running `sanity debug --secrets`, and look for the "Auth token" value under "Authentication". You then place this in an `Authorization` header:

```sh
curl -H "Authorization: Bearer <token>" https://<project>.api.sanity.io/v2021-06-07/data/query/production?query=*
```

> [!TIP]
> Protip
> Your API token is *personal*, and gives complete access to the Sanity API as your user. Take care not to share it with anyone, and use *robot tokens *instead to authenticate from applications and third-party services.

## Robot tokens

If you need to authenticate with the Sanity API from an application or third-party service, you should generate a dedicated robot token for it, with appropriate [permissions](https://www.sanity.io/docs/user-guides/roles). 

### Organization-wide tokens

Organization-wide robot tokens are used for scenarios where you need access to manage multiple projects, deploy or manage SDK apps, or access data in organization-wide Sanity apps like Media Library or Canvas.

To create an organization token, you must have developer or equivalent role in the organization. Navigate to your organization’s [management console](https://www.sanity.io/manage), then select *Settings > API > Tokens* and use the **Add new token** button to open the creation dialog.

### Project tokens

Project robot tokens can only perform actions on an individual project. 

To create a robot token, you must have developer, admin, or an equivalent custom role in the project. Navigate to your project's [management console](https://www.sanity.io/manage), then go to *Settings* > *API* > *Tokens* and use the **Add new token*** *button to open the token creation dialog. 

Using a separate token for each application makes it easier to replace it or revoke access, if necessary.

Once a token is generated, it will be displayed exactly once—be sure to make a secure copy of it, since it is not possible to recover the token later. You can then use the token in API requests as outlined above.

> [!WARNING]
> Gotcha
> Robot tokens last until deleted by default. As of June 2026, you can set an expiration date when you create a token, or add one to an existing token from the token's three-dot menu in the management console. Choose from 30-, 60-, or 90-day presets, or set a custom date. Expired tokens are rejected; they cannot be reactivated.
> Use descriptive names, prune unused tokens, and see [Token rotation](https://www.sanity.io/docs/content-lake/http-auth) below for operational guidance.

> [!NOTE]
> Not all APIs allow robot tokens
> Some APIs are only available for use with personal auth tokens. In these cases, their documentation will explicitly call out that a personal token is required.

## Securing your API token

After setting up your token, it's important to keep this secure and not in a publicly-visible space, such as code committed to GitHub or Bitbucket. When deploying code that needs your API token, many hosting companies provide ways of creating [environment variables](https://www.sanity.io/docs/studio/environment-variables). These variables are stored securely on your host's server and are not stored in plain text in a repository.

- [Setting up environment variables in Netlify](https://docs.netlify.com/configure-builds/environment-variables/)​ 
- [Setting up environment variables in Vercel](https://vercel.com/blog/environment-variables-ui)​ 

## Token rotation

Rotating tokens limits the blast radius if a token is leaked or a compromised environment is detected. Setting an expiry on a robot token guarantees the old credential stops working on schedule, but issuing its replacement is still up to you; without an expiry set, rotation depends entirely on operational discipline.

### Why rotate tokens

A leaked or stale token is an open door until it's revoked. Rotation shortens the window an attacker can use any single credential. The [OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) recommends regular rotation for machine credentials like API tokens and service-account keys, on the grounds that any stolen credential then only works for a short time. For Sanity specifically, setting `expiresAt` on a robot token guarantees the credential stops working on a fixed date, rather than relying on a rotation schedule you have to remember.

### Choose a rotation cadence

The management console presets (30, 60, or 90 days) are reasonable starting points. Pick a cadence based on the token's exposure and the cost of replacement:

- **30 days:** public-facing services, third-party integrations, or any environment where credential leakage risk is higher than usual.
- **60 to 90 days:** internal trusted services with limited exposure (build pipelines, internal tools running in private networks).
- **Custom date:** tokens that need to align with a project deadline, vendor contract, or a shorter audit cycle.

Programmatic callers can pass any ISO 8601 datetime; the presets are a UI convenience.

### Set expiry when creating a token

**From the management console:** navigate to *Settings > API > Tokens*, select **Add new token**, then choose 30, 60, or 90 days, or set a custom date.

![A UI form with a 'Name' field showing 'Temporary access' and an 'Expiration (optional)' dropdown set to '90 days (11 Oct 2026)'.](https://cdn.sanity.io/images/3do82whm/next/4f8984976ead5c4a71693f140f6962b1bea55aaf-1202x903.png)



**From the Access API:** send a `POST` to the robots endpoint with an `expiresAt` field.

```bash
curl -X POST \
  "https://api.sanity.io/v2026-07-10/access/project/<projectId>/robots" \
  -H "Authorization: Bearer $SANITY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "build-pipeline",
    "memberships": [{
      "resourceType": "project",
      "resourceId": "<projectId>",
      "roleNames": ["viewer"]
    }],
    "expiresAt": "2027-01-01T00:00:00.000Z"
  }'

# Response includes the secret token (returned once), plus id, tokenId, expiresAt.
```

### Update expiry on an existing token

**From the management console:** open the three-dot menu next to a token in *Settings > API > Tokens* and adjust the date.

![A dialog box for editing an expiration date, displaying date input fields with "Custom (11 Aug 2026)" selected, and an "Update expiry" button.](https://cdn.sanity.io/images/3do82whm/next/30c688e41939763998e651d897156191ccbf7cd9-1442x1082.png)

**From the Access API:** send a `PUT` to the same robot's endpoint. The PUT endpoint accepts `expiresAt` and no other fields: to change a token's label or memberships, create a new token and delete the old one.

```bash
curl -X PUT \
  "https://api.sanity.io/v2026-07-10/access/project/<projectId>/robots/<robotId>" \
  -H "Authorization: Bearer $SANITY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expiresAt": "2027-04-01T00:00:00.000Z"}'
```

> [!WARNING]
> You cannot remove an expiry once set
> After a token has an `expiresAt` value, you can move the date forward or backward while the token is still active, but you cannot return the token to never-expires. Once a token expires, it is rejected permanently; extending the date does not reactivate it. If you need an unbounded token, leave the expiry unset at creation.

### Rotate a robot token without downtime

Tokens can be rotated with no service interruption by overlapping the old and new credentials. The recommended sequence:

1. Create a new robot token with the same memberships as the existing one, via the management console or `POST .../robots` on the Access API. Capture the secret immediately.
2. Deploy the new token to your environment configuration (environment variables, secrets manager, or equivalent).
3. Wait for an overlap window, typically 24 to 48 hours, so that any in-flight requests, cached configurations, or replicas pick up the new token. If your services log outbound requests, confirm the old token has stopped appearing before you delete it.
4. Delete the old token: `DELETE .../robots/<oldRobotId>` (management console or Access API). The old token is revoked immediately and rejected on subsequent requests.

Because the Access API PUT endpoint only accepts `expiresAt`, role or membership changes always follow this create-and-delete pattern; you cannot mutate a token's permissions in place. Rotation is also a natural checkpoint for least privilege: grant the new token only the roles the service still needs.

> [!WARNING]
> Compromised tokens: delete first, rotate after
> The overlap sequence is for routine rotation. If a token has leaked or you suspect compromise, delete the token immediately to cut off access, accept the interruption, and restore service with a new token afterward.

### Detect expiration before it happens

The Access API `GET .../robots` endpoint returns an `expiresAt` field for each token. A scheduled job that walks the list and alerts on tokens within N days of expiry gives you time to rotate before requests start failing.

Requests made with an expired token are rejected with an HTTP 401 authentication error. Treat expiry rejections the same as any other auth failure path in your client: surface a clear error, prompt for credential rotation, and avoid burying the failure in retry loops.

> [!NOTE]
> The sanity tokens CLI does not yet support expiry flags
> As of `@sanity/cli@7.2.3`, [sanity tokens](https://www.sanity.io/docs/cli-reference/tokens) supports `add`, `list`, and `delete` but does not expose an expiry flag. Set and manage expiry through the management console or the Access HTTP API for now.





# JSONMatch

JSONMatch is widely used in the [patch](https://www.sanity.io/docs/content-lake/http-patches) mutation type when updating documents. All mutations types support JSONMatch at the root key level when targeting the operations. This means that a single `set`, `unset`, `append` or `inc` operation can easily target one or more values of the document, or use the powerful recursive filtering of JSONMatch to find the desired value of the document automatically.

## General format

A JSONMatch [path](https://www.sanity.io/docs/specifications/groq-syntax) is an expression that, when evaluated, resolves to one or more locations in JSON document. A path can traverse object keys and arrays.

## Examples

In this reference we will use the following example JSON object to extract data from:

```javascript
{
  "name": "fred",
  "friends": [
    {
      "name": "mork",
      "age": 40,
      "favoriteColor": "red"
    },
    {
      "name": "mindy",
      "age": 32,
      "favoriteColor": "blue"
    },
    {
      "name": "franklin",
      "favoriteColor": "yellow"
    }
  ],
  "roles": ["admin", "owner"],
  "contactInfo": {
    "streetAddress": "42 Mountain Road",
    "state": {
    "shortName": "WY",
    "longName": "Wyoming"
    }
  }
}

```

Given the example document, these expressions can be evaluated:

```javascript
"name" → "fred" 
"friends[*].name" → ["mork", "mindy", "franklin"] 
"friends[age > 35].name" → ["mork"] 
"friends[age > 30, favoriteColor == "blue"].name" → ["mork", "mindy"] 
"friends[age?].age" → [40, 32] 
"friends[0].name" → "mork" 
"friends[0, 1].name" → ["mork", "mindy"] 
"friends[1:2].name" → ["mindy", "franklin"] 
"friends[0, 1:2].name" → ["mork", "mindy", "franklin"] 
"contactInfo.state.shortName" → "WY" 
"contactInfo.state[shortName, longName]" → ["WY", "Wyoming"] 
"friends.age[@ > 35]" → [35] 
"roles" → ["admin", "owner"] 
"roles[*]" → ["admin", "owner"] 
"roles[0]" → "admin" 
"roles[-1]" → "owner" 
"contactInfo..shortName" → "WY" 
"[contactInfo.state.shortName, roles]" → ["WY", ["admin", "owner"]] 

```

## Keys

A single key matches that key in an object. For example, `name` returns `"fred"`. If [keys](https://www.sanity.io/docs/content-lake/ids) contain special characters the key name can be surrounded in single quotes, so `'name'` also returns `"fred"`.

> [!WARNING]
> Gotcha
> Since single quotes are used to denote field names, regular strings *must* be enclosed in double quotes.

## Descent operator

The `.` operator descends into a key and selects a nested key. It has the format: `key1.key2`

For example:

```javascript
friend.name
```

This will match the `name` attribute in:

```javascript
{
  "friend": {
    "name": "mork"
  }
}

```

## Recursive descent

The [..](https://www.sanity.io/docs/specifications/groq-operators) operator matches every value below the current selection descending through any objects, iterating over every array. Typical usage is to find a sub-object regardless of where it resides in an object. `content.blocks..[key == "abc123"]` will find the object having the `attribute` key equal to "abc123" wherever it resides inside the object or array at `content.blocks`.

## Arrays

Arrays can be subscripted with the `[]` operator. It has the formats:

```
"array[2]" → The second element of the array
"array[2, 3, 9]" → the second, third and ninth array element
"array[-1]" → the last array element
"array[1:9]" → array element 1 through 9 (non-inclusive)
"array[4:]" → array element 4 through to the end of the array
"array[:4]" → array elements from the start to element 4 of the array (non-inclusive)
"array[1, 4, 5:9, 12]" → union of array elements 1, 4, 5 to 9 and 12

```

## Constraints

Arrays can be filtered with constraints, e.g. `friends[age == 32]`. Constraints are separated by comma and are always a union ("or"), not an intersection. 

## Boolean operations

In its current implementation, JSONMatch do not support [boolean operators](https://www.sanity.io/docs/specifications/groq-operators) `&&` or `||`, BUT essentially a union is the same as boolean `or`, and chaining constraints work the same as boolean `and`:

`"numbers[@ < 50, @ > 60]"`: Select numbers that are < 50 OR > 60.

`"numbers[@ > 20][@ < 30]"`: Select number that are > 20 AND < 30.

`'employees[name == "John Smith", name == "Granny Smith"]'`: employees that have the name "John Smith" OR "Granny Smith".



# IP addresses used by Sanity

For environments where outgoing connectivity filtering restrictions apply (egress filtering), we recommend adding [the following IPs](https://www.sanity.io/files/all-customer-facing-ips.txt) to your network configuration to allow the Sanity Studio connectivity to work well for you.

The IP addresses will not change often, but we recommend that you use [the source file](https://www.sanity.io/files/all-customer-facing-ips.txt) to ensure you have the latest list.



# Setting up your studio

## Create a new Studio with Sanity CLI

![Video](https://stream.mux.com/wIMs3CS7T4pP7hRArpQZsBZ01Be02vCjbK)

Run the command in your Terminal to initialize your project on your local computer.

See the documentation if you are [having issues with the CLI](https://www.sanity.io/docs/help/cli-errors).

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

## Run Sanity Studio locally

Inside the directory of the Studio, start the development server by running the following command.

**npm**

```shell
# in studio-hello-world 
npm run dev
```

**pnpm**

```shell
# in studio-hello-world 
pnpm run dev
```

**yarn**

```shell
# in studio-hello-world 
yarn run dev
```

**bun**

```shell
# in studio-hello-world 
bun run dev
```

## Log in to the Studio

**Open** the Studio running locally in your browser from [http://localhost:3333](http://localhost:3333).

You should now see a screen prompting you to log in to the Studio. Use the same service (Google, GitHub, or email) that you used when you logged in to the CLI.



# Defining a schema

## Create a new document type

![Video](https://stream.mux.com/IfVfAwxfwOKN2khdGCQ3cs5IuF1rYte1)

Create a new file in your Studio’s `schemaTypes` folder called `postType.ts` with the code below which contains a set of fields for a new `post` document type.

**/studio-hello-world/schemaTypes/postType.ts**

```
import {defineField, defineType} from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: {source: 'title'},
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
      initialValue: () => new Date().toISOString(),
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'image',
      type: 'image',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{type: 'block'}],
    }),
  ],
})
```

## Register the `post` schema type to the Studio schema

Now you can import this document type into the `schemaTypes` array in the `index.ts` file in the same folder.

**/studio-hello-world/schemaTypes/index.ts**

```
import {postType} from './postType'

export const schemaTypes = [postType]
```

## Publish your first document

When you save these two files, your Studio should automatically reload and show your first document type. Click the `+` symbol at the top left to create and publish a new `post` document.



# Displaying content in Next.js

## Install a new Next.js application

![Video](https://stream.mux.com/QSs5g22NaumIiAkggFufaDtEpCumej1j)

If you have an *existing* application, skip this first step and adapt the rest of the lesson to install Sanity dependencies to fetch and render content.

**Run** the following in a new tab or window in your Terminal (keep the Studio running) to create a new Next.js application with Tailwind CSS and TypeScript.

**npm**

```shell
# outside your studio directory
npx create-next-app@latest nextjs-hello-world --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd nextjs-hello-world
```

**pnpm**

```shell
# outside your studio directory
pnpm dlx create-next-app@latest nextjs-hello-world --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd nextjs-hello-world
```

**yarn**

```shell
# outside your studio directory
yarn dlx create-next-app@latest nextjs-hello-world --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd nextjs-hello-world
```

**bun**

```shell
# outside your studio directory
bunx create-next-app@latest nextjs-hello-world --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd nextjs-hello-world
```

You should now have your Studio and Next.js application in two separate, adjacent folders:

```text
├─ /nextjs-hello-world
└─ /studio-hello-world
```

## Install Sanity dependencies

**Run** the following inside the `nextjs-hello-world` directory to install:

- [next-sanity](https://github.com/sanity-io/next-sanity) a collection of utilities for integrating Next.js with Sanity
- [@sanity/image-url](https://www.sanity.io/docs/apis-and-sdks/image-urls) helper functions to take image data from Sanity and create a URL

**npm**

```shell
# in nextjs-hello-world
npm install --legacy-peer-deps next-sanity @sanity/image-url @tailwindcss/typography
```

**pnpm**

```shell
# in nextjs-hello-world
pnpm add next-sanity @sanity/image-url @tailwindcss/typography
```

**yarn**

```shell
# in nextjs-hello-world
yarn add next-sanity @sanity/image-url @tailwindcss/typography
```

**bun**

```shell
# in nextjs-hello-world
bun add next-sanity @sanity/image-url @tailwindcss/typography
```

## Start the development server

**Run** the following command and open [http://localhost:3000](http://localhost:3000) in your browser.

**npm**

```shell
# in nextjs-hello-world
npm run dev
```

**pnpm**

```shell
# in nextjs-hello-world
pnpm run dev
```

**yarn**

```shell
# in nextjs-hello-world
yarn run dev
```

**bun**

```shell
# in nextjs-hello-world
bun run dev
```

## Configure the Sanity client

To fetch content from Sanity, you’ll first need to configure a Sanity Client.

**Create** a directory `nextjs-hello-world/src/sanity` and within it create a `client.ts` file, with the following code:

**/nextjs-hello-world/src/sanity/client.ts**

```
import { createClient } from "next-sanity";

export const client = createClient({
  projectId: "YOUR-PROJECT-ID",
  dataset: "production",
  apiVersion: "2026-05-15",
  useCdn: false,
});
```

## Display content on the homepage

Next.js uses server components for loading data at specific routes. The current home page can be found at `src/app/page.tsx`.

**Update** it to render a list of posts fetched from your Sanity dataset using the code below.

**/nextjs-hello-world/src/app/page.tsx**

```tsx
import Link from "next/link";
import { type SanityDocument } from "next-sanity";

import { client } from "@/sanity/client";

const POSTS_QUERY = `*[
  _type == "post"
  && defined(slug.current)
]|order(publishedAt desc)[0...12]{_id, title, slug, publishedAt}`;

const options = { next: { revalidate: 30 } };

export default async function IndexPage() {
  const posts = await client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options);

  return (
    <main className="container mx-auto min-h-screen max-w-3xl p-8">
      <h1 className="text-4xl font-bold mb-8">Posts</h1>
      <ul className="flex flex-col gap-y-4">
        {posts.map((post) => (
          <li className="hover:underline" key={post._id}>
            <Link href={`/${post.slug.current}`}>
              <h2 className="text-xl font-semibold">{post.title}</h2>
              <p>{new Date(post.publishedAt).toLocaleDateString()}</p>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}
```

## Display individual posts

**Create** a new route for individual post pages.

The dynamic value of a slug when visiting `/[slug]` in the URL is used as a parameter in the GROQ query used by Sanity Client.

Notice that we’re using [Tailwind CSS Typography](https://github.com/tailwindlabs/tailwindcss-typography)’s `prose` class to style the post’s `body` content. We installed `@tailwindcss/typography` in the dependencies step. Enable it by adding `@plugin "@tailwindcss/typography";` to `src/app/globals.css` below the existing `@import "tailwindcss";` line.

**/nextjs-hello-world/src/app/[slug]/page.tsx**

```tsx
import { PortableText, type SanityDocument } from "next-sanity";
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";
import { client } from "@/sanity/client";
import Link from "next/link";

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`;

const { projectId, dataset } = client.config();
const urlFor = (source: SanityImageSource) =>
  projectId && dataset
    ? createImageUrlBuilder({ projectId, dataset }).image(source)
    : null;

const options = { next: { revalidate: 30 } };

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const post = await client.fetch<SanityDocument>(POST_QUERY, await params, options);
  const postImageUrl = post.image
    ? urlFor(post.image)?.width(550).height(310).url()
    : null;

  return (
    <main className="container mx-auto min-h-screen max-w-3xl p-8 flex flex-col gap-4">
      <Link href="/" className="hover:underline">
        ← Back to posts
      </Link>
      {postImageUrl && (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          src={postImageUrl}
          alt={post.title}
          className="aspect-video rounded-xl"
          width="550"
          height="310"
        />
      )}
      <h1 className="text-4xl font-bold mb-8">{post.title}</h1>
      <div className="prose">
        <p>Published: {new Date(post.publishedAt).toLocaleDateString()}</p>
        {Array.isArray(post.body) && <PortableText value={post.body} />}
      </div>
    </main>
  );
}
```





# Deploying Studio and inviting editors

## Deploy your Studio with Sanity

![Video](https://stream.mux.com/CvYhCQr8e1oZt98NW202BZLLNv376VVKc)

In your Studio directory (`studio-hello-world`) run the following command to deploy your Sanity Studio.

The first time you run this command, the CLI will prompt you to enter a **hostname**. This is the unique name for your Studio's URL (entering *my-app* will make your Studio available at *my-app*.sanity.studio).

**npm**

```shell
npm run deploy
```

**pnpm**

```shell
pnpm run deploy
```

**yarn**

```shell
yarn run deploy
```

**bun**

```shell
bun run deploy
```

## Invite a collaborator

Now that you’ve deployed your Studio, you can optionally invite a collaborator to your project. Navigate to your project in [Sanity Manage](https://www.sanity.io/manage), then select "Members". 

They will be able to access the deployed Studio, where you can collaborate together on creating content.





# Administer organizations, projects, datasets, and users

#### Manage your team

[Roles](https://www.sanity.io/docs/user-guides/roles)
Sanity enforces user access control with roles. Roles help control resource access to datasets and documents.

[Projects, organizations, and billing](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing)
Create, manage, and delete organizations, and delete, archive, or leave a project.

[Roles @ Sanity Learn](https://www.sanity.io/learn/course/introduction-to-users-and-roles/introduction)
Dive deeper with this course on users and roles at Sanity Learn

#### Understand pricing

[Plans and payments](https://www.sanity.io/docs/platform-management/plans-and-payments)
Learn how billing and quotas work for different plans.

[Pricing plans](https://www.sanity.io/pricing)
Up to date comparisons of prices, plans, and quotas



# Platform terminology

## Organizations

![Illustrative graphic showing two separate boxes with connected user avatars within](https://cdn.sanity.io/images/3do82whm/next/2afb547252d5d719a8bba88aa7dbe2257eeac243-3840x2160.png)
*Organization example*

*Organizations collate billing for multiple projects, which may be queried by the same or unique frontends.*

An organization is an entity where multiple projects are grouped to give them a single billing point. It does not need to be a registered company.

Project configuration cannot be shared across projects in an organization, nor can its content be referenced across projects.

A member may be a member of multiple organizations but must be invited to each. The roles in each project are created uniquely.

Organizations are also where Single Sign-On (SSO) is configured so that members can register to your projects using the provider of your choice.

Splitting a Sanity implementation across organizations is rarely required. Only if you require different projects to be billed with different payment methods.

## Projects

![Illustrative graphic showing user avatars connected within a single box](https://cdn.sanity.io/images/3do82whm/next/d77647461411b1ab167d38fe8c9737b1df1e3f9e-3840x2160.png)

*Datasets inside a project can reference one another, be used independently by members, and be queried by one or many API consumers.*

A project is a self-contained collection of datasets, members, and configuration options such as webhooks and tokens. These cannot be shared between projects. A member of one project is not automatically granted access to any other, though an administrator member may invite them.

All administrator members in a project will have access to project-level configurations such as datasets, members, tokens, custom access control, and webhooks. Other members may get read-only or no access to these.

Your various frontends and the Sanity Studio can be configured to query from or write data to multiple projects.

However, content cannot be *referenced* between projects, only between datasets.

With plugins and scripts, it is possible to *migrate* content across projects.

Dividing how you use Sanity across projects is useful when you need absolute separation of developer and author teams with very different content creation goals.

## Roles

Within Sanity, you can control user access by assigning roles. Organization and project roles are two different sets of roles to control user access at different levels of granularity:

- **Organization**: global throughout the organization.
- **Project**: specific for each project.

To access organization and project role configuration options:

1. Go to the [management](https://www.sanity.io/manage) page.
2. The top navigation bar features two drop-down menus: click the first from the left to select the organization, and the second to select a project within the specified organization.

![The diagram represents the organization and project hierarchy with the respective roles.](https://cdn.sanity.io/images/3do82whm/next/f38b881860683d90654a1fd3c9db0be6c2f82d32-2624x1767.png)
*Differences between organization and project roles*

### Organization roles

An **Organization** is an entity that groups multiple projects to provide a centralized location to manage tasks that are common to all projects, such as billing.

The available roles within an [organization](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing) are:

- **Administrator**: administrators can manage billing details, legal contacts, organization members, and project ownership. Organization administrators have the ability to manage user access to each project in the organization.
- **Billing manager**: billing managers can manage billing details and legal contacts, and attach projects to the organization.
- **Developer**: developers can create and attach projects to the organization. They can also alter the metadata for an organization, such as the informal name.
- **Member**: this is the default role for users in an organization. Members are able to view teammates operating in all projects across the organization.- When a **Project Administrator** is also an **Organization Member**, they are able to autocomplete the name of teammates when inviting users to a project.



> [!TIP]
> Protip
> Organization administrators don't automatically have access to every project in the organization. However, they do have the ability to fully manage user membership in projects (including their own membership).
> If a user needs access to a project, then either an organization or project administrator can add them.

> [!WARNING]
> Gotcha
> Users with the **Organization Member** role are able to identify any other user that exists in a project across the organization. If you're using your Sanity organization in a multi-tenant setup, you may not want your users to be aware of what exists outside their accessible project.
> To avoid this situation, confirm that your users do not have the Organization Member role. To ensure that newly added users do not automatically inherit this role, you can remove the "default organization role" in your organization settings.

### Project roles

A **Project** is a self-contained collection of datasets, members, and configuration options such as webhooks and tokens.

[Project roles](https://www.sanity.io/docs/user-guides/roles) include an administrator role as well. Whereas organization administrators can manage users and billing for the whole organization, project administrators have read and write access to all project settings and datasets.

> [!WARNING]
> Gotcha
> - Organization administrators and project administrators are different roles and have different scopes.
> - Organization members are not automatically granted access to projects owned by the organization.
> - You can assign both the organization and project administrator roles to the same user.
> - Different sign-in methods—email and password, Google, or GitHub—create separate Sanity accounts, even when they use the same email address. Accounts can't be merged. To move project and organization membership to the account you want to keep, see [Account recovery](https://www.sanity.io/docs/help/account-recovery).
> - To [transfer ownership](https://www.sanity.io/docs/platform-management/plans-and-payments) of a project to another organization, you must be an administrator in both the project and the source organization, and have a billing manager, administrator, or developer role in the receiving organization.

## Members and custom access controls

![Illustrative graphic showing a single user avatar connected to various icons representing different workflows](https://cdn.sanity.io/images/3do82whm/next/3bf653b7198c56f5dea40461f666077e00445971-3840x2160.png)

*A project member may have a different view, create or edit permission depending on document values and dataset tags.*

Members in Sanity can be active across multiple organizations and projects, but will need to be invited to any of them to begin collaborating and have unique roles within each. Their roles in a project will determine their access to datasets.

Members may be invited to a Sanity project via our built-in authentication or with Single Sign-on (SSO) configured, the provider of your choice.

> [!TIP]
> Protip
> Our SAML support includes the ability to map groups from your authentication provider to roles within a Sanity project. [Read the docs to find out more](https://www.sanity.io/docs/developer-guides/sso-saml).

They can be privileged as administrators to have complete access to all project settings and the ability to modify any data. Using custom access controls, permissions can be scoped to no access. Or, at a minimum, view-only permissions of a single document based on any value within it.

Example: A member with the custom role “Junior Store Manager” may only be able to view documents of the type `product` with a `price` field greater than `100` on the **production** dataset.

For projects with multiple teams or lines of responsibility, member roles ensure that individual content creators and developers do not have their work disturbed unexpectedly.

## Datasets

![Illustrative graphic that shows connection between user avatars, the Sanity Studio, and an example frontend](https://cdn.sanity.io/images/3do82whm/next/b65714c033677e6485fa010e51d00cdcc43804bb-3840x2160.png)

*Think of the Sanity Studio as just one of the many frontends that interact with Content Lake APIs.*

A dataset is a collection of schemaless data stored as JSON and uploaded file and image assets. Members may have access to all datasets in a project by default, but their privileges can be filtered in each dataset using custom access controls.

Your applications can query data from multiple datasets, and your Studio can be configured to switch between them using [workspaces](https://www.sanity.io/docs/studio/workspaces). Content can be referenced between datasets using [cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-references).

Datasets are often used as “environments.” Many teams have dataset names mapped to **development**, **staging**, and **production** environments.

Predominantly in Sanity, multi-tenant configurations are performed by separating content between datasets. You may have individual datasets for teams, brands, or markets, in addition to datasets as global sources of truth, which all other datasets may reference.

## References

- [Projects, organizations, and billing](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing)
- [Plans and Payments](https://www.sanity.io/docs/platform-management/plans-and-payments)
- [Roles](https://www.sanity.io/docs/user-guides/roles)



# Plans and payments

All Sanity projects are on a plan (by default a free plan), which comes with a set of included features and monthly resource quotas. Our available plans are listed on the [pricing page](https://www.sanity.io/pricing), and can be ordered at any time from the project's [management](https://manage.sanity.io) page. Some plans offer additional features for purchase. Any resource usage beyond the quotas will be billed at overage rates, except for Free projects (no overages allowed) or on a Legacy Free plan without a credit card, which may be temporarily deactivated instead.

Paid plans require that the project belongs to an "organization", which is responsible for payment. This does not have to be an actual company–you can create your own personal organization if you want to. The organization simply holds the billing address and credit card information for one or more projects.

Projects are billed in advance each month, and follow calendar months; all projects in an organization are invoiced and charged together on the first of each month, along with any overage charges accrued during the previous month.

We prorate the price when you change plans or cancel: if you upgrade to a larger plan on the 20th, we will only charge you for the remaining third of the month, and subtract a third of what you may have already paid for the old plan. Similarly, if you cancel a project on the 20th, we will refund one third of the monthly cost. Features and resource quotas are prorated in the same way. Plan changes and cancellations can be performed at any time, and take effect immediately.

From time to time we may make changes to our plan offerings, but you will remain on your original plan unless you choose to switch to a newer plan yourself. We may occasionally move projects to newer plans automatically, but this will generally only happen when it is clearly in your best interest, and you will always be notified of this in advance.

In the case of payment failures, we will notify you by email, and retry the payment for three days. If payment still has not gone through, and we have not been able to contact you, we may temporarily deactivate the project.

## Change your plan

You change a project's plan from the project's **Plan** tab in Manage. Plan changes take effect immediately, and the plan cost is prorated for the remainder of the month.

To change a project's plan:

1. In Manage, select the project.
2. Go to the **Plan** tab.
3. Select the plan you want.
4. Follow the prompts to confirm the change.

Downgrading works the same way as upgrading. The Free plan is one of the plans you can select — there's no separate cancellation step. If the project is on the Growth trial, see [Understanding the Growth plan trial](https://www.sanity.io/docs/platform-management/growth-plan-trial) for what happens when the trial ends.

## Payment methods

Payment methods belong to the organization that owns the project, not to the project itself. To add or update a payment method, you need the **Administrator** or **Billing manager** role in that organization.

Project roles do not grant billing access. If you administer a project but hold the **Member** or **Developer** role in the organization, or no organization role at all, the option to add payment details is unavailable. Manage shows the message "You must be an administrator or billing manager of the organization to add payment details before you upgrade."

Payment is always automatic. There is no autopay setting to turn on or off: once a payment method is on file, Sanity charges it automatically each billing cycle. If a payment fails, correcting the billing details is enough, because the payment system retries on its own.

## Resource quotas and overage

Plans come with a set of resource quotas:

- **API requests:** number of HTTP(S) requests to [<project>.api.sanity.io](https://www.sanity.io/docs/content-lake/http-urls) during the month, excluding `OPTIONS` requests.
- **API CDN requests:** number of HTTP(S) requests to [<project>.apicdn.sanity.io](https://www.sanity.io/docs/content-lake/api-cdn) during the month, excluding `OPTIONS` requests. (This is typically the requests incurred when serving your content to end-users.)
- [Assets:](https://www.sanity.io/docs/content-lake/assets) total size of all uploaded assets and files at the end of the month
- **Bandwidth:** total outgoing bandwidth for API, API CDN, and asset traffic during the month
- [Datasets:](https://www.sanity.io/docs/content-lake/datasets) total number of datasets at the end of the month
- [Documents:](https://www.sanity.io/docs/content-lake/ids) total number of stored documents (including drafts) across all datasets at the end of the month
- **Non-admin users:** total number of [non-admin users](https://www.sanity.io/docs/user-guides/roles) (excluding [service tokens](https://www.sanity.io/docs/content-lake/http-auth), i.e. robots) at the end of the month
- **Agent Actions:** Billed via [AI Credits](https://www.sanity.io/docs/platform-management/how-ai-credits-work) rather than a metered request quota. Each agent action consumes credits based on the operation and model used.
- **Compute:** invocations and GB-seconds for Sanity Functions. Metered across every function in the organization rather than per project, and shown in the organization's **Usage** tab.

> [!NOTE]
> What counts as a billable request
> Not every request counts toward the API and API CDN request quotas. Failed requests (responses with a 4xx or 5xx status code) are not billed, and `OPTIONS` requests are excluded. Requests made by Sanity's own applications and tooling, such as Sanity Studio or the Dashboard, are also exempt.
> This means that during a traffic spike, error responses (for example, a large number of 401 Unauthorized responses from an expired token) do not add to your billable usage.
> That exemption covers Sanity's own applications, not code you deploy to Sanity's infrastructure. Requests made from inside a Sanity Function count as normal usage: the function's invocations and compute time are metered separately, but every Content Lake request it makes counts toward your API and API CDN quotas like any other request.

Resource usage is metered periodically, with statistics available on each project's management page. Quotas are metered and enforced per calendar month in UTC — there is no annual quota, and usage shown over a longer date range is a rollup of monthly periods. How we handle usage beyond your quota depends on your plan:

- **Paid Projects (Growth, Enterprise, and Legacy plans with a credit card):** Usage beyond the included quota is automatically billed as overage. Your project will remain fully functional. The current overage rates for a project can be found under the "Plan" tab in Manage and on our [Pricing page](https://www.sanity.io/pricing). Project admins may prevent extra overage fees by temporarily disabling the project manually.
- **Free, Growth Trial, and Legacy Free projects (without a credit card):** These plans have hard caps on resources. No overages are allowed. If a project reaches its quota for **Documents, Assets, API, APICDN, or Bandwidth**, specific functionality will be blocked until the quota resets or the plan is upgraded (see details below).

Please be aware that downgrading a project to a smaller plan will also prorate the quotas, which may trigger additional overage charges due to resources that have already been spent earlier in the month. For example: if you are on a plan with a 50,000 API request quota, and have used 35,000 of these so far, then downgrading to a tiny plan with only 10,000 API requests in the middle of the month with leave you with half of 50,000 + half of 10,000, which is 30,000 API requests. Since you used 35,000, you now have 5,000 overage on your effective plan for this month and the rest of this months API requests will be billed as overage.

### Document quota

The **Free, Growth Trial, and Growth plans** do not allow overages above the included document quota. When the document quota limit has been reached, you will not be able to create additional documents, including new drafts.

To resolve this, you can either reduce the number of documents in your projects by deleting documents or unused datasets, or upgrade your plan to increase the Document quota:

- **Free and Growth Trial plan** → Upgrade to Growth plan from the "Plan" tab in [Manage](https://www.sanity.io/manage)
- **Growth plan (without Extended Quota add-on)** → Purchase the Extended Quota Add-on from the "Plan" tab in [Manage](https://www.sanity.io/manage)
- **Growth plan (with Extended Quota add-on)** → Upgrade to Enterprise by  [reaching out to sales](https://www.sanity.io/contact/sales?ref=docs-plan-limits)

**Timing considerations:**

- When upgrading your plan or purchasing the Extended Quota add-on for Growth, the new quota will be updated instantly
- When deleting documents or datasets, it can take up to 1 hour for the document count to be updated

For questions about the document quota limits, you can [reach out to support](https://www.sanity.io/contact/billing?ref=docs-plan-limits).

### Asset quota

The **Free and Growth Trial plans** do not allow overages above the included Asset quota. When the total size of your uploaded assets reaches the limit, **you will not be able to upload new assets** (images, videos, or files). Existing assets will continue to be served.

To resolve this, you can:

- **Delete unused assets:** Permanently deleting assets from your dataset will free up space.
- **Upgrade to Growth:** Upgrading to the Growth plan unlocks unlimited asset storage (usage above the quota is billed as overage).

**Note for Growth plan users:** Unlike the Document quota, the Asset quota is *not* hard-capped on the Growth plan. You can continue to upload assets beyond the included amount, and the excess will be charged as overage.

### API, API CDN, and bandwidth quotas

**Free and Growth Trial plans** do not allow overages for API, APICDN, or Bandwidth usage. To help you manage your consumption, we automatically send email alerts to all organization and project administrators when usage reaches **80% (warning)** and **100% (blocked)**.

#### What happens at 100% usage

If your project consumes 100% of its included API, APICDN, or Bandwidth quota:

- **Public API access is blocked:** All additional requests to the API/CDN will fail. In practice, this means Sanity content will fail to load in your production application or website.
- **Studio remains functional:** The Sanity Studio will remain functional, allowing you to log in and manage your project settings or data.

#### What a blocked request returns

While a quota block is active, requests to the API and API CDN fail with HTTP `402` Payment Required. Asset uploads that exceed the Asset quota fail the same way. The response body names the reason in its `error` field:

```json
{
  "statusCode": 402,
  "error": "plan_limit_reached",
  "message": "Assets quota limit reached. Go to sanity.io/manage to upgrade your plan."
}
```

The `error` field is `plan_limit_reached` whenever the block comes from a plan quota, and `message` names the resource that reached its limit. To restore service, upgrade the plan or wait for the monthly quota reset.

#### How to resolve a blocked project

Unlike Document and Asset quotas, API, APCIDN and Bandwidth usage accumulates over time and resets monthly. Therefore, you cannot "lower" your usage for the current month once it has occurred (e.g., you cannot "un-spend" bandwidth).

To restore service to your application, you have two options:

1. **Upgrade to the Growth Plan (recommended):** Upgrading enables overages, which unlocks your API requests immediately. You can do this from the "Plan" tab in [Manage](https://www.sanity.io/manage).1. **Permissions:** If the organization does not have a payment method added, an **Organization Administrator** must perform the upgrade, adding payment details during the process. If a payment method is already added, both **Organization Administrators** and **Project Administrators** can upgrade the project.
2. **Costs:** You will be charged the Growth plan base fee plus overage fees for any usage exceeding the included quotas. The current overage rates for a project can be found under the "Plan" tab in Manage and on our [Pricing page](https://www.sanity.io/pricing).


2. **Wait for the quota reset:** Usage quotas reset automatically at 00:00 UTC on the first day of the next calendar month. The reset follows UTC, not your local time zone.

**Note for Growth plan users:** Projects on the paid Growth plan are not subject to these blocks. If you exceed your included quota, your content will continue to be served uninterrupted, and the excess usage will be added to your monthly invoice as overage.

## Project transfers

Projects may be transferred between organizations. You must be an administrator in the project and the source organization, but can be a billing manager, administrator, or developer in the receiving organization.

Once a transfer is completed, the sender and receiver are prorated the plan cost; the sender is refunded the already paid amount for the remainder of the month, while the receiver is charged for the remainder of the month at the time of the transfer. The receiver will be responsible for paying any overage charges accrued on the project. Since a transfer does not change the project plan, this has no effect on resource quotas.

> [!NOTE]
> Permissions Required
> To transfer a project, you must have the roles mentioned above. If using custom roles, your role must include the following permission grants for the originating organization and project:
> • `sanity.organization.projects.detach`
> • `sanity.project.update`
> And the following permission grants in the receiving organization and project:
> • `sanity.organization.projects.attach`
> • `sanity.project.update`

## Refunds and credits

Project downgrades, transfers, and cancellations take effect instantly, and the already paid plan cost will be prorated and refunded for the remainder of the month. Any overage will be tallied and charged at the end of the month, unless the entire organization is deleted, in which case overage is tallied and charged immediately.

Refunds are issued to the card that was originally charged, and may take up to 10 business days to complete depending on the card issuer.

### Downgrade to the Free plan

Downgrading to Free ends the paid subscription and applies the Free plan's quotas and features to the project. The steps are the same as any other plan change — see [Change your plan](https://www.sanity.io/docs/platform-management/plans-and-payments#cp-h2). The already paid plan cost is prorated and refunded for the remainder of the month. Any overage accrued before the downgrade is still charged at the end of the month.

Downgrading changes what the project can do:

- Private datasets become public.
- Members with non-admin roles are converted to viewers.
- Paid features from the Growth plan are no longer available.
- Overages are no longer allowed. If the project is already above a Free plan quota, the affected functionality is blocked until the quota resets.

Quotas prorate the same way plan cost does, so a downgrade partway through the month can leave a project above its effective quota on usage it has already spent. On the Free plan that means the affected functionality is blocked until the quota resets, rather than billed as overage. See [Resource quotas and overage](https://www.sanity.io/docs/platform-management/plans-and-payments#loGMyYwU).

> [!TIP]
> Archive instead of downgrading
> To stop paying for a project without giving up paid features and private datasets, archive it instead. See [Projects, organizations, and billing](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing).



# Projects, organizations, and billing

All projects on Sanity.io can be tied to an organization. An organization holds contact and billing information, and can have administrators, billing managers, and developers added to them. Agencies and freelancers can initiate projects and create organizations for their clients for a smooth hand-over.

These roles determine what each member can do with billing. Only organization administrators and billing managers can add or update a payment method; project roles, including project administrator, do not grant billing access. For details, see [Plans and payments](https://www.sanity.io/docs/platform-management/plans-and-payments).

> [!WARNING]
> Gotcha
> Looking to add a project to an organization? Projects are always assigned to an individual upon creation and can then be moved to an organization via the [Manage](https://www.sanity.io/manage) interface. To move a project between organizations you must have admin privileges in the organization that currently owns the project.

## Create an organization

1. Log into [manage.sanity.io](https://manage.sanity.io)
2. Select a project and go to **Settings**
3. Under **General** you'll find the “Organization” heading
4. Here you can either select an organization you're already a member of, or create a new one from the link
5. Fill in the Payment details and hit **Save**

## Manage an organization

1. Log into [manage.sanity.io](https://manage.sanity.io)
2. Projects will be listed out and sectioned by the organizations you're member of
3. In the organization headings, push the “edit organization” link to go to its settings
4. In the organization settings you get an overview over existing projects, as well as the team, billing information, and GDPR information.

## Add contact details for an EU representative and Data Protection Officer (DPO)

1. Follow the steps for managing your organization
2. Under **Settings** you'll find buttons for adding contact details for your EU representative and Data Protection Officer

## Delete an organization

1. Move or delete all projects connected to the organization in the projects’ settings
2. Follow the steps for managing your organization
3. Under **Settings**, click the **Delete Organization **button

## Delete a project

To delete a project:

1. In Manage, select the project and go to **Settings**.
2. Under **Danger zone**, click **Delete project**.
3. Confirm the deletion.

Deleting a project ends its subscription. The already paid plan cost is prorated and refunded for the remainder of the month, and any usage-based charges accrued before the deletion are still invoiced.

> [!WARNING]
> Projects billed through Vercel Marketplace
> Projects billed through Vercel Marketplace can't be deleted in Manage. Uninstall the resource from your Vercel dashboard to delete the project.

## Archive a project

Archiving a project removes access to it but does not delete it permanently. The project can be reactivated at any time. Here is what happens to billing when you archive a project:

1. **Subscription stops** — No further charges for your plan, seats, or add-ons.
2. **API and CDN access is blocked** — No new usage can accrue against your project.
3. **Final invoice for outstanding usage** — Any usage-based charges (API calls, bandwidth, etc.) incurred before archiving will still be invoiced.
4. **Reactivation available anytime** — You can reactivate your project whenever you are ready. When you reactivate, a new invoice will be issued for your plan.

Example: If you are on the Growth plan and archive your project mid-cycle, you will not be charged for the subscription going forward. However, if you had outstanding API overages before archiving, you will receive a final invoice for that amount.

## Leave a project or organization

Leaving removes you from a project or an organization and ends your access immediately. Every role can leave, including Viewer.

To leave a project:

1. In Manage, select the project and go to **Settings**.
2. Under **Danger zone**, click **Leave project**.
3. Confirm that you want to leave.

To leave an organization:

1. In Manage, select the organization and go to **Settings**.
2. Under **Danger zone**, click **Leave organization**.
3. Confirm that you want to leave.

Leaving doesn't delete anything. The project or organization keeps running and its content stays in place. If nothing should outlive your membership, delete it instead.

> [!WARNING]
> You can't leave as the only administrator
> Sanity blocks you from leaving a project or an organization where you hold the only Administrator role. Promote another member to Administrator first, or delete the project or organization. This is the same check that blocks account deletion — see [Delete your account](https://www.sanity.io/docs/platform-management/deleting-your-account).



# Understanding the Growth plan trial

During the trial period, you’ll have access to additional paid features from the Growth plan, including [private datasets](https://www.sanity.io/docs/content-lake/keeping-your-data-safe), more [user roles](https://www.sanity.io/docs/user-guides/roles), [Comments](https://www.sanity.io/docs/studio/comments), [Scheduled drafts](https://www.sanity.io/docs/studio/scheduled-drafts), and [AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist).

## Starting the trial

Every new Sanity project created automatically gets free access to additional paid features from the Growth plan for a limited period of time. Here's how to activate the trial on a new project:

- **New Sanity users**: [Create your first project](https://www.sanity.io/get-started?ref=trial-docs) and follow the instructions.
- **Existing Sanity users**: Create a new project by running `npm create sanity@latest` in your CLI/terminal and follow the instructions to create a new project.

## Trial limitations

The Growth trial unlocks additional features available in the Growth plan, but comes with the same usage limits as the Free plan:

- 20 users
- 2 datasets
- 2k unique attributes (per dataset)
- 10k documents
- 2 GROQ-powered webhooks

See the [plan comparison table on the Pricing page](https://www.sanity.io/pricing#compare-plans) for more details.

## End-of-trial decision

When the trial period is over, you can choose to either keep the paid features by upgrading to the Growth plan or do nothing and get automatically downgraded to the Free plan.

**We will not charge you for anything unless you upgrade to the Growth plan.**

### Upgrade to Growth plan

If you want to keep access to the additional features of the Growth plan, you'll need to upgrade your project and add a payment method in [Manage](https://www.sanity.io/manage). You can do this during the trial or after it ends. Here's how to do it:

1. Select your project from the dropdown menu labeled **Select project or organization**.
2. Navigate to the **Plan** tab.
3. Click **Upgrade to Growth.**
4. Follow the instructions on how to create an organization and add your payment method.

### Downgrade to Free plan

If you don't add your payment details and upgrade to the Growth plan, your project is automatically downgraded to the Free plan when the trial ends. Private datasets will become public, all team members with non-admin roles will be converted to viewers, and you'll lose access to the paid features from the Growth plan.

You can upgrade to the Growth plan anytime later by visiting your project page in [Manage](https://www.sanity.io/manage), navigating to the tab labeled **Plan**, and clicking **Upgrade to Growth**.



# Extending the Growth plan with paid add-ons

## How to enable add-ons

To enable one of the add-ons for your Growth plan project, you can:

1. Log into [Manage](https://www.sanity.io/manage?ref=docs-add-ons).
2. Select your project from the dropdown labeled **Select project or organization**.
3. Navigate to the tab labeled **Plan**.
4. Scroll down to the **Add-ons** section and click **See details** on the add-on you wish to enable.
5. Click **Enable add-on** in the modal.

The add-on is now enabled and will be billed in the subsequent billing cycle as a line item on your invoice.

#### Get all add-ons with Enterprise
Every add-on can be made available through our Enterprise plan, with further customization options. Contact our sales team to see if it's a fit for your project.
[Talk to sales](https://www.sanity.io/contact/sales?ref=docs-add-ons)

## Add-ons available

### Dedicated support

The dedicated support add-on gives you access to direct technical support from Sanity's support engineers over email.

If you have questions about this add-on, you can [contact our support team](https://www.sanity.io/contact/billing?ref=docs-add-ons).

### Increased quota

Extend the included quota of the Growth plan to:

- Documents: 50k (up from 25k)
- API CDN requests: 5M (up from 1M)
- API requests: 1M (up from 250k)
- Bandwidth: 500GB (up from 100GB)
- Assets: 500GB (up from 100GB)

Cost of additional usage remains unchanged, as listed on our [Pricing page](https://www.sanity.io/pricing?ref=docs-add-ons).

### Extra datasets

Unlock up to two additional datasets for your project, increasing the maximum number of datasets from two to four. Note that we only charge you when you create the additional dataset(s), not when you enable this add-on.

## Questions or feedback?

Please [reach out to our support team](https://www.sanity.io/contact/billing?ref=docs-add-ons) if you have any questions about the paid add-ons. And if there's another feature you'd like to see here, we'd love to hear from you in our [community Discord](https://www.sanity.io/community/join).



# Sanity's non-profit plan

## The plan

The non-profit plan mirrors the [Growth plan](https://www.sanity.io/pricing), but we offer it for free (no credit card required) as long as you stay within the quotas. Additionally, we've added the following features to the plan:

- 25 users included free of charge, with $15 per additional user without limit
- 3 datasets (+1 from Growth plan)

Note that [add-ons](https://www.sanity.io/docs/platform-management/growth-plan-add-ons) are not available, and you need to add a credit card to pay for additional overages and users.

## Who's eligible?

We offer the non-profit plan to:

- Small and mid-sized organizations that are “organized and operated for a collective, public or social benefit” and where the revenue exceeding expenses goes back into the cause
- Educational and academic institutions of smaller sizes and budgets
- Open-source projects that are based on sponsorships or voluntary effort (so not monetized)

## Who's not eligible?

- Organizations that qualify for our [Enterprise plan](https://www.sanity.io/pricing), including large non-profit organizations like global humanitarian operations, universities, etc.
- Organizations that can’t comply with our [Terms of Service](https://www.sanity.io/legal/tos).

## How to apply?

[Fill out the application form](https://forms.gle/xkQstGLFrujT2me39) and you'll hear back from us within 14 business days. Please note:

- If you don't provide a valid Sanity project ID, your application will be ignored.
- You'll receive an email when a decision has been made, but we're not able to provide technical support over email after this. Please join our community on Discord to get help.



# How AI Credits work

Whether working with [Content Agent](https://www.sanity.io/docs/content-agent), [Agent Actions](https://www.sanity.io/docs/agent-actions), or [certain MCP server tools](https://www.sanity.io/docs/ai/mcp-server), AI usage in Sanity is measured and billed using AI Credits. This article examines what AI Credits are and how they work.

> [!TIP]
> Free credits every month!
> Every organization receives a free number of AI credits each month to explore and experiment. See the [pricing page](https://www.sanity.io/pricing) more details.

## Credit pricing

Each AI credit costs **$0.05**. Credit consumption depends on the type and scope of interaction.

- **Query** (your message to Content Agent): **4 credits** ($0.20)
- **Action** (tool use by Content Agent): **2 credits** ($0.10)
- **Agent Action** (a request with any [Agent Action](https://www.sanity.io/docs/agent-actions)): **1 credit** ($0.05)

**Queries** are messages you send to the Content Agent. Each request includes a 4-credit query cost.

**Actions** are operations the agent performs on your behalf: GROQ queries, web searches, document analysis, content creation, and image generation. Each tool execution costs 2 credits.

A single request may involve multiple tool executions depending on the complexity of the task. The number of executions depends on the amount and type of work required; it is not a fixed number per document.

### MCP server

Certain [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server) tools invoke Agent Actions under the hood and consume credits at the same rate. Most MCP tools, however, are standard API calls and don't consume credits.

### Example cost estimates

Costs vary based on document size, structure, and workflow complexity. The examples below are directional. When Content Agent asks you to confirm a bulk operation, the estimate it shows is rounded up: to the nearest 10 credits below 100, and to the nearest 100 credits at or above 100. That figure can read higher than the ones in this table.

##### AI Credits cost examples

| Prompt | Estimated calculation | Estimated credits |
| --- | --- | --- |
| "Show Q3 blog posts" | 1 query (4) + small read (2) | ~6 credits |
| "Analyze 10 articles (~1 MB total)" | 1 query (4) + analysis (~2 credits per 100 KB) | ~24 credits |
| "Update 5 documents" | 1 query (4) + mutation (~6 credits per document) | ~34 credits |
| "Translate 3 documents into 2 languages" | 1 query (4) + translation (~12 credits per document per language) | ~76 credits |

For large bulk operations, Content Agent asks you to confirm before it proceeds. Confirmation is triggered when the estimated cost is 100 credits or more, or when the estimate meets or exceeds your organization's remaining credits. If your organization reaches its spending limit, AI operations pause until the start of the next calendar month or until the limit is increased.

## Controlling costs

Content Agent can easily operate on a large number of documents which can incur unexpected billing if used indiscriminately. To shield your organization from unintended costs you can set spending limits that will halt all AI operations once reached.

### Control usage

You can find detailed overviews of your AI usage by visiting sanity.io/manage and clicking the **Usage** tab in the top level navigation.

![An AI usage dashboard showing 6,826 total credits used, broken down by Agent Actions, Content Agent Queries, and Content Agent Actions, with a bar chart visualizing daily usage trends over 8 days.](https://cdn.sanity.io/images/3do82whm/next/498b3df2397fb1ad92f032114a0f3eb76aec0bf2-953x992.png)

The usage overview also shows which individuals in your organization are the most prolific users of AI features.

![Dashboard detailing AI usage by user, featuring a table of total usage and a stacked bar chart of daily usage over time.](https://cdn.sanity.io/images/3do82whm/next/364af7a3dc39983cf00722aa84dc4ba053f8b151-966x764.png)

#### Set spending limits for AI 

You can set a monthly spending limit to prevent unexpected charges. When your organization reaches the cap, AI features pause until the start of the next calendar month or until you raise the limit. If an operation is started while credits are still available, it will run to completion even if it exceeds the remaining budget, and usage is calculated with a short delay, so actual spend can slightly exceed the cap before AI operations pause. Visit [sanity.io/manage](https://sanity.io/manage) and navigate to your organization's **Settings** to set or change your spending limits. You can pick the default cap, a custom monthly amount, or no limit at all. Setting a custom cap requires accepting the AI Credits Additional Terms.

![AI usage dashboard showing $100 remaining and a $100 spending cap.](https://cdn.sanity.io/images/3do82whm/next/d14dba9878e5867eae73fc530116bb4823eb1eb3-1008x249.png)

### Roll out AI features to your team

Before you open Content Agent to a whole team, set an organization spending limit and plan for how that limit behaves under load:

- Set the limit before rollout. Only members with billing permissions on the organization can set or change it.
- Leave headroom above what you expect to spend. An operation that has already started runs to completion, and usage is metered with a short delay, so a month can end slightly above the limit.
- Expect more confirmation prompts as usage approaches the limit. Content Agent also asks for confirmation when an estimate meets or exceeds your organization's remaining credits, not only at the 100-credit threshold.
- Choose the limit deliberately. You can't lower it below what your organization has already spent in the current month.

### Tips for efficient usage

When working with large document sets, select a few documents first to refine your prompt before applying it to the entire set. This helps you optimize your queries and reduce unnecessary credit consumption.

## Credits when you change plans

AI credits are a monthly allowance. The credits included with your plan reset at the start of each calendar month, at 00:00 UTC, and don't carry over to the next one.

Your spending limit is set on your organization, separately from your plan. Changing plans, including converting a trial to a paid plan, doesn't raise or reset the limit, so upgrading isn't a reliable way to resume a paused Content Agent. If the agent is still paused after a plan change, raise or remove the limit.

A paused agent reports `Limit Hit - Content Agent Paused.` along with the credits you have used and your organization's monthly limit in both dollars and credits. To resume, raise or remove the limit under **Settings** at [sanity.io/manage](https://sanity.io/manage).





# Activity Feed

The Activity Feed lets you investigate what happened in your Sanity projects. If you are uncertain how a scenario took place, you can use the Activity Feed to investigate what actually happened.

*Screenshot of the project activity from sanity.io/manage*

## What is an event?

An event is created when various actions are performed in the system. This can be by a user, by Sanity, or even by a robot token. An event contains information about what happened and when. Events differ by action, and each contains a unique ID.

### List of team events

- user creates team
- user changes team’s name
- user changes billing address
- user changes payment method
- user changes EU Representative
- user changes Data Protection Officer
- user changes user’s role
- user removes user
- user invite user(s)
- user joins team
- user revoked invitation

### List of project events

- user creates project
- user changes project’s name
- user changes project’s custom studio URL
- user changes project’s plan
- user adds CORS origin
- user removes CORS origin
- user adds webhook
- user removes webhook
- user adds API token
- user removes API token
- user changes user’s permissions
- user removes user
- user invites user(s)
- user joins project
- user revokes invitation
- user creates dataset
- user deletes dataset
- user edits dataset
- user duplicates dataset

## Exporting

Actions for projects and teams are available as a CSV export from the [manage dashboard](https://sanity.io/manage) for each project. The export can be customized by the date when created.

### Data provided in the export

- action
- actorEmail
- actorId
- actorName
- correlationId
- datasetName
- description
- documentId
- id
- metadata.email
- metadata.invitedBy
- metadata.role
- organizationDisplayName
- organizationId
- projectDisplayName
- projectId
- timestamp
- transactionId
- userEmail
- userId
- userName
- version



# Request logs

Sanity can be set up to deliver detailed logs for all [API requests related to a project](https://www.sanity.io/docs/http-api). This allows you to make informed decisions about how content is requested and interacted with in the Content Lake.

You can use these logs to get insights into what’s driving requests and bandwidth usage, where requests come from, and more.

## Enrich your logs with request tags

Sanity’s Content Lake supports marking your requests with tags as a lightweight but powerful way of adding context to your API activities. Visit the [request tags reference article](https://www.sanity.io/docs/platform-management/reference-api-request-tags) to learn more about this feature.

[Request tags reference](https://www.sanity.io/docs/platform-management/reference-api-request-tags)
Learn how to tag your API and CDN requests to add useful context for data analysis

## Request logs for self-serve plans

You can access request logs on self-serve plans by going to the **Usage** section of [your project settings](https://www.sanity.io/manage). At the bottom of this page, you’ll find a button to download up to 1 GB of log data from the last 7 days up to the day before you download the data. You can request a new export every 24 hours.

### Export limits and truncation

The export is capped at 1 GB. If your project generates more log data than that within the export window, the file is truncated and covers less than the full 7 days.

Check the range your export actually covers before you draw conclusions from it. This command prints the first and last timestamp in the file:

```sh
gunzip --stdout request-logs.ndjson.gz | jq -r .timestamp | sort | sed -n '1p;$p'
```

### Analyzing request logs

The request log export will come as a compressed NDJSON file. You can use different tools to analyze this, such as [GROQ CLI](https://github.com/sanity-io/groq-cli) or [jq](https://jqlang.github.io/jq/). You can even convert it to CSV using a package like [json2csv](https://github.com/juanjoDiaz/json2csv):

```sh
gunzip --stdout [compressed logfile].ndjson.gz | npx json2csv --ndjson --output [output].csv
```

Exploring tools like [Jupyter Notebook](https://jupyter.org/), or AI tools, can also be helpful for more extensive analysis.

Visit the [request log data reference](https://www.sanity.io/docs/platform-management/reference-request-log-data) to learn how the logs are structured and formatted.

[Request log data reference](https://www.sanity.io/docs/platform-management/reference-request-log-data)
Examine the data structure in your API request logs

[Request logs analysis example](https://github.com/sanity-io/sanity-request-logs-analysis/blob/4e3db114b57c270ab899837d6a72ce6f2930471f/request-logs.ipynb)
Example of how to use Jupyter Notebook to analyze request logs from Sanity

### Find what's driving your bandwidth

Bandwidth covers outgoing traffic for API, API CDN, and assets. Asset downloads are usually the largest share, so start there. The recipes below run against the NDJSON export with jq.

#### Top assets by bandwidth

This groups CDN responses by URL and totals the megabytes each one served. It skips requests tagged `sanity.studio`, so the Studio's own traffic doesn't crowd out your production assets:

```sh
gunzip --stdout request-logs.ndjson.gz \
  | jq -r 'select(.attributes.sanity.domain == "cdn")
           | select(any(.attributes.sanity.tags[]?; startswith("sanity.studio")) | not)
           | "\(.body.responseSize)\t\(.body.url)"' \
  | awk -F'\t' '{bytes[$2] += $1}
                END {for (url in bytes) printf "%.1f\t%s\n", bytes[url]/1000/1000, url}' \
  | sort -rn \
  | head -10
```

The `sanity.studio` prefix marks requests from Sanity's own tooling rather than from your application. The [request tags reference](https://www.sanity.io/docs/platform-management/reference-api-request-tags) lists the reserved namespaces.

#### Top request tags by bandwidth

If you tag your own requests, this shows which parts of your application consume the most bandwidth, with a request count next to each total:

```sh
gunzip --stdout request-logs.ndjson.gz \
  | jq -r '.body.responseSize as $size
           | .attributes.sanity.tags[]?
           | "\($size)\t\(.)"' \
  | awk -F'\t' '{bytes[$2] += $1; count[$2]++}
                END {for (tag in bytes)
                       printf "%.1f\t%d\t%s\n", bytes[tag]/1000/1000, count[tag], tag}' \
  | sort -rn \
  | head -10
```

#### Top GROQ queries by bandwidth

Queries sent as GET requests get a `groqQueryIdentifier`, a stable hash you can group on:

```sh
gunzip --stdout request-logs.ndjson.gz \
  | jq -r 'select(.attributes.sanity.endpoint == "query")
           | select(.attributes.sanity.groqQueryIdentifier != null)
           | "\(.body.responseSize)\t\(.attributes.sanity.groqQueryIdentifier)"' \
  | awk -F'\t' '{bytes[$2] += $1; count[$2]++}
                END {for (q in bytes)
                       printf "%.1f\t%d\t%s\n", bytes[q]/1000/1000, count[q], q}' \
  | sort -rn \
  | head -10
```

Queries sent in a POST body have no `groqQueryIdentifier`, so they don't appear in this breakdown.

#### Narrow the export before you analyze it

On a large export it helps to filter first and analyze second. GROQ CLI reads the NDJSON stream and writes a smaller one:

```sh
gunzip --stdout request-logs.ndjson.gz \
  | npx groq-cli --ndjson '*[attributes.sanity.domain == "cdn"
      && count(attributes.sanity.tags[@ match "sanity.studio*"]) == 0]' \
  > cdn-requests.ndjson
```

#### Reduce recurring asset bandwidth

Once you know which assets dominate, resize and re-encode them through the image URL rather than re-uploading. `auto=format` serves modern formats to browsers that support them, and the width and quality parameters cut transfer size further. See [image transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls).

## Request logs on Enterprise plans

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

For projects on some Enterprise plans, logs are delivered as compressed NDJSON files to your [Google Cloud Storage (GCS)](https://cloud.google.com/storage) bucket, which then serves as a staging area for ingesting the reports into a data analysis tool of your choice. 

Visit the [request log data reference](https://www.sanity.io/docs/platform-management/reference-request-log-data) to learn how the logs are structured and formatted.

### Enable and configure log delivery

You can always extract, that is, download, the raw request log file on demand for ad hoc analysis. However, you can save time and make insights more broadly accessible to your team if you load logs into a data lake, such as [BigQuery](https://cloud.google.com/bigquery), and set up pre-defined queries for common reports.

The entire process, from enabling the log delivery service to querying your data for insights, requires a few separate steps to set up and follows the [Extract, Load, and Transform (ELT)](https://www.ibm.com/topics/elt) pattern for data integration. You will find an example implementation below, detailing how to implement Sanity request logs with [Google Cloud Storage](https://cloud.google.com/storage) and [BigQuery](https://cloud.google.com/bigquery).

> [!WARNING]
> Gotcha
> Currently, Google Cloud Storage is the only supported option for delivery.[Azure](https://learn.microsoft.com/en-us/azure/data-factory/connector-google-cloud-storage?tabs=data-factory) and [AWS](https://docs.aws.amazon.com/datasync/latest/userguide/tutorial_transfer-google-cloud-storage.html) both offer ways to copy data from GCS; however, Sanity has not yet tested and verified either of these solutions.

### Step 1: Extract (required)

In this step, you will enable log delivery in your Sanity project and connect your GCS bucket in the Sanity project management settings. The setup described in this step is the only part of this guide that is required to use the request log feature, while the subsequent steps are provided as an example implementation.

#### Prerequisites

- A Sanity account with administrator access to [project management settings.](https://sanity.io/manage)
- A [GCS account](https://cloud.google.com/storage) with permission to create and administrate GCS buckets.
- Optionally: Access to and familiarity with command line tooling like `node`, `npm`, and the [gcloud suite of tools](https://cloud.google.com/sdk/docs/install). While this guide demonstrates how to achieve the necessary setup in GCP using the command line, the same result can be achieved using the GCP web interface.

#### Configure project in `gcloud` CLI

Ensure that the Google Cloud CLI is configured to the correct project for where you want to store your request logs.

```bash
# Replace [PROJECT_ID] with your actual Google Cloud project ID

gcloud config set project [PROJECT_ID]
```

#### Create the bucket

Create a new GCS bucket where your files will be uploaded to.

```bash
# Replace [BUCKET_NAME] with your actual bucket name

gcloud storage buckets create gs://[BUCKET_NAME]
```

#### Give Sanity access to the bucket

For Sanity to deliver files to your GCS bucket, you must give our service account (`serviceAccount:delivery@sanity-log-delivery.iam.gserviceaccount.com`) the `storage.objectCreator` role:

```bash
# Replace [BUCKET_NAME] with your actual bucket name

gcloud storage buckets add-iam-policy-binding gs://[BUCKET_NAME] --member=serviceAccount:delivery@sanity-log-delivery.iam.gserviceaccount.com --role=roles/storage.objectCreator
```

#### Enable log delivery on your Sanity project

Log delivery is disabled by default. It must be enabled in the Sanity project settings by a project administrator.

1. Log in to Manage: [www.sanity.io/manage.](https://www.sanity.io/manage)
2. Navigate to your project settings. You should see an option to enable the log delivery feature by adding a GCS bucket.

![Shows the interface for enabling log delivery in the Sanity project management area](https://cdn.sanity.io/images/3do82whm/next/063ac57a3f3fc5390af1ef2840269c969b835bb1-767x177.png)

1. Click the button labeled **Add bucket** and you should be prompted to add the full URI of your GCS bucket.

![Shows the interface for entering your details for log delivery in the Sanity project management area](https://cdn.sanity.io/images/3do82whm/next/6d1b071c1494d3accf62977ad89c52d0f305bcf7-960x383.png)

Assuming everything went well, you should now be set to receive API request logs within a couple of minutes. You may read on to see an example implementation or roll your own with the tooling of your choice.

### Step 2: Load (optional)

Once the pipeline for log delivery has been configured, it’s time to hook up your preferred data analysis tool. The process will vary somewhat from tool to tool. The following section will show you how to accomplish this task using [BigQuery](https://cloud.google.com/bigquery) from Google.

You can set up a direct connection between BigQuery and GCS buckets using [External Tables](https://cloud.google.com/bigquery/docs/external-tables). Please read [the documentation to understand the costs and limitations](https://cloud.google.com/bigquery/docs/external-tables#pricing).

Sanity has structured the bucket key in a way that allows for partitioning per project, event type, and date.

We key the object using [Hive partitioning](https://cloud.google.com/bigquery/docs/hive-partitioned-queries#supported_data_layouts) with the following format:

```plaintext
gs://[BUCKET_NAME]/[PREFIX]/event-logs/project_id=[string]/kind=request-log/dt=[date:DATE]/[file-name:string].ndjson.gz
```

This allows the log data to be loaded into various data platforms with the project ID, data type, and date used as partitioning properties.

#### Prerequisites

- You should have completed the setup process described in step 1, so you are starting with the log delivery service already enabled and connected to your GCS bucket
- You’ll need `node`, `npm`, and the `gcloud` command line tools installed

#### Define your table schema

Create a JSON file locally named `schema.json` with the nested schema definition for your log data.

> [!TIP]
> Pro tip
> If you’re working on a Sanity Studio project, we recommend placing this schema file in its folder (for example, `/log-delivery/schema.json`) to avoid confusion with the Studio schema for your content model.

```json
{
  "sourceFormat": "NEWLINE_DELIMITED_JSON",
  "schema": {
    "fields": [
      { "name": "timestamp", "type": "TIMESTAMP", "mode": "REQUIRED" },
      { "name": "traceId", "type": "STRING", "mode": "REQUIRED" },
      { "name": "spanId", "type": "STRING", "mode": "REQUIRED" },
      { "name": "severityText", "type": "STRING", "mode": "NULLABLE" },
      { "name": "severityNumber", "type": "INT64", "mode": "REQUIRED" },
      {
        "name": "body",
        "type": "RECORD",
        "mode": "REQUIRED",
        "fields": [
          { "name": "duration", "type": "FLOAT64", "mode": "NULLABLE" },
          { "name": "insertId", "type": "STRING", "mode": "NULLABLE" },
          { "name": "method", "type": "STRING", "mode": "NULLABLE" },
          { "name": "referer", "type": "STRING", "mode": "NULLABLE" },
          { "name": "remoteIp", "type": "STRING", "mode": "NULLABLE" },
          { "name": "requestSize", "type": "INT64", "mode": "NULLABLE" },
          { "name": "responseSize", "type": "INT64", "mode": "NULLABLE" },
          { "name": "status", "type": "INT64", "mode": "NULLABLE" },
          { "name": "url", "type": "STRING", "mode": "NULLABLE" },
          { "name": "userAgent", "type": "STRING", "mode": "NULLABLE" }
        ]
      },
      {
        "name": "attributes",
        "type": "RECORD",
        "mode": "NULLABLE",
        "fields": [
          {
            "name": "sanity",
            "type": "RECORD",
            "mode": "NULLABLE",
            "fields": [
              { "name": "projectId", "type": "STRING", "mode": "REQUIRED" },
              { "name": "dataset", "type": "STRING", "mode": "NULLABLE" },
              { "name": "domain", "type": "STRING", "mode": "NULLABLE" },
              {
                "name": "groqQueryIdentifier",
                "type": "STRING",
                "mode": "NULLABLE"
              },
              { "name": "apiVersion", "type": "STRING", "mode": "NULLABLE" },
              { "name": "endpoint", "type": "STRING", "mode": "NULLABLE" },
              { "name": "tags", "type": "STRING", "mode": "REPEATED" },
              { "name": "studioRequest", "type": "BOOLEAN", "mode": "NULLABLE" }
            ]
          }
        ]
      },
      {
        "name": "resource",
        "type": "RECORD",
        "mode": "REQUIRED",
        "fields": [
          {
            "name": "service",
            "type": "RECORD",
            "mode": "NULLABLE",
            "fields": [{ "name": "name", "type": "STRING", "mode": "NULLABLE" }]
          },
          {
            "name": "sanity",
            "type": "RECORD",
            "mode": "NULLABLE",
            "fields": [
              { "name": "type", "type": "STRING", "mode": "NULLABLE" },
              { "name": "version", "type": "STRING", "mode": "NULLABLE" }
            ]
          }
        ]
      }
    ]
  },
  "compression": "GZIP",
  "sourceUris": ["gs://[BUCKET_NAME]/[PREFIX]event-logs/*"],
  "hivePartitioningOptions": {
    "mode": "CUSTOM",
    "sourceUriPrefix": "gs://[BUCKET_NAME]/[PREFIX]event-logs/{project_id:STRING}/{kind:STRING}/{dt:DATE}/"
  }
}

```

> [!WARNING]
> Gotcha
> Make sure to replace `[BUCKET_NAME]` and `[PREFIX]` with the appropriate values for your setup.

#### Creating the external table in BigQuery

Run the following command using the `bq` (BigQuery) CLI tool bundled with the `gcloud` CLI:

```bash
# Replace [DATASET_NAME] and [TABLE_NAME] with your details

bq mk --external_table_definition=schema.json [DATASET_NAME].[TABLE_NAME]
```

#### Query your log data in BigQuery

Once the log data is loaded into the table, you can run queries against it to test if the implementation works as expected.

**Example: Get data from yesterday.**

```sql
/* Replace [GCP_PROJECT_NAME], [DATASET_NAME], and [TABLE_NAME] with your details */

SELECT
  *
FROM
  `[GCP_PROJECT_NAME].[DATASET_NAME].[TABLE_NAME]`
WHERE
  project_id = '[SANITY_PROJECT_ID]' AND
  kind = 'request-log' AND
  dt = DATE_ADD(CURRENT_DATE(), INTERVAL -1 DAY)
```

### Step 3: Transform (optional)

Your log data is now ready to provide answers and insights into API and CDN usage. The following section will show how to query your logs using BigQuery and SQL.

> [!TIP]
> Pro tip
> You can also use AI solutions like ChatGPT to figure out queries for specific questions by giving it the log table schema and specifying that you are working with BigQuery.

#### Prerequisites

At this point, you should have accomplished the following:

- Enabling log delivery in the Sanity project management console
- Connecting your Google Cloud Storage (GCS) bucket, and verifying that logs are being delivered as expected
- Loading your log data into GCS BigQuery, so it’s ready for querying

You will also need the appropriate user privileges to query BigQuery in the Google Cloud Platform.

> [!WARNING]
> Gotcha
> Caution: BigQuery can get expensive when querying large datasets as they have a pay-per-usage model by default. Before running queries on this platform, understand the BigQuery pricing model and how your query will impact cost.

#### Example 1: Which asset is downloaded the most?

Sanity projects are metered on bandwidth usage. A large part of bandwidth usage can come from image and video downloads. Use this BigQuery query to understand which asset is using the most bandwidth.

```sql
/* Replace [PROJECT], [DATASET], and [TABLE_NAME] with your details */

SELECT body.url, sum(body.responseSize) / 1000 / 1000 AS responseMBs
FROM `[PROJECT].[DATASET].[TABLE_NAME]`
WHERE attributes.sanity.domain = 'cdn'
  AND timestamp > TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL -1 DAY)
GROUP BY 1
ORDER BY 2 DESC
LIMIT 10;
```

You can use this information to search your Sanity dataset for the documents using this asset and then optimize to reduce bandwidth.

#### Example 2: What is the average response time for a GROQ query?

```sql
/* Replace [PROJECT], [DATASET], and [TABLE_NAME] with your details */

SELECT
  DATE(timestamp) AS date,
  body.method,
  attributes.sanity.groqQueryIdentifier AS groq_query_identifier,
  COUNT(*) as times_called,
  AVG(body.duration) / 1000 AS average_response_time_seconds
FROM
  `[PROJECT].[DATASET].[TABLE_NAME]`
WHERE
  body.duration IS NOT NULL
  AND attributes.sanity.groqQueryIdentifier IS NOT NULL
  AND attributes.sanity.groqQueryIdentifier != ""
  AND body.method = "GET"
  AND attributes.sanity.endpoint = "query"
GROUP BY
  1,2,3
ORDER BY
  1 DESC,5 DESC,4 DESC
```

Note we cannot create a GROQ query identifier if the query is in a POST body.

#### Example 3: How many requests return user or server errors?

```sql
/* Replace [PROJECT], [DATASET], and [TABLE_NAME] with your details */

WITH ErrorCount AS (
  SELECT
    DATE(timestamp) AS date,
    COUNTIF(body.status >= 500) AS server_error_count,
    COUNTIF(body.status >= 400 AND body.status < 500) AS user_error_count,
    COUNT(*) AS total_requests
  FROM
    `[PROJECT].[DATASET].[TABLE_NAME]`
  WHERE
    body.status IS NOT NULL
  GROUP BY
    date
)

SELECT
  date,
  server_error_count,
  user_error_count,
  total_requests,
  ROUND((server_error_count + user_error_count) / total_requests * 100, 2) AS error_percentage
FROM
  ErrorCount
ORDER BY
  date;
```

#### Example 4: Analyze dataset usage

```sql
/* Replace [PROJECT], [DATASET], and [TABLE_NAME] with your details */

SELECT
  attributes.sanity.dataset AS dataset_name,
  COUNT(DISTINCT attributes.sanity.groqQueryIdentifier) AS unique_get_queries,
  COUNT(*) AS total_requests,
  SUM(body.responseSize) AS total_response_size
FROM
  `[PROJECT].[DATASET].[TABLE_NAME]`
WHERE
  attributes.sanity.dataset IS NOT NULL
GROUP BY
  dataset_name
ORDER BY
  total_requests DESC;
```

### Technical details for log delivery

#### Delivery

- **Process**: Logs are delivered to the customer’s Google Cloud Storage bucket in batches contained in compressed NDJSON files.
- **Data window**: Each file will contain 10,000 lines of data or 5 minutes’ worth of data.

#### Guarantees

- **Delivery assurance**: Logs are guaranteed to be delivered **at least once.** This means that the customer must perform deduplication processes if exact data is required.
- **Consumer responsibility**: Customers are responsible for deduplication of logs if necessary.

#### Retries

- **Retry mechanism**: In case of inaccessible customer storage, Sanity will attempt multiple retries with exponential back-off.
- **Retry limit**: There’s a cut-off time after which retries stop. Currently, five attempts will be made with exponential back-off, starting at 10 seconds and growing to a maximum of 5 minutes between attempts, before the service will cease further attempts.
- **Consequences of failure**: Persistent failure in file transfers will lead to disabling of the integration, requiring customers to reconfigure it.

#### Security

Customers have full control of the data and the security of their systems. The solution has multiple levels of security:

- Customers must allow Sanity access to their GCP environment by giving write access to a Sanity-owned Google service account.
- Sanity will deliver files from a static IP listed in this [file](https://www.sanity.io/files/request-log-delivery-ips.txt). Customers with greater security needs, for example, buckets behind a VPN, should be given a link to this file.
- Customers can whitelist Sanity in their Google environment by adding our `DIRECTORY_CUSTOMER_ID` as an allowed `gcloud` organization. Sanity’s customer ID can be found in the project management area during the setup process.



# Request tags

Request tags are values assigned to API and CDN requests that can be used to filter and aggregate log data within [request logs from your Content Lake](https://www.sanity.io/docs/platform-management/request-logs). The tagging can be achieved by adding the `tag` query parameter to the request URL, typically in the format:

```text
GET /data/query/<dataset>?query=<GROQ-query>&tag=<custom-defined-tag>
```

## SDK support

[@sanity/client](https://github.com/sanity-io/client) has out-of-the-box support for tagging every API and CDN request on two levels:

1. **Globally**: Using the `requestTagPrefix` client configuration parameter.
2. **Per request**: Pass the `tag` option to the SDK’s request method.

This provides a flexible method for tagging requests:

| requestTagPrefix | tag | result |
| - | - | - |
| - | landing-page | tag=landing-page |
| website | - | tag=website |
| website | landing-page | tag=website.landing-page |

## Code example

The following example will result in a query with `tag=website.landing-page`.

```javascript
const client = createClient({
  projectId: "<project>",
  dataset: "<dataset>",
  token: "",
  useCdn: false,
  apiVersion: "2024-01-24",
  requestTagPrefix: "website" // Added to every request
});

const posts = await client.fetch(
  '*[_type == "post"]',
  {}, // Query parameters
  {tag: "landing-page"} // Appended to requestTagPrefix for this individual request
);
```



## Sanity's own request tags

Sanity's own clients tag their requests too, so your logs contain tags you never set. 

Sanity Studio sets `sanity.studio` as its `requestTagPrefix`, and each subsystem adds its own tag on top. The two are joined with a dot, following the same rules as your own prefixes and tags above, so the Tasks store's `tasks-store` tag reaches your logs as `sanity.studio.tasks-store`.

Studio tags you're most likely to see:

- `sanity.studio.preview.observe-document-set.listen`: the preview system keeping document lists in sync. This is usually the highest-volume Studio tag.
- `sanity.studio.tasks-store`: the Tasks feature.
- `sanity.studio.documents.history` and `sanity.studio.transactions-log`: document history and the transaction log.
- `sanity.studio` on its own: requests that carry only the prefix, such as the real-time collaboration socket.

The list isn't fixed: Studio subsystems add and rename their tags between releases, so treat the whole `sanity.studio` namespace as first-party rather than matching on exact tags.

### A tag is not proof of authentication

A tag is a query parameter the client adds to the URL before it sends the request. Nothing ties it to the caller's identity, and any client can send any value. `@sanity/client` checks only that the value is 75 characters or fewer of letters, digits, dots, dashes, and underscores.

So a request tagged `sanity.studio.preview.observe-document-set.listen` that returned 401 was rejected as unauthenticated. The tag tells you what kind of client claims to have sent the request, not whether it signed in successfully. When you audit logs, read the tag and the status code together.

For what those failed requests mean for your bill, see [plans and payments](https://www.sanity.io/docs/platform-management/plans-and-payments).



# Request logs data reference

This article describes the file format and data structure delivered by the [API request log feature](https://www.sanity.io/docs/platform-management/request-logs).

## File format

Files are gzipped in newline-delimited JSON (NDJSON) format.

## File content

The logs will contain detailed HTTP event information, with each line representing an individual event.

> [!TIP]
> Pro tip
> Filter out `studioRequest` from your cost analysis.
> Requests from Sanity Studio are not counted toward API or bandwidth usage and do not incur any cost, so it might be useful to remove them from any workflow that involves monitoring or prediction of your billable data consumption. Studio is not the only exempt caller — requests from other Sanity applications and tooling, including the Dashboard, the App SDK, Canvas, Create, Manage, Media Library, and the CLI, are also exempt, and are identifiable by their sanity.* request tag prefixes. Responses with a 4xx or 5xx status code are not billed either.

### Available data

##### Available data

| Field ID | Field name | Type | Description | Mapping |
| --- | --- | --- | --- | --- |
| insertId | Insert ID | String | Unique identifier for log entry. Used in deduplication processes. | body.insertId |
| traceId | Trace ID | String | Can appear for multiple log entries. Useful for Sanity Support. | traceId |
| spanId | Span ID | String | Can appear for multiple log entries. Useful for Sanity Support. | spanId |
| timestamp | Timestamp | String | Time of request in RFC3339 UTC format. | timestamp |
| projectId | Project ID | String | Project ID associated with the request. | attributes.sanity.projectId |
| datasetName | Dataset Name | String | Dataset associated with the request. Not all APIs require a dataset name. | attributes.sanity.dataset |
| domain | Request Type | String | Type of request (api, apicdn, cdn, studio). | attributes.sanity.domain |
| requestMethod | Request Method | String | HTTP verb used (e.g., GET, POST). Useful for differentiating request types. | body.method |
| requestUrl | Full URL | String | Unaltered URL received by Sanity including query parameters. | body.url |
| groqQueryIdentifier | GROQ Query Identifier | String | Hashed version of the GROQ query string without parameters. Useful for grouping similar queries. Only available for GET requests. | attributes.sanity.groqQueryIdentifier |
| apiVersion | API Version | String | API version used, with the leading "v" stripped from the URL — for example 1, X, or YYYY-MM-DD. Not applicable to asset requests. | attributes.sanity.apiVersion |
| tags | Tags | String[] | Array of tags supplied by the caller. Useful for grouping requests by business needs. | attributes.sanity.tags |
| referer | Referrer | String | Referrer URL of the request, as defined in HTTP/1.1 Header Field Definitions. | body.referer |
| userAgent | User Agent | String | User agent sent by the client. Optional. Example: python-requests/2.21.0. | body.userAgent |
| remoteIp | Remote IP | String | IP address (IPv4 or IPv6) of the client that issued the HTTP request. Includes port information if available. | body.remoteIp |
| studioRequest | Is Studio Request | Boolean | Indicates if the request was sent from Sanity Studio. | attributes.sanity.studioRequest |
| returnStatus | Return Status | Integer | Response code indicating the status of the response (e.g., 200, 404). | body.status |
| requestSize | Request Size | Number | Size of the HTTP request message in bytes. | body.requestSize |
| responseSize | Response Size | Number | Size of the HTTP response message sent back to the client in bytes. Used for metering bandwidth. | body.responseSize |
| duration | Response Time | Decimal | Number of milliseconds between request and response within the service. Useful for performance analysis. Does not account for network latency. | body.duration |
| endpoint | Endpoint | String | The endpoint used in the request, e.g., graphql is used for GraphQL calls while query or mutate are GROQ calls. | attributes.sanity.endpoint |

> [!NOTE]
> The `groqQueryIdentifier` value will return empty for POST requests. To group or identify POST queries, use [request tags](https://www.sanity.io/docs/platform-management/reference-api-request-tags).

### Example output

```jsonc
{
	"timestamp": "2024-01-03T13:36:56.87202961Z",
	"traceId": "b48b918db42f0f0786702fa3ef7f6451",
	"spanId": "be245ae33db3cdaf",
	"severityText": "INFO", // INFO = <400, WARN = 400-499, ERROR = >=500
	"severityNumber": 9, // info = 9, warn = 13, error = 17
	"body": {
    "duration": 32,
		"insertId": "asdf93n03nasdf",
		"method": "GET",
		"referer": "",
    "remoteIp": "34.79.228.45",
    "requestSize": 421,
    "responseSize": 936,
    "status": 200,
    "url": "https://0ekpuoxg.apicdn.sanity.io/v2022-09-01/data/query/cache-delay?query=%0A%2A%5B_id+%3D%3D+%22cache-delay%22%5D%5B0%5D%7B%0A++++%22timestampUnixMs%22%3A+dateTime%28_updatedAt%29+-+dateTime%28%221970-01-01T00%3A00%3A00Z%22%29%2C%0A++++%22counter%22%3A+counter%0A%7D%0A",
    "userAgent": "python-requests/2.21.0"
	},
	"resource": {
		"service": {
			"name": "Sanity.io"
		},
		"sanity": {
			"type": "http_request",
			"version": "0.0.1"
		}
	},
	"attributes": { // information extracted/parsed from the glb log
		"sanity": {
			"projectId": "exx11uqh",
		  "dataset": "webhook-test",
		  "domain": "api",
			"endpoint": "query",
			"groqQueryIdentifier": "somehash",
			"apiVersion": "2022-09-01",
			"tags": [],
			"studioRequest": false
		}
	}
}

```

## File delivery for projects on enterprise plans

We key the object using Hive partitioning with the following format:

```text
gs://[BUCKET_NAME]/[PREFIX]/event-logs/project_id=[string]/kind=request-log/dt=[date:DATE]/[file-name:string].ndjson.gz
```

This allows data to be loaded into various platforms with the project ID, data type, and date used as partitioning properties.



# Manage your account

Your Sanity account holds your profile and your membership in projects and organizations. This article covers what you can change in your account settings, and the two things you can't: the sign-in method and the email address.

## What you can change

Account settings are personal and separate from any project or organization. In Manage, go to **Account settings**. From there you can:

- Edit your first name and last name.
- Reset your password, if you signed up with an email address and password.
- Set your telemetry preference.
- Choose which notifications you receive.
- Remove applications that have access to your account.
- Delete your account.

## You can't change your sign-in method

Your account is tied to the sign-in method you created it with, not to your email address. Signing up with Google, with GitHub, or with an email address and password creates a separate account, even when all of them use the same email address.

Sanity can't connect a different sign-in method to an existing account, and can't merge two accounts. A sign-in method that already belongs to one account can't be moved to another.

To use a different sign-in method for a project or organization you already belong to:

1. Create the account you want to use by signing up with the new sign-in method.
2. Ask a project or organization administrator to remove the old account from the project or organization.
3. Ask them to send a new invitation, and accept it while signed in with the new account.

Membership, roles, and accepted invitations belong to one account, so they don't carry over. The administrator grants them again on the new account.

## You can't change your email address

The email address on a Sanity account is fixed. Account settings has no field for editing it, and Account Support can't change it for you either.

For accounts created with Google or GitHub, the address comes from that provider and Sanity reads it at each sign-in. Changing the address on the provider's side doesn't move your Sanity membership, because membership belongs to the account, not to the address.

To move to a different email address, follow the steps in [You can't change your sign-in method](https://www.sanity.io/docs/platform-management/manage-your-account): create an account with the address you want, then have an administrator reinvite you.

## Next steps

- [Account recovery](https://www.sanity.io/docs/help/account-recovery): regain access when you can't sign in to your account.
- [Delete your account](https://www.sanity.io/docs/platform-management/deleting-your-account): remove your account and the personal data attached to it.



# Delete your account

Deleting your Sanity account permanently removes your user profile and the personal data attached to it. This guide covers how to delete your account, and how to clear the checks that block deletion while you're the only administrator of a project or an organization.

> [!WARNING]
> Deleting your account can't be undone
> Your account and your access to every project and organization you belong to are removed permanently. Treat deletion as irreversible: you can't restore the account yourself, and the account data is anonymized after deletion. Projects and organizations that other people still administer are not deleted along with it.

## Before you start

Have the following ready:

- The project ID and organization ID of every project you belong to. Sanity asks for these if you follow up with a request to erase your personal data, and you can't look them up once the account is gone.
- A decision about any project or organization where you're the only administrator. Deletion is blocked until someone else holds that role, or the project or organization is gone.

## Delete your account

Account deletion lives in your personal account settings in Manage.

1. Go to [Account settings](https://www.sanity.io/manage/personal/account-settings#delete-account) in Manage.
2. Under **Danger zone**, click **Delete account**.
3. In the confirmation dialog, in **Type in the name of your user to confirm**, enter your name exactly as the field description shows it.
4. Click **Delete**.

If no check blocks the deletion, your account is removed, your session ends, and you're signed out.

## Clear the last-administrator checks

Sanity blocks account deletion while you're the only administrator of a project or an organization. These are two separate checks, and the project check runs first, so resolve your projects before the organization message appears.

> [!CAUTION]
> Messages that mean a check failed
> - `Cannot delete user while being the last administrator of a project`
> - `Cannot delete user while being the last administrator of an organization`

![The account settings page in Manage showing the error "Cannot delete user while being the last administrator of a project".](https://cdn.sanity.io/images/3do82whm/next/1f5ec6f146165e18764c41edea64f576525b0d41-928x606.png)

You have two ways to clear each check.

### Add another administrator

Choose this when the project or organization should keep running after you leave. In every project and organization where you're the only administrator, invite or promote another member to the administrator role, then delete your account again.

### Delete the projects and organizations

Choose this when nothing should outlive your account. Work through the projects first, because the project check runs before the organization check.

1. Delete each project from its settings page, under **Danger zone**. See [Projects, organizations, and billing](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing).
2. Open each organization's settings, and under **Danger zone**, click **Delete organization**. An organization deletes only once every project in it has been deleted or moved to another organization.
3. Return to your account settings and delete your account.

> [!WARNING]
> Projects billed through Vercel Marketplace
> Projects billed through Vercel Marketplace can't be deleted in Manage. Uninstall the resource from your Vercel dashboard to delete the project.

## Request erasure of your personal data

Deleting your account is a prerequisite for a personal data erasure request under GDPR and comparable laws. Once the account is gone, contact Sanity support with the project IDs and organization IDs you recorded, and Sanity confirms in writing that your personal data has been erased.

## Next steps

- [Projects, organizations, and billing](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing): move projects between organizations, delete an organization, or archive a project.
- [Account recovery](https://www.sanity.io/docs/help/account-recovery): regain access to an account instead of deleting it.



# User Guides

#### The Sanity Applications

[Content operators quick start guide](https://www.sanity.io/docs/user-guides/content-operations-cheatsheet)
Practical tips and instructions for managing your content within the Sanity ecosystem.

[Media Library quick start guide](https://www.sanity.io/docs/user-guides/media-library-user-cheatsheet)
Practical tips and instructions for managing your media assets within the Sanity ecosystem.

[Content Agent quick start guide](https://www.sanity.io/docs/user-guides/content-agent-user-guide)
Practical tips and instructions for managing content with the Content Agent

[Dashboard](https://www.sanity.io/docs/dashboard/dashboard-introduction)
Get to know Sanity's home for all of your apps and studios.

[Canvas](https://www.sanity.io/docs/canvas/writing)
The AI-powered, free-form writing experience that understands your content.

#### Studio Fundamentals

[Tasks for Sanity Studio](https://www.sanity.io/docs/studio/tasks)
Learn to use Sanity Studio's tasks for collaboration, assign tasks, comment on tasks, and resolve tasks for efficient content creation.

[Comments for Sanity Studio](https://www.sanity.io/docs/studio/comments)
Learn to use Comments in Sanity Studio for effective collaboration, including leaving comments, @mentions, and resolving comments.

[Content releases](https://www.sanity.io/docs/user-guides/content-releases)
Learn to use Content Releases to organize and schedule updates across multiple documents.

[Compare document versions](https://www.sanity.io/docs/studio/compare-document-versions)
The document comparison view in Sanity Studio provides a side-by-side view of different document versions.

[Copy and paste fields](https://www.sanity.io/docs/user-guides/field-copy-and-paste)
How to copy and paste fields and documents within your Sanity Studio.



# Meet the Dashboard

> [!TIP]
> Find your dashboard
> To find your organization’s dashboard, visit [the Sanity welcome page](https://www.sanity.io/welcome).

## Dashboard at a glance

The Sanity Dashboard is the central hub for your organization's content operations. Here you'll find your deployed [studios](https://www.sanity.io/docs/studio), [custom apps](https://www.sanity.io/docs/app-sdk), and official Sanity apps like [Canvas](https://www.sanity.io/docs/canvas) and [Media Library](https://www.sanity.io/docs/media-library).

![A Sanity.io dashboard displaying an AI content agent prompt for creating an FAQ page, recent studios, and an activity log.](https://cdn.sanity.io/images/3do82whm/next/adc7e803466a5632c74be52f81268b55599cb802-2524x1790.png)

Your dashboard is centered around your organization, and gives access to deployed studios and apps within the organization, across projects and datasets.

#### Set up Dashboard

[Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure)

[Hosting and deployment](https://www.sanity.io/docs/studio/deployment)

> [!NOTE]
> What about the dashboard plugin?
> If the name sounds familiar, that’s because the Sanity ecosystem already has a dashboard: the official [dashboard plugin](https://www.sanity.io/docs/studio/dashboard) for Sanity Studio. That plugin remains available for in-studio dashboards.

## Tour the dashboard interface

Your dashboard has three parts: a main area that adapts to the app you're working in, the side navigation, and expanding panels.

![The dashboard home screen with four numbered regions: 1, the main content area; 2, the dashboard navigation and expanding panels; 3, the application navigation; 4, account settings and help.](https://cdn.sanity.io/images/3do82whm/next/f1dc7a6458982b0c80cf2f75b466d402091e24a7-2524x1790.png)

### 1. Main content area

The main content area adapts to whatever app you're working in, such as a studio.

![Sanity Studio interface displaying a list of articles, with "Query Cheat Sheet - GROQ" selected for editing.](https://cdn.sanity.io/images/3do82whm/next/e2c9baef842b76c4a9b06402d2aa6dcb8cb79efc-2480x1746.png)

If there is no active app, this area defaults to show you links to your most likely destinations, favorites, and insights about your content.

### 2. Dashboard navigation and expanding panels

The top section in the left sidebar is where you'll switch between your organizations and toggle panels like notifications, favorites, or the Content Agent.

![The dashboard with the top of the left sidebar highlighted, showing the organization switcher, home, notifications, favorites, and Content Agent.](https://cdn.sanity.io/images/3do82whm/next/e14f6d724711f3140f3eb1fc22396435c61b5d1d-2384x1650.png)

#### Organization switcher

Click your organization name to bring up a list of the organizations available to you. You can also access your organization’s *Manage* page from here.

#### Home

Returns the dashboard to the Home screen.

#### Notifications

Opens the notifications panel to display notifications, like comments or tasks, across your studios and Sanity apps.

#### Favorites

Opens the favorites panel to display any documents you’ve favorited across studios and Sanity apps. You can add a document to your favorites any time you see a star icon.

![A document header with the star icon selected to add the document to favorites.](https://cdn.sanity.io/images/3do82whm/next/c6781efc92c0679866583a145c932fe31daace98-1096x812.png)

#### Content Agent

If your organization has [Content Agent enabled](https://www.sanity.io/docs/content-agent/introduction), you can open it from here at any time. Content Agent lets you ask questions about your data, make changes, and more.

You can also use Content Agent from Slack. When you share a Dashboard URL in a Slack workspace with the Sanity app installed, Slack displays a rich preview card showing the document title, type, and status. See [Content Agent for Slack](https://www.sanity.io/docs/content-agent/content-agent-for-slack) for details.

### 3. Application navigation

![The dashboard with the application navigation highlighted in the left sidebar, listing Sanity apps, studios, and custom apps.](https://cdn.sanity.io/images/3do82whm/next/f01febd08277b4c9417ce2e987c64ac8e941d9af-2384x1650.png)

The application navigation section lets you navigate between Sanity applications, your studios, and your custom apps.

#### Canvas and Media Library

The official Sanity apps available to your organization, such as [Canvas](https://www.sanity.io/docs/canvas) and [Media Library](https://www.sanity.io/docs/media-library), appear here. Selecting them will open them in the main section of the dashboard.

#### Studios and custom apps

Below the official Sanity apps, you'll find any [custom apps](https://www.sanity.io/docs/app-sdk) and [studios](https://www.sanity.io/docs/studio) deployed by your organization. If you can't find an app or studio you expected to see here, it may not [be registered](https://www.sanity.io/docs/dashboard/dashboard-configure) yet. Studios deployed to Sanity hosting appear automatically, but a self-hosted studio has to be registered before it shows up.

> [!NOTE]
> Self-hosted studios need to be registered
> A studio you host yourself does not appear in Dashboard until you register it. Deploying the schema and serving the manifest from your own domain are not enough on their own. Run `npx sanity@latest deploy --external --url https://example.com/studio` from the studio folder. Registration itself persists, but repeat the command on every deployment to keep the schema and manifest current.
> The same gap affects other surfaces. An unregistered studio cannot be resolved from the Media Library "in use" dialog either, so reference rows there will not open. See [Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure) for the full setup.

You can pin any studio or app from the **Studios & Apps** page by selecting the pin icon. Pinning is per person: it does not pin the studio or app for everyone in your organization.

### 4. Account settings and help

![The dashboard with the bottom of the left sidebar highlighted, showing account settings and the help and feedback link.](https://cdn.sanity.io/images/3do82whm/next/6d7f0cd375299903b4e6e8db43a7af554fe3ff8a-2384x1650.png)

In the bottom left sidebar section you’ll find your user account settings and useful links for getting help and leaving feedback.

#### Help and feedback

Select the question mark icon to open the help and feedback menu. It links to support, the documentation, and the Sanity community.

![The help and feedback menu, including a link to join the Sanity community.](https://cdn.sanity.io/images/3do82whm/next/47fbef225e231fc20cde3af0780b7121559a427e-216x264.png)

#### Account settings

Select your profile image to open a menu with dashboard-wide theme options, a link to your account settings, and the option to sign out.

> [!NOTE]
> Light, dark, and system theme
> If you set dark or light mode in Studio before using Dashboard, your studio can stay locked to that theme. Clear the studio’s local storage for its origin in your browser to reset it.



# Quick start

## Getting started with Sanity Studio

Whether you're a content editor, marketer, or business stakeholder, this guide will provide you with practical tips and instructions for managing your content within the Sanity ecosystem.

Sanity Studio is a customizable content management interface where you'll create, edit, and organize your content. Here's how to get started:

1. **Logging in**: Access Sanity Studio through the URL provided by your development team which is typically [sanity.io/welcome](https://www.sanity.io/welcome) and will redirect you to your organization’s Sanity domain.
2. **Sanity Dashboard**: When you first log in, you’ll either see your organization’s dashboard from which you can quickly access studios you are assigned to and view recently edited documents with related insights. Navigate to the Studio you want to edit your respective content from.
3. **Studio navigation**: Your Studio’s navigation is configured by your development team using the Structure tool. Typically, the left sidebar groups content by type. Select a content type to see its documents, and use the top navigation to access the tools enabled in your workspace.

## Why Sanity works this way

Understanding structured content will transform how you think about creating and managing digital content. When you create content in Sanity, it’s best to think in concepts (events, products, articles) rather than pages (homepage, event page, email template).

Step back from "What will this page look like?" and ask "What does our business offer and how do people think about it?"

This shift in thinking is powerful, because it lets your work create more impact, scale better, and stay consistent across every touchpoint as your focus shifts to creating great content that works everywhere automatically.

## Creating and editing content

### Adding new content

![A dropdown menu from the "new document" button displays a list of document types](https://cdn.sanity.io/images/3do82whm/next/61dd1e8fd5358385e3ee7121d9f5c0131bf18660-1530x1049.png)
*The "+" button reveals a searchable list of content types.*

Select the “**+**” icon in the top navigation and select a content type, or navigate to the content type via the sidebar and click “**+**” in the document list.

Complete all required fields (marked with asterisks). When you’re ready, select **Publish** in the lower right. Your project may also include custom actions here (for example, approval or translation workflows) added by your development team. Ask them which actions are available in your Studio.

### Editing existing content

Use the **search bar** or browse** content lists** to find what you need. Click any item to open the editor.

Make your edits, then click **Publish** to update live content immediately.

### Removing existing content

![A UI menu showing options: Schedule publish, Duplicate, Discard changes, Link to Canvas, and Delete, next to a Publish button.](https://cdn.sanity.io/images/3do82whm/next/94014db2a70afbb2077cbfaad82f8e5ef3b6440b-1186x496.png)

If you have permissions, open the content item, open the **Document actions menu (⋮)** next to Publish and select **Delete**. Confirm the deletion when prompted. If other documents reference the one you’re deleting, Sanity warns you first so you don’t create broken links.

Deleting is permanent! Consider unpublishing or archiving content instead to preserve history.

If you’re working with a draft document, the **Discard changes **option removes the current draft content.

### Other useful document actions

The Document actions menu (⋮) and the pane header also include:

- **Duplicate:** create a copy of the current document to use as a starting point.
- **Copy document URL** and **Copy document ID**: share a direct link with a teammate, or give the ID to your developers.
- **Compare versions:** view two revisions side by side.
- **Inspect:** view the document’s underlying data. Useful when troubleshooting with developers.

## Finding and searching for content

The **global search bar** searches across all content types. Type keywords, document titles, or even content snippets to find what you need instantly.

![Search results with an open filter menu](https://cdn.sanity.io/images/3do82whm/next/5b2a38c9ad2abbc2e56f7258975315239421cbe0-1644x730.png)

Apply **filters** to narrow by document type, creation or edit date, or any field in your content model. The pinned **Contains document, image or file** filter finds every document that references a given document, image, or file. It’s handy before you delete or replace something. Your **recent edits** appear in the Dashboard for quick access.

Combine search with filters for precise results. Sort by date, title, or custom fields to find what you need. 

In any document list, open the pane menu **(⋮)** to sort by title, created date, or last edited, and to switch between **Compact** and **Detailed** views.

## Working with drafts and publishing

### Drafts vs. published documents

All new content starts as a **draft **and is** **visible only to your team in the Studio, not to end-users. This gives you space to perfect content before going live.

In content lists, an outlined ring means a document has unpublished edits and a filled circle means it’s published. A document that has never been published shows no icon.

These icons describe a document relative to the perspective selected in the toolbar. Select a release and you’ll see that release’s icon on the documents it contains, and nothing on the documents it doesn’t. Hover any icon to see a list of every version of that document.

### Path to publish

Use **preview mode** to see how content will appear before publishing. Follow your team's internal **approval workflow.**

Need to update published content? Edit directly and it will create a draft copy. When ready, select **Publish** to replace the live version.

Use scheduled publishing to plan content releases for specific dates and times (if configured).

### Reverting to previous versions

Select the document status text at the bottom of the editor (for example, “Published 2 days ago”) to open the **History panel**. The **Review changes** tab shows what changed since the last publish, and the **History** tab lists earlier revisions. Select a revision and choose **Revert to revision** to restore it.

![Studio document history panel](https://cdn.sanity.io/images/3do82whm/next/4ddae13aaf96112b8d68473f4ca2b75344359d00-2048x992.png)

### Unpublish content

You can also unpublish content. With the green “Published” view selected, select the **Document actions menu (⋮)** next to the Publish button and select **Unpublish**. This removes content from public view while preserving it as a draft in the Studio for future use or updates.

## Working with references and relationships

Update once, reflect everywhere. That's the power of references.

References connect content items, creating live relationships that update automatically. 

Think of references as smart links between content. When you reference an author on a blog post, you're connecting to the actual author document, not copying text.

Common reference use cases include author profiles, product specs, legal disclaimers, location data, and category tags. These are typically the "nouns" related to your business for which you want to maintain consistent information across all your digital channels.

To create a reference, select a document from the dropdown menus in reference fields. The system shows you where content is referenced, helping you understand content relationships. Your developer team will ensure your references are strong, so you can confidently use them across your content landscape.

Sanity warns you before deleting referenced content to prevent broken links. Always check usage before deleting.

## Working with rich text

The Portable Text Editor gives you formatting superpowers: **bold**, *italic*, headings, links, embedded media, lists, and tables, or any custom component relevant for your business (promotions, related content, etc.).

## Managing media and assets

### Media Library

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

If enabled, upload images, videos, and documents through the Media Library. Add descriptive **alt text** and **captions** for accessibility and search-ability.

Tag media assets with keywords to find and reuse them easily across your content.

The Media Library shows where each asset is used, helping you avoid accidentally deleting in-use images.

### Media plugin

If enabled, upload images, video and documents through the Media plugin. You can manage these assets later by selecting "Media" in your Studio's top navigation bar.

### Media best practices

![The upload image interface](https://cdn.sanity.io/images/3do82whm/next/0c2f865bb2363a50c2e3b875484041441456c29b-1358x560.png)

To upload: select image fields → **Upload** → select files.

Need to replace an image? Select the asset and select **Replace **for** **references to update automatically everywhere.

Create collections or folders (if enabled) to group related assets.

## Advanced content operations

### Content planning and scheduling

Sanity provides powerful tools to prepare, schedule, and publish content with confidence:

**Drafts** are work-in-progress (team only). Published content is live (public).

**Scheduling**: Use scheduling features to plan *individual* content publishing.

**Workflow management**: Follow established approval processes for content review and publication all from within Sanity, ensuring quality control across the team.

#### Planning content with Content Releases

Planning a campaign? Use [Content Releases](https://www.sanity.io/docs/user-guides/content-releases) to bundle multiple documents and publish them together at a scheduled time.

In your Studio, you can see both currently published content and drafts in progress. For strategic initiatives like holidays, promotions, or events, start by consulting your content calendar to identify upcoming needs.

Create or update the necessary content pieces and group them into a release, such as “winter campaign,” that you can publish simultaneously. Content Releases keep your seasonal and promotional content organized and ready to launch at the right moment. This ensures coordinated messaging across your digital presence. You can organize recurring campaigns using the same release planning pattern.

### Content as data

#### Why Content as data matters

Content as data means your content is platform-agnostic, future-proof, and infinitely reusable.

Create content once, use it everywhere. Your content can power websites, apps, digital signage, or future platforms you haven't even imagined yet. 

#### Real-world examples

- Author profiles: Write once, display on blog posts, author pages, and search results automatically.
- Product information: Core specs live in one document, used across product pages, category listings, and promotional banners.
- Legal disclaimers: Update your terms once, they update everywhere instantly. No hunting for duplicates.

This approach eliminates copy-paste errors, ensures consistency, and makes updates lightning-fast.

### Content governance and localization

Your **role** determines what you can view and edit. Use [comments](https://www.sanity.io/docs/studio/comments) to collaborate with team members directly in documents. Press @ to mention teammates in comments and they’ll receive an email notification.

Every change is tracked in the **audit trail**, showing who edited what and when. Perfect for accountability and compliance.

### Multi-language content management

#### Working with translations

Sanity provides two primary methods for managing translated content, each with specific advantages depending on your content strategy.

With **field-level translations**, you update all language versions simultaneously and publish them together. This is ideal for content where names, images, or product specifications stay the same across regions.

**Document-level translations** create separate documents for each language version. This gives you the freedom to manage and publish each language independently.

## Troubleshooting and getting help

### Common issues and solutions

#### Content won't publish

Check for **validation errors** (red indicators) and ensure all **required fields** are completed.

#### Media upload failing

Verify **file size** and **format** are supported. Try compressing large images.

#### Studio running slow

Refresh your browser or clear cache. Close unused browser tabs to free up memory.

Join your organization's Sanity SLA channel in slack.sanity.io for real-time help.

Close collaboration with your developer team who understand your custom setup will help you resolve most issues quickly. 

### Continue learning

#### Official user guides

Ready to dive deeper? Explore official Sanity documentation for advanced features, best practices, and pro tips.

Master advanced workflows with comprehensive guides on **Content Releases**, **Scheduled Publishing**, and **Localization**.

We recommend you start with our [Intro to Structured Content.](https://www.sanity.io/learn/course/hello-structured-content)

#### Studio customization

Work with your developers to customize your studio with **custom document actions**, **validation rules**, and **field-level permissions** tailored to your workflow.

Explore the [Sanity Studio technical documentation](https://www.sanity.io/docs/studio) on extending and customizing your studio environment.

#### Join the community

Connect with **thousands of Sanity users** worldwide. Share tips, get help, discover creative solutions, and stay updated on new features.

Join our [Discord community](https://www.sanity.io/community/join) to get support from both Sanity employees and experienced community members. 

## Glossary of common terms

Understanding the terminology used in Sanity will help you communicate effectively with your developer team:

- **Content model: **The underlying types of content and their relationships to one another for a given organization or project.
- **Content Lake:** The Database where all your content lives in a structured format, making it accessible via APIs. What you see in the Studio is not the entire Content Lake because the Studio provides the user interface to interact with *specific* content types based on your permissions. The Content Lake may contain additional content, historical versions, and data that is managed by other teams or used by different applications within your organization's digital ecosystem. 
- **Document**: The fundamental unit of content in Sanity that represents a single, complete piece of information. Think of a document as a digital container that holds all the details about one specific item—whether it's a webpage, blog post, product description, or promotional message. Documents can be referenced and reused across your digital properties, ensuring consistency while eliminating the need to duplicate information.
- **GROQ (Graph-Relational Object Queries)**: The query language used to retrieve content from Sanity. It is used by developers to fetch and filter content from your dataset. While you won't need to write GROQ queries yourself, understanding that it powers the content delivery can help you communicate with your technical team about content needs.
- **Media Library:** The centralized repository where all your digital assets (images, videos, documents) are stored and managed. When you insert media into your content, you're creating a reference to the asset in the Media Library rather than duplicating the file itself.
- **Mutations**: Changes made to your content. Developers use this term to describe operations that add, update, or delete content in your Sanity dataset. When you publish, edit, or delete content, you're creating "mutations" in the system. The system tracks these changes, and you can revert them if needed. This gives you, as an editor, confidence in the work you are doing.
- **Portable Text**: Sanity's rich text format that stores content as structured data. You may be familiar with traditional rich text editors that format content visually. Portable Text goes beyond that because it can include references to other content, custom components, and can be rendered across different platforms consistently. 
- **Reference**: A connection between different content items. Unlike traditional copy-paste approaches, references maintain a live connection between content pieces. When you update a referenced item, those changes automatically reflect everywhere the item is referenced. This creates a single source of truth for your content and helps maintain consistency across your digital experiences. References can be created in the workspace you are assigned to or in a separate workspace where all the information is stored that needs to remain consistent across your digital properties (e.g. product names, legal disclaimers, people). This approach ensures that when core information changes, it's automatically updated across all connected content without manual updates.
- **Schema**: The blueprint defining your content structure. What fields exist, what types they are, and what rules they follow.
- **Structure**: The customized navigation and organization of your studio. Visually it’s represented at the top of your studio under “Structure”. This is where you'll find different content types organized into categories. The customized structure should match your specific workflow and content organization needs, so you can find what you need quickly.
- **Structured content:** Information that is broken into its smallest reasonable pieces, which are explicitly organized and classified to be understandable by computers and humans.
- **Studio**: The customizable content editing interface where you create, edit, and manage your content. It's a web-based application that provides a user-friendly way to interact with your content stored in the Content Lake. The studio can be tailored to your specific needs, with custom document types, fields, and workflows designed for your organization. 
- **Workspaces**: Separate environments within Sanity that allow teams to organize content by project, department, or function. Workspaces help maintain clear boundaries between different content areas and can have their own permissions, schemas, and configurations. This separation enables specialized teams to focus on their specific content responsibilities without interfering with other areas.
- **Webhook**: Automated notifications sent to your websites or applications when content changes in your Sanity dataset. 



# Comments

![Shows a comment about to be posted](https://cdn.sanity.io/images/3do82whm/next/83eeef7375c7da21cd2c3a162ed97e5de6d3c502-352x139.png)

Comments for Sanity Studio enables effective collaboration workflows right where the work is done. Leave comments on specific document fields or even single words in Portable Text, *@mention* your colleagues, and streamline your content workflow without ever leaving the Studio.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

[Enable Comments for Sanity Studio](https://www.sanity.io/docs/studio/configuring-comments)
Learn how to enable and configure Comments for Sanity Studio

[Enabling Tasks for Sanity Studio](https://www.sanity.io/docs/studio/configuring-tasks)
Enable and configure Tasks for Sanity Studio

## Comments workflow

Once Comments has been enabled for your project, open any document in your studio to start exploring how they work. If someone has already left comments on any field in the document, you will notice a small speech bubble icon 💬 adorning the input showing how many comments have been posted. If no comments have yet been posted, hover any field to bring up the speech bubble to leave the first!

### Leaving comments

Hover over any comment-enabled field and click on the comment icon 💬 to open a popover dialog, then type your comment in the input field and hit **Send** to post it.



To mention a colleague, type **@** followed by their name. A list of users with access to the document will appear. Click on the user you want to mention, and they will receive an email notification.

Your comment will now be visible to others with access to the document, and any mentioned users will receive a notification by email.

![Shows a string field with an icon indicating it has 1 comment attached](https://cdn.sanity.io/images/3do82whm/next/333aac237fe95c447fa1ea002f0ead1396c31096-501x158.png)

Unlike their closely related cousin [Tasks](https://www.sanity.io/docs/studio/tasks), comments are always directly coupled with a specific piece of content in your studio. Comments can be attached to any compatible field, or even to distinct sentences or words within Portable Text!



Clicking the 💬 comments icon on a field will open the comment inbox for the document so you can easily browse through existing comments. Comments are neatly grouped into the fields they correspond to.



### Resolving comments

When a comment has been addressed or is no longer relevant, you can mark it as resolved. To do this, click on the **Resolve** option in the popover menu that appears when hovering. Resolved comments will be hidden from the main view but can still be accessed in the **Resolved Comments** list.

![Comment being resolved](https://cdn.sanity.io/images/3do82whm/next/8acabde302218caf03c6d7f865c28bdfaf8e9bef-315x311.png)
*Shows a comment being resolved*

### Reactions, editing, and deleting comments

In addition to resolving comments, the popover menu includes a few more options. You can leave a reaction emoji for effective communication, copy a direct link to the comment, and you have options to edit or delete your comment. These options all work as you'd expect.

![Shows options for reacting to, editing, and deleting comments](https://cdn.sanity.io/images/3do82whm/next/d11b21191a257b198bf9616caf0e514dd109e9cb-649x175.png)

## Comment notifications

You'll receive notifications when tagged in a comment. You can adjust notifications in your user settings, as shown in the [Notifications](https://www.sanity.io/docs/studio/studio-notifications) documentation.

> [!NOTE]
> Incorrect links with external studios?
> If your studio is hosted externally, it must be added to the Studio's list for the project in [sanity.io/manage](https://www.sanity.io/manage) in order for notification links to point to the correct studio. 



# Tasks

Tasks for Sanity Studio are perfect for collaborating on content with your team, or even for solo content creators who need to keep track of their outstanding to-dos in the same environment where the work is to be done. Assign tasks to the appropriate team member, and they will get a notification alerting them to the new item in their inbox. Keep the discussion going in dedicated comment threads for every task, and tag in those who might be missing out with *@mention*s.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

[Configuring Tasks](https://www.sanity.io/docs/studio/configuring-tasks)
Enable and configure the Tasks feature in Sanity Studio

[Comments in Sanity Studio](https://www.sanity.io/docs/studio/configuring-comments)
Learn how to set up and use the Comments feature for collaborative content creation

## Working with tasks

### Find your tasks inbox

Your tasks inbox is located in the top-right corner of your Studio. Look for the checkmark icon in the navbar, to the left of the presence avatars and help menu. Here, you’ll find any new tasks assigned to you, any in-progress tasks that you’ve subscribed to, and all open tasks for the currently active document, whether or not you’ve been tagged in yet.

![Shows the tasks inbox in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/a215b192a21a60726137274df4e2e44ffccc6390-360x448.png)

### Create a task

Click the link aptly labeled **+ New task** to create a new task. You can give your task a due date, and assign it to the appropriate person who will then receive a notification email. You can also *@mention* Studio users to notify them that their input is requested.

> [!TIP]
> Pro tip
> Memo to self? Assigning a task to yourself, or @mentioning yourself in a task will not trigger any notifications, so talking to yourself in the Studio is perfectly fine, and won’t flood your inbox.

![Shows an unpublished task with a due date requesting a review from a colleague on the target article](https://cdn.sanity.io/images/3do82whm/next/b2cb65d7be8edd2e277891c0564c82f6510abeb0-359x642.png)

You can also choose to attach your task to a target document or leave it empty if that’s more appropriate. Adding a target document facilitates discovery and contextualizing, and will also put a handy notice next to the publish button for the relevant document, listing unfinished tasks.

### Comment on tasks

Tasks can have comment threads attached so you can keep related discussions in one easy-to-find place. Just as with comments elsewhere, you can *@mention* your team members to let them know about discussions they should be aware of.

![Shows a comment in a task thread tagging a team member with a @mention](https://cdn.sanity.io/images/3do82whm/next/bd10f58b6cd0d3683a2d0815aee22df7a7438601-359x254.png)

### Resolve tasks

Once dealt with, a task can be satisfactorily checked off your to-do list. Resolved tasks are still available by accessing the list of **Done** tasks at the bottom of your inbox.

![Shows a popover allowing users to mark a task as done](https://cdn.sanity.io/images/3do82whm/next/bf65a2958195f26ab55fb6dada339e3febca1850-370x217.png)



# Copy and paste for fields

The field copy-and-paste feature in Sanity Studio enables you to copy and paste field values or entire documents within your Studio. This feature can be a significant time saver when you need to duplicate content or move it between different document types.

You can access these specialized copy-and-paste actions in the following ways:

- Through the **Field Actions** menu on individual fields.
- Using the standard **Ctrl/Cmd+C** and **Ctrl/Cmd+V** keyboard shortcuts on supported field types.

## Copy and paste fields

To copy and paste individual fields within a document:

1. Hover over a field to reveal the **Field Actions** menu.

![The Field Actions menu open on a field, showing the Copy field option](https://cdn.sanity.io/images/3do82whm/next/2885ad78b489aa44a290a88452e726116f2c5e28-492x235.png)

1. Select **Copy field** to copy the contents of that field.
2. Navigate to another field of the same type and select **Paste field** in the **Field Actions** menu to paste the copied content.

Additionally, certain field types support using the standard **Ctrl/Cmd+C** and **Ctrl/Cmd+V** keyboard shortcuts for copying and pasting:

- Array fields
- Object fields
- Reference fields
- Image and file fields

Using keyboard shortcuts can be a quick way to duplicate content within these field types.

## Copy and paste documents

To copy and paste entire documents:

1. Open the **Document Actions** menu and select **Copy document** to copy the current document to your clipboard.

![The Document Actions menu with options for copying and pasting documents](https://cdn.sanity.io/images/3do82whm/next/37300fc59a415e495fe7baaafdcae1bddeeae6d9-491x330.png)

1. Navigate to the document list where you want to create a new document.
2. Create a new document.
3. Select **Paste document** from the **Document Actions** menu or use the keyboard shortcut **Ctrl/Cmd+V**.

Another advantage of the copy/paste workflow over using the **Duplicate** action is that you can paste documents across different document types. The Studio will try to map the fields from the source to the destination document.

## Examples

Here are some examples where copy-and-paste for fields can come in handy.

### Copying between array types

There might be cases where it's more efficient to copy existing items from an array into a new one and edit them. For example, if you use array fields to build things like landing pages and newsletters, and want to keep the same structure or have minor variations between them.

**Note that pasting into an array will replace all the items in it. **However, if you do this accidentally, you can use **Review changes** and restore to the content you want to keep.

### Copying between object types

Say you have an `object` field of type `bio` with the fields `name`, `image`, and `history`. If you copy that entire object and paste it into an object field of type `author` which has the fields `name` and `image`, the Studio will transfer over the field values that are in common between the two types (`name` and `image`) and discard the field that doesn't exist in the destination (`history`).

### Copying between document types

Similarly, if you copy a whole document of type `author` and paste it into a document of type `person`, the Studio will copy over any fields that the two document types have in common (e.g., `name` and `image`). Fields that do not exist in the destination type (e.g., `publicationsList` in `author`) will be discarded.

If only some of the copied fields exist in the destination, you'll see a "Could not paste all values" warning describing what couldn't be transferred. If none of the copied fields exist in the destination — or there's nothing on your clipboard when you try to paste — you'll get a "Nothing to paste" notification instead.

## Limitations

There are a few known limitations to be aware of with the new copy-and-paste feature:

- When pasting a reference, Studio checks that the referenced document exists, that its type is allowed by the target field, and that it satisfies any filter set on the field. Pasting is asynchronous for this reason, and you'll see an error if the reference can't be used in the target field.
- For images and files, Studio fetches the asset document to check its MIME type against the target field's `accept` option, and blocks the paste if the type isn't accepted.
- When focused inside a text input, copy and paste is handled by the input's own clipboard event management to avoid interfering with native editing behaviors such as undo and redo.
- Pasting a whole array field into another array field replaces the entire array rather than appending to it. A single copied array item, by contrast, is appended to the end of the target array. This replace behavior might be unexpected for users accustomed to appending when pasting in other contexts.
- Arrays of anonymous object types, sometimes referred to as inline objects, are supported — Studio resolves the item's type from the array's member types.



# Preview and page building

The Presentation Tool in Sanity Studio lets editorial teams work visually with structured content. It connects your content model to your front end, so content teams can navigate and manage content in context.

> [!WARNING]
> Gotcha
> The Presentation Tool can be customized and configured in considerable detail, so your implementation may not exactly match the examples shown in this article. Talk to your studio maintainer to get the specifics of your setup.

## Key features

### Live previews in your studio

Preview your drafted changes in the Sanity Studio editorial interface, as they appear in your front end. The preview area updates in real time as you edit your content.

![Shows the studio interface with the Presentation Tool active](https://cdn.sanity.io/images/3do82whm/next/b0f669b0d430bea82135cc29d7e56d9b457ab6b5-1459x1110.png)
*The Presentation Tool shows your front end side by side with the Sanity Studio editorial interface, so you can preview your changes in real time*

### Overlays link preview elements to their fields

Click any element in the preview to navigate to the corresponding field in your studio editor pane, even deep within a Portable Text block. Because pages in your front end can consist of content from any number of documents, these overlays are a fast way to find exactly what you are looking for.



### Build pages, block by block

Compose entire pages with drag-and-drop page building. The Presentation Tool supports page building with predefined design components, so pages stay consistent with your brand guidelines and UX patterns.

*Array-type fields can be configured to enable drag-and-drop reordering*

## The anatomy of the Presentation Tool

The Presentation Tool is in the top studio toolbar, alongside other tools you might have access to, such as the Structure and Vision tools.



When you select the Presentation Tool, it shows an interactive preview area side by side with the Sanity Studio editorial interface. When no specific route is defined, the preview area opens a default view, such as the homepage or an index of available routes. The editor pane then lists all the documents used by the current preview.

*The preview area (left) appears side by side with the editorial interface (right)*

When you interact with the preview area, blue outlined overlays appear with labels indicating the source documents for elements in the preview. Click an overlay to navigate the document editor pane to the matching document and field. Your changes appear in the preview in real time.



The preview area has a toolbar of its own, modeled after a typical web browser address bar.



The preview toolbar contains the following elements:



1. An **Edit** toggle button that lets you switch the click-to-edit overlays in the preview area on or off. This is useful for navigating the preview without accidentally switching the form editor to a different context. Hold down the **Alt** / **Option** to temporarily disable click-to-edit and navigate your front end in the Presentation Tool.
2. An address field where you can manually enter the route you want to preview, refresh the preview area, and open the current front-end route in a new tab.
3. A button to switch the preview area between desktop and mobile viewport sizes, so you can check that your drafted edits work on all devices.
4. A **Share** icon that lets you share a preview of your draft by copying a link or QR code.

## Open a specific page in preview

There are several different ways to open a specific front-end route in the Presentation Tool. The most straightforward is to type or paste the path of the page you want to preview into the tool's address field.

![The preview url bar interface](https://cdn.sanity.io/images/3do82whm/next/6b5f751947dae80603c74852ef43f4643c1d219a-1890x102.png)

> [!WARNING]
> Gotcha
> You can enter a path or a full URL. The Presentation Tool only accepts URLs on the origins its configuration allows; anything else is rejected with a validation message.



For a more studio-first approach, navigate to the relevant document in the Structure Tool. Beneath the document title you'll see a **Used on N pages** banner. Select it to expand the list of routes, then select a route to open it in the preview area.

> [!WARNING]
> Gotcha
> If you can't see the list of routes, your studio maintainer may have to set up a [location resolver](https://www.sanity.io/docs/visual-editing/presentation-resolver-api) first.

## Preview and edit content

When the **Edit** toggle is active, you can hover any element in the preview area to see a blue outline with a label indicating the source document in Sanity Studio. Click the overlay to navigate the form editor to the relevant document and field. Because any page on your front end can include content from multiple documents, this is a fast way to reach the right document in your studio.

*Overlays let you navigate to the source document for any element*

When you edit the field, the preview updates in real time to reflect your changes.

*The preview updates as you edit the source document*

The overlays let you navigate to specific pieces of content, no matter how deep in your content structure. For example, you can target specific blocks within a Portable Text field. In the following example, clicking an image within a block of rich content in the preview area opens the details dialog for that image in the Portable Text editor.

![Shows content deeply linked in the Structure Tool](https://cdn.sanity.io/images/3do82whm/next/30fd109aa7b8942038d103744a0293c2cfd2733e-1459x1110.png)

> [!TIP]
> Protip
> Make sure your images have [alt text](https://moz.com/learn/seo/alt-text)! Not only is it important for accessibility, but it also helps the Presentation Tool find your images.

## Build pages with drag-and-drop

The Presentation Tool can also be set up to accommodate page-building, with a drag-and-drop interface to rearrange content blocks. Drag-and-drop can be enabled for array fields and configured to allow for either vertical or horizontal positioning.

![Shows a block being dragged horizontally](https://cdn.sanity.io/images/3do82whm/next/af06a4742694e9a13e86f6b8c393f4f81d011df2-930x332.png)
*Array fields can be configured to accommodate drag-and-drop on both the x-axis and the y-axis*

When you hover any draggable element, your cursor changes to a move cursor, indicating that you can move the element by clicking and dragging. A simplified representation of the block you are repositioning then appears.

*A complex content block is represented by a gray rectangle while dragging*

When the repositionable area extends beyond the visible preview, zoom out to see its full context. Hold down **Shift** while dragging to enter minimap mode.

*Hold Shift while dragging a block to zoom out and reveal the entire interactive area*

Draggable blocks also have a context menu, accessible by right-clicking, that offers options for repositioning, removing, or adding content.

*Right-click an interactive block to reveal its context menu*

## The preview address bar

![The Sanity.io preview address bar with an 'Edit' toggle and a web URL.](https://cdn.sanity.io/images/3do82whm/next/2ea0fbb6b7ccf890749020644a0db77f70e2af2e-2440x1088.png)

### Toggle edit mode to navigate inside the preview

You can temporarily disable overlays so you can click links and navigate your front end within the preview area. Do so by toggling the **Edit** switch in the preview address bar.

![Browser address bar displaying the Sanity.io user guide URL for preview and page building.](https://cdn.sanity.io/images/3do82whm/next/fe3aac026050808f202ba8f0a4fd4d463553cd2d-1890x102.png)

## Switch between draft and published modes

![Global perspective drop-down menu](https://cdn.sanity.io/images/3do82whm/next/c5a0ce8c517fa06c78e33714278c44f2ca58f162-1166x518.png)

To switch between previewing your drafted changes, a content release, or your published content, open the **Global perspective** dropdown in the studio toolbar, then select the perspective you want to preview.

## Change the viewport size to check responsive content

To resize the viewport, click the **Phone** icon in the preview address bar.

![Toggle browser size preview interface](https://cdn.sanity.io/images/3do82whm/next/2838a0b92810fef73446f258f4d46a2b6e18d041-1890x102.png)

The preview area resizes to a mobile viewport, so you can check how your content looks on small screens.



## Share a preview

To share a preview link, click the **Share** icon in the preview address bar. The Share icon appears only if your front end allows shared preview access, and enabling sharing requires permission to create and update the project's share-access document. If you don't see the icon or the toggle, contact your studio maintainer.

![Preview share interface](https://cdn.sanity.io/images/3do82whm/next/2041eb5e6f1a4c005c30319b74323f919a61ff07-1890x102.png)

A popover appears with options to enable or disable sharing. Sanity generates a QR code so you can open the preview on any device with a camera, or you can copy the link as plain text.



This shares the document's perspective. If your application supports content releases and one is selected, the viewer sees any changes in the release. If you share a draft, visitors can also see other draft content.

Once shared, the preview link stays active until you turn sharing off. Toggling sharing off and back on generates a new link and immediately invalidates the previous one.



# Content Releases

Content Releases lets you organize and schedule updates across multiple documents. You can plan, preview, and validate significant changes in advance, then publish them together.

Content Releases provide several key benefits:

- **Coordinate updates:** Simultaneously manage and publish updates across multiple pages and channels, ensuring consistency.
- **Reduce manual effort and risk:** Automate scheduling to minimize manual tracking and prevent errors or conflicting changes.
- **Gain confidence with previews and validation:** Preview and validate scheduled releases to guarantee readiness before going live.

For developer documentation on how to configure, integrate, and interact with Content Releases programmatically, go here:

[Configure Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration)
Configure the studio and visual editing experience

[Content Releases API](https://www.sanity.io/docs/content-lake/content-release-document-flow)
Programmatically manage Content Releases with the API and clients.

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

> [!NOTE]
> Scheduled Drafts is also available
> For teams without access to Content Releases, or if you don’t need to schedule groups of documents to go out at once, the [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts-user-guide) feature is also available.

## Before you begin

Content Releases requires Sanity Studio v3.77.0 or later, where it is enabled by default. Official plugins, such as AI Assist, the Vision Tool, and presentation-related plugins, also need to be up to date. If you're experiencing issues using Content Releases, check with your administrator and direct them to the [Studio configuration](https://www.sanity.io/docs/studio/content-releases-configuration).

## The Content Releases workflow

Content Releases introduces the concept of a **release**. Releases are a way to group multiple document changes together into a single unit that can be previewed, validated, scheduled, and published as one.

The most basic workflow is as follows:

1. Create a release.
2. Add documents to a release to create new document versions.
3. Make changes to the documents.
4. Publish the release.

### Release types

When you [create a release](https://www.sanity.io/docs/user-guides/content-releases), you must choose a release type. There are three available types:

- **As soon as possible** (ASAP): You plan for these changes to go live as soon as they're ready. They'll have a prominent **Run release** action available on the release details screen.
- **At time**: You have a planned date and time in mind. You'll be able to schedule these for a specific time from the release details screen.
- **Undecided**: You're unsure, or don't want to set a type. These will also hide the publish and schedule actions behind the release's **More options** menu to prevent accidental publishes.

The type dictates the order a release shows up in Studio to reflect when it will publish compared to other releases, but you can [change the type](https://www.sanity.io/docs/user-guides/content-releases) at any time from the release detail screen.

> [!NOTE]
> Release quotas
> Your plan dictates how many active releases your organization can have at a time. Any release that isn't **Archived** or **Published** is considered active, including scheduled releases that have yet to publish.

### The document view

![content releases document screen](https://cdn.sanity.io/images/3do82whm/next/9b0426bfd894bfeae9393ab1af169efde4bd21fb-2142x1820.jpg)
*The document screen*

When you're working on a release, the document screen displays details about **versions** of the document. Document version names correspond to release names. Published and drafts are always enabled, but additional versions are displayed as documents are added to releases.

Much like each published document can only have one draft, each release can only contain one version of a document.

Select a version name to switch between versions. Right-click the name to reveal a menu with options, including: copy versions between releases, or discard a version.

> [!TIP]
> Protip
> The release color highlights the global toolbar and document list to remind you that you're working on a specific release. **ASAP** releases are orange, **At time** releases are purple, and **Undecided** releases are gray.

### The releases view

![the content releases screen](https://cdn.sanity.io/images/3do82whm/next/c0c85652fcd67d3ab43e416b74dca7390085aa8f-1180x780.png)
*The releases screen*

The releases screen displays any upcoming releases. Bold dates in the calendar indicate releases with date estimates. You can also see the number of changes in each release, and warnings if there are validation errors.

### Global perspective

The global perspective is your view into the state of all documents relative to the selected release. By selecting a release, you're viewing not only its changes, but all changes in published documents and to-be-published documents. You can hide individual releases from view, if they are higher in the list than your selected release, or view just the Published perspective.

![The global release picker with the "hide release" tooltip displayed](https://cdn.sanity.io/images/3do82whm/next/ac8acdb5a932ccb29e7cc78de7e2765a2cb3723b-1761x719.png)

> [!WARNING]
> Gotcha
> Does it seem like all documents are read-only? You might be in the **Published** perspective. Select **Draft** or a release from the document screen to make changes to a document.

### How do drafts fit in with releases?

You can work directly on a draft and publish it without creating a release. You can also work on a draft, then copy it to a release.

One important thing to keep in mind. Publishing a release will not reset a draft. If you created a draft and made changes, then copied it to a release, that draft still exists. When you run a release, the confirmation dialog offers an **Update existing drafts** option. Selecting it discards the existing drafts of documents in the release so drafts match what was published. Unpublished draft changes are lost. There are two ways you can keep these leftover drafts in check:

- If you know you're working on a release, start the changes in the release. This way a draft document is never created.
- After copying a draft to a release, return to the draft document and discard the draft version.

## Technical limits

Content Releases is designed to work with most workflows, but you may experience issues with exceptionally large documents and releases.

- A single release can contain a maximum of 1,000 documents.
- The total size of all of your JSON documents combined in a release cannot exceed 100 MB. This is the size of the document's JSON data itself, not linked assets like images or files.
- Releases publish documents in batches based on size and reference connections. For larger releases, there may be small delays when individual documents go live. To avoid this, smaller releases of dependent documents can help ensure they release at the same time.
- Releases are published one at a time and are ordered by the time they will be published. If releases will be published at the same time, their order will be chosen at random.
- If a release is blocked by another release, it waits up to 10 minutes before the release is marked as failed. If multiple large releases are scheduled for the same time, consider staggering their release times.

## Create a release

To add new documents and changes to a release, you first need to create a release.

![The Studio toolbar with the release dropdown open, showing Published, Drafts and the list of releases](https://cdn.sanity.io/images/3do82whm/next/11b8bc5d691bf1b1629566f75fe5ba181ddeec70-488x738.png)
*Select the release dropdown*

1. Locate the **calendar** icon in the top right corner of the toolbar.
2. Select the **down arrow** icon to reveal a list of releases.
3. Select **New release** to create a new release.1. Select an approximate time of release.
2. Enter a release title (optional).
3. Enter a description for the release (optional).
4. Select **Create release**.



![The Create a new release dialog with fields for release time, title, and description](https://cdn.sanity.io/images/3do82whm/next/dc780f442bb5fa52f63adfeab058d7ecd08bdc82-1410x962.jpg)
*Create a new release*

You can change these values later by navigating to the release on the **Releases** screen.

> [!TIP]
> Protip
> You can also create a release from the **Releases** screen by selecting **New release** in the top right corner.

## Add a document to a release

When a document is part of a release, it's no longer connected to changes in drafts or the published document. It's like a snapshot in time that has its own future. Keep this in mind when interacting with different versions of the same document.

There are multiple ways to add a document to an existing release.

### Add a document from the releases screen

1. Navigate to the **Releases** screen by selecting the **calendar** icon in the top right of Studio.
2. Select the **release name** to navigate to its detail screen.
3. At the bottom of the list of documents, select **Add document**.
4. Search for and select a document.

### Add a document from the document screen

1. Ensure you are in a release perspective by pinning a release. You'll know you've pinned a release if the release name is next to the **calendar icon** in the toolbar.
2. In a document's editor view, select the **Add to release** button in the top bar. This button and bar should match the color scheme associated with the release perspective.

Alternatively, you can right-click a release label at the top of a document and select **Copy version to** to copy the selected document version to a release.

> [!NOTE]
> Adding a document to a release uses the published version
> When adding a document to a release, unless you are using the **Copy version to** method, the published version will be used as the basis for the new version.
> To use a draft or different release version, use the **Copy version to** method.

Once a document is part of a release, you'll be able to edit the release version by ensuring the release is selected at the top of the document.

## Remove a document from a release

Removing a document from a release discards any changes unique to that version. This action won't remove the document from other releases.

There are three ways to remove a document from a release.

### Remove a document from the releases screen

1. Select the **release name** to navigate to its details screen.
2. Identify the document you want to remove and select the **"..." icon** to reveal additional options.
3. Select **Discard version** and confirm the selection when prompted.

### Remove a document from the document screen

1. Confirm you are in the perspective for the desired release. You should see the release name next to the calendar icon in the toolbar, as well as the highlighted release name at the top of the document.
2. At the bottom right of the document screen, select the **"..." icon**.
3. Select **Discard version** and confirm the selection when prompted.

### Remove a document from the version menu

1. On the document header, find the chip with the version you want to discard.
2. Right-click the chip to open the context menu.
3. Select **Discard version** and confirm the selection when prompted.

## Copy a document from one release to another

You can copy a document version to a different release from the document view.

![Version action user interface](https://cdn.sanity.io/images/3do82whm/next/7f067c31c8845d0650b1a15320125eb13b5a6bd9-1608x820.jpg)
*Right-click a version name to reveal the version action menu.*

1. Navigate to the document you want to copy.
2. Right-click the release name you want to copy from.
3. Hover over **Copy version to**.
4. In the popover menu, select the destination release.

## Unpublish a document as part of a release

Sometimes you want a release to unpublish, or remove a live document. This converts a published document back to a draft once the release is published.

1. Add the document to a release.
2. In the bottom right corner of the document screen, select the **"..." icon**.
3. Select **Unpublish when releasing** and confirm the selection when prompted.

![Document screen popover menu](https://cdn.sanity.io/images/3do82whm/next/a4f0e9c5e58bcf2fa020b8996ce7e5b9d26f06de-846x400.jpg)
*Unpublish when releasing*

When unpublishing a document, the contents of the published document are used to create a new draft document associated with the published ID.

If a draft document with the same published ID as the version document already exists, it will remain and the unpublished contents will be lost.

All strong references will be converted to weak references on *unpublish*. If the draft document is subsequently re-published, those references will be converted back to strong references.

## Discard a draft version

To discard a document version, follow the steps listed in *Remove a document from a release*.

To discard changes from the **Draft** version, select the **More options** at the bottom right of the document screen and select **Discard changes**.

## Publish a release

After creating a release, you can choose to publish it on demand or schedule a publish.

1. Navigate to the **release screen** for the release you want to publish.
2. Select **Run release** and confirm. For **At time** and **Undecided** releases, this action is in the release's **More options** menu rather than the primary button.

## Schedule a release

To schedule a release, first set a release time and date. You can do this when creating a release, or by selecting the **release time** label and selecting **At time** from the **release** screen. You can adjust this time later if needed.

![The release screen with the release time label selected and a date and time picker open](https://cdn.sanity.io/images/3do82whm/next/75b7b10a61d63b82240f3937c2a8a65774719131-1816x986.jpg)
*Set an estimated release time*

Next, select **Schedule release** in the bottom left of the **release** screen.

![The release screen with the Schedule release button in the bottom left](https://cdn.sanity.io/images/3do82whm/next/aadb1c9274e77f04d984ada034df5c50e90fb9e3-2134x1232.jpg)
*Schedule release*

Confirm the release time and date, then select **Yes, schedule**.

> [!WARNING]
> Gotcha
> Setting a release time alone does not schedule the release. You must set a time, and schedule the release using the **Schedule release** button.

While a release is scheduled, its version documents are locked. Select **Unschedule release** before editing a document in the release or adding another one.

## Unschedule a release

To unschedule a release, select the **Unschedule release** button in the bottom right of the release screen.

## Archive a release

The **Archived** tab lists both archived releases and releases that have already published. Archiving is a separate action you can take on a release that hasn't published yet, to take it out of the active list while preserving it for reference.

> [!WARNING]
> Gotcha
> You cannot archive a scheduled release. First unschedule it, then archive it.

There are two ways to manually archive a release.

### Archive a release from the releases screen

1. Select the **"..." icon** for the release you want to archive.
2. Select **Archive release**.

### Archive a release from the release detail screen

1. In the bottom right, next to the Publish / Schedule button, select the **"..." icon**.
2. Select **Archive release**.

## Unarchive a release

You may unarchive an archived, unpublished release. Published releases cannot be unarchived.

There are two ways to manually unarchive a release.

### Unarchive a release from the releases screen

1. Select the **"..." icon** for the release you want to unarchive.
2. Select **Unarchive release**.

### Unarchive a release from the release detail screen

1. In the bottom right select the **"..." icon**.
2. Select **Unarchive release**.

## Change the release type

Release order is determined by when the release will be live, with exceptions for *ASAP* and *Undecided*. This is the release type.

- ASAP releases come first, in order of creation.
- Dated releases come next, ordered by date.
- Undecided releases come last, ordered by creation.

To change the order of a release, change the date and time associated with it.

## Pin a release (global perspective)

Pinning a release sets the global perspective in Studio. This is indicated by the color change in the toolbar, as well as the highlighted release name throughout Studio.

You can only pin one release at a time.

![A pinned release tinting the Studio toolbar and document list, with the release name highlighted](https://cdn.sanity.io/images/3do82whm/next/800c52d96c7fcf8e28cf092e8c869fa4d5331e3d-1180x345.png)
*A pinned release highlights the Studio experience*

There are three ways to pin a release.

### Pin a release from the toolbar

1. In the top toolbar, select the dropdown arrow next to the **calendar icon**. If a release is currently pinned, the arrow will display next to the pinned release.
2. Select the **release name** for the release.

### Pin a release from the releases screen

1. Locate the release to pin.
2. Select the **pin** **icon** to the left of the release name.

### Pin a release from the release detail screen

1. Navigate to the release you want to pin.
2. Select the pin icon on the top left, above the release name.

## Document status in lists

Document lists show a status icon for each document, describing it relative to the pinned release.

A document with a version in the pinned release shows that release's icon: a bolt for ASAP, a clock for timed, and a question mark for undecided. A document with no version in that release shows no icon.

When no release is pinned, the icons describe the document itself. An outlined ring means the document has a draft, and a filled circle means it's published. A document that has never been published shows no icon.

Hover the icons on any document to list its versions, ordered published first, then drafts, then releases.

## View release history

You can view past releases, including unpublished ones, from the **Archived** tab on the **main releases screen**. Published and archived releases are retained for a limited period based on your plan's retention window, after which they're automatically removed.

## Edit properties of an existing release

You can edit the name, estimated release time, or description directly on the **release** screen.

To change the title or description, select the field and begin typing.

To change the estimated publish time, select the **release time** label and choose a new time.

> [!WARNING]
> Gotcha
> You can edit the name and description of scheduled releases, but in order to change the schedule date or time you first need to **unschedule** the release.

## Hide releases from the global perspective view

When viewing a future release, you can choose to hide earlier releases from the global perspective view. This lets you hide document changes made by specific releases, while still previewing a subset of changes across releases.

![The release dropdown with open and closed eye icons controlling release visibility](https://cdn.sanity.io/images/3do82whm/next/2ff2a670d4b12d4cd311d5170b82e65273622949-1128x734.jpg)
*Toggle release visibility*

1. To hide versions from a specific release, first set your global perspective.
2. In the release dropdown view, select the **open eye icon** next to any release you want to hide.
3. To reveal a hidden release, select the **closed eye icon**.

## Preview releases in Presentation

If your team has enabled Presentation, you can preview a release by **pinning it** and then selecting the Presentation Tool in Studio. You'll know it's been pinned if the name displays alongside the calendar icon instead of **Drafts**.

Keep the release layering concept in mind, and use the *hide release* feature to customize your preview perspective.

## Revert a release

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You can return to the state prior to when a release was published by reverting the release. When reverting, you can either revert the release immediately or create a new release, which you can then review and schedule.

![The Revert release button in the Content Releases interface.](https://cdn.sanity.io/images/3do82whm/next/215e2906bb39a536daa7e33435188e1ebbe207e4-876x440.png)

1. Navigate to the releases screen by selecting the **Calendar** icon from the perspective picker in the top bar.
2. Select **Archived** to view published and archived releases.
3. Select the release you want to revert to navigate to the release.
4. In the lower right, select the **Revert release** button.

When you revert a release, any new documents that didn't exist outside of the release will be reverted to drafts in your dataset.

## Duplicate a release

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You can build off of an existing release by duplicating it. This is useful in scenarios where you want to work beyond a current release, but want any scheduled changes to carry over. There are two ways to duplicate a release.

### Duplicate a release from the releases screen

1. Select the **"..." icon** for the release you want to duplicate. You can duplicate an active or scheduled release. **Duplicate release** isn't available for releases that have already published or been archived.
2. Select **Duplicate release**.

### Duplicate a release from the release detail screen

1. In the bottom right, next to the Publish / Schedule button, select the **"..." icon**.
2. Select **Duplicate release**.

## Release layering

Release layering is the concept of displaying documents based on where a release falls in the release timeline and which perspective is active.

This allows editors to preview document changes across multiple releases. You can see a simplified version of this in how *drafts* override published documents in Presentation.

In Studio, release layering works on a timeline. The type and time of release indicates where a release falls on the timeline. You already know *published* and *draft*, but there are also *as soon as possible (ASAP)*, *timed*, and *undecided*.

The layer follows this order, starting at 1 and adding changes.

1. Published
2. Draft
3. As soon as possible (ASAP)
4. Timed (A planned time in the future)
5. Undecided

When viewing a release with an undecided release time, you will see all changes in other documents from drafts, ASAP releases, and timed releases stacked atop published documents—plus any changes on the undecided release(s). These views of your content in Studio are the *global perspective.*

> [!NOTE]
> Documents display based on release order
> The global view and document list will show changes across releases based on the layering order, but when viewing a version of a document, you'll only see that version's changes. References to other documents will display their content in relation to where their release, and your active release, sit in the layering order.
> This only applies to other documents. Your selected document will always show the contents of the selected release or perspective (if drafts or published is selected).



# Compare document versions

## Prerequisites

- [Sanity Studio](https://www.sanity.io/docs/studio/installation) v3.78.0 or later, which added the document comparison view.
- A document with at least two versions to compare, such as a draft and a published document.
- To compare a version that belongs to a release, [Content Releases](https://www.sanity.io/docs/user-guides/content-releases) must be enabled in your studio.

## Side-by-side comparison

Use the document comparison view to compare document versions. This includes drafts, published, and release versions. To get started, open a document in Sanity Studio that contains multiple document versions.

1. In the top right corner of the document view, click the **...** icon.
2. Select **Compare versions**.

![The document actions menu in Sanity Studio, opened from the top right of the document pane, with the Compare versions option in the list.](https://cdn.sanity.io/images/3do82whm/next/cdb7012fcd612207338ade2426a87d897c623d57-1474x896.jpg)
*Select Compare versions from the More options menu.*

The document comparison view opens over your studio window. It contains a version selector and two panels, one for each version you compare.

> [!WARNING]
> Gotcha
> This view only works when multiple document versions exist. If **Compare versions** is disabled, its tooltip reads "There are no other versions of this document to compare." — make a change to a draft or release version in addition to the published version.

![The document comparison view in Sanity Studio, with the Published version in the left panel and the Draft version in the right panel, and the Overview field differing between them.](https://cdn.sanity.io/images/3do82whm/next/4f834f0e91d3b28a0861c0fad7303e5d3808e982-2962x1710.png)
*The document comparison panel.*

Differences between the fields in the right version are highlighted in yellow. You may recognize this from other history or diff tools. In the screenshot, the **Overview** field has a yellow highlight along its edge to indicate changes.

You can adjust the compared versions with the version selector at the top of the window.

![The version selector at the top of the document comparison view, open and listing the Published and Draft options.](https://cdn.sanity.io/images/3do82whm/next/5c140b6852622c79f2e4a310db0f211b7729d910-1706x872.png)
*The version selector in the document comparison view.*

> [!TIP]
> Protip
> You cannot leave comments or tasks from within the comparison view. It's best to save major changes and workflows for the document view.

## Advanced Version Control

> [!WARNING]
> Experimental feature
> This functionality is likely to change as we improve and expand it. Let us know if you have any feedback.

Advanced Version Control adds inline diff annotations to fields, letting editors see how content has changed between versions while they work on it. This functionality is available for `string` fields and Portable Text fields.

![Screenshot of string field in Sanity Studio show diff annotation from "Fall Collection 2025" to "Autumn Collection 2025"](https://cdn.sanity.io/images/3do82whm/next/103c95376415df0aab808acad6a5847a803baa10-1320x240.png)

### Switching on Advanced Version Control

To switch on Advanced Version Control, set the `advancedVersionControl.enabled` configuration option to `true`. This feature can be switched on or off for different workspaces.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  advancedVersionControl: {
    enabled: true,
  },
  // …
})
```

Editors must also switch on inline annotations per document. In the document's **Show more** menu (the **...** button in the top right of the document pane), select **Inline changes**. The setting persists as you navigate.



# History experience

## History retention

In order to make Sanity Studio real-time, it sends edits as patches to the backend. All these patches are stored as transactions. Together they make up your documents’ revision history. 

History retention is the amount of time you have access to these revisions before they are automatically deleted. The latest version of your published and drafted document will always be available.

The retention period on your documents are defined by the plan you are on. We count retention time backward from the current day. 

The retention time for the [different plans](https://www.sanity.io/pricing/compare) are:

- Free: 3 days
- Growth: 90 days
- Enterprise: 365 days, or contact us for custom retention

Revisions that are older than the cutoff will be truncated into one revision item, older transactions will be permanently deleted. The document history is truncated regularly every day.

### GDPR

We introduced history retention to make it possible to use Sanity and be GDPR compliant. You can learn more about our [security and compliance here](https://www.sanity.io/security).

### Upgrading the retention time

If you change retention time by changing plans, or upgrading on your current plan, this will only affect the retention cutoff time by postponing it to however long your retention time is. The retention history for your documents will stay as it was before the upgrade.

### Downgrading the retention time

The revision history for all your documents will be truncated to your new cutoff time when downgrade either by turning off the upgrade on your current plan, or switching to one with less included retention time.

## Exploring history in Sanity Studio

While viewing a single document in the studio editor, you can access the history either by clicking the document status indicator in the very bottom of the editor view, or by opening the contextual menu by clicking the ellipsis icon in the top right corner of the editor and selecting **History**.

![The document editor in the studio showing the context menu with the links for opening document revision history highlighted.](https://cdn.sanity.io/images/3do82whm/next/6931f709fcce41230840fbd590bc61551c034a7a-1590x1068.png)
*How to access document revision history*

### Nested release history

When a document is published as part of a Content Release, it combines all of the changes into a single entry. You can explore the individual history entries from when the document was part of the release by:

1. Select the "**Published**" pill at the top of the document
2. Select the "**...**" for the edit from the release.
3. Select the "**Inspect"** option to view the history of the document's edits in the release.

![Screenshot of the history interface highlighting the previous steps.](https://cdn.sanity.io/images/3do82whm/next/68e4038c8d14e7c043d517468ab9bba8134b27fd-2062x792.png)

Once selected, the history will update to display changes made to the document prior between when the version was created and when the release was published.

![Interface showing a highlighted view of the revert document history.](https://cdn.sanity.io/images/3do82whm/next/410c2bac148279257e65159b1c517cad599b7a71-2070x1768.png)

## Document status labels

The labels under the title in the document editor shows whether the content you are looking at is published and/or a draft.

### Published

The content in the editor is the same that is published to the API.

### Draft

The content in the editor has not yet been published, or has been unpublished. 

### Published, Draft

The content has been edited after the document has been published.

### Live

The document is in live edit mode. All changes are published real-time and skip the draft workflow. This is not to be confused with the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api), which is a way of rendering published content changes instantly.

## History status labels

These are the labels for the revision items in the history view.

### Published

The document was published to the API.

### Unpublished

The document was unpublished from the API.

### Edited

The document was edited.

### Truncated

Revisions before the cutoff date.



# Create instructions with AI Assist

AI Assist is the official plugin for Sanity Studio that brings artificial intelligence features to the editorial experience. Beyond running simple text generation prompts, it can interact with your structured content in numerous ways.

This article covers the different capabilities and affordances that this plugin has once installed and configured in your Sanity Studio.

> [!NOTE]
> Paid feature
> This article is about a feature currently available for all projects on the [Growth plan](https://www.sanity.io/pricing) and up.

[Install and configure Sanity AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)

[Common instructions for AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-cheat-sheet)

[Content translation with AI Assist](https://www.sanity.io/docs/studio/ai-assist-content-translation)

## Document- and field-level instructions

You might be familiar with the term *prompt;* it's called *instruction* for AI Assist. AI Assist is not a chat interface (like ChatGPT) but a way to describe tasks that the AI can do to your content. That being said, you can bring techniques and methodologies from prompt engineering here, too.

AI Assist lets users of Sanity Studio add instructions for whole documents and specific fields. The instructions can be visible only to those who made them or shared with all users of the Studio.

### Creating document-level instructions

Once the plugin is successfully installed and activated, you will find a new button with a sparkle ✨ icon at the top of every document form (side by side with the ellipsis … button where you find options to inspect the document or review its history). If comments are enabled for your project, this is also where you’ll find the speech bubble 💬 button to open the comments panel.

Clicking the sparkle button will open the AI Instruction editor in a side panel to the right. The editor opens with the entire document as its “target” because it was opened from the root-level sparkle button in the top right corner of the editor.

Create a new instruction by clicking **+ Add item**. Give your instruction an appropriate and informative name, and if you wish, click the sparkle icon on the left to select a fitting icon.

From this view, you can name your instruction, decide whether or not to make it available to other users of this Studio once you’re happy with it, and, of course, edit and run your instruction.

### Creating field-level instructions

To target a specific field, hover your cursor over the relevant field to reveal its very own dedicated sparkle button. Then click it to switch the context to that field. This opens up the same panel type as with the document-level instructions described above.

Note that not all field types are supported, as elaborated further in this article.

## Instruction editor

Instructions for AI Assist are written in a normal, human-readable style. These instructions can be enhanced by adding references to fields’ content from the current document, which the assistant can access in real time. You can also prompt the user for input or refer to reusable contextual documents (such as a style guide or a description of your target audience) to further inform the assistant about the expected output.

[Common instructions for AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-cheat-sheet)

### Instruction contexts

AI Assist will include a description of your schema by default but not any content unless you explicitly include references to fields or other contexts in your instructions:

- Document fields: Add a placeholder for a field’s content. AI Assist will include the field name and its content, meaning that you can insert these anywhere in your instruction, inline or on separate lines.
- User input: Opens a box to which the user running the instruction can paste plain text. You can customize the title and instructions given to the user that triggers this box. Only the contents of what’s inserted in the box will be put into the instruction.
- AI Context: Includes the content of an AI Context document that you can also manage in the Studio. This is where you would typically include brand and style guides.

### Allowed fields

Below the instruction editor, there is a collapsed option for **Allowed fields.** This is relevant for document-, object-, and array-level instructions. By default, an instruction will run for all supported fields. But there might be cases where you want to prevent AI Assist from interacting with certain nested fields. You can uncheck field labels for which the AI Assist *should not* add content.

## Sharing instructions

When you create a new instruction, it will only be visible to you by default. Select the **Make visible to all Studio members** switch to share it with other Studio users.

Note that AI Assist runs with the same permissions as the user who runs the instruction. Someone with limited access might still be able to see and trigger an instruction, but it won’t successfully run if it assumes permissions they don’t have.

## Running instructions

There are two ways of running an instruction for AI Assist:

- Clicking the **Run instruction** button at the bottom of the instruction editor
- Clicking its name in the sparkle menu (✨) once the instruction is added

Once you have triggered AI Assist, it will run the instruction in a real-time collaborative mode as if it’s a user of the Studio. You are then free to continue working on the same document in real time or you can navigate away from the document. You will get a pop-up message telling you that AI Assist is done unless you have closed the Studio, which you can do without disrupting the AI.

How long it will take to run an instruction depends on many factors. AI Assist does quite a lot under the hood to bring your content model and other contexts into its tasks.

## Restricting access to AI instructions

You can control which users can create new AI instructions while preserving access to existing ones by combining [content permissions](https://www.sanity.io/docs/user-guides/roles) with role-based access control.

### Implementation

To restrict the creation of instructions, filter out the corresponding document type `sanity.assist.schemaType.annotations` in a [content resource](https://www.sanity.io/docs/user-guides/roles). This approach allows you to:

- Grant read-only access to existing AI instructions
- Prevent specific roles from creating new instructions
- Maintain workflow continuity for teams using established instructions

**Content resource filter (Manage console, under Roles)**

```groq
_type == "sanity.assist.schemaType.annotations"
```

Be aware that permissions are additive ([see gotcha for more](https://www.sanity.io/docs/user-guides/roles)). 

## Supported field types

AI Assist can use most fields in your schema as a context in an instruction.

These are the field types it can write content to, including custom schema types based on the following:

- String and text
- Objects and the fields within them
- Arrays with inline objects and references
- Portable Text, including default formatting and custom blocks, but **not** custom marks and annotations
- Image assets (and image fields)
- References (requires additional configuration)
- Number, boolean, slug, and URL
- Date and datetime

### Conditionally hidden and read-only fields

Fields and field sets that are conditionally visible or read-only can have instructions and can be written to by an instruction, as long as the field is non-hidden when the instruction is initiated.

> [!WARNING]
> Gotcha
> AI Assist will ignore any field that is hidden or in read-only mode when the instruction starts running. Changes to these conditions that occur **while the instruction is running** will **not** alter this behavior. 

### Unsupported fields

There are some field types that AI Assist can use as context but not write content for:

- Geolocation
- Cross Dataset References
- File assets

## Working with images

AI Assist can generate image assets based on instructions and generate image descriptions from an uploaded image.

[Install and configure Sanity AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)
Go to the AI Assist configuration docs for enabling image generation and automatic captioning

### Generating images

There are two ways of working with image generation with AI Assist:

- Write an instruction, as with any other field
- Configure image generation from an “instruction field” (allowing AI-assisted image instruction generation)

> [!WARNING]
> AI Assist cannot see and create images at the same time
> While AI Assist can see images and generate images, it can't do both together. For example, you can't write an instruction to use an image as the foundation for a new image. You may be able to use the description of an image instead.
> Alternatively, you can use the [Transform agent action](https://www.sanity.io/docs/agent-actions/introduction) alongside [custom field actions](https://www.sanity.io/docs/studio/ai-assist-field-actions) to create custom workflows.

### Generating image captions / alternative text

AI Assist can add image descriptions for image asset fields. This feature can typically be used to autogenerate alternative text or image captions.

A developer can enable this as part of [the schema configuration](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist). Note that the instruction will automatically run when an image is uploaded or replaced. 

For images that were uploaded before it was enabled, there will also be a "Generate image description" instruction that you can find in the description field’s sparkle (✨) menu.

## Working with references

AI Assist can work on reference fields and pull in relevant articles based on an instruction. This feature requires configuration and works only on documents that have been included in an embeddings index.

It’s good to note that this feature relies on (vector) embeddings and not a regular string search or matching. This means that AI Assist will look for *semantic similarity* between your instruction and what’s indexed in your documents. This means that it also works across languages, which, in some cases, can be powerful but, in other cases, can lead to undesired results.

Preventing certain documents from being referenced can be solved either by adjusting what’s being included in the embeddings index or in content queries, the latter being less transparent for Studio users.

[Embeddings Index API](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview)

## Working with translations

You can use AI Assist to generate content translations. It’s built to be compatible with the localization content models used by the [Document Internationalization](https://github.com/sanity-io/document-internationalization) and [Internationalized Array Field](https://github.com/sanity-io/sanity-plugin-internationalized-array) plugins.

[Content translation with AI Assist](https://www.sanity.io/docs/studio/ai-assist-content-translation)

When the configuration for translation is enabled, users of the Studio will be able to trigger an AI-assisted translation depending on which approach is used:

- Document-level translations can be found in the document-level sparkle menu (✨)
- Field-level translations from the sparkle (✨) menu on localized fields that have AI translation enabled

## AI context documents

When the AI Assist plugin is enabled, it will add a document type called **AI Context** that can be found in the Structure tool (unless intentionally hidden). You can use these documents to centralize parts of instructions that you want to be consistent across your project.

Typical examples include:

- Style guide for text copy and images
- Brand guidelines
- Company descriptions
- Specific instruction tunings

## AI technologies powering AI Assist

AI Assist uses different AI technologies, largely large language models, under the hood and is built to be service- and model-agnostic. We will change the underlying models and services to improve and secure the performance of AI Assist.

Technologies we use:

- Models by OpenAI, like GPT and DALL·E
- Models by Google, like those available through Vertex AI
- Models by Anthropic, like Claude



# Instruction ideas for AI Assist

Here, you will find instructions for common tasks that AI Assist can do. Use these as inspiration and a starting point for more specialized instructions. 

> [!NOTE]
> Paid feature
> This article is about a feature currently available for all projects on the [Growth plan](https://www.sanity.io/pricing) and up.

[Install and configure Sanity AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)

[Create and run instructions with AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-working-with-instructions)

[Content translation with AI Assist](https://www.sanity.io/docs/studio/ai-assist-content-translation)

## Good to know

Sanity AI Assist knows about your Studio's schema. When running instructions at the document level, for instance, you can still target specific fields by referring to them by title. E.g., *“Create a description based on title, then add a relevant callout to body. Do not add anything to any other fields.”*

Using explicit commands and clear language like “Important: <command>” can help guide the assistant in a certain direction if it’s being too creative.

## Write more of a body

**Where**: `body` (Portable Text or plain text field)

**Instruction**: 

Given the `title` `body`, keep writing copy.

## Get started on an article

**Where**: document instruction

**Instruction**: 

Given the following inspiration `User input (What should the article be about?)` Create an article.

## Summarize a field

**Where**: `summary` (Portable Text or plain text field)

**Instruction**: 

Summarize `body`

**Instruction variation** (to suggest how the summary should be formatted when saving to a formatted text field):

Summarize `body`. Do not use lists, headlines, or quotes.

## List categories

**Where**: On an array field with strings

**Instruction**:

List the categories relevant to `body`

## Shorten a field

**Where**: On the field to shorten

**Instruction**:

Shorten `<the field itself>`

## Translate a field

**Where**: On the field where the translation should be stored

**Instruction**:

Translate `title` to Norwegian.

## Sentiment analysis

**Where**: On a text field where the sentiment should be stored

**Instruction**:

Classify the sentiment of the following text `body`

Determine if it is elated, happy, neutral, sad, or angry.

## Create a catchy title

**Where**: On the title field

**Instruction**:

Create a catchy and engaging headline about `User input (What should the title be about?)`

## “Smart” paste

**Where**: On any field or at the document level

**Instruction**:

Given the following User input:

`User input (Paste your stuff here)`

Format the User input so it aligns with the schema. Do not omit sections or paragraphs; use formatting, headings, and lists as appropriate.

Infer item types based on context.



# Quick start

This guide helps you manage media assets in the Sanity ecosystem. You'll find practical tips for your Media Library workflow, whether you're a content editor, marketer, designer, photography specialist, or product owner. Media Library typically integrates with Sanity Studio. You can access, search, and use it while working on content.

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

## Get started with Media Library

Media Library is your organization's asset management interface. You'll centrally manage assets like images, videos, and documents so they can be reused across multiple projects and datasets. Here's how to get started:

1. **Log in**: Access your workspace through [sanity.io/welcome](https://www.sanity.io/welcome), which redirects you to your organization's domain.
2. **Sanity Dashboard**: When you first log in, you'll see your organization's dashboard. From here you can quickly access Media Library, your studios, and other apps. You can find Media Library in the left hand menu, along with any pinned studios.
3. **Media Library navigation**: Once in Media Library, you'll see all your organization's assets organized in a grid or list view. Use the left sidebar to filter by asset type (images, videos, documents) or collections if your team has set them up. The search bar at the top helps you quickly find assets by filename, description, or tags.

As an editorial user, you mainly interact with it through the web interface. The screen is divided into three main areas:

- **Asset list:** Browse assets, filter results, and upload new files.
- **Library menu:** Narrow your view, explore collections, and see recent uploads.
- **Asset sidebar:** Edit asset metadata, apply aspects, and see details.

Refer to the [Media Library interface guide](https://www.sanity.io/docs/media-library/interface) for more details.

## Finding and searching for media assets

![Filter user interface open](https://cdn.sanity.io/images/3do82whm/next/61c6362bcfb5315ef8419b97a0be44296b2f7ddb-1430x1166.png)

Navigating the **asset list** can be challenging when your organization has numerous assets. To make browsing easier, use the **filter menu** with its various controls to narrow down your results. These filters typically allow you to sort by asset type, collection, visibility, keywords, uploader, aspect ratio, file type, and usage status. 

## Media asset versioning

Asset versioning helps you introduce new versions safely, control when they go live, and track usage. It's useful for managing subtle variations like retouched photos or updated files, without creating separate assets.


First, select the asset you want to update. You’ll see a dropdown labeled **Aspects** in the right panel**. Select it**, then select **Versions**.** **When you select it,** **it will reveal the **Versions panel**: 

- The **current version** (blue indicator).
- Any **outdated versions in use** (orange indicators). 

### Upload a new version

Versions can be anything from retouched originals, watermarked images, or new logos and appear as a **new** **version** **of the same asset**, not as a separate asset. 

Learn more about [interacting with asset versions](https://www.sanity.io/docs/media-library/asset-versions).

## Advanced Media Library operations

### Aspect-based media management

Aspect-based management streamlines your workflow. It eliminates tedious folder searches, prevents rights violations, and enables smart filtering across your entire asset library. When information changes, such as extended usage rights or updated product details, you update it once. The changes automatically apply everywhere the asset appears. This structured approach prevents disorganization and creates a more efficient system. It saves time and reduces errors.

#### Real-world examples

- **Campaign photography**: Tag assets with campaign name, usage rights, and expiration dates so teams can quickly find approved images and avoid using expired content.
- **Product imagery**: Attach product IDs, SKU information, and seasonal relevance to images so they automatically appear in the correct product listings across all digital channels.
- **Brand assets**: Apply corporate identity guidelines, approved usage contexts, and regional restrictions to ensure consistent brand representation worldwide.
- **Event photography**: Tag with event details, featured people, and consent information to maintain compliance while making assets easily searchable for future content creation.

## Best practices for media management

### Organizing your assets

It’s not a new best practice to use consistent naming conventions for all uploaded files or to use focused collections for major projects and campaigns. What is new though, is the possibility to apply comprehensive aspects immediately upon upload by setting up automations such as:

- **AI-powered content recognition** that can identify objects, scenes, colors, and people in images.
- **Extraction of embedded metadata** from file properties like camera settings, creation date, and location.
- **Integration with third-party systems** to maintain metadata consistency within your assets. For example, product photography can be automatically tagged with the correct product IDs, campaign assets can inherit campaign-specific metadata, and usage rights can be applied based on source or creator information.

### Localizing media assets

For global organizations, effective media asset localization is essential for delivering exceptional user experiences across different regions. This can be achieved by:

- **Enriching assets with region-specific metadata** to ensure proper contextual usage. Add country-specific usage rights, regional campaign tags, market-specific product descriptions, or localized seasonal relevance indicators. For example, an image might have aspects indicating it's approved for European markets but not North American ones. It might contain culturally-specific content appropriate only for certain regions.
- **Create region-specific collections** to organize assets by market or territory.
- **Implement language-specific aspects** to quickly filter assets by supported languages.
- **Track regional usage rights** with expiration dates to maintain compliance.
- **Apply cultural context aspects** to prevent inappropriate asset usage across regions.

### Optimizing workflow efficiency

- Document your organization's media management guidelines.
- Train team members on best practices for asset uploading and tagging.
- Establish clear naming conventions for collections.
- Use aspects consistently across similar asset types.
- Use bulk operations for efficient updates to multiple assets.
- Establish clear roles and responsibilities for media management.
- Set up automated workflows for common tasks like archiving outdated assets.

## Continue learning

### Official user guides

Ready to dive deeper? Explore official Sanity documentation for advanced features and best practices.

We recommend you start with our [Media Library Overview](https://www.sanity.io/docs/media-library/introduction).

## Glossary of common terms

Understanding the terminology used in Sanity Media Library will help you navigate the system more effectively:

- **Aspects**: Metadata attributes attached to assets that describe their properties, usage rights, and relationships to other content.
- **Asset sidebar**: Panel displaying detailed information and editing options for a selected asset.
- **Asset versions**: Different iterations of the same asset that allow for controlled updates and tracking of usage.
- **Bulk operations**: Actions performed on multiple assets simultaneously.
- **Collections**: Curated groups of assets organized for specific purposes like campaigns, projects, or themes.
- **Filters**: Controls that help narrow down asset search results based on specific criteria.
- **Sync**: Process of updating asset versions across multiple references.
- **Usage tracking**: Feature that shows where assets are being used across your content.



# Meet the library

## Media Library at a glance

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

Media Library is home to your organization's shared assets. It stores assets for use across your projects and datasets, and allows content teams to have a central source of truth for their media.

![a screenshot of a media library showing various images](https://cdn.sanity.io/images/3do82whm/next/cae386064a9678b739ff46b3370a3773a92c6c10-3136x1596.png)

Media Library is an organization-wide application. [You can access it from the dashboard](https://www.sanity.io/docs/dashboard) by selecting the "Media" icon in the left navigation bar. Media Library requires the dashboard.

> [!NOTE]
> Where are my existing assets?
> If you've been using Sanity already, you may have images and other files that you're using in your studios. These files are saved within your datasets, and they are not automatically copied into the media library.
> Soon, we will add the capability to migrate existing assets into the media library and preserve connections to those assets within your studios.

## The library interface

The library adapts based on the assets you have selected.

![a view of the three main panels in the media library](https://cdn.sanity.io/images/3do82whm/next/0771a14ab13fa57617b60a79fda2c3f416825d5b-3128x1596.png)

The core of the interface is split into three sections:

1. The asset list: View existing assets, filter the results, and upload new assets.
2. The library menu: Narrow your view of the asset list, explore collections, navigate folders, and see recently uploaded assets.
3. The asset sidebar: Edit asset metadata, apply aspects, and view additional details about the asset. 

## Assets

### Uploading assets

There are two ways to upload assets in the library interface:

1. Select the **Upload** button in the top right of the asset list to upload an asset.
2. Drag-and-drop one or more assets directly into the asset list to start an upload.

As your assets upload, you'll see a status screen showing the progress of each asset.

### Select multiple assets

Click **Select** in the top-right of the asset list, then click each asset to add to your selection.

![A dark-themed media library interface displaying a grid of pink, purple, and blue image thumbnails, with the "Select" button highlighted and "4 assets selected" visible.](https://cdn.sanity.io/images/3do82whm/next/85243b40d8e9e34c244fad9fab00ecd77c49d0ce-1585x966.png)

### Delete assets

To delete one or more assets, first select them in the asset list.

Next, select the vertical **"..."** icon from the popover at the bottom of the asset list.

![a screenshot of the popover that says delete 1 asset](https://cdn.sanity.io/images/3do82whm/next/1582b2b41d1a0b92c88cb6742a396be2af2da257-1306x826.png)

Select **"Delete 1 asset"** to delete the asset.

> [!TIP]
> Deleting an asset also removes any [shortcuts](https://www.sanity.io/docs/media-library/interface) that point to it. If the asset is currently referenced by a document in one of your studios, deletion is blocked until those references are removed.

## Folders

Folders organize your assets into a navigable hierarchy, similar to a file system. Use folders to reflect your team's structure, projects, or any taxonomy that matches how you work. Each asset can live in one folder, and shortcuts let a single asset appear in additional folders without duplicating it.

### The folder tree

The folder tree lives in the library menu on the left of the asset list. Click a folder to view its contents, assets and any subfolders inside it. Breadcrumbs above the asset list show your current location and let you click back to any ancestor folder.

### Create a folder

To create a folder, open the **Add** menu in the header and select **New folder**. The new folder appears in the tree at the location you're currently viewing. To create a subfolder, navigate into the parent folder first.

### Move assets into a folder

To move an asset, select it in the asset list and use the **Location** action in the asset sidebar to pick a destination folder. To move multiple assets at once, select them, then use the same Location action in the bulk-edit sidebar.

You can also drag files from your operating system onto the Media Library window while viewing a folder. The upload starts immediately and the assets land in that folder.

### Shortcuts

Sometimes the same asset belongs in more than one place. Create a **shortcut** from an asset's actions menu. The asset appears in your chosen destination folder with a small badge to mark it as a shortcut.

If you delete an asset or remove it from its folder, every shortcut that points to it is cleaned up automatically. Moving an asset between folders preserves its shortcuts.

### Delete a folder

Open the folder, then use the **Delete folder** action. A confirmation dialog shows a summary of the folder's contents so you know what will be removed. Folder deletion is permanent and removes everything inside.

If any asset inside the folder is currently referenced by a document in one of your studios, the deletion is blocked until those references are removed. The dialog shows you which assets are blocking.

For the developer guide to folders, including programmatic operations, query patterns, and the API surface, see [Organize assets with folders](https://www.sanity.io/docs/media-library/folders).

## Aspects

![a screenshot of a media library with an asset detail panel open](https://cdn.sanity.io/images/3do82whm/next/6bb17c72377a527f1b361a8ede94a1877f2360b1-3388x1910.png)

Aspects let you organize your assets with custom fields. Aspects are defined programmatically with a schema-like syntax.

#### Developing aspects

[Create an aspect](https://www.sanity.io/docs/media-library/create-aspect)
Create and deploy aspects for Media Library.

[Aspect patterns](https://www.sanity.io/docs/media-library/aspect-patterns)
Common patterns for defining aspects

You can use aspects to sort and filter results in the asset list, or to store internal metadata.

### Add aspects to an asset or edit an aspect

To add aspects to an asset, first select one or more assets in the asset list.

The sidebar will list all available aspects. You can click the title of any aspect to expand it and change its values.

![A digital asset manager interface showing a grid of image thumbnails, with an image selected  and its metadata details in a side panel](https://cdn.sanity.io/images/3do82whm/next/f9d9bacf93e99fe5ec37866b8f69b1b59305a36a-720x556.png)

Once you've made changes to an aspect, select the** "Publish"** button to publish the changes to the asset.

> [!TIP]
> Publishing changes
> Don't forget to publish changes whenever you add or remove aspects, or when you make updates to the asset title.

### Filters and unpublished aspect changes

Filters in the asset list compare against the values you're currently editing. If an asset has unpublished changes, its draft aspect values decide whether it matches a filter, not its published ones. Clear an aspect field and the asset disappears from a filtered view right away, before you select **Publish**.

An asset that disappears this way is still in the library. Remove the filter to see it again, then restore the aspect value or select **Publish** to confirm the change. To see which assets have unpublished changes, add the **Status** filter and select **Has draft**.

## Collections

![a screenshot of the media library showing a collection of landscapes](https://cdn.sanity.io/images/3do82whm/next/de4563243f810e5c856444146aa6c22544debc80-3070x1596.png)

Collections allow further grouping of assets and are not limited to available aspects. You can create new collections while selecting an asset, or from the collection's screen.

### Add an asset to a collection

You can add an asset to a collection in two ways:

1. Navigate to the collection, then select **"Add"** in the top right, where the upload button normally is.
2. In any view, select the asset then, then select the vertical **"..."** icon, then select **"Add to existing collection"** from the popover menu.

> [!WARNING]
> Collection deletion is permanent
> Media Library has no trash can or restore mechanism for deleted collections. Once a collection is deleted, it cannot be recovered. The assets within the collection are not deleted, but the collection grouping is gone permanently.
> Before deleting a collection, note its contents or export a record of the assets it contains.

## Public and private assets

By default, assets are public to any person or app with the URL or identifier. You can set an asset to private to limit its visibility to logged-in users of the Media Library.

To change an asset's visibility:

1. Select the asset in Media Library.
2. In the [asset sidebar](https://www.sanity.io/docs/media-library/interface), select the visibility indicator. If the asset is public, it will display **Public** with a globe icon. If the asset is private, it will display **Private** with a lock icon.
3. Select the desired visibility from the popover list.

![User interface with the visibility selector open and "Private" selected.](https://cdn.sanity.io/images/3do82whm/next/c73070a8ecc248111f9324f07fb7d430bd73b6d2-770x672.png)

### Private asset restrictions

When setting an asset's visibility to private, keep the following in mind:

- Assets set to "Private" are not served through their normal public URL. Logged-in Media Library users can still see them, and an app or website can be granted time-limited access with a signed URL. Without a signed URL or a Media Library session, the asset isn't accessible.
- Switching visibility does not require a "Publish" for changes to take effect. 
- When changing from public to private, the asset's URL may remain active for up to 30 days if it was previously cached. To limit this, set assets to private during upload.



# Introduction

Canvas is a collaborative writing environment with AI assistance, contextual notes, and real-time collaboration. 

This guide covers everything you need to know about writing and collaborating in Canvas.

## The document editor

Canvas offers a clean, distraction-free writing environment that should feel instantly familiar to anyone who has used a modern word processor or text editor. The interface is designed to put your content front and center, allowing you to focus on getting your thoughts down without any clutter or unnecessary features getting in the way.

![A note-taking app displays a document titled "Top destinations for potato lovers" with an option for AI-assisted note organization.](https://cdn.sanity.io/images/3do82whm/next/43d41a8deae8f183c8da1c263ecd4fa6408b43d8-1668x739.png)

### Documents

Documents are the core unit of Canvas. You can browse all your existing documents in **All documents**, where you'll also find templates created by you or others in your organization.

![The Canvas document management application interface, with "Created by me" selected in the sidebar, displaying a list of documents.](https://cdn.sanity.io/images/3do82whm/next/ab9f36e4914d0e8a62f147b1874ab636d8c74438-1668x739.png)

To create a new document, use the sidebar or the document browser. To delete a document, click the ellipsis menu in the top right corner of the editor and select **Delete**. This action is permanent.

## Formatting

Canvas supports the formatting habits you already have. 

![Text "Are you a true potato lover looking for your next adventure?" with "looking" highlighted, above an editing toolbar.](https://cdn.sanity.io/images/3do82whm/next/a79b0dcde124bfae5790e4f658b4a42323945c0c-495x114.png)

- Inline ****markdown**** formatting
- `/ ` slash commands 
- Select some text and click the **B** or *i* icon 

### Slash commands

You can use familiar slash ` / `commands to quickly apply headings, lists, quotes, and more without taking your hands off the keyboard. 

![A command palette menu showing 'Instruction' highlighted under the AI section.](https://cdn.sanity.io/images/3do82whm/next/08f3864336efce1792362c5cd78cdb58eef25e65-1026x537.png)

### Keyboard shortcuts

Canvas supports many common keyboard shortcuts for formatting text. And has a couple additional shortcuts added to the roster for common operations. See [Keyboard shortcuts](https://www.sanity.io/docs/canvas/keyboard-shortcuts) for the full list.

### Formatting toolbar

For those who prefer a more visual approach, basic formatting options like bold, italic, and underline are also available via buttons in a popover whenever text is selected.

![Text "Are you a true potato lover looking for your next adventure?" with the word "looking" highlighted, above a text editing toolbar.](https://cdn.sanity.io/images/3do82whm/next/a79b0dcde124bfae5790e4f658b4a42323945c0c-495x114.png)

## Working with images

You can add images to your Canvas document by pasting or dragging and dropping them into the editor, or by using the slash ` / ` command menu. Images are stored with your document and can be included when you send content to Studio.

![Exterior of the Canadian Potato Museum with a giant potato statue and two people posing.](https://cdn.sanity.io/images/3do82whm/next/319fb543251733a28510f5e0ef467a4f3d1bea9d-1312x857.png)

## Content references

Once a content type is set, you can reference existing Studio content directly from Canvas using the `@` shortcut. This lets you search for and insert references to existing Studio documents: authors, topics, related articles, or any other content type you have access to in the connected studio. When searching for references inside a field label, reference search is filtered to include only matching content types.

Writers can build document relationships as part of the writing process rather than as a separate data-entry step in Studio.

The `@` reference shortcut requires at least read access to the connected Studio. If you have Canvas-only access, you can work with field labels but won't be able to insert content references. Request access through the Canvas UI or ask your administrator for Studio read access if your workflow requires references.

## AI writing assistance

Canvas offers multiple modes of AI support. Most readily apparent is the subtle circle icon that follows you around the document, affectionally known as "the Blip".

![A "Resources" list of bullet points, with a "Ghostwrite" menu open displaying options such as "Show me options" and "Rewrite paragraph."](https://cdn.sanity.io/images/3do82whm/next/f74d577ad55cc8d410755677c92c9349bbc8d1fe-1920x1197.png)

### Ghostwrite

**Ghostwrite** is your go-to for generating new content or expanding on existing ideas. When you select this option, the AI assistant analyzes your current position in the document, along with any relevant notes and surrounding context, to suggest a continuation of your writing. Depending on where your cursor is placed, the AI may suggest completing the current sentence, starting a new paragraph, or even beginning a new section with a relevant heading.

### Show options

**Show options** presents you with a range of alternative suggestions for how to continue your writing. When you click this option, the AI generates multiple possible paths forward based on your current context and notes. These options might include different ways to complete the current thought, introduce a new idea, or transition to a related topic.

![A webpage showing a "Conclusion" about potato museums, with text about interactive exhibits and a sidebar menu with "Hands-On Potato Experiences" highlighted.](https://cdn.sanity.io/images/3do82whm/next/a9c43018335d28b381b91fe61784d1313e1f03df-3840x1739.png)

### Rewrite

**Rewrite paragraph** generates an alternative version of the current paragraph, with the option to provide a brief on what to change.

![A UI showing an original text about potatoes and a more enthusiastic rewritten version, with options to accept or restore.](https://cdn.sanity.io/images/3do82whm/next/d730d574ed398199e95bf830af49de24cdc07ab4-1920x1957.png)

### AI instructions

Create and run AI prompts directly from text in your document, or use persistent instruction blocks that can be included in templates.

![AI-generated draft blog post about potato tourism.](https://cdn.sanity.io/images/3do82whm/next/525d7d09170a65deb37d6e9754bf0329d464b537-1332x693.png)

## Notes

Notes provide context, facts, style guidelines, and inspiration to inform your writing, and they help the built-in ghostwriter make relevant and informed suggestions. By attaching relevant notes to your document or template, you give the AI the background knowledge and topical awareness it needs to be of actual help.

The more relevant and specific your notes, the better the AI can tailor its output.

### Note types

Canvas supports four types of notes, each serving a different purpose:

- **Context notes** provide high-level background information and framing for the document, such as project briefs, target audience details, or internal enablement material.
- **Fact notes** contain specific data points, quotes, or pieces of information that should be treated as factual and incorporated into the content where relevant.
- **Style notes** outline the desired voice, tone, and stylistic guidelines for the document, so the ghostwriter adopts the appropriate tone and style for the piece.
- **Inspiration notes** collect examples, analogies, or creative prompts to inspire the writing and infuse it with engaging elements.

![A notes application interface displaying four notes: 'Best destinations for carrot lovers blog post', 'Mission statement', 'List of potato museums', and 'Voice and tone'.](https://cdn.sanity.io/images/3do82whm/next/74c7e94c183c57c27da253fab29ed22e70e25ef8-486x248.png)

### Creating and managing notes

To create a note, click the **+** button at the top of the notes panel. Add text, images, or PDF files. Canvas will classify the note type and suggest a title automatically, though you can override both. When you paste a URL into Notes, you have the option to include the linked content as context.

![Context menu for "https://sanity.io" with options "Paste as", "Context", and "Link".](https://cdn.sanity.io/images/3do82whm/next/a680bcae2a7514d9fb8320c9290f683633bd5c49-274x290.png)

Move and rearrange notes by dragging them in the notes panel. Right-click any note to duplicate or delete it. Rename a note by clicking its title.

### How notes inform the AI assistant

When you provide notes, the AI uses this information to guide its content generation. Context notes help the AI understand the big picture and overall purpose of the document. Fact notes ensure accuracy by providing specific data points to incorporate. Style notes allow the AI to adopt the appropriate voice and tone for the piece. And inspiration notes give the AI creative fodder to draw from, helping to make the writing more engaging and colorful.

### AI assistance inside notes

You can also use the AI assistant inside individual notes. Select text and press **Cmd+Return** to run it as an instruction, or click the AI contextual menu icon (the subtle circle that follows your cursor) to access **Ghostwrite**, **Show options**, and **Rewrite**. This is useful for refining notes without leaving the notes panel.

## Writing with structure in mind

If a content type has been set on your document, document content can be annotated with field labels. These labels show which parts of your content map to which Studio fields, so you can see the structure of your content without leaving the editor.

You can still write freely: field labels are visible context, not obstacles. They give you full control over how your content transfers to Studio fields, and you'll be able to send your content to Studio with a single click when you're ready.

If you prefer starting with a clear structure, add field labels to the document or template before adding any content. Alternatively, you can apply field labels manually or automatically to any free-form content in the document once the content type is set.

For the full walkthrough on setting content types, applying field labels, and sending content to Studio, see [Structuring content for Studio](https://www.sanity.io/docs/canvas/structuring-content).

## Collaboration

Canvas supports real-time collaborative editing. Presence indicators show who else is in the document and where they're working. Leave comments on specific content, and tag colleagues for review. History logs provide an audit trail of who made changes and when.

### Real-time editing

Multiple people can work in the same Canvas document at the same time. Changes appear in real time for all collaborators.

### Presence indicators

Presence indicators show who else is in the document and where they're currently working.

### Comments

Leave comments on specific content to provide feedback or ask questions. Tag colleagues with `@` mentions in comments to bring them into the conversation. Note that `@` mentions in comments are separate from `@` content references in the document body.

### History

History logs provide an audit trail of who changed what and when, so your team can track the evolution of a document.



# Content mapping

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Canvas is a great tool for freestyle writing, but when it's time to put your creative output to work, you'll want to move everything into a structured environment where it can enjoy all of the benefits of treating content as data—Sanity Studio! 

> [!WARNING]
> Gotcha
> Some initial setup by a studio maintainer is needed to make content mapping work. Visit the article on [configuring content mapping](https://www.sanity.io/docs/canvas/configure-content-mapping) to learn more.

For example, you might sketch out a blog post in Canvas, and then connect your work to a new document in Sanity Studio of a specific content type—like a `blogPost`, with fields like `title`, `excerpt`, `body`, and `tags`. 

A pretty clever mapping agent—from here on lovingly referred to as "the bot"—will go to work in the background identifying which parts of your rich content in Canvas corresponds to which document fields in your studio and automatically mapping content appropriately. Subject to your overrides, of course. 

![A side by side view of Canvas and the Studio form](https://cdn.sanity.io/images/3do82whm/next/12a7a0278863030e0037ae130977b58feaf2ae27-5348x3516.png)
*Left: mapping content in Sanity Canvas. Right: The resulting document in Sanity Studio.*

You also have the option of marking certain parts of your document as **context**, to make the bot ignore your "notes to self" and other non-content. You can even include little helpful pointers to the bot, like:  `// slug: my-cool-post` or `!! title below`. The bot will try to infer meaning and decide what is content and what is context. Anything it gets wrong, you can fix!

## Get started

### Locate your project in the studio panel

- In Canvas, look for the button in the top right corner labeled **Studio **or, on smaller screens, with **an icon resembling three boxes arranged in a diagram** (a schema!).

![A side by side view of Studio buttons](https://cdn.sanity.io/images/3do82whm/next/ea28be77f21b02a45d72e5d7642c856326596f16-602x335.png)
*Look for one of these in the top right corner*

- Click the **Studio** button to reveal the **Studio** panel:

![The Studio panel in Canvas](https://cdn.sanity.io/images/3do82whm/next/86ebdb07bb1ab73bd890e6654b05342c1745ba0c-1276x1023.png)

- Find your project in the **Studio** menu. If not automatically selected for you, find your studio deployment and workspace in the appropriate dropdowns. Then, find the document type you want to map your content to. 

> [!WARNING]
> Gotcha
> Can't see any projects in the dropdown? You may have to contact the person or people responsible for maintaining the studio and ask them to [enable content mapping in Sanity Studio](https://www.sanity.io/docs/canvas/configure-content-mapping).

![Shows the studio link panel, now populated with the appropriate details](https://cdn.sanity.io/images/3do82whm/next/a3394db81990857e917df2e629caa7eb26f2284e-963x1023.png)

### Select and apply a document type 

- With your document type selected, click the button labeled **Connect and start mapping ->** to proceed.
- The link panel will change to show a "minimap" of the selected content type, with its fields laid out in a tree structure. Fields with a little arrow on their left can be clicked to expand and reveal their values, or drill down deeper into nested fields.

![A mapped document with the Studio panel open](https://cdn.sanity.io/images/3do82whm/next/db2f76b43ac5950a1f1613979bf103893ed50747-2674x1758.png)

Note also the bottom right status indicator, which shows the mapping agent already making progress. It will keep working in the background, intelligently mapping your content to corresponding fields. 

## Exploring the Link Panel further

As your content is mapped, you'll see the minimap tree of document fields starting to fill out with content. You might also notice the colors changing as the mapping agent finishes with a field.

![A mapped document with the Studio panel open, with all fields unfurled](https://cdn.sanity.io/images/3do82whm/next/5c2ad5fd6ae5911c1c59a92c4e9c7c3f5935853e-2674x1758.png)

### Using colors to discern mapping state

![Green – Automatically mapped. Gray – Treated as context. Yellow – Manually mapped.  Black / white – Not yet analyzed.](https://cdn.sanity.io/images/3do82whm/next/c165edd6bd1216f399d6a998f6377747e4b3e64b-2800x1078.png)

As the content is mapped, you'll notice your screen getting progressively more colorful. Content that was automatically mapped to a field will be tinted **green**, as will the corresponding field in the link panel minimap, while anything the bot has decided is **context** will get a light gray color.

The **yellow**-colored field in the screenshot above indicates a field where an editor has actively overruled the suggested mapping and manually linked a bit of content to a field, while text in **black** or **white** (depending on whether or not the dark mode is active) indicates content that the mapping agent hasn't yet analyzed.

### Adjusting the results

While the automatic mapping is quite good (really!), you may at times want to manually adjust how your content in Canvas matches up with your studio schema. The tools you need to make these changes are at your fingertips.

- To map a content block, like a paragraph or an image, to a specific field, click on the item to reveal its context menu, and find the option to **Map to field... **as shown below.



- Selecting **Map to field...** will cause the interface to direct focus to the link panel, where you can select an appropriate field to map your selected content to. In the example below, we mapped the first image in the document to the **Cover Image > Asset** field. User-defined mappings are shown in yellow, instead of green for auto-mapped or gray for context.

![The studio panel when custom field mapping mode is enabled](https://cdn.sanity.io/images/3do82whm/next/73d809751a129e01dd1cbc3fa354a648a5bd5352-2674x1758.png)

- Similarly, if any content is mapped incorrectly, you can unmap it by clicking the **Clear mapping** button. Note that unless you explicitly reassign it as  context, the mapping agent will try to re-map on its next pass until everything has been neatly categorized with a color.



- For more granular control, you can select specific sentences or phrases and map them individually to Studio fields.



- As also demonstrated in the previous screenshot, leaving some contextual hints for the mapping agent can be quite effective. You can read more about this in the section on [content mapping tips and tricks](https://www.sanity.io#block_40).

With these tools, you can control exactly how your content in Canvas will be translated into structured data in Sanity Studio. If you haven't already, this would be a good time to link your work to a new document in your studio.

## Link your work to a new document in your studio 

> [!TIP]
> Protip
> In this article, we’ve chosen to complete the mapping work first, and then create the studio document for narrative clarity. However, you’re free to do it the other way around—choose the workflow that suits you best!

Once you're happy with your mappings, find the button labeled **+ New studio document** near the top of the studio panel. If you have any scheduled [content releases](https://www.sanity.io/docs/user-guides/content-releases), you will also have the option to choose a specific release to link your document to.

![A Studio user interface with a 'New studio document' button's dropdown menu open, displaying 'Create a draft document' and a list of 'Releases', including 'Christmas launch Dec 24 2025'.](https://cdn.sanity.io/images/3do82whm/next/df88a28caeda0ebfc04a4796d57b3b03bf25fb13-1080x743.heif)

Clicking it should result in a visual confirmation of success, and the button label changing to **Linked document**. Click it to open the connected studio with your new document selected. 

![A view of the studio once it's been linked to Canvas](https://cdn.sanity.io/images/3do82whm/next/930f33274d3a4b467219a8f98ebdcaca8a8c7bba-2674x1758.png)

You'll notice that your new document in the studio is in a read-only state while linked to its counterpart in Canvas. 

Any further changes you make in Canvas will be synced with the studio document automatically, so you can continue refining and expanding your content without worrying about manually transferring anything.

### Unlink your document from Canvas to edit it in Sanity Studio

As mentioned, your document will appear as **read-only** in your studio while linked to Canvas. You can think of this as the Canvas document being the **source of truth** for both versions while the link persists. In order to edit your document in Sanity Studio, you need to unlink it from its source in Canvas.

When the time comes, locate the **Unlink** button in the contextual menu next to your **Publish** button to sever the connection and edit your document in the studio. 

![The document context menu showing how to unlink a document from Canvas](https://cdn.sanity.io/images/3do82whm/next/4cce0bd87287bbac6d05eecb23608c4a79b1b3e1-817x677.png)
*Find the Unlink button where you'd might normally expect to see a Publish button, along with a handy shortcut to open the corresponding document in Canvas.*

Clicking **Unlink** will cause a dialog to appear, informing you of the consequences. Confirm to dismiss it and unlock the document for editing in the studio.



> [!TIP]
> Protip
> Unlinking does not delete anything! You can keep on editing your document in Sanity Canvas, though the changes will no longer sync to the studio version. They are no longer connected.

## Content mapping tips and tricks

- **Procedural discovery!** The bot works procedurally on one bit of content at a time, but it can and will make several passes, so it might re-visit and re-evaluate mappings as it moves through your content. You can use this to your advantage by adding content hints above content blocks to quickly make the bot reconsider its choices. 



In the example above, the image was originally judged to be part of the blog post `body` field, but remapped to the `coverImage` field after some gentle nudging.

- **Provide some context!** Mark words, lines, or whole blocks as context to make the mapping agent treat your notes as notes that should not be mapped to any field. The bot will also read you context for clues on how to treat content, so feel free to be conversational. `// slug: my-cool-slug` or `[description below]` might do wonders. 



## Troubleshooting

### Can't find your project?

Make sure your studio has been [configured properly](https://www.sanity.io/docs/canvas/configure-content-mapping) to allow Canvas to connect. This involves configuring and deploying the relevant studio.

### Can't see your content type, or content type is missing some fields

Make sure the relevant types or fields aren't configured to be excluded from content mapping. This, too, involves configuring and deploying the studio in question.

### The bot is making too many mistakes when mapping content

Try leaving some contextual clues to help the bot figure out what's what. There are no hard rules when it comes to what the bot will and will not pick up on, but as a general guideline: If it would be hard for a human co-author to catch your context notes, the bot will probably not do great either. Some examples:

- Partial mapping simple values with a simple inline instruction like `slug: my-cool-slug`
- Using headings as mapping clues for blocks
- Leave a note in plain text. `Note: Use this part for description`
- ... and if all else fails, manually adjust the mapping to get it just right

### I can't seem to map the title of my Canvas document to any field

Mapping the title is currently not possible, due to vague unspecified technical limitations. We're working on it!



# Roles

You can manage access to content and settings in your Sanity Content Lake by setting roles and permissions for project members. Each member may have different roles for granular access control to your Content Lake. All projects have default roles available, but you can also create **custom roles** that define granular access to datasets and project settings. You can also use GROQ to define custom **content resources**. Content permissions are typically set to either all or individual datasets, but you can use **Tags** to group datasets that should share permissions.

Roles and permissions can be [configured through the API](https://www.sanity.io/docs/http-reference/roles), or through the project settings available at [sanity.io/manage](https://sanity.io/manage). This article will focus mainly on the latter option.

## Default roles per plan

Each plan type has access to specifically defined roles. Custom roles are available for Enterprise customers.

#### Properties

**Administrator** (All plans)

Read and write access to all datasets, with full access to all project settings.

**Viewer** (All plans)

Read-only access to all datasets, with no access to project settings. Note: viewers can comment in projects where comments are enabled.

**Editor** (Growth and Enterprise)

Read and write access to all datasets, with limited access to project settings.

Editors can modify existing datasets, but cannot create new ones.

**Developer** (Growth and Enterprise)

Read and write access to all datasets, with access to project settings for developers.

**Contributor** (Growth and Enterprise)

Read and write access to draft content within all datasets, with no access to project settings. Can write but not publish documents.

**Custom** (Enterprise)

Fully custom roles and permissions, with custom access to project settings.

## Assigning roles to members

To assign roles to users, navigate to the Member section in your project settings at [sanity.io/manage](https://sanity.io/manage). You'll see each project member's roles listed by their name and login info.

The login info shows which sign-in method each member's account uses. A Sanity account belongs to a sign-in method rather than to an email address, so a member who signs in with a different method than the one they were invited under arrives as a separate account with no membership and no roles. To hand membership over to that account, remove the old one and send a new invitation, as described in [Account recovery](https://www.sanity.io/docs/help/account-recovery).

![Overview of project members in manage](https://cdn.sanity.io/images/3do82whm/next/554f0afe514787948ac29011ff22443a72e91450-796x385.png)

When using Single Sign-On (SSO), roles can be [automatically assigned](https://www.sanity.io/docs/developer-guides/sso-saml) to users using rules that evaluate each user’s group membership in your identity provider. Role assignment can be restricted to be set only through mapping rules, or allow for manual modification. If role assignment is restricted to be set only through mapping rules, you cannot manually change the role of a user in this screen.



![Shgows a popover alerting the user that roles are handled by identity provider and cannot be manually updated](https://cdn.sanity.io/images/3do82whm/next/9a7a942da4941567856316ac1613ac4a72ca57f9-1605x1365.png)

## Creating custom roles

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

To define custom roles, navigate to the **Access** tab in your project settings. You will see a list of your currently defined roles with a summary of each role's access privileges. To create a new custom role, click the button in the upper right corner.

![Shows the button indicated above](https://cdn.sanity.io/images/3do82whm/next/ee47a8e618a0fbcae78c8dd451ba40f27eded47e-1041x648.png)

You will be asked to provide some basic details for your role.

![Shows dialog for creating new role](https://cdn.sanity.io/images/3do82whm/next/787e50a9c2ed123cabbc98ed5d57ef0feedb799f-648x516.png)

Once created you'll have the option of adding members to the role or proceeding to define permissions and restrictions. These are divided into two main categories: **Content Permissions** and **Management Permissions**.

### About viewer roles

Users with the "Viewer" role are free and don't count toward a plan's available seats. If a user with the viewer role is assigned an additional role, that user will count as a billable user. 

> [!WARNING]
> Gotcha
> Only the built-in viewer role is considered a "free viewer." Any custom role, even if it only grants read-only access, is billed as a regular user.

## Management Permissions

These settings grant a role access to your project's settings which are typically accessed in the project management console at [sanity.io/manage](https://sanity.io/manage). Access to a project's details and usage statistics, members and roles, API settings, and datasets and tags are currently available for configuration.



> [!WARNING]
> Gotcha
> In order for a role to have access to the project, **Project Details** should be set to **read**. For content editor roles, also setting **Project Members** to **read** will ensure they get the best studio experience with the full advantage of [Presence](https://www.sanity.io/blog/introducing-presence) features.
> Custom roles that work with datasets also need **Project datasets** set to **Read**, in the **Datasets and tags** section of **Management Permissions**. Every built-in role that grants project access includes this permission, so it's easy to miss when you build a custom role from scratch. Without it, requests that read the project's datasets fail with `Unauthorized - User is missing required grant sanity.project.datasets/read to perform this operation`.

## Content permissions

This is where you define the role's access to your Content Lake. You can grant any role wide-reaching privileges that extend to all your datasets or use GROQ filters to set up granular access to only certain content types.

Once you've navigated to the role you want to configure you'll be presented with a list of your datasets that can be individually configured, as well as the opportunity to set some base permissions for *all datasets*.

![Shows overview of permissions for role](https://cdn.sanity.io/images/3do82whm/next/1934bbd28a3ca4b6cc182beed7d3a04b9b9035e3-910x780.png)

By default, all permissions are set to **No access**. Permissions cascade down from more general contexts to more specific ones, so it's generally better to start restrictive and grant privileges on each dataset separately as any permission granted on **All datasets** will override more restrictive settings in the individual datasets.

![Shows default permissions for all datasets set to "No access"](https://cdn.sanity.io/images/3do82whm/next/8d2232e47247037206b615fbac27a3b0bbebcc2a-677x313.png)

> [!WARNING]
> Gotcha
> Permissions are additive in nature. 
> That means you cannot remove a permission that has been granted to a role elsewhere.
> **Example**: If you defined that a role has `publish` rights for all documents in all datasets, it is impossible to define a resource (via GROQ filter) which only grants `read` access to a specific subset of documents.

This hereditary characteristic of permissions is visualized when you go to edit the permission for a single dataset. The dialog shown below demonstrates how the final permissions for the dataset are derived from both the privileges set generally for all datasets and from the privileges set specifically for this dataset.

![Shows permissions on several levels of specificity](https://cdn.sanity.io/images/3do82whm/next/7e324ed31d419d7136c61cdbe4e9b0830d77db16-826x735.png)

The base set of content resources available for access control are general in nature but powerful enough to cover many use-cases. You may grant privileges to read, create and update, and publish each of the widely encompassing options; **All documents**, **Image assets**, and **File assets**.

> [!WARNING]
> Gotcha
> If your dataset is **public** all project members will have read access to your **published content** *even if their role is set to no access*!

## Content resources

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

In addition to the basic set of permission scopes that lets you configure access to **All documents**, **Image assets**, and **File assets,** you may also create custom content resources to control access to particular content types, which you may then control the access to with your custom roles. To create a new content resource, find the **Resources** section in the left column menu, under the **Access** tab.

> [!WARNING]
> Gotcha
> The filter does not support dereferencing! This will **not** work: `referenceField->`! Instead, check against the `_ref` property when creating custom resources: `referenceField._ref == "my-referenced-doc-id"`.

![Shows the button described below](https://cdn.sanity.io/images/3do82whm/next/4f324593a0373521a2486807bf47e5afa91483b6-683x470.png)

In our example, we'll be working with the default starter template called *Movie Project*. This gives us a prefilled dataset with content types like `movie`, `person`, and *screening*. Click the button in the top right of the section to create a new content resource.

Name the content resource “Movies” and select the `movie` document type.

![A "Create new content resource" dialog showing "Document types" step with "Movie" selected.](https://cdn.sanity.io/images/3do82whm/next/b48cc6f3767b8b707c5a4c79888d5f907d3caae2-964x326.png)

No additional filter conditions are needed right now. Content resources leverage the power of [GROQ](https://www.sanity.io/docs/content-lake/how-queries-work) to filter which content types are affected by the privileges you choose to grant. In this example, we're using a simple but powerful GROQ expression to return only documents of type movie.

![A "Create new content resource" screen showing the active "Filter" step with a GROQ query `_type == "movie"`.](https://cdn.sanity.io/images/3do82whm/next/c928d4ad5e3a6e169c3b2f2e7630dbacafb74fdc-966x327.png)

> [!TIP]
> Protip
> With a deployed studio, the visual builder lets you build conditions using your schema definitions.

If we’re working with a studio without a deployed schema, we’ll need to provide the GROQ filter manually.

![Content resource creation screen showing the 'Filter' step with `_type == "movie"`.](https://cdn.sanity.io/images/3do82whm/next/e76344c99672de90f9783629033acc455fa1493a-998x331.png)

Once you hit save, you should see your new content resource added to the list.

![Shows the new content resource in the list of resource definitions](https://cdn.sanity.io/images/3do82whm/next/0707eba5207d5c510e085be4984a7dd6111e73cb-1766x576.png)

Revisiting the **Roles** section in the left column menu, we can now set `movie`-specific privileges on our custom role.

![Shows dialog for specifying permissions on content resource](https://cdn.sanity.io/images/3do82whm/next/926857104bb65dbd336033dadbaf08ef442f51f2-649x728.png)

To test your role, make sure you have actually set the role on a member account and then proceed to log into the studio with the account in question.

![Shows list of project members, one with new role specified](https://cdn.sanity.io/images/3do82whm/next/dd43be021181aa698dcc53d7ed502f17534dd9ee-667x189.png)

Your account should be able to view, create, update and publish any document of the `movie` type, but should be unable to edit documents of any other type.

![Shows a notification stating that current user does not have permissions to update document](https://cdn.sanity.io/images/3do82whm/next/850bcc2e259960e29685bd8742b20f7aab09eaae-640x357.png)

![Shows a notification stating that current user does not have permissions to update document](https://cdn.sanity.io/images/3do82whm/next/2cdd6060d774cc8654bd556f6ef2c79eab88d276-641x287.png)

## Permissions for Studio features

Some features, like drafts, Content Releases, and Scheduled Drafts read and write system documents that don't come from your schema. Those documents are addressed by a fixed document-ID path rather than by a type: drafts are stored as `drafts.<documentId>`, the copy of a document inside a release is stored as `versions.<releaseId>.<documentId>`, and the release itself is stored as `_.releases.<releaseId>`. A custom role reaches them through content resources that filter on the document ID, so there is no schema type or `sanity.*` wildcard to grant instead. A role scoped only to your own document types blocks these features, even when the same user can edit the underlying content.

Create one content resource per filter, then set the role's access level for that resource. Access levels are cumulative: **Read** grants read only, **Update and create** adds creating and editing, and **Publish** adds deleting and publishing.

The following table lists the content resource each feature needs:

##### Permissions for Studio features

| Studio feature | Content resource filter | Access level |
| --- | --- | --- |
| Work with drafts | `_id in path("drafts.**")` | Update and create |
| Edit documents inside a content release | `_id in path("versions.**")` | Update and create |
| Create, schedule, publish, and archive releases | `_id in path("_.releases.**")` | Publish |
| Upload images and files | The built-in image assets and file assets resources | Update and create |

### Grant Schedule drafts on a single document

[Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) on a single document is a scheduled release with one document in it. The Studio creates a release document, copies the draft into the release as a version document, then schedules the release. A role that can use it therefore needs all three of the document-path resources in the table, not just the one for the document being scheduled.

### Troubleshoot a role that can't schedule or publish

- `_id in path("_.draft.**")` matches no documents: drafts use the `drafts.` prefix, so the filter you want is `_id in path("drafts.**")`.
- Scheduling fails with `Insufficient permissions; permission "update" required`: the `_.releases.**` resource is set below **Publish**. Scheduling a release is a deferred publish, so **Update and create** isn't enough on its own.

The Access API expresses release permissions at a finer grain. Each release action is gated by a synthetic document ID of the form `_.releases.<releaseId>.actions.<action>`, so a filter such as `_.releases.*.actions.schedule` authorizes one action at a time and lets you allow scheduling while withholding publishing. The `_id in path("_.releases.**")` filter matches the release document and every one of its action IDs at once, which is why it takes a single access level. To grant release actions individually, see [Build a custom role with the Access API](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

## User attributes

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

[User attributes](https://www.sanity.io/docs/http-reference/user-attributes) are key-value pairs that describe a user within your organization; things like `location="torrevieja"`, `department="front_desk"`, or `year_joined=2019`. You can reference these attributes in content resource filters to create **parameterized roles** that adapt to each user automatically, rather than creating separate roles for every location, department, or team.

### Where attributes come from

Attributes can come from two sources:

- **SAML**: When users authenticate via SSO (Okta, Azure AD, Auth0, etc.), Sanity automatically captures all attributes included in the SAML assertion. These are refreshed on every login. No pre-configuration is needed. If the identity provider sends it, Sanity stores it.
- **Sanity (manual)**: Administrators can define additional attributes and set values directly in Manage or through the API. You can also use manual attributes to override a SAML-provided value for a specific user.

When both SAML and Sanity provide a value for the same attribute key, the Sanity value takes precedence. Removing the Sanity override reveals the SAML value again.

### Defining attributes

To define and manage attributes, navigate to the **Members** tab in your organization settings and find the **Attributes** section. Any attributes that have been captured from SAML logins will appear here automatically.

> [!WARNING]
> Gotcha
> User attributes are defined and managed at the organization level, not on individual projects. If you don't see the **Attributes** section, make sure you're viewing your organization's settings in Manage rather than a project's settings.

To create a new attribute, click the button in the upper right corner. You'll be asked to provide an attribute key (the name) and a type. Supported types are `string`, `integer`, `number`, `boolean`, and array variants of each (except boolean).

![The "Attributes" page of a web application, showing a table of custom member attributes including "brand," "department," and "email," with a search bar and "Create attribute" button.](https://cdn.sanity.io/images/3do82whm/next/ca3a656f935250d83701c20968da31f08ce05a84-1031x366.png)

> [!WARNING]
> Gotcha
> You cannot create a Sanity attribute definition when a SAML definition already exists for that key. If your identity provider sends an attribute like `location`, it will appear automatically—you don't need to define it again. You can set Sanity override values directly on individual users.

### Setting attribute values on users

To view and manage a user's attributes, navigate to that user within the Members section. You'll see all of their current attribute values, including which source each value comes from (SAML or Sanity) and which value is currently active.

From here you can:

- **Set a Sanity value** to override a SAML-provided attribute for this user.
- **Add a value** for an attribute that the user doesn't have from SAML.
- **Remove a Sanity override** to revert to the SAML value.

![A user role inspection panel, showing editable attributes and an 'Add attribute' dropdown open with options like 'brand' and 'department'.](https://cdn.sanity.io/images/3do82whm/next/0c2af3fa87daf1e83ef00f0eaa03259be0a53e26-708x842.png)

> [!TIP]
> Protip
> Overriding attributes is useful for temporary changes, like reassigning a user to a different location for a project, without modifying your identity provider. When you're done, remove the override and the SAML value takes effect again on the user's next login.

### Using attributes in content resources

Attributes become powerful when referenced in GROQ filters for content resources. Instead of hardcoding a value like `_type == "post" && branch == "london"`, you can use the `user::attributes()` function to make the filter dynamic:

```groq
_type == "post" && branch == user::attributes().branch
```

When a user with `branch="london"` accesses content through a role using this resource, the filter resolves to `branch == "london"`. A user with `branch="tokyo"` sees only Tokyo content. One role covers both users.

To create a parameterized content resource, follow the same steps as creating any content resource, but reference `user::attributes()` in your GROQ filter. The visual builder gives you an option to add user attribute conditions directly from your schema.

!["Create new content resource" screen with the "Filter" step selected, showing a GROQ filter configured to match documents where the user's "brand" attribute equals "brand".](https://cdn.sanity.io/images/3do82whm/next/c325a3c7921339ff819aa94d901c0d85dc3cd862-966x470.png)

#### Filter best practices

When you use `user::attributes()` in a content resource filter, be aware that if the referenced attribute is missing (due to a typo, a migration, or the user simply not having that attribute set), the expression evaluates to `null`. If the document field you're comparing against is also missing, the filter could simplify to `null == null`, which evaluates to `true`, granting access to all matching documents.

This means a misconfigured filter can silently escalate privileges rather than deny access.

To prevent this, either wrap `user::attributes()` in a `coalesce()` function or add an explicit `!= null` check:

**filters.groq**

```groq
// Unsafe: fails open when both sides are null
_type == "hotel" && location == user::attributes().userLocation

// Safe with coalesce: fails closed when the attribute is missing
_type == "hotel" && coalesce(user::attributes().userLocation, "__no_value__") == location

// Safe with null check: fails closed when the attribute is missing
_type == "hotel" && user::attributes().userLocation != null && location == user::attributes().userLocation
```

**Common scenarios where this matters:**

- The attribute name in the filter has a typo. For example, `userLocaton` instead of `userLocation`.
- An attribute migration removes or renames the attribute, but existing filters still reference the old name.
- A user has not been assigned the attribute referenced in the filter.
- The document field being compared against does not exist on some documents.

If any of these occur without a null guard, the filter will match all documents of the specified type, effectively disabling the access restriction.

### Example: genre-based editing

Continuing with our *Movie Project* example, imagine your team has editors who specialize in different genres. One group handles horror films, another covers documentaries, and so on. Without user attributes, you'd need a separate role for each genre: "Editor - Horror", "Editor - Documentary", "Editor - Comedy", and so on.

With user attributes, you define a single content resource with the filter:

```groq
_type == "movie" && genre == user::attributes().genre
```

Then create one role, such as "Genre Editor", that uses this content resource with read, create, update, and publish permissions. Assign the role to all editors. Each editor sees only the movies matching their genre, based on the `genre` attribute from their identity provider or set in Manage.

If an editor needs to temporarily cover a different genre, an administrator can set a Sanity override for that user's `genre` attribute without changing anything in the identity provider.

### Gotcha: SAML attribute types are inferred, and they can change to a list

SAML has no single-value type. Your identity provider always sends a claim as a list of values. Sanity infers the type of the attribute from the number of values that it receives:

- One value becomes a single value.
- Two or more values become a list.

A single value is not a final type. It only shows that no member of your organization has sent two values for that claim yet. When the first member signs in with two values, Sanity changes the attribute to a list for all members that hold that attribute. The stored values stay the same, but they become one-item lists.

Example, for a `department` claim:

```batchfile
Before   Alice   department = "Engineering"
         Bob     department = "Support"

Carol signs in, and her identity provider sends two departments.

After    Alice   department = ["Engineering"]
         Bob     department = ["Support"]
         Carol   department = ["Sales", "Ops"]
```

Alice and Bob did not sign in, and their department did not change. Only the shape of the value changed.

**Effect on filters:** Write your filters so that they work with a list. A filter that compares the attribute to an exact value stops to match after the change:

```typescript
// Fragile: fails after the attribute becomes a list.
user::attributes().department == "Engineering"

// Recommended: works for a single value and for a list.
"Engineering" in coalesce(
  user::attributes().department[],
  [user::attributes().department]
)
```

Because a filter controls access, a filter that is not updated can deny access to members who had access before.

The change to a list is permanent for that attribute. A later sign-in with one value does not change the attribute back to a single value.

## Tags

Tags are a useful feature that lets you group datasets with similar characteristics together so that roles and permissions can be conveniently set on multiple datasets in a single operation. You might create tags for different environments, such as `production` and `staging`, or combine tags for different publications and locales, E.g. `elle` `us` or `vogue` `jp`.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

To create a new tag, navigate to the **Datasets** tab in your project settings and find the **Tags** section in the left column menu.

![Shows the button described above](https://cdn.sanity.io/images/3do82whm/next/55c7e0536da439f29d08e05ec42a4f53e1d56042-1540x466.png)

In the example shown below we'll be creating a tag for staging and production datasets for our movie blog, and then assigning editing privileges in both for our `movie-critic` role.

![Shows setup dialog for new dataset tag](https://cdn.sanity.io/images/3do82whm/next/80c860d3162574acd4c512d218d997eefdafec7b-645x639.png)

Once created, we can add datasets to the tag and define permissions to content resources for our custom roles.

![Shows content permissions for dataset tag](https://cdn.sanity.io/images/3do82whm/next/17b14929387db6f537a1b496a87029d25490f0f7-1546x988.png)

The change is reflected and can be edited in the content permissions for the custom role.

![Shows the permission setting as described above](https://cdn.sanity.io/images/3do82whm/next/552ddfb71be659d716500c07b44cfd7ed3d7e64f-1554x1280.png)





# Quick start

Content Agent understands your content: your schema, your structure, your relationships. Ask it to find, update, audit, and improve content across your entire library, without writing a single GROQ query or filing a developer ticket.

Surface your oldest articles, flag missing fields, bulk update SEO content, or add alt text across hundreds of documents in one prompt. 
If your team is currently juggling Grammarly, spreadsheets, and a separate SEO tool, Content Agent handles all of it from inside the Studio, where your content already lives.

It can also go deeper. Ask it to identify content gaps, suggest improvements, or help your content perform beyond traditional search as answer engines like ChatGPT and Perplexity become primary discovery channels.

Nothing goes live until you're ready. Content Agent stages all edits as drafts in a bundle, so you can review, adjust, and publish on your own terms.

Log in to your organization's Dashboard at [sanity.io/welcome](https://www.sanity.io/welcome) to get started.



Here are practical examples, ordered from straightforward to more advanced, including [bulk updates across your entire content library.](https://www.sanity.io/docs/user-guides/content-agent-user-guide)

## Searching through your content

- *"Show me all [projects/products] in [location] that are currently [status]"*
- *"Find articles published in the last 3 months about [topic]"*
- *"List all [documents] with [specific criteria]"*
- *"What [member benefits/offers] are currently live?"*
- *"Which content types contain a [field name] field?"*
- *“Do we have any content about [topic]?"*
- *"List all [articles/documents] about [topic] published [this quarter/time period]"*
- *"Find all documents that reference [topic]"*
- *"Show content with the status draft for more than [time period]"*

> [!NOTE]
> Contextual Awareness
> Content Agent pays attention to what you have open. Open a document, and it focuses its answers on that content. Nothing open? It draws on your full project to answer.
> This means you don’t need to explain what you’re working on every time. 



## Surfacing information 

- *"Find the contact details for our [location] office" (see screenshot below)*
- *"Show me the FAQ about [service/process]"*
- *"Pull up the press release about our [report/announcement]"*
- *"Find the [project/product] page for [name]"*
- *"Show me the schema for [document type]" (see screenshot below)*
- *"What fields are available on the [document type]?"*





## Identifying content opportunities

### Generate insights

- *"What are the most common topics across our [articles/blog posts]?"*
- *"Which [projects/products] have the most referenced content?" (see screenshot below)*
- *"Summarize the key themes in our [content type] from the past [time period]"*
- *"Map content across [customer journey/product line/topic]"*



### Analyze the market and trends

- *"Show me which [topics/categories] have seen the most new content recently"*
- *"What [document type] are we publishing more of compared to last [time period]?"*
- *"Identify seasonal patterns in our [content type] publishing"*
- *"Which [topics/categories] are we under-representing compared to [previous time period]?" (see screenshot below)*
- *"Search the web for recent developments in [industry/topic]"*
- *"What are the current trends in [topic] we haven't covered yet?"*
- *"Identify gaps between our [content type] and current [market/industry] topics"*











### Identify gaps

- *"Which [locations/categories] don't have a profile page yet?"*
- *"Are there any [projects/products] missing [asset type] images?"*
- *"What [topics/services] do we mention frequently but don't have dedicated pages for?"*
- *"Show me [customer segments/audiences] we haven't created content for" (see screenshot below)*
- *"Which [product categories/sections] have fewer related [blog posts/supporting content]?" (see screenshot below)*

### Check facts

- *"Flag any [articles/pages] that reference [statistics/data] older than [time period]"*
- *"Which [documents] mention [outdated term/name/policy]?"*
- *“Here are our current prices: [paste text]. Now check all pricing pages in Sanity and flag anything that doesn’t match.”*
- *“Verify pricing in Sanity against what’s on [URL]”*





### Audit content

#### Freshness and completeness

- *"Show me [content type] that hasn't been updated in over a year"*
- *"Which articles about [topic] have the least content?"*
- *"List all [pages/documents] with missing [required field]"*
- *"Find [content type] with fewer than [number] words"*
- *“Which articles have no reviewer assigned, or were edited after their last review?”*
- *"Find articles with long unbroken text blocks and no subheadings”*

#### Consistency and compliance

- *"*Check this *[promotion/campaign/document] *for internal contradictions*"*
- *“Check if the [dates/numbers/details] in [document title/type] match across all fields”*
- *"Spot any conflicts between [field A] and [field B] in this [content type]"*
- *"Flag any [pricing/offers] that have expired but are still published"*
- *"Find blog posts that reference docs articles, then check the top 10 pairs for whether the post summarizes, extends, or contradicts the docs"*
- *"Find [content] with [legal disclaimers/terms] that don't match current [policy/regulations]" (see screenshot below)*
- *"Show [documents] referencing [products/services] with status [discontinued/out of stock/archived]"*







#### Tone and writing style

- *"Which [articles/pages] don't match our [brand voice/tone guidelines]?"*
- *"Identify documents in the [category name] category with inconsistent tone compared to others in the same category. Focus on the description field. Flag any products that feel noticeably different in voice, formality, sentence structure, or use of technical vs. aspirational language and explain specifically what makes them stand out for our audience."*
- *"Identify instances where [headlines/titles] do not match our brand tone" (see screenshot below)*



#### Duplicates and overlaps

- *"Find the [docs/help] section with the most articles and then check which ones cover overlapping ground and could be merged"*
- *"Find [docs/help] articles that answer the same question, then identify which is the most complete and up-to-date, mark the others as candidates for redirection"*
- *"Find glossary terms that match a docs article title for a potential definition overlap between glossary"*
- *"Show docs articles referenced by more than 2 blog posts, then check whether those blog posts explain the same concepts differently from the docs"*

#### Terminology and accuracy

- *"Find [content] using [deprecated term] instead of [preferred term]"*
- *"Scan article and post titles and descriptions for known product name variants and flag any using unofficial or deprecated names"*
- *"Flag [content type] with terminology that doesn't match our [style guide/glossary]"*

####  Tagging and categorization

- *"Which [tags/categories] are overused or underused?"*
- *"Show me [content] that's missing [tags/categories] entirely"*
- *"Identify [documents] with inconsistent or mismatched [tagging/categorization]"*
- *"Find [tags/categories] that are redundant or could be merged" (see screenshot below)*





#### Inconsistent tone

- *"Review this [page/article] and rewrite it to match our [brand voice/tone guidelines]"*
- *"Make this description more [conversational]: use second person ('you'), active voice, and sentences under 20 words. Don't change any facts or product names."*
- *"Standardize the tone across all [document type] descriptions."*
- *"Rewrite this [introduction/section] to align with our brand guidelines."*
- *"Suggest [number] meta description options for this [page]."*

> [!NOTE]
> Cost Awareness
> As with human work, AI-assisted work requires energy to deliver results. Looking at a prompt like *“Read the body of [blog post from the last 12 months] and flag any links whose text is non-descriptive” this is what you can expect:*
> **Cost estimate:** For a website like Sanity.io this amounts to 64 blog posts, typically run 1–5KB of body content each. Total ~100–300KB of content. That's roughly **2–4 tool executions** on top of the initial query and about **10–12 AI credits total** for the full set.
> **What you'll get back:** A list of posts with flagged links, showing the link text and enough surrounding context to understand what the link is pointing to.
> **One important caveat:** This will catch pattern matches like short, generic link text. It won't catch every bad link (e.g. a URL used as link text, or a vague phrase like "this approach" that happens to be linked). Those require editorial judgment, not pattern matching.
> **Tip**: You can start your planning by asking Content Agent to “*Estimate how many AI credits you'd need to…” *and then write the instructions of the action you’d want it to plan for.

#### Accessibility

- *"Show me articles/posts that have a hero image but no alt text"*
- *"Which [videos/media] are missing captions or transcripts?"*
- *"Read the body of [blog post from the last 12 months] and flag any links whose text is non-descriptive"*
- *"Identify [headings/content] that may not follow a logical hierarchy" (see screenshot below)*





### Specific QA tasks

- *"Check this [article/page] for spelling and grammar errors"*
- *"Review all [document type] for broken links or missing references published in the past [x months]" (see screenshot below)*
- *"Flag any [content] that mentions outdated [pricing/dates/names]"*
- *"Verify that all [listings/items] have complete [required fields]"*



### Ops planning

- *"Show me all [draft/unpublished] content awaiting review" (see screenshot below)*
- *"List [campaigns/launches] coming up that need supporting content"*
- *"Generate a content calendar for [topic/initiative] over the next [time period]"*
- *“Which campaigns will expire in the next [x days] -> (continued by) -> “What content is associated with [campaign]?” -> “What else on the site links to that content?” --> “Create redirect documents for the old URLs” (add to a release A with publish date = campaign expiry date) -> “[Hide/disable] the expired pages” (add to a release B with * publish date = day after expiry*)*



## Generating content updates

### New content generation

- *"Write a short description for our new [project/product] targeting [audience]"*
- *"Generate a summary of this [document] for our news section"*
- *"Create a welcome email draft for new [members/customers]"*
- *"Suggest 5 article ideas about [topic/trend]" (see screenshot below)*
- *"Create [number] headline options in our brand tone"*
- *"Draft a FAQ section for this [page/topic]"*
- *"Draft a [product announcement/release note] for this update"*
- *"Create a structured [event page/landing page] for [webinar/campaign]"*
- *"Turn this [press release/brief/document] into a [blog post/article]"*



### Bulk updates

- *"Replace [old URL/brand name/term] with [new value] across all [document type]"*
- *"Bulk update [field name] to [value] on all documents matching [criteria]"*

#### Create translations

- *"Translate this [article/page] into [language]"*
- *"Generate [language] versions of all [product/service] descriptions"*
- *"Localize this [content] for our [region/market] audience"*

> [!NOTE]
> **Not sure how your content is structured? 
> **Ask Content Agent. It can walk you through your document types, explain what each field does, and tell you what’s required before you can publish.

**Discover available fields**

- *"What fields are available on [document type]?" (see screenshot below)*
- *"Which document types have fields related to [keyword]?"*
- *"Show me all required fields for [document type]"*

**Explore field values**

- *"Show me all [document type] options"*
- *"What options exist for [field name] on [document type]?" (see screenshot below)*
- *"What are the most common values in [field name] across [document type]?"*

**Understand relationships**

- *"Which [document type] share the same [field value]?"*
- *"Which [document types] reference [other document type]?" (see screenshot below)*
- *"Show me all [document type] with no [reference field] for orphaned/unlinked content."*
- *"Map content across [customer journey/product line/topic]."*







### Outdated or missing content

- *"Find all [articles/pages] that haven't been updated in over [time period] and suggest refreshed copy"*
- *"Rewrite this [description/intro] to reflect our current [offerings/messaging]"*
- *"Find the latest [specific statistic] from [source or topic area], show me what you found and where, then update [document] if I confirm."*
- *"Generate meta descriptions for all [document type] missing meta descriptions"*
- *"Write alt text for images in [section/document type] that don't have any"*
- *"Create excerpt summaries for [articles/pages] with empty preview fields" (see screenshot below)*



> [!NOTE]
> Workflow tips
> - Use specific field names in your prompts for more accurate results.
> - Before running a bulk action, test your prompt on one document first. 
> - Review, adjust, and share staged bundles with a teammate before publishing.
> - Filter your document selection before running bulk actions. Fewer documents means fewer unnecessary changes and keeps your AI usage in check.
> - Once you’re happy with your changes, collect them in a bundle and share with a teammate before publishing. Nothing goes live until you say so.



## Creating and transforming images

- *"Change this image to a [white/transparent/colored] background"*
- *"Adjust the [colors/lighting/composition] on this image"*
- *"Make this image match our brand [colors/style]" (see screenshot below)*
- *"Generate a hero image for this article in the style of [description or reference to existing images and/or using our brand's visual style: color palette, illustration style, etc.]"*
- *"Create [number] hero image concepts for [topic/page], one abstract/structural, one human/team-focused, one product-interface-focused. Describe each as a visual brief before generating."*
- *"Create a social media graphic announcing our new [project/product] in [location]"*
- *"Generate an illustration showing the [customer journey/process]"*



## Before making changes

A single prompt can touch hundreds of documents, so check the scope before running it. Each edit consumes AI credits, so a focused document selection saves money.

> [!TIP]
> Batch large jobs
> A single request can be too large even when the total amount of work isn't. Split a large set into smaller batches, run one batch at a time, and confirm each batch before starting the next. See [Request size and batching](https://www.sanity.io/docs/content-agent/introduction).

**Check impact**

- *"What will happen if I update [field] on [document type]?" (see screenshot below)*
- *"How many [document type] documents will be affected by this change?"*
- *"Which pages or components use this [document/field]?"*

**Check dependencies**

- *"What will break if I delete this [document/asset]?"*
- *"Are there any drafts or scheduled documents that reference this document?"*

**Preview and validate**

- *"Show me what this [document] looks like currently."*
- *"Are there any validation rules for [field name] on [document type]?"*
- *"Are there any fields on [document type] I can't edit because they are read-only in the Studio?"*



Content Agent can handle far more than what’s listed here, so feel free to experiment, combine ideas, and adapt prompts to your own workflows and content needs. 

## Next steps

For a deeper look at how Content Agent works, read the [Content Agent introduction](https://www.sanity.io/docs/content-agent/introduction). **We specifically recommend** non-technical users to review the following sections with their developer team and Sanity admin:

- [FAQs](https://www.sanity.io/docs/content-agent/introduction)
- [How searches and changes affect cost](https://www.sanity.io/docs/content-agent/introduction)
- [Limitations](https://www.sanity.io/docs/content-agent/introduction)
- [System Requirements & Permissions](https://www.sanity.io/docs/content-agent/introduction)



# Setting up your studio

## Create a new Studio with Sanity CLI

![Video](https://stream.mux.com/wIMs3CS7T4pP7hRArpQZsBZ01Be02vCjbK)

Run the command in your Terminal to initialize your project on your local computer.

See the documentation if you are [having issues with the CLI](https://www.sanity.io/docs/help/cli-errors).

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

## Run Sanity Studio locally

Inside the directory of the Studio, start the development server by running the following command.

**npm**

```shell
# in studio-hello-world 
npm run dev
```

**pnpm**

```shell
# in studio-hello-world 
pnpm run dev
```

**yarn**

```shell
# in studio-hello-world 
yarn run dev
```

**bun**

```shell
# in studio-hello-world 
bun run dev
```

## Log in to the Studio

**Open** the Studio running locally in your browser from [http://localhost:3333](http://localhost:3333).

You should now see a screen prompting you to log in to the Studio. Use the same service (Google, GitHub, or email) that you used when you logged in to the CLI.



# Defining a schema

## Create a new document type

![Video](https://stream.mux.com/IfVfAwxfwOKN2khdGCQ3cs5IuF1rYte1)

Create a new file in your Studio’s `schemaTypes` folder called `postType.ts` with the code below which contains a set of fields for a new `post` document type.

**/studio-hello-world/schemaTypes/postType.ts**

```
import {defineField, defineType} from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: {source: 'title'},
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
      initialValue: () => new Date().toISOString(),
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'image',
      type: 'image',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{type: 'block'}],
    }),
  ],
})
```

## Register the `post` schema type to the Studio schema

Now you can import this document type into the `schemaTypes` array in the `index.ts` file in the same folder.

**/studio-hello-world/schemaTypes/index.ts**

```
import {postType} from './postType'

export const schemaTypes = [postType]
```

## Publish your first document

When you save these two files, your Studio should automatically reload and show your first document type. Click the `+` symbol at the top left to create and publish a new `post` document.



# Querying content with GROQ

## Write your first GROQ query

![Video](https://stream.mux.com/Mc12Sdeu00ugrGuQyz00Du1G4AQZmT36UV)

Open **Vision** in your Studio's top nav bar and paste this query into the **Query** code block field.

**Vision**

```groq
*[_type == "post"]{
  _id,
  title,
  slug,
  publishedAt
}
```

- `*` represents all documents in a dataset as an array
- `[_type == "post"]` represents a **filter** to only return matching documents
- `{ _id, title, slug, publishedAt }` represents a **projection** which defines the attributes from those documents that you wish to include in the response.

## Run the query

Click **Fetch** to see the JSON output in **Results**. You should see the document you previously published in the results.

Queries run in Vision use your authenticated session, so you will see private documents – which have a `.` in the `_id` key, like `drafts.`. You will not see when queried from your front end in the next step.



# Displaying content in an Astro front end

## Install a new Astro application

![Video](https://stream.mux.com/BRpQTRNc2nAWQweqMyPFw5QoX7019MMOT)

If you have an *existing* application, skip this first step and adapt the rest of the lesson to install Sanity dependencies to fetch and render content.

**Run** the following in a new tab or window in your Terminal (keep the Studio running) to create a new Astro application with Tailwind CSS and TypeScript.

**npm**

```shell
# outside your studio directory
npm create astro@latest -- astro-hello-world --template with-tailwindcss --install --git --yes
cd astro-hello-world
```

**pnpm**

```shell
# outside your studio directory
pnpm create astro@latest astro-hello-world --template with-tailwindcss --install --git --yes
cd astro-hello-world
```

**yarn**

```shell
# outside your studio directory
yarn create astro@latest astro-hello-world --template with-tailwindcss --install --git --yes
cd astro-hello-world
```

**bun**

```shell
# outside your studio directory
bun create astro@latest astro-hello-world --template with-tailwindcss --install --git --yes
cd astro-hello-world
```

You should now have your Studio and Astro application in two separate, adjacent folders:

**your-project-folder**

```text
├─ /astro-hello-world
└─ /studio-hello-world
```

## Install Sanity dependencies

**Run** the following inside the `astro-hello-world` directory to:

- Install and configure the official Sanity integration [@sanity/astro](https://www.sanity.io/plugins/sanity-astro)
- Install [astro-portabletext](https://github.com/theisel/astro-portabletext) to render Portable Text

**npm**

```shell
# your-project-folder/astro-hello-world
npx astro add @sanity/astro -y
npm install astro-portabletext @sanity/image-url @tailwindcss/typography
```

**pnpm**

```shell
# your-project-folder/astro-hello-world
pnpm dlx astro add @sanity/astro -y
pnpm add astro-portabletext @sanity/image-url @tailwindcss/typography
```

**yarn**

```shell
# your-project-folder/astro-hello-world
yarn dlx astro add @sanity/astro -y
yarn add astro-portabletext @sanity/image-url @tailwindcss/typography
```

**bun**

```shell
# your-project-folder/astro-hello-world
bunx astro add @sanity/astro -y
bun add astro-portabletext @sanity/image-url @tailwindcss/typography
```

## Add Types for Sanity Client

**Update **`tsconfig.json` with the following additional code for TypeScript support of Sanity Client.

**/astro-hello-world/tsconfig.json**

```json
{
  // ...other settings
  "compilerOptions": {
    "types": ["@sanity/astro/module"]
  }
}

```

## Configure the Sanity client

**Update** the integration configuration to configure a Sanity Client to fetch content.

**/astro-hello-world/astro.config.mjs**

```
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";

import sanity from "@sanity/astro";

// https://astro.build/config
export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
  integrations: [
    // 👇 update these lines
    sanity({
      projectId: "YOUR_PROJECT_ID",
      dataset: "<YOUR_DATASET>",
      useCdn: false, // for static builds
    }),
  ],
});
```

## Start the development server

**Run** the following command and open [http://localhost:4321](http://localhost:4321) in your browser.

**npm**

```shell
# your-project-folder/astro-hello-world
npm run dev
```

**pnpm**

```shell
# your-project-folder/astro-hello-world
pnpm run dev
```

**yarn**

```shell
# your-project-folder/astro-hello-world
yarn run dev
```

**bun**

```shell
# your-project-folder/astro-hello-world
bun run dev
```

## Display content on a posts index page

Astro performs data fetching inside front-matter blocks (`---`) at the top of `.astro` files

**Create** a route for a page with a list of posts fetched from your Sanity dataset, and visit [http://localhost:4321/posts](http://localhost:4321/posts)

**/astro-hello-world/src/pages/posts/index.astro**

```tsx
---
import Layout from "../../layouts/main.astro";
import type { SanityDocument } from "@sanity/client";
import { sanityClient } from "sanity:client";

const POSTS_QUERY = `*[
  _type == "post"
  && defined(slug.current)
]|order(publishedAt desc)[0...12]{_id, title, slug, publishedAt}`;

const posts = await sanityClient.fetch<SanityDocument[]>(POSTS_QUERY);
---

<Layout content={{ title: 'Posts' }}>
	<main class="container mx-auto min-h-screen max-w-3xl p-8">
		<h1 class="text-4xl font-bold mb-8">Posts</h1>
		<ul class="flex flex-col gap-y-4">
			{posts.map((post) => (
					<li class="hover:underline">
						<a href={`/posts/${post.slug.current}`}>
							<h2 class="text-xl font-semibold">{post.title}</h2>
							<p>{new Date(post.publishedAt).toLocaleDateString()}</p>
						</a>
					</li>
				))}
		</ul>
	</main>
</Layout>
```

## Display individual posts

**Create** a new route for individual post pages.

The dynamic value of a slug when visiting `/posts/[slug]` in the URL is used as a parameter in the GROQ query used by Sanity Client.

Notice that we’re using [Tailwind CSS Typography](https://github.com/tailwindlabs/tailwindcss-typography)’s `prose` class to style the post’s `body` content. We installed `@tailwindcss/typography` in the dependencies step. Enable it by adding `@plugin "@tailwindcss/typography";` to `src/styles/global.css` below the existing `@import "tailwindcss";` line.

**/astro-hello-world/src/pages/posts/[slug].astro**

```tsx
---
import Layout from "../../layouts/main.astro";	
import type { SanityDocument } from "@sanity/client";
import { sanityClient } from "sanity:client";
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";
import { PortableText } from "astro-portabletext";

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`;
const post = await sanityClient.fetch<SanityDocument>(POST_QUERY, Astro.params);

export async function getStaticPaths(): Promise<{ params: { slug: string } }> {
  const SLUGS_QUERY = `*[_type == "post" && defined(slug.current)]{
    "params": {"slug": slug.current}
  }`;
  return await sanityClient.fetch(SLUGS_QUERY);
}

const { projectId, dataset } = sanityClient.config();
const urlFor = (source: SanityImageSource) =>
  projectId && dataset
    ? createImageUrlBuilder({ projectId, dataset }).image(source)
    : null;
const postImageUrl = post.image
  ? urlFor(post.image)?.width(550).height(310).url()
  : null;
---

<Layout content={{ title: post.title }}>
	<main class="container mx-auto min-h-screen max-w-3xl p-8 flex flex-col gap-4">
		<a href="/posts" class="hover:underline">&larr; Back to posts</a>
		{
			postImageUrl && (
				<img
					src={postImageUrl}
					alt={post.title}
					class="aspect-video rounded-xl"
					width="550"
					height="310"
				/>
			)
		}
		<h1 class="text-4xl font-bold mb-8">{post.title}</h1>
		<div class="prose">
			<p>Published: {new Date(post.publishedAt).toLocaleDateString()}</p>
			{Array.isArray(post.body) && <PortableText value={post.body} />}
		</div>
	</main>
</Layout>
```





# Deploying Studio and inviting editors

## Deploy your Studio with Sanity

![Video](https://stream.mux.com/CvYhCQr8e1oZt98NW202BZLLNv376VVKc)

In your Studio directory (`studio-hello-world`) run the following command to deploy your Sanity Studio.

The first time you run this command, the CLI will prompt you to enter a **hostname**. This is the unique name for your Studio's URL (entering *my-app* will make your Studio available at *my-app*.sanity.studio).

**npm**

```shell
npm run deploy
```

**pnpm**

```shell
pnpm run deploy
```

**yarn**

```shell
yarn run deploy
```

**bun**

```shell
bun run deploy
```

## Invite a collaborator

Now that you’ve deployed your Studio, you can optionally invite a collaborator to your project. Navigate to your project in [Sanity Manage](https://www.sanity.io/manage), then select "Members". 

They will be able to access the deployed Studio, where you can collaborate together on creating content.





# Agent Actions

#### Jump right in

[Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)
Get started with Generate by writing your first instructions to create and modify documents.

[Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
Get started with Transform by writing your first instructions to modify documents.

[Translate quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart)
Learn to translate documents with the Translate Agent Actions action.

#### Core concepts

[Agent Actions introduction](https://www.sanity.io/docs/agent-actions/introduction)
Get to know Agent Actions and how to start using them.

[Creating instructions](https://www.sanity.io/docs/agent-actions/instructions)
How instructions and style guides shape what Agent Actions produce, and how to pass data into them with parameters.

[Operations](https://www.sanity.io/docs/agent-actions/operations)
Use the `targetDocument` property to control how Agent Actions create or edit documents.

#### Dive deeper

[Custom field actions](https://www.sanity.io/docs/studio/ai-assist-field-actions)
Set up and use custom field actions in AI Assist to add Agent Actions or other custom actions to the document or field action menus.

[HTTP reference](https://www.sanity.io/docs/http-reference/agent-actions)
Reference documentation for the Agent Actions HTTP API.



# Introduction

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Agent Actions let you programmatically run schema-aware AI instructions to create and modify Sanity documents. You can run instructions from anywhere you can execute code, such as Sanity Functions, custom components, webhook listeners, CI/CD pipelines, and migration scripts.

With Agent Actions, you can:

- Add AI-assisted content suggestions.
- Generate draft documents with new content.
- Generate images based on fields within your document.
- Translate documents automatically or on demand.
- See live AI presence so your editors know when an Agent Action is working on a document.

You can create powerful AI-driven workflows by combining Agent Actions with Functions, Content Releases, the Actions API, and the rest of Content Lake's APIs. Each request with an Agent Action uses AI credits. [Learn more about Sanity's AI pricing](https://www.sanity.io/docs/platform-management/how-ai-credits-work).

#### Get started with the actions

[Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
Get started with Transform by writing your first instructions to modify documents.

[Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)
Get started with Generate by writing your first instructions to create and modify documents.

[Translate quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart)
Learn to translate documents with the Translate Agent Actions action.

[Prompt quick start](https://www.sanity.io/docs/agent-actions/prompt-quickstart)
Learn to send requests to the LLM using Agent Actions Prompt.

[Patch quick start](https://www.sanity.io/docs/agent-actions/patch-quickstart)
Make schema-aware patches with Agent Action Patch.

## Requirements

- You need a place to execute code, such as a custom component, cloud functions, a webhook listener, or any service that can run the JavaScript client or initiate HTTP requests.
- Sanity client (`@sanity/client`) version 7.4.0 or later and API version vX. Generate, Transform, and Translate are available from 7.1.0; Prompt and Patch require 7.4.0.

### Presence support

Install and [enable the AI Assist plugin](https://www.sanity.io/docs/ai-assist) for Sanity Studio to enable presence support. AI Assist is available to projects on the Growth plan and up, and requires Sanity Studio v3.26.0 or later.

### Image and reference support

Some Agent Actions, like Generate, can create images and connect references. To enable automation for image and reference fields, further setup is required.

#### Configure image and reference support

[Create images with Agent Actions](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
Generate and transform images with Agent Actions, either by targeting an image asset directly or by configuring image prompt fields for AI Assist.

[Enable references in Generate](https://www.sanity.io/docs/agent-actions/generate-add-references)
Use references in Generate to populate fields and connect documents based on your instructions.

## Core concepts

### Get to know the actions

Agent Actions all share the same core but are specialized for different uses.

#### Generate

When you want to create brand new content, you want Generate. It excels at creating new content based on the information you pull in from your existing Sanity documents.

Generate runs in `mixed` operation mode by default: it sets non-array fields, overwriting any existing value, and appends new items to arrays. Set `operation` on a target to change this — `set` replaces a field value entirely, `append` adds to it. When working with arrays, Generate adds new items but will not replace existing items in the array.

- Create full, structured documents in a single command.
- Reference multiple documents to use as the source for new documents.
- Generate images and make reference connections to existing documents.

[Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)
Get started with Generate by writing your first instructions to create and modify documents.

[Generate common patterns](https://www.sanity.io/docs/agent-actions/generate-cheatsheet)
Common patterns and best practices for using Generate

#### Transform

When you need to modify existing documents, Transform can walk through your document fields and make changes. It keeps the formatting and style, while only making the changes you tell it to make.

Transform only edits existing content—it does not create new fields or add new items to arrays. It modifies what is already present in the document.

- Change a document's tone.
- Rename a product across your entire site.

[Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
Get started with Transform by writing your first instructions to modify documents.

[Transform common patterns](https://www.sanity.io/docs/agent-actions/transform-cheatsheet)
Common patterns and techniques for using Agent Actions Transform.

#### Translate

A specialized version of Transform, Translate is designed with internationalization in mind. It supports both document-level translation and field-level translation. It's a fast way to translate documents into multiple languages.

[Translate quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart)
Learn to translate documents with the Translate Agent Actions action.

[Translate cheat sheet](https://www.sanity.io/docs/agent-actions/translate-cheatsheet)
Common patterns and examples for using Translate.

#### Prompt

Need to make prompts to a large language model (LLM) without reaching for another service? Prompt lets you use your content in Sanity to make requests, then process that information however you like.

[Prompt quick start](https://www.sanity.io/docs/agent-actions/prompt-quickstart)
Learn to send requests to the LLM using Agent Actions Prompt.

#### Patch

Agent Actions are schema-aware, which lets them validate and safely modify your documents. Patch uses this same approach with no LLM involved.

[Patch quick start](https://www.sanity.io/docs/agent-actions/patch-quickstart)
Make schema-aware patches with Agent Action Patch.

### Operations

Should an action create a new document? Should it edit an existing one? Operations tell each Agent Action how to act on your data.

[Operations](https://www.sanity.io/docs/agent-actions/operations)
Use the `targetDocument` property to control how Agent Actions create or edit documents.

### Instructions

Agent Actions use the concept of instructions to describe what tasks you want them to perform. These are combined with each action's configuration.

[Creating instructions](https://www.sanity.io/docs/agent-actions/instructions)
How instructions and style guides shape what Agent Actions produce, and how to pass data into them with parameters.

### Agent Actions understand your schema

Agent Actions know about your content model, which lets them map content accurately to your documents and fields. However, you must deploy an up-to-date schema version to make this work.

If you're already hosting your studio at Sanity, you're all set. Run `sanity deploy` from your project to ensure the latest schema is uploaded. If you're hosting your studio elsewhere, you can manually deploy the schema.

Refer to any of the [quick start guides](https://www.sanity.io/docs/agent-actions/generate-quickstart) for instructions on deploying your schema.

### How Agent Actions compare to AI Assist

[AI Assist](https://www.sanity.io/docs/ai-assist) is a plugin for Sanity Studio. It lets content editors create AI commands directly in the studio.

Agent Actions let you trigger, or invoke, AI workflows from anywhere you can make an API call or run the Sanity client. They also let you supply additional contextual information beyond what lives in a single Sanity document or field.

You can combine the two by [creating custom field actions](https://www.sanity.io/docs/studio/ai-assist-field-actions) that invoke Agent Action workflows.

## Usage and spending limits

Agent Actions usage is shared across the organization. For details on your plan's limits, see the [pricing page](https://www.sanity.io/pricing).

You can set spending limits and view your remaining budget in your organization's settings in Manage.

1. Navigate to [Manage](https://sanity.io/manage), or run `sanity manage` from the CLI.
2. Select your organization.
3. Go to **Settings > Spending limits**.

![The spending limits settings page located in your organization settings.](https://cdn.sanity.io/images/3do82whm/next/a1b82171bdfda2cce6213e02166a38d9929c9f15-2576x1120.png)

## Limitations

Agent Actions don't support the [File](https://www.sanity.io/docs/file-type) field type.

The following field types are supported, but with limitations:

- [Slug](https://www.sanity.io/docs/slug-type): Agent Actions don't perform uniqueness validation to check if the slug conflicts with others.
- [URL](https://www.sanity.io/docs/url-type): Agent Actions only write to this field type if the instruction includes links.

The following types require additional setup:

- [Image](https://www.sanity.io/docs/image-type) ([See the configuration guide](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)).
- [Reference](https://www.sanity.io/docs/reference-type) ([See the configuration guide](https://www.sanity.io/docs/agent-actions/generate-add-references). Requires the [Embeddings Index API](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview)). The Embeddings Index API is deprecated and will be sunset in a future release. There is currently no replacement for using references with Agent Actions.
- [Date](https://www.sanity.io/docs/date-type) and [Datetime](https://www.sanity.io/docs/datetime-type): To enable these fields, use the `localeSettings` in the request. [Learn more in the configuration guide](https://www.sanity.io/docs/agent-actions/agent-actions-date-support).

Agent Actions don't write to explicitly `hidden` or `readOnly` fields. Fields with conditional `hidden` or `readOnly` functions are also skipped by default. Opt back in per request with the `conditionalPaths` parameter. See the [common patterns guide](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet) for more details.

Agent Actions Generate and Transform can create images, but don't save new images to Media Library.

Agent Actions Generate cannot create annotations, custom marks, or inline blocks in Portable Text fields. It can generate content into existing elements if provided with the full path, but cannot create these elements otherwise.

## Third-party sub-processors

For a list of third-party sub-processors and the terms of use for Sanity's AI products, see [AI terms of use](https://www.sanity.io/legal/tos-ai).

## Troubleshooting

Agent Actions requests fail with a status code that tells you which layer rejected them. For the errors you're most likely to hit, including what does and doesn't cause a `401`, see [Troubleshoot Agent Actions requests](https://www.sanity.io/docs/agent-actions/troubleshooting).



# Operations

Agent Actions use the `targetDocument` property to establish how they write to your dataset.

> [!TIP]
> Generate and Patch support initial values
> The `generate` and `patch` actions also accept an `initialValues` property on `create`, `createOrReplace`, and `createIfNotExists` targets. With it, you can set initial values similar to how you would with [initial values templates](https://www.sanity.io/docs/studio/initial-value-templates). See the Generate examples under the `create` and `createOrReplace` operations.

## Default write behavior for Agent Actions

By default, **Agent Actions never mutate a published document**. Whenever you supply a published ID, the action creates a draft first before applying any changes. If a draft already exists, the action uses the existing draft as the source.

To change this behavior, you can supply `forcePublishedWrite: true` to the action request. For example:

**Transform**

```typescript
await client.agent.action.transform({
  schemaId: 'your-schema-id',
  documentId: 'publishedId',
  targetDocument: {
    operation: 'edit',
    _id: 'publishedId'
  },
  forcePublishedWrite: true,
  instruction: 'Replace "Create" with "Canvas"',
})
```

Documents that use `liveEdit: true` in their schema are treated as `forcePublishedWrite: true` by default.

For content release version documents, the operations only create version documents when a version ID is paired with an operation that creates a new document.

> [!TIP]
> Check the returned document ID
> Check the returned `_id` of the response to confirm whether the action wrote to a draft, a published document, or a version. See [@sanity/id-utils](https://github.com/sanity-io/id-utils) for helpers that classify document IDs.

## `targetDocument` operation types

This is `targetDocument.operation`, which selects how the document is written. It is separate from `target.operation`, which selects how a field value is written.

### `edit` operation

Requires an `_id` for an existing document. This is the verbose version of omitting `targetDocument` and only relying on `documentId`. Each action accepts `edit` as follows:

**Transform**

```typescript
await client.agent.action.transform({
  schemaId: 'your-schema-id',
  documentId: 'drafts.id',
  targetDocument: {
    operation: 'edit',
    _id: 'drafts.id'
  },
  instruction: 'Replace "Create" with "Canvas"',
})
```

**Generate**

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',

  // In generate, `edit` is equivalent to using `documentId`
  // without a targetDocument.
  targetDocument: {
    operation: 'edit',
    _id: 'drafts.id',

  },
  instruction: 'Create a blog post about Sanity, the Content Operating System',
})
```

**Translate**

```typescript
await client.agent.action.translate({
  schemaId: 'your-schema-id',
  documentId: 'fromLanguageDoc.id',

  targetDocument: {
    operation: 'edit',
    _id: 'drafts.id'
  },

  fromLanguage: { id: 'en-GB', title: 'English' },
  toLanguage: { id: 'nb-NO', title: 'Norwegian Bokmål' },
})
```

**Patch**

```typescript
await client.agent.action.patch({
  schemaId: 'sanity.workspace.schema.production',
  targetDocument: {
    operation: 'edit',
    _id: 'documentId'
  },
  target: {path: 'title', operation: 'set', value: 'New title'}
})
```

### `create` operation

The `_id` is optional. If omitted, a draft document is created. You can provide a valid version ID as the `_id` to create a release version of a document. For Generate and Patch, `targetDocument` also requires `_type`, the document type to create. Transform and Translate take the type from the source document. Each action accepts `create` as follows:

**Transform**

```typescript
await client.agent.action.transform({
  schemaId: 'your-schema-id',
  documentId: 'document-id',
  targetDocument: {
    operation: 'create',
    _id: 'new-document-id' // optional
  },
  instruction: 'Replace "Create" with "Canvas"',
})
```

**Generate**

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',

  targetDocument: {
    operation: 'create',
    _type: 'post',
    _id: 'DOCUMENT_ID', // optional
    // Use initialValues to set fields when the document is created.
    initialValues: {
      author: {
        _type: 'reference',
        _ref: 'AUTHOR_REFERENCE_ID'
      }
    }

  },
  instruction: 'Create a blog post about Sanity, the Content Operating System',
})
```

**Translate**

```typescript
await client.agent.action.translate({
  schemaId: 'your-schema-id',
  documentId: 'fromLanguageDoc.id',

  targetDocument: {
    operation: 'create',
    _id: 'toLanguage.id' // optional
  },

  fromLanguage: { id: 'en-GB', title: 'English' },
  toLanguage: { id: 'nb-NO', title: 'Norwegian Bokmål' },
})
```

**Patch**

```typescript
await client.agent.action.patch({
  schemaId: 'sanity.workspace.schema.production',
  targetDocument: {
    operation: 'create',
    _type: 'DOCUMENT_TYPE',
    _id: 'documentId'
  },
  target: {path: 'title', operation: 'set', value: 'New title'}
})
```

### `createOrReplace` operation

If you provide an existing `_id`, the new document overrides it. If the provided `_id` doesn't exist, the action creates a new document with that ID. Each action accepts `createOrReplace` as follows:

**Transform**

```typescript
await client.agent.action.transform({
  schemaId: 'your-schema-id',
  documentId: 'document-id',
  targetDocument: {
    operation: 'createOrReplace',
    _id: 'new-document-id'
  },
  instruction: 'Replace "Create" with "Canvas"',
})
```

**Generate**

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',

  targetDocument: {
    operation: 'createOrReplace',
    _type: 'post',
    _id: 'DOCUMENT_ID', // Replaces the document if the ID exists; otherwise creates it.
    // Optional: Use initialValues to set fields when the document is created.
    initialValues: {
      author: {
        _type: 'reference',
        _ref: 'AUTHOR_REFERENCE_ID'
      }
    }

  },
  instruction: 'Create a blog post about Sanity, the Content Operating System',
})
```

**Translate**

```typescript
await client.agent.action.translate({
  schemaId: 'your-schema-id',
  documentId: 'fromLanguageDoc.id',

  targetDocument: {
    operation: 'createOrReplace',
    _id: 'toLanguage.id'
  },

  fromLanguage: { id: 'en-GB', title: 'English' },
  toLanguage: { id: 'nb-NO', title: 'Norwegian Bokmål' },
})
```

**Patch**

```typescript
await client.agent.action.patch({
  schemaId: 'sanity.workspace.schema.production',
  targetDocument: {
    operation: 'createOrReplace',
    _type: 'DOCUMENT_TYPE',
    _id: 'documentId'
  },
  target: {path: 'title', operation: 'set', value: 'New title'}
})
```

### `createIfNotExists` operation

If the provided `_id` does not exist, a document is created using the `documentId` as the source. If it does exist, the action uses the provided `_id` document as the source. Each action accepts `createIfNotExists` as follows:

**Transform**

```typescript
await client.agent.action.transform({
  schemaId: 'your-schema-id',
  documentId: 'document-id',
  targetDocument: {
    operation: 'createIfNotExists',
    _id: 'new-document-id'
  },
  instruction: 'Replace "Create" with "Canvas"',
})
```

**Generate**

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',

  targetDocument: {
    operation: 'createIfNotExists',
    _type: 'post',
    _id: 'DOCUMENT_ID', // if the ID doesn't exist, a new document is created with the ID.
    // Optional: Use initialValues to set fields when the document is created.
    initialValues: {
      author: {
        _type: 'reference',
        _ref: 'AUTHOR_REFERENCE_ID'
      }
    }

  },
  instruction: 'Create a blog post about Sanity, the Content Operating System',
})
```

**Translate**

```typescript
await client.agent.action.translate({
  schemaId: 'your-schema-id',
  documentId: 'fromLanguageDoc.id',

  targetDocument: {
    operation: 'createIfNotExists',
    _id: 'toLanguage.id'
  },

  fromLanguage: { id: 'en-GB', title: 'English' },
  toLanguage: { id: 'nb-NO', title: 'Norwegian Bokmål' },
})
```

**Patch**

```typescript
await client.agent.action.patch({
  schemaId: 'sanity.workspace.schema.production',
  targetDocument: {
    operation: 'createIfNotExists',
    _type: 'DOCUMENT_TYPE',
    _id: 'documentId'
  },
  target: {path: 'title', operation: 'set', value: 'New title'}
})
```



# Targets and paths

In addition to [document operations](https://www.sanity.io/docs/agent-actions/operations), many Agent Actions use `target` to identify specific parts of a document to interact with.

If you haven't already, complete one of the quick start guides. The examples in this document use a mix of Generate, Patch, Transform, and Translate code.

#### Quick starts

[Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)
Get started with Generate by writing your first instructions to create and modify documents.

[Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
Get started with Transform by writing your first instructions to modify documents.

[Translate quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart)
Learn to translate documents with the Translate Agent Actions action.

[Patch quick start](https://www.sanity.io/docs/agent-actions/patch-quickstart)
Make schema-aware patches with Agent Action Patch.

## Target

Agent Actions that create or modify documents include a `target` property that determines which sections of the document will be affected by the instruction.

In actions where the target is optional, omitting it will set the document root as the target.

Actions accept a single `target` object or an array of `target` objects to support editing multiple parts of the document.

### Path

A target can be as simple as providing an individual [JSONMatch-style](https://www.sanity.io/docs/content-lake/json-match) path to a field. For example:

```
await client.agent.action.generate({
  schemaId: "your-schema-id",
  documentId: "<document-id>",
  instruction: `Rewrite the title to something more catchy`,
  target: {
    path: "title",
  }
});
```

Or even multiple paths, for example:

```
await client.agent.action.generate({
  schemaId: "your-schema-id",
  documentId: "<document-id>",
  instruction: `Write an article about cats`,
  target: [
    { path: "title" }, // target title
    { path: ["body", "description"] } // target body.description
  ]
});
```

### `maxPathDepth`

By default, Agent Actions will traverse 4 levels deep, starting at the root of the document or the target you define, to mutate your data. Depth is calculated based on the path depth. For example:

- `title` has a depth of 1.
- `array[_key="no"].title` has depth of 3.

```
await client.agent.action.generate({
  schemaId: "your-schema-id",
  documentId: "<document-id>",
  instruction: `Write an article about cats`,
  target: [
    { path: "title" },
    { path: ["body", "description"], maxPathDepth: 2 }
  ]
});
```

### `include` and `exclude`

Targets also accept `include` and `exclude` arrays of paths or targets. By default, all children up to the maxPathDepth are included. Setting either `include` or `exclude` will invalidate the other. For example, if you set include to a field, all sibling fields will be excluded.

```
await client.agent.action.generate({
  schemaId: 'default-schema',
  targetDocument: { operation: 'create', _type: 'article'},
  instruction: 'Stuff about dogs',
  target: {include: ['title', 'description']},
  // target: {exclude: ['title', 'description']} // or exclude
});
```

You can also combine these with path to refine a target.

```
await client.agent.action.generate({
  schemaId: 'default-schema',
  targetDocument: { operation: 'create', _type: 'article'},
  instruction: 'Stuff about dogs',
  target: {path: ['objectField'], include: ['title', 'description']}
});
```

Include also accepts the same shape as `target`, so you can recursively nest target configurations if needed.

```
await client.agent.action.generate({
  schemaId: 'default-schema',
  targetDocument: { operation: 'create', _type: 'article'},
  instruction: 'Stuff about dogs',
  target: {
    path: 'objectField', 
    include: [
      {path: ['nestedObject', 'title']}, 
      {path: ['otherObject', 'deeplyNested']} 
    ]
  }
});
```

### `types`

The `types` property is an object that accepts either `include` or `exclude`. These are mutually exclusive, and let you define an array of types to include or exclude from the target.

```
await client.agent.action.generate({
  schemaId: 'default-schema',
  targetDocument: { operation: 'create', _type: 'article'},
  instruction: 'Stuff about dogs',
  target: {
    path: 'objectField', 
    types: {
      include: ['string', 'text']
    }
  }
});
```

### `operation`

Generate and Patch support the `operation` property to influence how they should affect the target field or fields.

- `set`: Overwrites and replaces the value of the field. For Patch, `set` will merge objects when targeting an object. Otherwise it will overwrite as expected.
- `append`:- Array fields: Appends new items to the end of the array.
- String fields: Adds the new content to the end of the existing content.
- Text fields: Adds the new content to a new line at the end of the existing content.
- Number fields: Adds (+) the new number to the existing number, resulting in the sum as the final value.
- All other fields will use `set` instead of append.


- `mixed`: (default) Applies `set` to non-array fields, and `append` to array fields.
- `unset`: (Patch only) Removes the value of the target field.
- `image-description`: (Transform only) Lets the transform action select an image asset, select a field, and describe the image in the field. You can also set an `imageUrl` to describe remote images. See usage below.  

Nested fields inherit the operation from their parent. Use `include` to perform per-path overrides.

```
await client.agent.action.generate({
  schemaId: 'default-schema',
  targetDocument: { operation: 'create', _type: 'article'},
  instruction: 'Add more stuff about dogs',
  target: {path: ['title'], operation: 'append'}
});
```

#### Patch values

When using `operation` with patch, a `value` or array of values is required for each operation type except `unset`.

```
await client.agent.action.patch({
  schemaId: 'default-schema',
  documentId: 'docId',
  target: {
    path: ['object', 'title'],
    operation: 'set',
    value: 'New title'
  }
});
```

#### `image-description` usage

Unlike the other operations, `image-description` accepts an object instead of the operation type as a string. This feature works for any `text`, `string`, or Portable Text (`array` with `block`) field.

For adjacent sources, where the field you want Transform to write to sits alongside the asset—like in a parent wrapper—you only need to set the type. Transform will infer which image you want to describe.

For sources that aren't colocated with the field, use `sourcePath` as shown in the example below to set the path to the image asset.

**Adjacent source**

```
await client.agent.action.transform({
  schemaId: '_.schemas.default',
  documentId: 'document-id',
  instruction: 'Describe the image in one to two sentences.',
  target: [{
    path: ['image', 'alt'],
    operation: {
      type: 'image-description'
    }
  }]
});
```

**External sourcePath**

```
await client.agent.action.transform({
  schemaId: '_.schemas.default',
  documentId: 'document-id',
  instruction: 'Describe the image in one to two sentences.',
  target: [{
    path: ['content','description'],
    operation: {
      type: 'image-description',
      sourcePath: ['image', 'asset']
    }
  }]
});
```

**Remote image (URL)**

```
await client.agent.action.transform({
  schemaId: '_.schemas.default',
  documentId: 'document-id',
  instruction: 'Describe the image in one to two sentences.',
  target: [{
    path: ['content','description'],
    operation: {
      type: 'image-description',
      imageUrl: "https://www.sanity.io/static/images/favicons/android-icon-192x192.png?v=2"
    }
  }]
});
```

As with other Transform operations, you can apply target-level instructions to the  operation if you need to change the instruction on a per-path basis.

#### Additional target resources

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Explore common patterns across all Agent Actions

[Generate common patterns](https://www.sanity.io/docs/agent-actions/generate-cheatsheet)
Common patterns and best practices for using Generate

### `instruction` / `styleGuide`

Some actions also allow target-level instructions. Transform uses `instruction` and Translate uses `styleGuide`. These operate the same way as their top-level counterparts, and have access to any `instructionParams` or `styleGuideParams` from the request.

**Translate**

```
await client.agent.action.translate({
  schemaId: 'default-schema',
  documentId: 'drafts.id', 
  
  fromLanguage: { id: 'en-GB',title: 'English' },
  toLanguage: { id: 'no-NB', title: 'Norwegian Bokmål' },
  
  languageFieldPath: ['language'],
  
  styleGuide: 'Follow the vibe when translating: $vibe',
  styleGuideParams: {
    vibe: { type: 'field', path: ['vibe']}
  },
  target: [
    {path: 'title'}, // Uses the default style guide
    {path: 'description', styleGuide: 'Only lowercase.' }, // Uses its own style guide
  ]
})
```

**Transform**

```
await client.agent.action.transform({
  schemaId: 'default-schema',
  documentId: 'drafts.id', 
  instruction: 'Make everything all-caps.',
  target: [
    {path: 'title'}, // Uses the default instruction
    {path: 'description', instruction: 'Use only lowercase' }, // Uses its own instruction
  ]
})
```



# Creating instructions

Instructions tell Agent Actions how to manipulate your data. Some actions, like Generate, rely almost entirely on your instructions and your schema. Others, like Translate, use instructions to further refine their default behavior. If you've used other AI tools, instructions are like prompts.

There are two types of instructions:

- `instruction`: Used by Generate, Transform, and Prompt. This pairs with the `instructionParams` option to pass data into the instruction.
- `styleGuide`: Used by Translate. This pairs with the `styleGuideParams` option to pass data into the instruction.

Aside from the difference in syntax, the concepts are the same for both `instruction` and `styleGuide`.

> [!TIP]
> Sanity client
> The code examples on this page use `client` to refer to a configured `@sanity/client`. For details on configuring the client, refer to the [client documentation](https://reference.sanity.io/_sanity/client/) or one of the Agent Actions guides, like the [Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart).

## Basic instructions

At their most basic, instructions are a string telling the Agent Action what you want it to do:

**Generate**

```typescript
await client.agent.action.generate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client to create a new 'movie' document type.
  targetDocument: { operation: "create", _type: "movie" },

  // Provide an instruction, or prompt.
  instruction: "Create a movie about cats.",
});
```

**Transform**

```typescript
await client.agent.action.transform({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to transform.
  documentId: "YOUR_DOCUMENT_ID",

  // Provide an instruction, or prompt.
  instruction: "Change all instances of 'Alien' to 'Lifeform from another planet'. Match the case of the existing text.",
});
```

**Translate**

```typescript
await client.agent.action.translate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},

  // Use `styleGuide` instead of instruction for Translate
  styleGuide: "Use a formal tone when translating.",
});
```

## Instruction parameters

You can provide additional information to the instructions by defining and passing `instructionParams` (and `styleGuideParams` for Translate).

Here's an example that uses a basic constant value:

**Generate**

```typescript
await client.agent.action.generate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client to create a new 'movie' document type.
  targetDocument: { operation: "create", _type: "movie" },

  // Provide an instruction, or prompt.
  instruction: "Create a movie about $topic.",

  instructionParams: {
    topic: 'cats'
  }
});
```

**Transform**

```typescript
await client.agent.action.transform({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to transform.
  documentId: "YOUR_DOCUMENT_ID",

  // Provide an instruction, or prompt.
  instruction: "Change all instances of '$old' to '$new'. Match the case of the existing text.",

  instructionParams: {
    old: 'Alien',
    new: 'Lifeform from another planet'
  }
});
```

**Translate**

```typescript
await client.agent.action.translate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},

  // Use `styleGuide` instead of instruction for Translate
  styleGuide: "Use a $tone tone when translating.",
  styleGuideParams: {
    tone: 'formal'
  }
});
```

There are two essential things to note about this example:

- The parameter names (`topic`, `old`, `new`, and `tone`) can be any variable name.
- The parameter name is passed into the instruction by prepending `$`.

This example uses a constant value, but there are multiple types of parameters.

### Constant parameters

The `constant` parameter type sets a fixed value. Its shorthand form assigns the value directly; here's the full version:

**Generate / Transform**

```typescript
await client.agent.action.generate({
  // ...
  instruction: "Write the details for a movie about $topic",
  instructionParams: {
    topic: {
      type: "constant",
      value: "cats"
    }
  }
})
```

**Translate**

```typescript
await client.agent.action.translate({
  // ...
  styleGuide: "Use a $tone tone when translating.",
  styleGuideParams: {
    tone: {
      type: "constant",
      value: "formal"
    }
  }
});
```

### Field parameters

The `field` parameter type picks the value from a field in the source document (if one exists), then passes that to the instruction with the `$`-prefixed parameter syntax.

You can also provide an optional `documentId` along with the field path to pick a value from any document in your dataset. If you omit it, the action uses the source document set by the top-level `documentId`, or the ID given in an edit operation:

**Generate / Transform**

```typescript
await client.agent.action.generate({
  // ...
  instruction: "Write the details for a movie about $topic",
  instructionParams: {
    topic: {
      type: "field",
      path: "movie_idea",
      documentId: "YOUR_DOCUMENT_ID" // Optional. Defaults to the source document.
    }
  }
})
```

**Translate**

```typescript
await client.agent.action.translate({
  // ...
  styleGuide: "Use a $tone tone when translating.",
  styleGuideParams: {
    tone: {
      type: "field",
      path: "formality",
      documentId: "YOUR_DOCUMENT_ID" // optional
    }
  }
});
```

The path should be a full path to the field in the document. For examples of paths, see the [common patterns guide](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet).

### Document parameters

The `document` parameter type sets the parameter to the full contents of a document. For larger documents, this might become too large and cause issues with the accuracy of the instruction:

**Generate / Transform**

```typescript
await client.agent.action.generate({
  // ...
  instruction: "Develop a new movie. Use these details to generate the concept: $background",
  instructionParams: {
    background: {
      type: "document",
      documentId: "YOUR_DOCUMENT_ID"
    }
  }
})
```

**Translate**

```typescript
await client.agent.action.translate({
  // ...
  styleGuide: "Use the following company guidelines when translating: $guidelines",
  styleGuideParams: {
    guidelines: {
      type: "document",
      documentId: "YOUR_DOCUMENT_ID"
    }
  }
});
```

This can be useful for passing in singleton-style documents, or using existing documents as background context.

### GROQ parameters

If `field` and `document` aren't powerful enough, you can also write GROQ queries to populate the contents of your instruction parameters:

**Generate / Transform**

```typescript
await client.agent.action.generate({
  // ...
  instruction: "Develop a new movie. Use these details to generate the concept: $background",
  instructionParams: {
    background: {
      type: "groq",
      query: "*[_id == $id][0]",
      perspective: "drafts",
      params: {
        id: "YOUR_DOCUMENT_ID"
      }
    }
  }
})
```

**Translate**

```typescript
await client.agent.action.translate({
  // ...
  styleGuide: "Use the following company guidelines when translating: $guidelines",
  styleGuideParams: {
    guidelines: {
      type: "groq",
      query: "*[_id == $id][0]",
      perspective: "drafts",
      params: {
        id: "YOUR_DOCUMENT_ID"
      }
    }
  }
});
```

GROQ queries accept filters, projections, perspective, and can receive their own parameters as seen in the example. The `perspective` option only accepts a single perspective.

## Multiple parameters

An instruction can take more than one parameter, and you can mix any combination of parameter types to build it:

**generate-movie.ts**

```typescript
await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  targetDocument: { operation: "create", _type: "movie" },

  instruction: "Create a movie with the title: $title. Use the following details to come up with a synopsis and characters: $backgroundDetails",

  instructionParams: {
    title: {
      type: "constant",
      value: "Sanity: The Content Operating System"
    },
    backgroundDetails: {
      type: "document",
      documentId: "YOUR_DOCUMENT_ID"
    }
  },
});
```

## Per-path instructions

Some Agent Actions support per-path instructions. For example, you can provide a top-level instruction for all fields, and then specific instructions for individual fields. Learn more about this approach in the [targets and paths documentation](https://www.sanity.io/docs/agent-actions/targets-paths).

## Instruction size limits

The `instruction` and `instructionParams` settings are only part of what Agent Actions use when creating content. They also know about your schema and any document used as the source.

The `instruction` for Transform and the `styleGuide` for Translate are capped at 2,000 characters, measured after `instructionParams` and `styleGuideParams` have been interpolated. As instructions get larger, they can also exceed the maximum size accepted by the large language model. If you're experiencing inconsistent or unexpected results, try reducing the information you pass to the instructions.



# Common patterns

Agent Actions offer an interface to enhance Sanity documents with the use of large language models (LLMs). This document showcases a collection of common patterns and concepts that apply to all Agent Actions.

Prerequisites:

- Complete the quick start for one or more of the Agent Actions.
- `@sanity/client` v7.1.0 or later and an environment to run client requests. 
- API version vX or later for any requests using Agent Actions.

#### Quick starts

[Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)
Get started with Generate by writing your first instructions to create and modify documents.

[Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
Get started with Transform by writing your first instructions to modify documents.

[Translate quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart)
Learn to translate documents with the Translate Agent Actions action.

Many examples in this document use `@sanity/client` and expect that you've installed and configured it for your project. If your client is named something other than `client`, update the code examples accordingly. 

Here's an example of the client implementation:

```typescript
// client.ts
import { createClient } from "@sanity/client";
export const client = createClient({
  projectId: '<project-id>',
  dataset: '<dataset-name>',
  useCdn: 'true',
  apiVersion: 'vX',
  token: '<read-write-token>'
})
```

Then, import `client` in your code before using the examples below.

> [!NOTE]
> Multiple action examples
> Some examples in this guide are for specific Agent Actions, so the code may differ slightly. For instance, Translate doesn't have an `instruction` concept in the same way Generate does, but the techniques in each example are the same regardless of action.

## Use `noWrite` to avoid mutations

The `noWrite` property prevents the instruction from writing changes to your dataset. This is useful when creating in-memory documents, previewing changes, or working with multiple requests before making a final write. You could even use it to combine multiple Agent Actions.

All Agent Actions support the `noWrite` option.

**Generate**

```typescript
const response = await client.agent.action.generate({
  schemaId: "your-schema-id",
  noWrite: true,
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: "Write the details for a movie titled $title.",
  instructionParams: {
    title: { type: "constant", value: "Sanity: The Content Operating System" },
  },
});
console.log(response);

```

**Transform**

```
const response = await client.agent.action.transform({
  schemaId: "your-schema-id",
  noWrite: true,
  documentId: '<source-document-id>',
  instruction: "Replace every instance of 'Create' with 'Canvas'",
});
console.log(response);

```

**Translate**

```
const response = await client.agent.action.translate({
  schemaId: 'your-schema-id',
  documentId: '<source-document-id>', 
  noWrite: true,
  fromLanguage: { id: 'en-GB',title: 'English' },
  toLanguage: { id: 'no-NB', title: 'Norwegian Bokmål' },
})
```

Instead of creating or modifying a document in your dataset, this code returns the document to the `response` constant and logs it to the console.

Keep in mind that these requests still count against your usage limits. 

## Enable read/write on conditional fields

By default, Agent Actions ignore [conditional](https://www.sanity.io/docs/studio/conditional-fields) `readOnly` and `hidden` fields. You can allow actions to interact with these fields by setting the `conditionalPaths` parameter. The examples below use the `generate` syntax, but the `conditionalPaths` configuration is the same across all actions.

To enable access to all hidden and readOnly fields across your schema, use the following to change the default behavior:

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',
  targetDocument: {operation: 'create', _type: '<document-type>'},
  instruction: `<insert instruction here>`,
  instructionParams: { ... },
  conditionalPaths: {
    defaultReadOnly: false,
    defaultHidden: false
  }
})
```

You can also limit read/write to specific `paths`. Add additional paths to the array as needed.

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',
  targetDocument: {operation: 'create', _type: '<document-type>'},
  instruction: `<insert instruction here>`,
  instructionParams: { ... },
  conditionalPaths: {
    paths: [
      {
        path: ['secretPathName'],
        readOnly: false,
        hidden: false,
      }
    ]
  }
})
```

## Exclude a field from an action

Agent Actions skip any field the schema marks as `hidden` or `readOnly`. That gives you a schema-level way to keep a field away from the AI, without changing the requests that run against the document.

```typescript
import { defineField } from 'sanity'

defineField({
  name: 'legalDisclaimer',
  title: 'Legal disclaimer',
  type: 'string',
  // Editors can still read this field, but no action will write to it.
  readOnly: true,
})
```

Use `readOnly: true` when editors should still see the field, and `hidden: true` when it shouldn't appear in the Studio at all. Either way, the action leaves the field alone.

To decide per request instead of per schema, make the condition a function. Conditional `hidden` and `readOnly` fields are excluded by default, so `hidden: () => true` keeps a field out of every action until a request opts back in with `conditionalPaths`.

**Schema**

```typescript
defineField({
  name: 'internalNotes',
  title: 'Internal notes',
  type: 'text',
  hidden: () => true,
})
```

**Request**

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',
  documentId: '<document-id>',
  instruction: 'Summarize the article into the internal notes field.',
  conditionalPaths: {
    paths: [{ path: ['internalNotes'], readOnly: false, hidden: false }],
  },
})
```

> [!NOTE]
> aiAssist.exclude doesn't apply here
> `options.aiAssist.exclude` turns a field off for the AI Assist plugin in the Studio. It has no effect on Agent Actions run through `client.agent.action`. Use `hidden` or `readOnly` instead.

### Copy a field without translating it

Translate with a `targetDocument` runs in two steps: it copies the source document to the target, then translates the target. The copy step isn't schema-aware, so it transfers every field, including the ones the translation step skips. A field marked `hidden` or `readOnly` arrives in the translated document with its source value intact.

Excluding the field with `target.exclude` behaves differently. That filter applies to both steps, so the field is never copied and never reaches the translated document.

```typescript
await client.agent.action.translate({
  schemaId: 'your-schema-id',
  documentId: '<source-document-id>',
  targetDocument: { operation: 'create' },
  fromLanguage: { id: 'en-US', title: 'English' },
  toLanguage: { id: 'el-GR', title: 'Greek' },
  // Left out of both the copy and the translation.
  target: { exclude: ['internalNotes'] },
})
```

If you use the document internationalization plugin, Translate also skips fields marked with `options.documentInternationalization.exclude`. Those fields are still copied to the target document. Prefer `hidden` or `readOnly` when the plugin isn't part of your setup.

## Asynchronously modify multiple documents

The `async` parameter helps initiate instructions and move on. Asynchronous calls to actions return a document ID rather than the complete document shape. In this example:

- We loop through the IDs of documents from a GROQ request.
- `async` is set to true to enable asynchronous requests.
- The instruction rewrites the title of each document.

```typescript
const ids = await client.fetch(`*[_type == 'movie' ][0...5] { _id }`);

for (const id of ids) {
  await client.agent.action.generate({
    schemaId: "your-schema-id",
    documentId: id._id,
    instruction: `Re-imagine the title, $title, so that it is more engaging and interesting.`,
    async: true,
    path: "title",
    instructionParams: {
      title: {
        type: "field",
        path: "title",
      },
    },
  });
}
```

> [!TIP]
> Protip
> We're calling this an asynchronous call, but we're also using `await`. That's because the asynchronous call happens behind the scenes. We aren't waiting on the AI to finish and Content Lake to update. Instead, we're waiting on the underlying request to Sanity to respond that it initiated those actions.

Note that you can't combine `async` and `noWrite`, as `noWrite` would require the request to wait for a response from the AI.

## Target specific fields 

Restrict the fields that actions can write to by setting a single `path` or multiple `include` fields in the `target` parameter.

To write to a single path, or all child paths of a single parent, use the `target` property with `path`.

```typescript
await client.agent.action.generate({
  schemaId: "your-schema-id",
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: `Your instruction here.`,
  // ... other properties
  target: {
    path: "body", // set to whichever field or fieldset you like. Ex. 'title', 'name', etc.
  }
});
```

To define specific fields, use `include`. They will be relative to a path, if set, or the document if not set. In the example below, they are relative to the document.

```typescript
await client.agent.action.generate({
  schemaId: "your-schema-id",
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: `Your instruction here.`,
  // ... other properties
  target: {
    include: ["title", "overview", "poster"],
  },
});
```

You can also do the same to exclude any paths you want to block the instruction from mutating.

```typescript
await client.agent.action.generate({
  schemaId: 'your-schema-id',
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: `Your instruction here.`,
  // ... other properties
  target: {
    exclude: ['humanOnlyField']
  }
})
```

### Target patterns

The following are an assortment of examples using `target` and its options. For Transform and Translate, omit the Generate instruction and add the source `documentId` property.

```typescript
/*
using path
this sets 'title' field
*/
{
 targetDocument: {operation: 'create', _type: 'article'},
 schemaId: 'your-schema-id',
 instruction: 'A title for an article about dogs',
 target: {path: ['title']}
}

/*
using include
 this sets:
 - title
 - description 
 */
{
 targetDocument: {operation: 'create', _type: 'article'},
 schemaId: 'your-schema-id',
 instruction: 'Stuff about dogs',
 target: {include: ['title', 'description']},
}

/*
 this sets:
 - objectField.title
 - objectField.description 
*/
{
 targetDocument: {operation: 'create', _type: 'article'},
 schemaId: 'your-schema-id',
 instruction: 'Stuff about dogs',
 target: {path: ['objectField'], include: ['title', 'description']}
}


/*
multiple target paths
 this sets:
 - objectField.title
 - objectField.description
 - people[_key=="someKey"].name //ie, the name of a single item in the people array 
*/
{
 targetDocument: {operation: 'create', _type: 'article'},
 schemaId: 'your-schema-id',
 instruction: 'Stuff about dogs',
 target: [
    {path: ['objectField'], include: ['title', 'description']},
    {path: ['people', {_key: 'someKey'}], include: ['name']}
 ]
}

/* 
Deeply nested fields from a common target path.
This sets:
 - objectField.nestedObject.title
 - objectField.otherObject.deeplyNested 
   - all its children(assuming deeplyNested is an object)
*/
{
 targetDocument: {operation: 'create', _type: 'article'},
 schemaId: 'your-schema-id',
 instruction: 'Stuff about dogs',
 target: {
	 path: 'objectField', 
	 include: [
		 {path: ['nestedObject', 'title']}, 
		 {path: ['otherObject', 'deeplyNested']} 
	 ]
 }
}
```

## Agent Actions in field actions

Agent Actions can leverage custom AI Assist field actions to create on-demand features for content editors directly in Studio. Learn more in the [custom field actions documentation](https://www.sanity.io/docs/studio/ai-assist-field-actions), or jump right into the [field action patterns](https://www.sanity.io/docs/studio/field-actions-patterns).



# Enable references

> [!WARNING]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. It has been replaced with the new [Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) feature, now natively available within Sanity datasets.
> At this time, we do not have a replacement solution available for using references with Agent Actions.

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Agent Actions can populate reference fields with the help of the AI Assist Studio plugin and the [Embeddings Index API](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview). This guide will help you enable related content references for instructions.

Prerequisites:

- Complete the [Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart).
- An embeddings index connected to your project and dataset. [Follow the setup process](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview) if you haven't done so already. You can also use the [Studio plugin](https://www.npmjs.com/package/@sanity/embeddings-index-ui).
- Access to your Studio codebase.

If you've previously set up the AI Assist plugin and have been using it to generate images inside Sanity Studio, you can skip the setup and configuration steps.

## Install the AI Assist plugin

While Agent Actions don't require the Assist plugin, the plugin provides type completion and enables presence in your Studio when Actions are actively mutating a document or field. 

**npm**

```shell
npm install sanity@latest @sanity/assist@latest
```

**pnpm**

```shell
pnpm add sanity@latest @sanity/assist@latest
```

**yarn**

```shell
yarn add sanity@latest @sanity/assist@latest
```

**bun**

```shell
bun add sanity@latest @sanity/assist@latest
```

Next, import and add the plugin to your Studio config's `plugins` array.

```tsx
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist(),
  ]
})
```

## Enable indexing of reference fields

Generate needs to know which index to use when making connections. Add the `aiAssist.embeddingsIndex` option to any references that use the index. In our movie schema example, we've created an index called "people" that targets all documents of `_type == "person"`.

```typescript
defineField({
  name: 'person',
  title: 'Person',
  type: 'reference',
  to: [{type: 'person'}],
  options: {
    aiAssist: {
      embeddingsIndex: 'people',
    }
  }
}),
```

The code above tells AI Assist and Generate to use the `people` index for this `person` reference. 

With those changes, you're now set to use Assist with references. 

## Create an instruction

Agent Actions can often intuit the needs of your instruction based on your schema, but it's also helpful to be explicit. These examples use Generate, but other actions support the same concept. First, set up your client if you haven't already.

```typescript
// instruction.ts

import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: "<project-id>",
  dataset: "<dataset-name>",
  apiVersion: "vX",
  token: "<editor-token>",
});

```

Next, create an instruction.

```typescript
// instruction.ts
// ...client setup

await client.agent.action.generate({
  schemaId: "your-schema-id",
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: `
    Come up with an idea for a movie. 
    Give it a title and overview.
    Generate a poster image based on the overview and title.
    Select cast members to be involved in the movie as the cast. Give their characters names that fit the theme.
    Assign crew members to work on the movie.
  `,
});
```

The code above creates a detailed instruction that explicitly lists the steps we want it to take. Notice that it doesn't use a GROQ query to inject information about the `person` type. Because Generate knows about your schema and has access to the index, it understands there are references for cast and crew. 

If you wanted to be more explicit, perhaps to limit which people it chooses, you could combine this with a GROQ or other instruction parameters to provide more context.

Give the code, or your modified version, a try. Generate will do its best to match related references based on your instructions. Sometimes, you may need to be more explicit to help it make the best decisions.

## Related resources

[Generate images](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)

[Common use cases and patterns](https://www.sanity.io/docs/agent-actions/generate-cheatsheet)





# Enable image generation

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

This guide takes you through the steps required to enable image generation with Generate or Transform.

**Prerequisites:**

- Complete the [Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart) or [Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart).
- If using the AI Assist plugin approach, you'll need access to your Studio codebase.
- Each Agent Action request consumes 1 AI credit.

There are two ways to generate images with Agent Actions. You can either explicitly target the image's asset with the `target` property, or you can enable the AI Assist plugin along with image prompts in your schema. Target the image asset directly to start, since that approach needs no schema change.

This guide assumes you have a configured Sanity client. The examples for both approaches use the following configuration and reference `client`:

**client.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

## Create images with explicit targets

Generating images with the explicit targets approach requires instructions that directly target an image asset, but doesn't require a schema change and limits generation to Agent Actions.

> [!TIP]
> Image generation is asynchronous
> The API returns a success status before images are fully generated. Studios will show an in-progress status as if a user were uploading an image, but it won't be available until it completes. This results in asset references that aren't updated until after the image generation completes. Keep this in mind if you rely on the returned asset data at the time of generation.

Both Generate and Transform use the `target` property to narrow instructions down to a specific field or fields.

To allow the actions to create or update an image, your request needs to target the image's `asset` field directly. Transform changes an image that already exists, while Generate creates one where the asset is empty. In this example, the target provides a direct path to the asset:

**Generate**

```typescript
await client.agent.action.generate({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Create an image about cats wrangling project managers.',
  target: {path: ['image', 'asset']}
})
```

**Transform**

```typescript
await client.agent.action.transform({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Change the image to cats wrangling project managers.',
  target: {path: ['image', 'asset']}
})
```

You can also target related fields at the same time, such as the image alt text:

**Generate**

```typescript
await client.agent.action.generate({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Create an image about cats wrangling project managers.',
  target: [
    {path: ['image', 'alt']},
    {path: ['image', 'asset']}
  ]
})
```

**Transform**

```typescript
await client.agent.action.transform({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Change the image to an image about cats wrangling project managers.',
  target: [
    {path: ['image', 'alt']},
    {path: ['image', 'asset']}
  ]
})
```

This approach doesn't require you to write image-only instructions. You can provide instructions that apply to multiple field types. In this example, the instruction is more generic and uses `include` alongside the asset path in `target`:

**Generate**

```typescript
await client.agent.action.generate({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Create content about cats wrangling project managers.',
  target: [
    {include: ['title', 'description', 'body', 'image']},
    {path: ['image', 'asset']},
  ]
})
```

**Transform**

```typescript
await client.agent.action.transform({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Change this content to be about cats wrangling project managers.',
  target: [
    {include: ['title', 'description', 'body', 'image']},
    {path: ['image', 'asset']},
  ]
})
```

Transform can perform path-level instructions. Path-level instructions let you apply specific image updates when transforming a document:

**Transform**

```typescript
await client.agent.action.transform({
  documentId: 'someDocumentId',
  schemaId: 'your-schema-id',
  instruction: 'Create content about cats wrangling project managers.',
  target: {
    path: ['image'],
    include: [
      {path: 'asset', instruction: 'Make it a blue dog.'},
      'alt',
    ]
  }
})
```

See additional target examples in the [common patterns guide](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet).

## Create images with AI Assist

The AI Assist plugin is available for projects on the Growth plan and up.

The AI Assist method lets you write less specific instructions, but requires adding an image prompt field to your studio's schema. Installing the [AI Assist plugin](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist) is optional: it adds type completion for the schema option and renders AI presence in the studio.

If you have previously set up the AI Assist plugin and used it to generate images within Sanity Studio, you can skip the setup and configuration steps.

### Install the AI Assist plugin

While Generate doesn't require the AI Assist plugin to operate, the plugin provides type completion and adds AI Assist to presence, the avatars that show who is currently editing a document or field:

**npm**

```shell
npm install sanity@latest @sanity/assist@latest
```

**pnpm**

```shell
pnpm add sanity@latest @sanity/assist@latest
```

**yarn**

```shell
yarn add sanity@latest @sanity/assist@latest
```

**bun**

```shell
bun add sanity@latest @sanity/assist@latest
```

Next, import and add the plugin to your studio config's `plugins` array:

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist(),
  ]
})
```

### Enable instructions for image fields

Image generation in schemas works by having Generate write an image prompt to a text field, then using the field's contents to generate the image. Having an explicit field for the prompt lets content editors view it and make changes. One way to set this up is to create a new field as part of your images. For example:

**schemaTypes/movie.ts**

```typescript
import {defineType, defineField} from 'sanity'

export default defineType({
  type: 'document',
  name: 'movie',
  fields: [
    defineField({
      name: 'image',
      type: 'image',
      fields: [
        defineField({
          type: 'text',
          name: 'instruction',
          title: 'Image prompt',
        })
      ],
      options: {
        hotspot: true,
        aiAssist: {
          imageInstructionField: 'instruction',
        }
      },
    }),
  ]
})
```

This code creates a new `instruction` text field that Generate uses to write an image prompt. It also configures the AI Assist plugin and Generate to recognize that field and associate it with the parent `image`.

You must implement this pattern for any images you'd like AI Assist to interact with.

### Deploy the updated schema

To make the new field available to Generate, deploy your studio to Sanity with the `sanity deploy` command, or deploy just the schema with the `sanity schema deploy` command.

**npm**

```shell
npx sanity@latest schema deploy
```

**pnpm**

```shell
pnpm dlx sanity@latest schema deploy
```

**yarn**

```shell
yarn dlx sanity@latest schema deploy
```

**bun**

```shell
bunx sanity@latest schema deploy
```

Note the resulting `schemaId` if you haven't previously used this workspace/dataset combination with Generate.

### Write an image generation instruction

With your schema deployed, write a script that sends an instruction to Generate to create a document with a generated image.

> [!TIP]
> Examples use the starter movie schema
> The examples in this section follow the same pattern as the Generate quick start: Node.js invoking a TypeScript file. They also use the starter movie schema and dataset available through `sanity init`. Modify document types and fields as you follow along to fit your schema.

First, set up your client:

**instruction.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

Next, create a new instruction:

**instruction.ts**

```typescript
// ...client setup

await client.agent.action.generate({
  schemaId: 'your-schema-id',
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: `
    Come up with an idea for a movie.
    Give it a title and overview.
    Generate a poster image based on the overview and title.
  `,
})
```

This instruction doesn't explicitly call out the image fields, but that's okay. Generate is good at finding fields and intuiting what you mean. To be more explicit, set a target path. The following example reads an existing movie document and targets the poster image field to generate the image:

**instruction.ts**

```typescript
// ...client setup

const docId = 'your-movie-id'
await client.agent.action.generate({
  schemaId: 'your-schema-id',
  documentId: docId,
  instruction: `
    Add a poster image for this movie.
    Use the information in $background to come up with the image.
  `,
  instructionParams: {
    background: {
      type: 'document'
    },
  },
  target: {
    path: 'poster'
  }
})
```

The code in this example does the following:

- It uses `documentId` instead of `targetDocument` to update an existing document.
- It sets the `path` to `poster`, which is the image in the movie schema. Setting the path tells Generate to apply the instruction to that field.
- It uses a document-type instruction parameter to query the details of the existing document.

Generate writes the image prompt to the text field named by `imageInstructionField`, but this example targets `poster`. The example works because Agent Actions can navigate to children of the supplied path and use the fields they need to generate the image.

## Next steps

[Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)

[Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
Write your first Transform instruction to modify an existing document.

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Common patterns and options shared by every Agent Action.

[Generate common patterns](https://www.sanity.io/docs/agent-actions/generate-cheatsheet)

[Troubleshoot Agent Actions requests](https://www.sanity.io/docs/agent-actions/troubleshooting)
Diagnose failed requests by status code, including image generation that writes text but no image.



# Enable date and datetime support

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Agent Actions can interact with `date` and `datetime` field types by adding time and location details to each request. Without `localeSettings`, Agent Actions ignore `date` and `datetime` fields — the request succeeds and those fields are left unchanged. This guide explains how to configure a request so an instruction can write natural language dates and times to those fields.

**Prerequisites:**

- Complete any of the Agent Actions quick start guides, or be familiar with making requests through the actions.
- API version `vX` and `@sanity/client` version `7.1.0` or later.
- Each Agent Actions request consumes 1 AI credit.

## Include `localeSettings` in the request

To support natural language and relative time, the instruction needs the locale you're writing in and the time zone to use as a baseline. Without both, a phrase like "tomorrow" or "three hours from now" has no fixed reference point.

The following code:

1. Sets up a client.
2. Creates an instruction to change a `datetime` field at the path `publishedAt` and configures the locale settings.

This example uses Generate, but the same `localeSettings` apply to Transform and Translate. Patch sets values you supply directly, and Prompt doesn't write to documents, so neither accepts `localeSettings`:

**instruction.ts**

```typescript
import { createClient } from "@sanity/client";

const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "production",
  apiVersion: "vX",
  token: process.env.SANITY_API_TOKEN,
});

await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  documentId: "YOUR_DOCUMENT_ID",
  instruction: `
    Set the publishedAt date to tomorrow at 9am.
  `,
  target: { path: "publishedAt" },
  localeSettings: {
    locale: "en-US",
    timeZone: "America/Los_Angeles",
  },
});
```

The `localeSettings` object requires two properties:

- `locale`: A BCP 47 locale identifier, such as `en-US` or `no-NO`. [Learn more about the specification](https://en.wikipedia.org/wiki/IETF_language_tag).
- `timeZone`: An IANA time zone identifier, such as `America/New_York` or `Europe/Berlin`. [Learn more about the supported values](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).

These settings enable Agent Actions to understand natural language dates and times, and apply them to `date` and `datetime` fields in a predictable way.

#### Next steps

[Targets and paths](https://www.sanity.io/docs/agent-actions/targets-paths)
Restrict a request to a specific field, array item, or path.

[Creating instructions](https://www.sanity.io/docs/agent-actions/instructions)
Write instructions and style guides that produce consistent results.

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Browse common patterns for Generate, Transform, and Translate requests.



# Quick start

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Generate lets you programmatically run schema-aware AI instructions on Sanity documents. You can run instructions from anywhere you can execute code, such as cloud functions, webhook listeners, CI/CD pipelines, migration scripts, and more.

In this guide, you'll use Generate to create a document and write content based on your instructions. You'll use `@sanity/client` to create the instructions (you can also make requests using the [HTTP API](https://www.sanity.io/docs/http-reference/agent-actions) directly).

**Prerequisites**:

- `@sanity/client` v7.1.0 or later and an environment to run client requests.
- In Node.js v23.6 or later, you can run the TypeScript examples in this guide without additional servers or build processes. Alternatively, you can use [earlier versions with an experimental flag](https://nodejs.org/en/learn/typescript/run-natively).
- `sanity` CLI v3.88.0 or later.
- A Sanity project for testing. The examples in this guide use details from the sample "Movies" studio schema that you can select when initializing a new project.- A read/write API token to authenticate requests.
- A valid `projectId` and `dataset` name.



## Step 1: Obtain a schema ID

Generate requires an uploaded schema. If you've deployed recently, you can check for a list of uploaded schemas by running the `schemas list` command. If you don't see a schema or want to deploy the latest version, redeploy your studio to Sanity or deploy the schema.

**List schema**

```sh
npx sanity@latest schemas list
```

**Deploy Studio**

```sh
npx sanity@latest deploy
```

**Deploy schema**

```sh
npx sanity@latest schemas deploy
```

Copy the schema ID, which you'll need for making Agent Actions requests.

[Learn more about schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment).

## Step 2: Configure the client

Import and configure `@sanity/client` with the `projectId`, `dataset`, API `token`, and an `apiVersion` of `vX`.

**instruction.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

If you're already using the client elsewhere in an application, you can reuse its base configuration. If you need to adjust the token or the API version, use the `withConfig` method to create a new client based on your existing one. For example:

**instruction.ts**

```typescript
// ...
const generateClient = client.withConfig({
  token: process.env.SANITY_API_TOKEN,
})
```

## Step 3: Create an instruction

[Instructions](https://www.sanity.io/docs/agent-actions/instructions) describe the content to target and the actions to take upon that content. They can create new documents or update existing ones. In the simplest form, `generate` takes the following settings:

- `targetDocument` or `documentId`: The `targetDocument` setting defines an `operation`. Setting `documentId` is shorthand for using `targetDocument` with the `edit` operation.
- `instruction`: The instruction you want to send to Generate.
- `schemaId`: The ID of your schema.

In this example, you'll create an instruction that adds a new movie to your dataset.

Update the code to include the following instruction:

**instruction.ts**

```typescript
await client.agent.action.generate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client to create a new 'movie' document type.
  targetDocument: { operation: "create", _type: "movie" },

  // Provide an instruction, or prompt.
  instruction: "Write the details for a movie titled $title.",

  // Optionally, provide any params for the instruction.
  // You can access them with the $key syntax.
  instructionParams: {
    title: { type: "constant", value: "Sanity: The Content Operating System" },
  },
});
```

This code creates a new draft document of the `movie` type, then tells Generate to write details about the movie. In this case, rather than directly telling the AI in the instructions that the title should be "Sanity: The Content Operating System", `instructionParams` is used to pass it in as the `$title` parameter.

> [!WARNING]
> Gotcha
> Depending on your schema, you may find that the instruction doesn't generate images or connect references. To enable these features, you'll need additional schema changes. See the linked guides to configure each feature.
> - [Enable image generation](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
> - [Add support for references](https://www.sanity.io/docs/agent-actions/generate-add-references)

Run the file with `node instruction.ts`, replacing the filename with the path to your own file. Generate adds a new movie titled "Sanity: The Content Operating System" to your dataset.

By default, the `create` operation creates a draft. Agent Actions never write to a published document unless you set `forcePublishedWrite: true` on the request. To create a published document, provide an `_id` to `targetDocument` in addition to the `_type` and `operation`, and set `forcePublishedWrite: true`. Providing a version ID as the `_id` creates a content release version instead.

> [!TIP]
> Protip
> You might wonder why this example uses `instructionParams` to pass variables instead of string interpolation or another way of building the instruction string. By using `instructionParams` and then passing them to the instruction with the `$key` syntax, Generate has more control over how it shapes and sends your requests to the LLMs.

## Modify an existing document

To update an existing document, set the `documentId` or use the edit operation with `targetDocument: { operation: "edit", _id: "DOCUMENT_ID" }`.

This example uses the existing document details to rewrite the title. Obtain the document ID from your studio by selecting **Inspect** from the **More options** menu in the document title bar, querying the document in [Vision Tool](https://www.sanity.io/docs/content-lake/the-vision-plugin), or querying it with `client.fetch()`.

**instruction.ts**

```typescript
const docId = "EXISTING_DOCUMENT_ID";
await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  // documentId is equivalent to targetDocument: {operation: 'edit', _id: docId }
  documentId: docId,
  instruction: `
    Update the title based on the details about the movie.
    Use the information in $details to come up with the new title.
  `,
  instructionParams: {
    details: {
      type: "field",
      path: "overview",
    },
  },
  target: {
    path: "title",
  }
});
```

In addition to swapping the `targetDocument` property for `documentId`, this example also has a new `instructionParams`.

As with the previous title example, you can name these keys whatever you like. The `details` key in this instance is a `field` type, and just like `title` in the earlier example, you can reference it in the instructions with a `$` prefix (`$details`).

Field-type instruction parameters expect a path leading to fields in the document. In this case, it uses the `overview` field to read a summary of the movie that the instruction can use as context.

Another approach is to use a GROQ-type query and capture the whole or parts of other documents as context.

**instruction.ts**

```typescript
await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  documentId: "EXISTING_DOCUMENT_ID",
  instruction: `
    Update the title so that it aligns closer to the other movie titles.
    Use the information in $background to come up with the new title.
  `,
  instructionParams: {
    background: {
      type: "groq",
      query: `*[_type == $type] | order(_createdAt desc)[0...20].title`,
      params: {type: 'movie'},
    },
  },
  target: {
    path: "title",
  }
});
```

GROQ-type instruction parameters take a GROQ query and pass the result to the parameter. In this case, it passes the titles of other movie documents in your dataset.

> [!TIP]
> Protip
> If you want to pass an individual document, you can use the `document` type param. For example: `thisDocument: { type: 'document', documentId: 'DOCUMENT_ID'}`. If you omit the document ID, the parameter is set to the current document.

This example also introduces the `target` parameter, and its child `path`. Target lets you explicitly tell the instruction which fields to write to. Check out more examples of `target` in [the Generate common patterns page](https://www.sanity.io/docs/agent-actions/generate-cheatsheet).

When you run either of these examples, Generate responds with an updated document, including a new title based on other titles in your dataset.

## Next steps

These examples run once, but you can loop over multiple documents, build multi-step workflows, and much more. These resources provide additional examples and details.

[Create images with Generate](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
Configure your schema to enable image generation.

[Enable references in Generate](https://www.sanity.io/docs/agent-actions/generate-add-references)
Configure your schema to enable the API to connect references.

[Generate common patterns](https://www.sanity.io/docs/agent-actions/generate-cheatsheet)
View common patterns and best practices.

[Agent Actions](https://www.sanity.io/docs/http-reference/agent-actions)
Send Agent Actions requests over HTTP instead of the client.



# Common patterns

Generate offers an interface to enhance Sanity documents using large language models (LLMs). This page collects common patterns and concepts.

Prerequisites:

- Complete the [Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart).
- `@sanity/client` v7.2.0 or later and an environment to run client requests.
- API version vX or later for any requests using Generate.

Many examples in this document use `@sanity/client` and expect that you've installed and configured it for your project. If your client is named something other than `client`, update the code examples accordingly.

Here's an example of the client implementation:

**client.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  useCdn: false,
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

Then, import `client` before running any example on this page.

## Patterns shared across Agent Actions

The patterns in this guide are unique to Generate, but there are more patterns shared across all Agent Actions.

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Explore common patterns across all Agent Actions

## Create multi-stage instructions

A single instruction is often fine for smaller tasks like updating an individual field. However, splitting instructions into multiple steps or stages for more significant tasks like writing complete documents with complex schemas returns better results. Each Agent Actions request consumes 1 AI credit, so splitting one task into several instructions multiplies the cost — the following example makes three requests.

One way to improve the LLM's success rate is to structure instructions the way a person would work through the task. For example:

1. Make a skeleton or outline by populating simple, foundational fields like title, description, and categories or topics.
2. Run instructions for more complex areas, like an article's main content field, individually by passing in the results of step 1 as field parameters.
3. Run any summarization tasks at the end for content like SEO fields, social copy, or connecting related content.

This example creates a document with an instruction and then uses the generated content to influence future instructions:

**instruction.ts**

```typescript
const customTopic =
  "A multi-generational epic, but all the characters are cats.";

const { _id } = await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  targetDocument: {operation: 'create', _type: 'movie'},
  instruction: `
    Come up with a movie idea.
    Use the information in $topic as the basis for the movie.`,
  instructionParams: {
    topic: { type: "constant", value: customTopic },
  },
  target: {
    include: ["title", "overview"],
  },
});

await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  documentId: _id,
  instruction: `Create a poster for the movie based on the $document.`,
  target: { path: "poster" },
  instructionParams: {
    document: { type: "document" },
  },
});

await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  documentId: _id,
  instruction: `Translate the $overview into Japanese.`,
  target: { path: "overviewJPN" },
  instructionParams: {
    overview: { type: "field", path: "overview" },
  },
});
```

## Create release versions for AI changes

You can combine Generate with Content Releases to power a safer, supervised content pipeline. Content Releases is available on certain Enterprise plans. This example:

1. Reads the document's draft, falling back to the published version if no draft exists, and rewrites the title based on the instruction.
2. Takes that document and uses the Actions API to create a new version document attached to an existing release, leaving the original published version unchanged. Version actions require API version `v2025-02-19` or later.

**instruction.ts**

```typescript
const releaseId = "YOUR_RELEASE_ID";
const documentId = "YOUR_DOCUMENT_ID";

// Build the version ID by combining the release ID and the document ID.
const versionId = `versions.${releaseId}.${documentId}`;

// Create an instruction to rewrite the title
const result = await client.agent.action.generate({
  schemaId: "YOUR_SCHEMA_ID",
  documentId: documentId,
  noWrite: true, // only write the changed document to the `result` variable
  instruction: `
    Re-imagine the title so that it is more engaging and interesting.
    Use the information in $document to help you come up with a new title.
  `,
  instructionParams: {
    document: {
      type: "document",
    },
  },
  target: {
    path: "title",
  }
});

// Call the Actions API with the client to create a new version.
await client.action(
  {
    actionType: 'sanity.action.document.version.create',
    publishedId: documentId,
    document: {
      ...result,
      _id: versionId,
    }
  }
)
```

> [!TIP]
> Pro tip
> You can use this same approach to create a draft document. The `sanity.action.document.version.create` action works the same for drafts, with one minor modification.
> Instead of `versions.releaseId.documentId`, set a draft ID with `drafts.documentId`. For example, `drafts.movie12345`.

#### Next steps

[Creating instructions](https://www.sanity.io/docs/agent-actions/instructions)
Write instructions and style guides that produce consistent results.

[Targets and paths](https://www.sanity.io/docs/agent-actions/targets-paths)
Restrict a Generate request to a specific field, array item, or path.

[Enable references in Generate](https://www.sanity.io/docs/agent-actions/generate-add-references)
Populate reference fields from an instruction.

[Create images with Agent Actions](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
Configure your schema so Generate can create images.



# Quick start

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Translate is a Sanity Agent Actions action that lets you programmatically run schema-aware AI translations on Sanity documents. You can run translations from anywhere you can execute code, such as [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction), custom components, webhook listeners, CI/CD pipelines, migration scripts, and more.

In this guide, you'll first use Translate to convert a document into a new language. You'll use `@sanity/client` to create the translation requests (you can also make requests using the [HTTP API](https://www.sanity.io/docs/http-reference/agent-actions) directly).

## Prerequisites

- `@sanity/client` v7.1.0 or later and an environment to run client requests.
- API version `vX` is required for all requests to the Agent Actions API.
- Optional: In Node.js v23.6 or later, you can run this guide's TypeScript examples without additional servers or build processes. Alternatively, you can use [earlier versions with an experimental flag](https://nodejs.org/en/learn/typescript/run-natively). Converting the examples to JavaScript is okay too.
- `sanity` CLI v3.88.0 or later.
- A Sanity project for testing. This guide's examples use details from the sample "Movies" studio schema that you can select when initializing a new project.- A read/write API token to authenticate requests.
- A valid `projectId` and `dataset` name.



## Obtain a schema ID

Translate requires an uploaded schema. If you've deployed recently, you can check for a list of uploaded schemas by running the `schemas list` command. If you don't see a schema or want to deploy the latest version, redeploy your studio to Sanity or deploy the schema:

**List schemas**

```sh
npx sanity@latest schemas list
```

**Deploy studio**

```sh
npx sanity@latest deploy
```

**Deploy schemas**

```sh
npx sanity@latest schemas deploy
```

Copy the schema ID, which you'll need for making Agent Actions requests.

For more information, see [Deploy a schema](https://www.sanity.io/docs/apis-and-sdks/schema-deployment).

## Configure the client

Import and configure `@sanity/client` with the `projectId`, `dataset`, API `token`, and an `apiVersion` of `vX`:

**translate.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

If you're already using the client elsewhere in an application, you can reuse its base configuration. To adjust the token or the API version, use the `withConfig` method to create a new client based on your existing one. For example:

```typescript
// ...
const translateClient = client.withConfig({
  token: process.env.SANITY_API_TOKEN,
})
```

## Translate a document

It's common to want a complete, translated version of a document. To achieve this with Translate:

- Provide a source `documentId` of the original document.
- Define the `fromLanguage`. This is optional, but it avoids the AI interpreting the document as a language other than the one you expect.
- Define the `toLanguage` that you want the document translated into.
- Set the operation. Operations tell Agent Actions what to do. This example uses `create`. [Learn more about operations](https://www.sanity.io/docs/agent-actions/operations).

Here's a minimal example that uses an existing English language document to create a new translation in Greek:

**translate.ts**

```typescript
await client.agent.action.translate({
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},
});
```

This creates a new draft document based on the source (`documentId`).

> [!NOTE]
> Create makes an unlinked draft
> Using the create operation without an ID in Translate creates a new, unlinked draft. This means it's not directly associated with the original document the way a draft of a published document is.

## Customize the output with style guides

If you're familiar with the other Agent Actions, `styleGuide` is Translate's version of `instruction`. It lets you add additional context and guidance beyond setting a target language.

This example tells Translate to use a formal tone:

**translate.ts**

```typescript
await client.agent.action.translate({
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},

  styleGuide: "Use a formal tone when translating.",
});
```

You can also pass information into the style guide with `styleGuideParams`:

**translate.ts**

```typescript
await client.agent.action.translate({
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},

  // Use `styleGuide` instead of instruction for Translate
  styleGuide: "Use a $tone tone when translating.",
  styleGuideParams: {
    tone: 'formal'
  }
});
```

This example uses a `constant` type parameter to assign the string "formal" to the `tone` key, then passes it into the style guide as `$tone`. This is one type of parameter. You can do everything from including full documents to making GROQ queries. Learn more about [passing parameters into style guides](https://www.sanity.io/docs/agent-actions/instructions).

## Next steps

To learn more about what you can do with Translate, explore the other guides and resources available for [Agent Actions](https://www.sanity.io/docs/agent-actions).

#### Explore more

[Translate cheat sheet](https://www.sanity.io/docs/agent-actions/translate-cheatsheet)
Common patterns and examples for using Translate.

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Explore common patterns across all Agent Actions

[Agent Actions](https://www.sanity.io/docs/http-reference/agent-actions)
Reference documentation for the Agent Actions HTTP API.



# Common patterns

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Translate offers an interface to translate Sanity documents using large language models (LLMs). This document showcases a collection of common patterns and concepts.

Prerequisites:

- Complete the [Translate quick start](https://www.sanity.io/docs/agent-actions/translate-quickstart).
- `@sanity/client` v7.1.0 or later and an environment to run client requests.
- API version `vX` is required for any requests to the Agent Actions API.

Many examples in this document use `@sanity/client` and expect that you've installed and configured it for your project. If your client is named something other than `client`, update the code examples accordingly.

Here's an example of the client implementation:

**client.ts**

```typescript
import { createClient } from "@sanity/client";
export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

Then, import `client` into the file where you call Translate.

## Define protected phrases

Translate can be told to leave certain words or phrases untranslated. Supply an array of strings to the `protectedPhrases` property, and the model is instructed not to translate any of them that appear in the input. Requires `@sanity/client` v7.1.0 or later, with the client configured for API version `vX`:

**translate.ts**

```typescript
await client.agent.action.translate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},

  // Words and phrases to leave untranslated
  protectedPhrases: [
    "Sanity",
    "Media Library",
    "Agent Actions"
  ]
});
```

## Set a document's language

Set a field in the new document to record its language. This matters for routing and automation, where a check against a field determines the document's language. Target the field explicitly with `languageFieldPath`. Translate sets that field to the same value as the `toLanguage` ID.

For example, if your document has a `language` field where editors select the document language, set `languageFieldPath` to `language`. Requires `@sanity/client` v7.1.0 or later, with the client configured for API version `vX`:

**translate.ts**

```typescript
await client.agent.action.translate({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to use as the source.
  documentId: "YOUR_DOCUMENT_ID",

  // Set the operation mode
  targetDocument: { operation: "create" },

  // Tell Translate to set this field to the target language,
  // in this case, 'el-GR'.
  languageFieldPath: "language",

  // Set the 'from' and 'to' language
  fromLanguage: {id: "en-US", title: "English"},
  toLanguage: {id: "el-GR", title: "Greek"},
});
```

## Find patterns shared across Agent Actions

In addition to the patterns on this page, there are many common patterns that apply to all Agent Actions.

#### Explore more patterns

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Explore common patterns across all Agent Actions



# Quick start

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Transform is part of Agent Actions and lets you programmatically run schema-aware AI transformations on Sanity documents. You can run instructions from anywhere you can execute code, such as Sanity Functions, custom components, webhook listeners, CI/CD pipelines, migration scripts, and more.

In this guide, you'll use Transform to run a find/replace style instruction on content across multiple fields in a document. You'll use `@sanity/client` to create the instructions (you can also make requests using the [HTTP API](https://www.sanity.io/docs/http-reference/agent-actions) directly).

**Prerequisites**:

- `@sanity/client` v7.1.0 or later and an environment to run client requests.
- API version `vX` is required for any requests to the Agent Actions APIs.
- Optional: In Node.js v23.6 and above, you can run the TypeScript examples in this guide without additional servers or build processes. Alternatively, you can use [earlier versions with an experimental flag](https://nodejs.org/en/learn/typescript/run-natively). You can also convert the examples to JavaScript.
- `sanity` CLI v3.88.0 or later.
- A Sanity project for testing. The examples in this guide use details from the sample "Movies" studio schema that you can select when initializing a new project.- A read/write API token to authenticate requests.
- A valid `projectId` and `dataset` name.



## Obtain a schema ID

Transform requires an uploaded schema. If you've deployed recently, you can check for a list of uploaded schemas by running the `schemas list` command. If you don't see a schema or want to deploy the latest version, deploy the schema:

**List schemas**

```sh
npx sanity@latest schemas list
```

**Deploy studio**

```sh
npx sanity@latest deploy
```

**Deploy schemas**

```sh
npx sanity@latest schemas deploy
```

Copy the schema ID, which you'll need for making Agent Action requests.

[Learn more about schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment).

## Configure the client

Import and configure `@sanity/client` with the `projectId`, `dataset`, API `token`, and an `apiVersion` of `vX`:

**instruction.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production',
    apiVersion: 'vX',
    useCdn: false,
    token: process.env.SANITY_API_TOKEN
})
```

If you're already using the client elsewhere in an application, you can reuse its base configuration. To adjust the token, the API version, or both, use the `withConfig` method to create a new client based on your existing one. For example:

**instruction.ts**

```typescript
// ...
const transformClient = client.withConfig({
  token: process.env.SANITY_API_TOKEN,
})
```

## Transform a document

Transform uses the [concept of an instruction](https://www.sanity.io/docs/agent-actions/instructions). This is where you tell Transform what to do with the content in a document. In the simplest form, `transform` takes the following settings:

- `schemaId`: The ID of your schema.
- `documentId`: The `documentId` defines both the source and the target document. This lets you run the transformation in-place. You can provide a published ID, draft ID, or a version ID.
- `instruction`: The instruction is where you tell the system how to act.

In this example, you create an instruction that changes the term "Alien" or "Aliens" in a movie document to "lifeform from outer space" and "lifeforms from outer space".

> [!TIP]
> Get a document ID
> This example uses the existing document details to rewrite the title. Obtain the document ID from your studio by selecting **Inspect** from the **"..."** menu in the document title bar, querying the document in [Vision Tool](https://www.sanity.io/docs/content-lake/the-vision-plugin), or querying it with `client.fetch()`.

Update the code to include the following instruction, and change the document ID to a valid ID in your project, and the instruction to one that matches your content:

**instruction.ts**

```typescript
await client.agent.action.transform({
  // Replace with your schema ID
  schemaId: "YOUR_SCHEMA_ID",

  // Tell the client the ID of the document to transform.
  documentId: "YOUR_DOCUMENT_ID",

  // Provide an instruction, or prompt.
  instruction: "Change all instances of 'Alien' to 'lifeform from outer space'. Match the case of the existing text.",
});
```

This code reads each field in the document and runs the instruction against them.

> [!WARNING]
> Gotcha
> Depending on your schema, you may find that Transform doesn't interact with images or connect references. To enable these features, you'll need additional schema changes. See these guides to configure each feature.
> - [Enable image generation](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
> - [Enable references in Generate](https://www.sanity.io/docs/agent-actions/generate-add-references)

Run the code to see Transform edit the document and update the content. In this example, it updates the movie's title and parts of the description.

By default, Transform never writes to a published document. If you pass a published document ID, it edits the existing draft, or creates one from the published document if no draft exists. To write to the published document, add `forcePublishedWrite: true`.

> [!TIP]
> Protip
> With the latest version of Node.js, you can run TypeScript files directly from your terminal. Run `node instruction.ts`, replacing instruction with the path to your file.

## Create a new document with instruction parameters

Transform can also create new documents based on the content in the source. If you want to create new documents from scratch, use [Generate](https://www.sanity.io/docs/agent-actions/generate-quickstart) instead.

In this step, modify the `transform` call in `instruction.ts` so it creates a new document from the original as its source. This version also passes instruction parameters.

- Add `targetDocument`: This takes an operation type, `create`, and optionally an ID.
- Add `instructionParams`: The parameters can have any `$key` name you like. This example uses the `field` and `constant` parameter types. The `field` type targets a specific field in the source document, which, in this example, is the `title` field. The `constant` type has a shorthand: assign a plain string.
- Update the instruction: Include the newly defined parameters, and prefix them with `$`. In the example, these are `$title` and `$new`.
- Add `target` paths (optional): This example also adds explicit targets. Instead of affecting the whole document, only the paths set in `target` change.

**instruction.ts**

```typescript
const docId = "YOUR_DOCUMENT_ID";
await client.agent.action.transform({
  schemaId: "YOUR_SCHEMA_ID",
  // documentId is equivalent to targetDocument: {operation: 'edit', _id: docId }
  documentId: docId,
  targetDocument: {
    operation: 'create'
  },
  instruction: "Replace every instance of $title with $new. Match the case of the existing text.",
  instructionParams: {
    title: {
      type: "field",
      path: "title",
    },
    new: "lifeforms from outer space"
  },
  target: [
    { path: ['title'] },
    { path: ['overview'] },
  ]
});
```

Field-type instruction parameters expect a path leading to fields in the document. In this case, it uses the title field to read the title.

> [!TIP]
> Protip
> You may wonder why this example uses `instructionParams` to pass variables when string interpolation or other methods could build the instruction string. By using `instructionParams` and then passing them to the instruction with the `$key` syntax, Transform has more control over how it shapes and sends your requests to the LLMs.

Another approach is to use a GROQ-type query and capture the whole or parts of other documents as context:

```typescript
await client.agent.action.transform({
  // ...
  instructionParams: {
    title: {
      type: "groq",
      query: '*[_id == $id].title',
      params: { id: 'SOURCE_DOCUMENT_ID' },
    },
  },
  // ...
});
```

GROQ-type instruction parameters take a GROQ query and pass the result to the parameter. In this case, it passes the title of another movie document in your dataset.

> [!TIP]
> Protip
> If you want to pass an individual document, you can use the `document` type param. For example: `thisDocument: { type: 'document', documentId: 'YOUR_DOCUMENT_ID'}`. If you omit the ID, the parameter defaults to the current document.

`target` lets you explicitly tell the instruction which fields to write to, and it can take field-level `instruction` requests. For more `target` examples, see [the Transform common patterns guide](https://www.sanity.io/docs/agent-actions/transform-cheatsheet).

Run the example to see a new draft populate for your document.

## Next steps

These examples run once, but you can loop over multiple documents, build multi-step workflows, custom components, and much more. These resources provide additional examples and details.

[Create images with Agent Actions](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
Configure your schema to enable image generation.

[Targets and paths](https://www.sanity.io/docs/agent-actions/targets-paths)
Use target and path to write to specific parts of a document.

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
View common patterns and best practices.

[Operations](https://www.sanity.io/docs/agent-actions/operations)
Control how Agent Actions create or edit documents.



# Common patterns

Transform offers an interface to enhance Sanity documents using large language models (LLMs). This document showcases a collection of common patterns and concepts.

Prerequisites:

- A completed [Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart).
- `@sanity/client` v7.5.0 or later and an environment to run client requests.
- API version vX or later for any requests using Transform.

Many examples in this document use `@sanity/client` and expect that you've installed and configured it for your project. If your client is named something other than `client`, update the code examples accordingly.

Here's an example of the client implementation:

**client.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  useCdn: false,
  apiVersion: 'vX',
  token: process.env.SANITY_API_TOKEN
})
```

Then, import `client` before using the examples on this page.

## Edit a full document

Perform in-place edits on a document. This does not create a new document. By default, Transform never writes to a published document. If you provide a published `documentId`, the action edits the existing draft, or creates one from the published document if no draft exists. To write directly to the published document, add `forcePublishedWrite: true`.

```typescript
await client.agent.action.transform({
  schemaId: 'YOUR_SCHEMA_ID',
  documentId: 'YOUR_DOCUMENT_ID',
  instruction: 'Replace "Create" with "Canvas"',
})
```

## Edit part of a document

Perform an in-place edit on only part of a document.

```typescript
await client.agent.action.transform({
  schemaId: 'YOUR_SCHEMA_ID',
  documentId: 'YOUR_DOCUMENT_ID',
  instruction: 'Replace "Create" with "Canvas"',
  target: {path: ['body']} // only transforms body (and any sub-fields/items)
})
```

## Use multiple instruction parameters

This instruction pulls one parameter from a GROQ query, and another from a field, `year`, in the source document and creates a new draft with the changes.

```typescript
await client.agent.action.transform({
  schemaId: 'YOUR_SCHEMA_ID',
  documentId: 'YOUR_DOCUMENT_ID',
  targetDocument: {
    operation: 'create',
  },
  instruction: 'Add $fieldValue to every instance of $groqTitle',
  instructionParams: {
    groqTitle: {
      type: 'groq',
      query: '*[_id==$id].title',
      params: {
        id: 'abc123'
      }
    },
    fieldValue: {
      type: 'field',
      path: 'year'
    }
  }
})
```

## Apply instructions to individual fields

This transformation has a top-level instruction, but sets an individual instruction for the title field using `target`.

```typescript
await client.agent.action.transform({
  schemaId: 'YOUR_SCHEMA_ID',
  documentId: 'YOUR_DOCUMENT_ID',
  targetDocument: {
    operation: 'create',
  },
  instruction: 'Replace "$old" with "$new"',
  instructionParams: {
    new: 'lifeform from another planet',
    old: 'alien'
  },
  target: [
    {
      path: ['title'],
      instruction: 'Replace "$old" with "$new". Use title-case.'
    },
    { path: 'body' } // anything in or below 'body' uses the default instruction.
  ]
})
```

Learn more about targeting individual fields in the [Targets and paths](https://www.sanity.io/docs/agent-actions/targets-paths) documentation.

## Create captions and alt text

Transform's `image-description` operation type describes the contents of an image asset in the field you target.

This example creates a draft of the document defined in `documentId`, then describes the image asset adjacent to the `['image', 'alt']` field.

```typescript
await client.agent.action.transform({
  schemaId: 'YOUR_SCHEMA_ID',
  documentId: 'YOUR_DOCUMENT_ID',
  instruction: 'Describe the image in one to two sentences.',
  target: [{
    path: ['image', 'alt'],
    operation: {
      type: 'image-description'
    }
  }]
});
```

Learn more about Transform's `image-description` operation in the [Targets and paths](https://www.sanity.io/docs/agent-actions/targets-paths) documentation.

## Transform an image

Transform can change images. Describe the instruction and target the asset itself. For example:

```typescript
await client.agent.action.transform({
  schemaId: 'YOUR_SCHEMA_ID',
  documentId: 'YOUR_DOCUMENT_ID',
  instruction: 'Add cats to this image.',
  target: {
    path: ['mainImage', 'asset'],
  }
});
```

Learn more about `target` in the [common Agent Actions patterns guide](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet).



# Quick start

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Prompt is a Sanity Agent Action that lets you make large language model (LLM) requests without bringing in external AI tooling. You can run prompts from anywhere you can execute code, such as [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction), custom components, webhook listeners, CI/CD pipelines, migration scripts, and more.

In this guide, you'll first use Prompt to make a request to the LLM. You'll use `@sanity/client` to run the Prompt (you can also make requests using the [HTTP API](https://www.sanity.io/docs/http-reference/agent-actions) directly).



**Prerequisites**:

- `@sanity/client` v7.8.2 or higher and an environment to run client requests.
- API Version `vX` is required for any requests to the Agent Actions APIs.
- Optional: In Node.js v23.6 and above, you can run the TypeScript examples below without additional servers or build processes. Alternatively, you can use [earlier versions with an experimental flag](https://nodejs.org/en/learn/typescript/run-natively). Converting the examples to JavaScript is okay too.
- An API or personal token to make authenticated requests.

## Configure the client

Import and configure `@sanity/client` with the `projectId`, `dataset`, API `token`, and an `apiVersion` of vX.

**prompt.ts**

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset-name>',
    apiVersion: 'vX',
    token: '<editor-token>'
})
```

If you're already using the client elsewhere in an application, you can reuse its base configuration. If you need to adjust the token and/or API version, use the `withConfig` method to create a new client based on your existing one. For example:

```typescript
// ...
const promptClient = client.withConfig({
  token: '<your-token>',
})
```

## Prompt for ideas

Prompts don't edit or create documents. They return text, or json, so you can use it however you please. 

```
const response = await client.agent.action.prompt({
  // write an instruction
  instruction: `Give me some ideas for a blog post 
    about using AI with structured content.`
});

console.log(response)
```

This prompt returns a text response that you can use. The examples on this page log the response to the console.

## Use parameters

You can further customize the instruction by passing in parameters. For example, if you want use a document as background information for the prompt, you use the `document` parameter type.

```
const response = await client.agent.action.prompt({
  // write an instruction
  instruction: `Give me some ideas for a blog post 
    about using AI with structured content. Use the following as context for the ideas: $background`,
  instructionParams: {
    background: {
      type: 'document',
      documentId: '<target-document-id>'
    }
  }
});
console.log(response)
```

You can learn more about parameters and the available types in the [creating instructions guide](https://www.sanity.io/docs/agent-actions/instructions).

## Change the output

Prompt will return a string response by default, but you can also tell it to return JSON. To do so, you must set the `format` to "json" and explicitly include the world "JSON" or "json" in the instruction. It also helps to provide an example shape in the instruction.

```
const response = await client.agent.action.prompt({
  // write an instruction
  instruction: `Give me some ideas for a blog post 
    about using AI with structured content. Respond in JSON with the following format: { "ideas": ['idea one', 'idea two', 'etc'] }`,
  format: 'json'
});
console.log(response)
```

## Add variety

You can tune the variance of responses by adjusting the `temperature` of the request.

```
const response = await client.agent.action.prompt({
  // write an instruction
  instruction: `Give me some ideas for a blog post 
    about using AI with structured content.`,
  temperature: '0.8' // Set between 0 and 1, inclusively. Default: 0.3
});
console.log(response)
```

Higher values result in more variety of responses, while lower values result in more predictable results when given the same instruction.

## Next steps

To learn more about what you can do with Translate, explore the other guides and resources available for [Agent Actions](https://www.sanity.io/docs/agent-actions).

#### Explore more

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Explore common patterns across all Agent Actions

[Agent Actions](https://www.sanity.io/docs/http-reference/agent-actions)
Reference documentation for the Agent Actions HTTP API.





# Quick start

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Patch is a Sanity Agent Action that helps you make schema-aware patches to documents. You can run Patch from anywhere you can execute code, such as [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction), custom components, webhook listeners, CI/CD pipelines, migration scripts, and more.

In this guide, you'll use Patch to modify documents in a safe, schema-aware way. You'll use `@sanity/client` to run Patch (you can also make requests using the [HTTP API](https://www.sanity.io/docs/http-reference/agent-actions) directly).

> [!TIP]
> Patch doesn't use an LLM
> Unlike many Agent Actions, Patch doesn't use an LLM and instead relies on your schema.
> This means it uses standard Sanity API billing for API requests.

**Prerequisites**:

- `@sanity/client` v7.4.0 or higher and an environment to run client requests.
- API Version `vX` is required for any requests to the Agent Actions APIs.
- Optional: In Node.js v23.6 and above, you can run the TypeScript examples below without additional servers or build processes. Alternatively, you can use [earlier versions with an experimental flag](https://nodejs.org/en/learn/typescript/run-natively). Converting the examples to JavaScript is okay too.
- An API or personal token to make authenticated requests.

## Obtain your schema ID

Patch requires an uploaded schema. If you've deployed recently, you can check for a list of uploaded schemas by running the `schema list` command. If you don't see a schema or want to deploy the latest version, redeploy your studio to Sanity or deploy the schema.

**List schema**

```sh
npx sanity schema list
```

**Deploy Studio**

```sh
npx sanity deploy
```

**Deploy schema**

```sh
npx sanity schema deploy
```

Copy the schema ID, which you'll need for making Agent Action requests. 

[You can learn more about schema deployment here](https://www.sanity.io/docs/apis-and-sdks/schema-deployment).

## Configure the client

Import and configure `@sanity/client` with the `projectId`, `dataset`, API `token`, and an `apiVersion` of vX.

```typescript
import { createClient } from "@sanity/client";

export const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset-name>',
    apiVersion: 'vX',
    token: '<editor-token>'
})
```

If you're already using the client elsewhere in an application, you can reuse its base configuration. If you need to adjust the token and/or API version, use the `withConfig` method to create a new client based on your existing one. For example:

```typescript
// ...
const patchClient = client.withConfig({
  token: '<your-token>',
})
```

## Patch basics

At it's core, Patch works much like the patch format used by many Content Lake APIs. The big difference is that Agent Action Patch is aware of your schema. It validates paths and ensures that the provided values are compatible with the target schema.

Patch relies heavily targets, paths, and operations. 

- **Targets** and **paths **tell Patch which parts of a document to affect.
- **Operations** tell Patch how to reconcile new and old data.

To learn more about these concepts, see the [Targets and paths documentation](https://www.sanity.io/docs/agent-actions/targets-paths).

Here's an example of a patch request that updates a nested title field and changes the title to "New title".

```
await client.agent.action.patch({
  schemaId: 'sanity.workspace.schema.production',
  documentId: 'documentId',
  target: {
    path: ['metadata', 'title'], // path to metadata.title
    operation: 'set',
    value: 'New title'
  }
});
```

## Multi-target patches

Patch excels at targeted, multi-target edits. This example uses multiple targets with different operations to mutate the document.

```
await client.agent.action.patch({
  schemaId: 'sanity.workspace.schema.production',
  documentId: 'documentId',
  target: [
    { path: ['title'], operation: 'set', value: 'New title' },
    {
      path: ['array'], 
      operation: 'append', 
      value: [
        { _type: 'item', title: 'New Array item' }, // key will be generated
        { _type: 'item', title: 'Another new array item', _key: 'explicitKey' }
      ]
    },
    { path: ['customFieldName', {_key: 'abc'}, 'title'], operation: 'unset'},
    
    // 'mixed' will set non-array fields, and append to array fields.
    // Objects are merged, not overwritten.
    {
      path: ['customObject'],
      operation: 'mixed', 
      value: {
        // mixed mode implies set for string fields
        description: 'Hello',
        // mixed mode implies append for arrays
        otherArray: [{_type: 'item', title: 'a'}]
      }
    }
  ]
});
```



# Troubleshooting

Agent Actions return their own errors and also pass through statuses from the core API, so the status code tells you which layer rejected a request. The sections below are organized by the status code you get back.

Read `err.statusCode` and `err.responseBody` on the `ClientError` rather than matching on message text. Agent Actions pass core API messages through unchanged, so the wording varies even when the cause doesn't.

> [!NOTE]
> A 401 is not a permissions problem
> A `401` means the token wasn't accepted at all. A valid token that lacks access to the target project returns `403` or `404`, and a malformed request returns `400`. Widening a token's project or dataset scope won't resolve a `401`.

## 401 Unauthorized

A `401` means the request carried no usable token. The causes are a missing `Authorization` header, a token that fails verification, and an expired session, which returns `Session expired`. Confirm the token reaches the client at runtime, then confirm it hasn't been revoked in [Manage](https://sanity.io/manage).

## 403 Forbidden

A `403` means the token is valid but the request isn't allowed. Two causes account for most of them:

- `AI features have been disabled at this organization's request.`: Returned with the message `Feature disabled.` when an organization has turned AI features off. An organization owner re-enables them in [Manage](https://sanity.io/manage). No change to the token or the request works around this.
- Project access: A token that can't see the target project returns the core API's own `403` or `404`, passed through unchanged. Check that the token belongs to the project named in the client's `projectId`.

## 400 Bad Request

A `400` means the request itself was malformed. These are the ones most often mistaken for authentication failures:

- `Invalid request body`: Returned with a details list naming the offending field, most often a missing `schemaId`.
- `Agent Actions are only available on apiVersion vX`: Agent Actions run only on `apiVersion: 'vX'`, and any dated API version is rejected.
- `Only bearer tokens are allowed as authorization`: The `Authorization` header uses a scheme other than `Bearer`.
- `Could not resolve projectId`: The request reached the API without a project ID. Set `projectId` on the client.

Every Agent Actions request needs `schemaId`. The client doesn't validate it, so omitting it is only a TypeScript error locally. Take the value from the output of `sanity schema deploy` or `sanity deploy`.

## 404 Not Found

`No deployed Studio schema found for` is returned when the target project and dataset have no deployed schema. Agent Actions read the deployed schema rather than your local files, so an undeployed change behaves exactly like a missing one. Run `sanity schema deploy` or `sanity deploy`, then pass the resulting `schemaId`.

## 429 Too Many Requests

`Plan or Budget Limit reached.` is returned when the plan or the Agent Actions budget is exhausted. Change either one in [Manage](https://sanity.io/manage). The response body includes `usageValue` and `quota`, so you can log how far past the limit the request was.

## Requests succeed but content is missing

### Text is generated but no image

With the AI Assist approach, an image whose type has no `imageInstructionField` configured is skipped. Generate writes the text fields and produces no image. The request succeeds and returns no error, so nothing reports the missing configuration.

Check two things: that the option is set on the image type, and that you deployed the schema after adding it. Generate reads the deployed schema, not your local files, so an undeployed change behaves exactly like a missing one. To skip schema configuration altogether, target the image's asset path directly.

## Related resources

[Agent Actions introduction](https://www.sanity.io/docs/agent-actions/introduction)
Requirements, core concepts, and limitations for Agent Actions.

[Create images with Agent Actions](https://www.sanity.io/docs/agent-actions/agent-actions-image-generation)
Set up image generation with explicit targets or with AI Assist.

[Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)
Common patterns and options shared by every Agent Action.



# Setting up your studio

## Create a new Studio with Sanity CLI

![Video](https://stream.mux.com/wIMs3CS7T4pP7hRArpQZsBZ01Be02vCjbK)

Run the command in your Terminal to initialize your project on your local computer.

See the documentation if you are [having issues with the CLI](https://www.sanity.io/docs/help/cli-errors).

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

## Run Sanity Studio locally

Inside the directory of the Studio, start the development server by running the following command.

**npm**

```shell
# in studio-hello-world 
npm run dev
```

**pnpm**

```shell
# in studio-hello-world 
pnpm run dev
```

**yarn**

```shell
# in studio-hello-world 
yarn run dev
```

**bun**

```shell
# in studio-hello-world 
bun run dev
```

## Log in to the Studio

**Open** the Studio running locally in your browser from [http://localhost:3333](http://localhost:3333).

You should now see a screen prompting you to log in to the Studio. Use the same service (Google, GitHub, or email) that you used when you logged in to the CLI.



# Defining a schema

## Create a new document type

![Video](https://stream.mux.com/IfVfAwxfwOKN2khdGCQ3cs5IuF1rYte1)

Create a new file in your Studio’s `schemaTypes` folder called `postType.ts` with the code below which contains a set of fields for a new `post` document type.

**/studio-hello-world/schemaTypes/postType.ts**

```
import {defineField, defineType} from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: {source: 'title'},
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
      initialValue: () => new Date().toISOString(),
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'image',
      type: 'image',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{type: 'block'}],
    }),
  ],
})
```

## Register the `post` schema type to the Studio schema

Now you can import this document type into the `schemaTypes` array in the `index.ts` file in the same folder.

**/studio-hello-world/schemaTypes/index.ts**

```
import {postType} from './postType'

export const schemaTypes = [postType]
```

## Publish your first document

When you save these two files, your Studio should automatically reload and show your first document type. Click the `+` symbol at the top left to create and publish a new `post` document.



# Query content with GROQ

## Write your first GROQ query

![Video](https://stream.mux.com/Mc12Sdeu00ugrGuQyz00Du1G4AQZmT36UV)

Open **Vision** in your Studio's top nav bar and paste this query into the **Query** code block field.

**Vision**

```groq
*[_type == "post"]{
  _id,
  title,
  slug,
  publishedAt
}
```

- `*` represents all documents in a dataset as an array
- `[_type == "post"]` represents a **filter** to only return matching documents
- `{ _id, title, slug, publishedAt }` represents a **projection** which defines the attributes from those documents that you wish to include in the response.

## Run the query

Click **Fetch** to see the JSON output in **Results**. You should see the document you previously published in the results.

Queries run in Vision use your authenticated session, so you will see private documents – which have a `.` in the `_id` key, like `drafts.`. You will not see when queried from your front end in the next step.



# Deploying the Studio

## Deploy your Studio with Sanity

![Video](https://stream.mux.com/CvYhCQr8e1oZt98NW202BZLLNv376VVKc)

In your Studio directory (`studio-hello-world`) run the following command to deploy your Sanity Studio.

The first time you run this command, the CLI will prompt you to enter a **hostname**. This is the unique name for your Studio's URL (entering *my-app* will make your Studio available at *my-app*.sanity.studio).

**npm**

```shell
npm run deploy
```

**pnpm**

```shell
pnpm run deploy
```

**yarn**

```shell
yarn run deploy
```

**bun**

```shell
bun run deploy
```

## Invite a collaborator

Now that you’ve deployed your Studio, you can optionally invite a collaborator to your project. Navigate to your project in [Sanity Manage](https://www.sanity.io/manage), then select "Members". 

They will be able to access the deployed Studio, where you can collaborate together on creating content.





# Dashboard

#### Explore Dashboard

[Meet the Dashboard](https://www.sanity.io/docs/dashboard/dashboard-introduction)
The central hub for all your content operations.

[Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure)
Get started with Sanity Dashboard, the central hub of all your content operations.

#### Custom apps for Dashboard

[App SDK](https://www.sanity.io/docs/app-sdk)
Create custom apps on the Sanity platform with the App SDK.

[App SDK Quickstart Guide](https://www.sanity.io/docs/app-sdk/sdk-quickstart)
Get up and running quickly with the Sanity App SDK by following this step-by-step guide!



# Meet the Dashboard

> [!TIP]
> Find your dashboard
> To find your organization’s dashboard, visit [the Sanity welcome page](https://www.sanity.io/welcome).

## Dashboard at a glance

The Sanity Dashboard is the central hub for your organization's content operations. Here you'll find your deployed [studios](https://www.sanity.io/docs/studio), [custom apps](https://www.sanity.io/docs/app-sdk), and official Sanity apps like [Canvas](https://www.sanity.io/docs/canvas) and [Media Library](https://www.sanity.io/docs/media-library).

![A Sanity.io dashboard displaying an AI content agent prompt for creating an FAQ page, recent studios, and an activity log.](https://cdn.sanity.io/images/3do82whm/next/adc7e803466a5632c74be52f81268b55599cb802-2524x1790.png)

Your dashboard is centered around your organization, and gives access to deployed studios and apps within the organization, across projects and datasets.

#### Set up Dashboard

[Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure)

[Hosting and deployment](https://www.sanity.io/docs/studio/deployment)

> [!NOTE]
> What about the dashboard plugin?
> If the name sounds familiar, that’s because the Sanity ecosystem already has a dashboard: the official [dashboard plugin](https://www.sanity.io/docs/studio/dashboard) for Sanity Studio. That plugin remains available for in-studio dashboards.

## Tour the dashboard interface

Your dashboard has three parts: a main area that adapts to the app you're working in, the side navigation, and expanding panels.

![The dashboard home screen with four numbered regions: 1, the main content area; 2, the dashboard navigation and expanding panels; 3, the application navigation; 4, account settings and help.](https://cdn.sanity.io/images/3do82whm/next/f1dc7a6458982b0c80cf2f75b466d402091e24a7-2524x1790.png)

### 1. Main content area

The main content area adapts to whatever app you're working in, such as a studio.

![Sanity Studio interface displaying a list of articles, with "Query Cheat Sheet - GROQ" selected for editing.](https://cdn.sanity.io/images/3do82whm/next/e2c9baef842b76c4a9b06402d2aa6dcb8cb79efc-2480x1746.png)

If there is no active app, this area defaults to show you links to your most likely destinations, favorites, and insights about your content.

### 2. Dashboard navigation and expanding panels

The top section in the left sidebar is where you'll switch between your organizations and toggle panels like notifications, favorites, or the Content Agent.

![The dashboard with the top of the left sidebar highlighted, showing the organization switcher, home, notifications, favorites, and Content Agent.](https://cdn.sanity.io/images/3do82whm/next/e14f6d724711f3140f3eb1fc22396435c61b5d1d-2384x1650.png)

#### Organization switcher

Click your organization name to bring up a list of the organizations available to you. You can also access your organization’s *Manage* page from here.

#### Home

Returns the dashboard to the Home screen.

#### Notifications

Opens the notifications panel to display notifications, like comments or tasks, across your studios and Sanity apps.

#### Favorites

Opens the favorites panel to display any documents you’ve favorited across studios and Sanity apps. You can add a document to your favorites any time you see a star icon.

![A document header with the star icon selected to add the document to favorites.](https://cdn.sanity.io/images/3do82whm/next/c6781efc92c0679866583a145c932fe31daace98-1096x812.png)

#### Content Agent

If your organization has [Content Agent enabled](https://www.sanity.io/docs/content-agent/introduction), you can open it from here at any time. Content Agent lets you ask questions about your data, make changes, and more.

You can also use Content Agent from Slack. When you share a Dashboard URL in a Slack workspace with the Sanity app installed, Slack displays a rich preview card showing the document title, type, and status. See [Content Agent for Slack](https://www.sanity.io/docs/content-agent/content-agent-for-slack) for details.

### 3. Application navigation

![The dashboard with the application navigation highlighted in the left sidebar, listing Sanity apps, studios, and custom apps.](https://cdn.sanity.io/images/3do82whm/next/f01febd08277b4c9417ce2e987c64ac8e941d9af-2384x1650.png)

The application navigation section lets you navigate between Sanity applications, your studios, and your custom apps.

#### Canvas and Media Library

The official Sanity apps available to your organization, such as [Canvas](https://www.sanity.io/docs/canvas) and [Media Library](https://www.sanity.io/docs/media-library), appear here. Selecting them will open them in the main section of the dashboard.

#### Studios and custom apps

Below the official Sanity apps, you'll find any [custom apps](https://www.sanity.io/docs/app-sdk) and [studios](https://www.sanity.io/docs/studio) deployed by your organization. If you can't find an app or studio you expected to see here, it may not [be registered](https://www.sanity.io/docs/dashboard/dashboard-configure) yet. Studios deployed to Sanity hosting appear automatically, but a self-hosted studio has to be registered before it shows up.

> [!NOTE]
> Self-hosted studios need to be registered
> A studio you host yourself does not appear in Dashboard until you register it. Deploying the schema and serving the manifest from your own domain are not enough on their own. Run `npx sanity@latest deploy --external --url https://example.com/studio` from the studio folder. Registration itself persists, but repeat the command on every deployment to keep the schema and manifest current.
> The same gap affects other surfaces. An unregistered studio cannot be resolved from the Media Library "in use" dialog either, so reference rows there will not open. See [Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure) for the full setup.

You can pin any studio or app from the **Studios & Apps** page by selecting the pin icon. Pinning is per person: it does not pin the studio or app for everyone in your organization.

### 4. Account settings and help

![The dashboard with the bottom of the left sidebar highlighted, showing account settings and the help and feedback link.](https://cdn.sanity.io/images/3do82whm/next/6d7f0cd375299903b4e6e8db43a7af554fe3ff8a-2384x1650.png)

In the bottom left sidebar section you’ll find your user account settings and useful links for getting help and leaving feedback.

#### Help and feedback

Select the question mark icon to open the help and feedback menu. It links to support, the documentation, and the Sanity community.

![The help and feedback menu, including a link to join the Sanity community.](https://cdn.sanity.io/images/3do82whm/next/47fbef225e231fc20cde3af0780b7121559a427e-216x264.png)

#### Account settings

Select your profile image to open a menu with dashboard-wide theme options, a link to your account settings, and the option to sign out.

> [!NOTE]
> Light, dark, and system theme
> If you set dark or light mode in Studio before using Dashboard, your studio can stay locked to that theme. Clear the studio’s local storage for its origin in your browser to reset it.



# Configuring the Dashboard

## Set up your content operations dashboard

> [!TIP]
> Find your dashboard
> To find your organization dashboard, go to the [Sanity welcome page](https://www.sanity.io/welcome).

The Sanity Dashboard is the hub for your organization's content operations. Here you'll find your deployed studios, custom apps, and official Sanity apps like [Canvas](https://www.sanity.io/docs/canvas) or [Media Library](https://www.sanity.io/docs/media-library).

![Sanity Studio dashboard](https://cdn.sanity.io/images/3do82whm/next/adc7e803466a5632c74be52f81268b55599cb802-2524x1790.png)

Your dashboard is centered around your organization, and gives access to all deployed studios and apps within the organization, across projects and datasets.

> [!NOTE]
> Dashboard plugin
> Sanity Studio already has an official [dashboard plugin](https://www.sanity.io/docs/studio/dashboard). That plugin remains available for your in-studio dashboard needs.

## Disable Dashboard

You can disable the dashboard for your organization by navigating to the organization in [Sanity Manage](https://www.sanity.io/manage).

![The Settings tab for an organization in Sanity Manage, showing the Dashboard is enabled switch.](https://cdn.sanity.io/images/3do82whm/next/0d5bc2581522298518ade2793794c8bf25906b1a-2572x1130.png)

Disabling Dashboard affects every user in your organization. From your organization's manage page:

1. Select the **Settings** tab.
2. Toggle the **Dashboard is enabled** switch to disable Dashboard.

## Hide a studio from Dashboard

You can hide a studio from the dashboard in [Sanity Manage](https://www.sanity.io/manage).

1. Open [Sanity Manage](https://www.sanity.io/manage) and select your project.
2. Navigate to the **Studios** tab.
3. Open the context menu (⋯) for the studio you want to hide.
4. Click **Hide in Dashboard**.

![Screenshot of the studios settings in Manage](https://cdn.sanity.io/images/3do82whm/next/8a0c1aa26686f791563afc19fe02022ec54d7661-2194x956.png)

## Configure your studios

![Sanity Studio inside the dashboard](https://cdn.sanity.io/images/3do82whm/next/e2c9baef842b76c4a9b06402d2aa6dcb8cb79efc-2480x1746.png)

Studios you deployed before Dashboard keep working as before, with your customization intact. To get the benefits of the integrated dashboard, deploy the studio once more. The exact steps depend on your setup.

### Requirements

Dashboard works with studios back to v2.28.0, but for the best experience, [upgrade](https://www.sanity.io/docs/studio/upgrade) to the latest `sanity` release.

- Studio version: v2.28.0 at minimum. Use the latest `sanity` release unless you have a reason not to.
- The workspace schema has to be deployed. Both `sanity deploy` and `sanity deploy --external` do this. For details, see [Schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment).
- For self-hosted and embedded studios that are not compiled using Sanity build tools (`sanity build` or `sanity deploy`), you'll also need to add a small bridge script to connect with the dashboard.

## Sanity-hosted studio

If you are using Sanity's hosting service, you get the most straightforward route. To set up your project to automatically generate the necessary schema and manifest files on every deployment, follow these steps:

1. [Upgrade](https://www.sanity.io/docs/studio/upgrade) your studio to the latest `sanity` release. Schema deployment requires v3.88.0 or later.
2. Deploy your studio by running the command `npx sanity deploy`.

The Sanity CLI automatically builds your studio and manifest files and deploys them to the configured host. The manifest file is available at `YOUR_STUDIO_HOST.sanity.studio/static/create-manifest.json`.

> [!TIP]
> Auto-updating studios still need one deployment
> Even if you are opted into auto-updating studios, you still need one manual deployment to fully integrate with Dashboard.
> Update your local studio to `sanity@latest`, then run `npx sanity deploy`.

## Self-hosted studio

If you are not using Sanity's hosting service, setting up Dashboard comes down to one command. Running `sanity deploy --external` records where your studio is served and deploys its workspace schema in the same run. Dashboard, Media Library, and the App SDK read the manifest that is registered with your project, not a copy served from your own host, so serving the manifest yourself is neither required nor sufficient.

1. Update your studio to the latest `sanity` release. Schema deployment requires v3.88.0 or later.
2. Build your studio with `npx sanity@latest build` and upload the output to your host, the same way you would any static site.
3. Make sure the studio is publicly reachable at that URL, without authentication. Dashboard cannot embed a studio it cannot load.
4. Register the studio and deploy its schema by running `npx sanity@latest deploy --external --url https://example.com/studio` from your studio folder. Use the full public URL, including any base path. Run it on every deployment: registration itself persists, so `--url` is only needed on the first run or when the URL changes, but each run refreshes the schema and manifest.

> [!WARNING]
> Registration is required for Dashboard and Media Library
> Without `sanity deploy --external`, your studio has no registered manifest. Dashboard, Media Library, and the App SDK cannot resolve which workspaces the studio has, so features that link back into the Studio (such as the Media Library in-use dialog) will not work. Running `sanity schemas deploy` alone does not register the studio, and neither does adding the studio URL in Manage.

### Deploy to Vercel

To self-host your studio and schema files on Vercel, use the following configuration:

**vercel.json**

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "framework": "sanity",
  "buildCommand": "sanity build && sanity deploy --external --url https://example.com/studio"
}
```

### Studio embedded in Next.js

For Next.js projects with embedded studios, follow the steps in "Self-hosted studio", with two differences: how you generate the manifest files, and the bridge script you add.

1. [Upgrade](https://www.sanity.io/docs/studio/upgrade) your studio to the latest `sanity` release. Schema deployment requires v3.88.0 or later.
2. Optional: generate the manifest files by running `npx sanity@latest manifest extract --path public/studio/static`, matching your studio's path relative to the Next.js project root. Dashboard reads the manifest registered by `sanity deploy --external`, so you only need this if something in your setup fetches the manifest from your domain directly.
3. Register the studio and deploy its schema by running `npx sanity@latest deploy --external --url https://cool-domain.com/admin`. Use the full public URL of your studio, including the path it is served from.
4. If you extracted the manifest files, Next.js serves them when you deploy your application.
5. Finally, add the Dashboard bridge script to your studio route as shown in "Add the bridge component", then deploy your project.

### Add the bridge component

For self-hosted and embedded studios that are not compiled using `sanity build` or `sanity deploy`, or that use [next-sanity](https://github.com/sanity-io/next-sanity), you also need to add a small script so Dashboard can interact with your studios:

**index.html**

```html
<script src="https://core.sanity-cdn.com/bridge.js" async type="module"></script>
```

Exactly where you should put the script varies depending on your exact setup, but a generalized example might look as follows:

**./route/to/studio/layout.tsx**

```tsx
import {preloadModule} from 'react-dom'

const bridgeScript = 'https://core.sanity-cdn.com/bridge.js'

export default function StudioLayout({
  children,
}: {
  children: React.ReactNode
}) {
  preloadModule(bridgeScript, {as: 'script'})
  return (
    <>
      <script src={bridgeScript} async type="module" />
      {children}
    </>
  )
}
```

### Allow embedding for protected domains

Some services, such as Cloudflare’s domain protection and Vercel’s deployment previews, may restrict your studio’s ability to be embedded in Dashboard by setting restrictive headers.

#### Check the X-Frame-Options header

If this is set to `DENY`, Dashboard cannot embed your studio. If set to `SAMEORIGIN`, your studio can only be framed by a page on the studio's own origin, so Dashboard at sanity.io is still blocked. Because both options prevent Dashboard from embedding your studio, set the `frame-ancestors` policy instead.

#### Set the `Content-Security-Policy: frame-ancestors` directive

If your service provider lets you define custom headers, allow Dashboard by including a `frame-ancestors` directive in `Content-Security-Policy`. Note that `'self'` alone is not enough, because it permits framing only by the studio's own origin, which leaves Dashboard at sanity.io blocked. The expression list has to include at least one of:

- `https://www.sanity.io`
- `https://*.sanity.io` (recommended)
- `https:`, which permits framing by any HTTPS origin. Prefer one of the Sanity-specific values.

For example:

**Response header**

```text
Content-Security-Policy: frame-ancestors https://*.sanity.io;
```

## Add a token for CI/CD pipelines

If you deploy your studio as part of an automated workflow, add a deploy token to your project in Sanity Manage, then run the registration step with it:

**CLI**

```sh
SANITY_AUTH_TOKEN=YOUR_DEPLOY_TOKEN npx sanity@latest deploy --external --url https://example.com/studio
```

One command covers both jobs: `sanity deploy --external` keeps the stored schema current and keeps the registered manifest current. A deploy token carries the `deployStudio` grant it needs; a write token isn't required. Skip this step in your pipeline and Dashboard and Media Library fall out of sync with your studio.

To get a deploy token, open [Sanity Manage](https://www.sanity.io/manage) and go to **API** for your project.

## Update studio icons in Dashboard

Dashboard retrieves the icon for each studio workspace from the `icon` property defined in that studio’s workspace configuration:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'
import {StudioIcon} from './StudioIcon'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [structureTool()],
  schema: {
    types: schemaTypes,
  },
  icon: StudioIcon,
  // ... rest of config
})
```

**StudioIcon.tsx**

```tsx
export const StudioIcon = () => (
  <svg width="1em" height="1em" viewBox="0 0 25 25">
    <text x="50%" y="50%" textAnchor="middle" dominantBaseline="middle">
      🤘
    </text>
  </svg>
)
```

This icon must be a simple, serializable React component that renders a static element. Because the icon is extracted from the configuration and added to the studio manifest, it cannot depend on external values, context, hooks, or any dynamic logic. Static, self-contained SVG components work best.

### Icons for studios with multiple workspaces

Dashboard uses a workspace’s `icon` only when it resolves a single workspace for a studio. For a studio with more than one workspace, Dashboard shows a generated avatar with the studio’s initials instead, and the `icon` property has no effect.

Which workspaces count depends on who is looking. Dashboard leaves out any workspace whose project the viewer can’t access. When a studio’s workspaces span several projects, someone with access to one of those projects sees that workspace’s icon, while a teammate with access to all of them sees the initials avatar for the same studio.

To learn more about configuring studio workspaces, visit [Configuration](https://www.sanity.io/docs/studio/configuration).

## Change a studio’s name in Dashboard

Dashboard resolves the name it shows for a studio in this order:

1. The **Title** set for the studio in Sanity Manage, if there is one.
2. The project’s display name, for a Sanity-hosted studio with more than one workspace.
3. The title of the studio’s first workspace.

So a studio with a single workspace shows the `title` from its workspace configuration, while a Sanity-hosted studio with several workspaces shows the project name until someone sets a studio title.

To set a studio title:

1. Open [Sanity Manage](https://www.sanity.io/manage) and select your project.
2. Navigate to the **Studios** tab.
3. Open the context menu (⋯) for the studio you want to rename.
4. Click **Edit**.
5. Enter a name in the **Title** field, then click **Save**.

The title applies to everyone who sees the studio in Dashboard, and it takes precedence over both the project name and the workspace title.

#### Next steps

[Canvas](https://www.sanity.io/docs/canvas)
Content-focused, AI-assisted writing app. In your dashboard.

[Media Library](https://www.sanity.io/docs/media-library)
Keep track of your assets, from all throughout your organization.

[Studio](https://www.sanity.io/docs/studio)
Configure, customize, and deploy Sanity Studio.

[App SDK](https://www.sanity.io/docs/app-sdk)
Create fully custom applications on the Sanity platform.



# Integrate Sanity with your Next.js app

#### The basics

[Start here](https://www.sanity.io/docs/nextjs/introduction)
Discover the different ways to integrate Next.js and Sanity

[Next.js Quick start](https://www.sanity.io/docs/next-js-quickstart)
New to Sanity and Next.js? Follow these step by step instructions to set up your site and studio.

[Work-ready Next.js](https://www.sanity.io/learn/track/work-ready-next-js)
Build a functional, content-driven and dynamic web application that best serves your end-users, fellow developers and content authors.

[Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)
Set up visual editing between Sanity Studio and a Next.js App Router frontend, including the Sanity client, Draft Mode, Visual Editing, and Live Content.

#### Configuration

[Configure the next-sanity client](https://www.sanity.io/docs/nextjs/configure-sanity-client-nextjs)
Reference for Sanity client configuration in Next.js. Covers base config, environment setup, useCdn, feature layers, token handling, and per-request overrides.

[Embedding Sanity Studio in Next.js](https://www.sanity.io/docs/nextjs/embedding-sanity-studio-in-nextjs)
Mount Sanity Studio as a route in your Next.js application using NextStudio. Covers auto and manual installation, App Router catch-all route, metadata, and route separation.

#### Query content

[Querying content in Next.js](https://www.sanity.io/docs/nextjs/query-content-nextjs)
Use the next-sanity library to write typed GROQ queries with defineQuery and fetch content in App Router or Pages Router.



# Introduction

Sanity gives Next.js developers a structured content backend with real-time collaboration, a customizable editing environment, and APIs designed for modern React patterns. Whether you're building a marketing site, a documentation platform, or a full-scale web application, Sanity provides the content infrastructure while Next.js handles rendering and routing.

## Get started

- [Quickstart](https://www.sanity.io/docs/next-js-quickstart): get a working Next.js + Sanity project running in minutes.
- [Learn course](https://www.sanity.io/learn/track/work-ready-next-js): build a full content-driven application from scratch with video walkthroughs.
- [Starter template](https://www.sanity.io/templates/nextjs-sanity-clean): clone a preconfigured project with Sanity Studio, Visual Editing, and live content already wired up.

## The next-sanity toolkit

`next-sanity` is the official integration package for Next.js. Rather than piecing together `@sanity/client`, `@sanity/visual-editing`, and framework-specific glue code, it gives you a single dependency with exports designed for Next.js patterns like Server Components, the App Router data cache, and Draft Mode.

Install it inside an existing Next.js project:

**npm**

```shell
npx sanity@latest init
```

**pnpm**

```shell
pnpm dlx sanity@latest init
```

**yarn**

```shell
yarn dlx sanity@latest init
```

**bun**

```shell
bunx sanity@latest init
```

This detects your Next.js project and scaffolds the configuration files, a configured Sanity client, and an optional embedded Studio route. For manual installation or full configuration options, see [Configuring the Sanity client for Next.js](https://www.sanity.io/docs/nextjs/configure-sanity-client-nextjs).

The package includes:

- **Sanity Client** (`createClient`): a Sanity client configured for Next.js `fetch` caching.
- **Live Content API** (`defineLive`, `SanityLive`): real-time content updates and automatic cache revalidation.
- **Visual Editing** (`VisualEditing`, `defineEnableDraftMode`): click-to-edit overlays for draft content in the Presentation Tool.
- **Embedded Studio** (`NextStudio`): mount Sanity Studio as a route in your Next.js app.
- **GROQ utilities** (`defineQuery`, `groq`): query helpers with [TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) support and syntax highlighting.
- **Webhook validation** (`parseBody`): secure webhook signature validation for revalidation handlers.
- **Portable Text**: re-export of `@portabletext/react` for rendering rich text.
- **Image URL**: companion `@sanity/image-url` package for CDN image transformations.

## Key features

### Visual Editing + Live Content API

Visual Editing lets content editors click directly on rendered content in the Presentation Tool to open the corresponding field in the Studio. It uses stega encoding to invisibly map rendered text back to its source document and field, creating a seamless editing workflow.

The Live Content API (`defineLive`) gives your application real-time content updates and automatic cache revalidation. Content editors see changes reflected on the live site within seconds of publishing, without manual cache busting or webhook configuration. This is the recommended approach for most Next.js applications.

#### Add Visual Editing and Live Content
Follow our integration guide to update your existing app to use Visual Editing and Live Content, or spin up a new Next.js app and Studio to get started.
[Get started](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)

### Caching and revalidation

For applications that need fine-grained control over caching (or don't use the Live Content API), Sanity integrates with Next.js data caching through time-based, tag-based, and path-based revalidation strategies. Webhook handlers validate incoming payloads and trigger cache invalidation when content changes.

[Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs)
Manual caching strategies for Next.js + Sanity apps. Covers the sanityFetch helper, time-based, tag-based, and path-based revalidation, and debugging.

[Validating Sanity webhooks in Next.js](https://www.sanity.io/docs/nextjs/validating-sanity-webhooks-nextjs)
Set up webhook-based cache revalidation in Next.js using parseBody from next-sanity/webhook. Covers path-based and tag-based revalidation for App Router and Pages Router.

### Embedded Studio

The [recommended path is to deploy your studio to Sanity](https://www.sanity.io/docs/studio/deployment), but you can also mount Sanity Studio as a route inside your Next.js application using the `NextStudio` component. This means your content editing environment lives at a path like `/studio` in the same deployment as your frontend, with no separate hosting required. See our guide on [embedding Studio in a Next.js app](https://www.sanity.io/docs/nextjs/embedding-sanity-studio-in-nextjs).

## Next steps

- [Next.js App Router quickstart](https://www.sanity.io/docs/next-js-quickstart): Get started with Next.js and Sanity.
- [Configure the Sanity client for Next.js](https://www.sanity.io/docs/nextjs/configure-sanity-client-nextjs): client setup, environment variables, `useCdn`, tokens, GROQ queries, and fetching patterns.
- [Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router): enable click-to-edit overlays, live preview, and more in the Presentation Tool.
- [Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide): set up `defineLive` for automatic caching and real-time updates.



# Configure the next-sanity client

The Sanity client is the foundation for all data fetching in a Next.js + Sanity application. This reference covers the base setup and explains what each feature layer adds to it. See the [Sanity and Next.js introduction](https://www.sanity.io/docs/nextjs/introduction) for installation and getting started details.

## Base configuration

The minimal client for fetching published content:

**src/sanity/lib/client.ts**

```typescript
import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '../env'

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: true,
})
```

This is enough for basic content fetching in Server Components. The sections below explain each option and how to extend this configuration.

### Environment variables

Centralize your project configuration in an `env.ts` file:

**src/sanity/env.ts**

```typescript
function assertValue<T>(v: T | undefined, errorMessage: string): T {
  if (v === undefined) {
    throw new Error(errorMessage)
  }
  return v
}

export const dataset = assertValue(
  process.env.NEXT_PUBLIC_SANITY_DATASET,
  'Missing environment variable: NEXT_PUBLIC_SANITY_DATASET'
)

export const projectId = assertValue(
  process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  'Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID'
)

export const apiVersion =
  process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2026-03-01'
```

The `NEXT_PUBLIC_` prefix makes these values available in client-side code. This is safe because project ID and dataset name are not sensitive. They're visible in any API request your frontend makes.

The `assertValue` helper fails fast with a clear error if an environment variable is missing. This catches misconfiguration at startup instead of producing cryptic errors at runtime.

Set these values in `.env.local` for local development and in your hosting provider's environment for production. See the [next-sanity overview](https://www.sanity.io/docs/nextjs/introduction) for the full installation walkthrough.

### useCdn: choosing between CDN and API

Sanity offers a <span class="unknown__pt__mark__docsLinkAnnotation">CDN for read queries</span> that caches API responses at the edge. Whether to use it depends on your fetching context:

##### useCdn decision matrix

| Scenario | useCdn | Why |
| --- | --- | --- |
| Client-side fetches (useEffect, user interactions) | true | Fast cached responses, reduces API load |
| High-traffic SSR (e.g., personalized feeds) | true | Prevents API rate limiting under load |
| Static generation (generateStaticParams) | false | Need guaranteed-fresh data at build time |
| ISR webhook handlers | false | Must bypass CDN to get the latest content |
| Preview or Draft Mode | false | Must see unpublished drafts |
| Tag-based revalidation with webhooks | false | Revalidated pages must fetch fresh data |

When in doubt, start with `useCdn: true` and switch to `false` for specific use cases using per-request overrides.

## Feature layers

The base configuration works for basic fetching. When you enable additional features, the client configuration grows. Here's what each feature adds and why.

### Visual Editing (stega overlays)

To enable click-to-edit overlays in the Presentation Tool, add the `stega` option:

**src/sanity/lib/client.ts**

```typescript
// src/sanity/lib/client.ts

import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '../env'

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: true,
  stega: {
    studioUrl: '/studio',
  },
})
```

The `stega` option embeds invisible characters in string fields that the Presentation Tool uses to map rendered text back to its source document and field. The `studioUrl` tells the overlay where to open the editor.

> [!WARNING]
> Never allow stega in metadata
> When generating metadata for SEO (`generateMetadata`, `generateSitemaps`), use `stega: false` or clean the data with `stegaClean()` from `@sanity/client/stega`. Invisible characters in title or meta tags will break search indexing.

See [Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router) for the full setup.

### Live Content API

To enable real-time content updates and automatic cache revalidation, configure `defineLive` with a token:

**src/sanity/lib/live.ts**

```typescript
// src/sanity/lib/live.ts

import { defineLive } from 'next-sanity/live'
import { client } from './client'

const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
  throw new Error('Missing SANITY_API_READ_TOKEN')
}

export const { sanityFetch, SanityLive } = defineLive({
  client,
  serverToken: token,
  browserToken: token,
})
```

`defineLive` takes your base client and returns a `sanityFetch` function that handles caching and revalidation automatically, plus a `<SanityLive />` component that listens for real-time updates.

The token needs <span class="unknown__pt__mark__docsLinkAnnotation">Viewer role</span> permissions to fetch draft content. The same token can be used for both `serverToken` and `browserToken`. Per the `next-sanity` implementation, the `browserToken` is only sent to the browser when Draft Mode is enabled. Draft Mode can only be initiated by the Presentation Tool or the Vercel Toolbar, so the token is not exposed to regular visitors.

See the [Live Content guide](https://www.sanity.io/docs/developer-guides/live-content-guide) or the [Visual Editing guide](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router) for the full setup including `<SanityLive />` placement and Draft Mode configuration.

### Recommended production setup (both features)

For a production application with both Visual Editing and Live Content, the complete configuration looks like this:

**src/sanity/lib/client.ts**

```typescript
// src/sanity/lib/client.ts

import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '../env'

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: true,
  stega: {
    studioUrl: '/studio',
  },
})
```

**src/sanity/lib/live.ts**

```typescript
// src/sanity/lib/live.ts

import { defineLive } from 'next-sanity/live'
import { client } from './client'

const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
  throw new Error('Missing SANITY_API_READ_TOKEN')
}

export const { sanityFetch, SanityLive } = defineLive({
  client,
  serverToken: token,
  browserToken: token,
})
```

## Per-request overrides

Use `client.withConfig()` to create a client variant with different options for specific use cases:

```typescript
// Bypass CDN for static generation
const freshClient = client.withConfig({ useCdn: false })

// Add a token for authenticated server-side requests
const authClient = client.withConfig({
  token: process.env.SANITY_API_READ_TOKEN,
})

// Disable stega for metadata generation
const metadataClient = client.withConfig({ stega: false })
```

`withConfig()` returns a new client instance. The original client is unchanged.

## Next steps

- [Query content](https://www.sanity.io/docs/nextjs/query-content-nextjs) with the `next-sanity` client.
- [Live Content guide](https://www.sanity.io/docs/developer-guides/live-content-guide): full defineLive setup with SanityLive and Draft Mode.
- [Visual Editing with Next.js](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router): complete Visual Editing setup for App router.
- [next-sanity reference documentation](https://reference.sanity.io/next-sanity/).



# Embedding Sanity Studio in Next.js

Sanity Studio can run as a route inside your Next.js application, giving you a single deployment for both your frontend and content editing interface. Mounting the Studio at a route like `/studio` means shared authentication context, one build pipeline, and the ability to dynamically configure the Studio based on environment or user context.

> [!NOTE]
> Consider your project size
> Co-locating the Studio with your Next.js app is convenient for small to medium projects. For larger projects and teams, a standalone Studio or monorepo setup prevents your content model from becoming too website-centric and makes collaboration easier.

## Prerequisites

- An existing Next.js 16+ project using App Router. If you're starting from scratch, follow the `create-next-app` setup first.
- Node.js 20.9 or later.
- A Sanity account and project. If you don't have one, the automatic installation flow creates one for you.
- `next-sanity` installed in your project.

## Automatic installation (recommended)

The fastest way to embed the Studio:

**npm**

```shell
npx sanity@latest init
```

**pnpm**

```shell
pnpm dlx sanity@latest init
```

**yarn**

```shell
yarn dlx sanity@latest init
```

**bun**

```shell
bunx sanity@latest init
```

Run this inside your existing Next.js project. The CLI detects your framework and prompts you to:

1. Create or connect a Sanity project.
2. Choose a Studio route (default: `/studio`).
3. Scaffold configuration files (`sanity.config.ts`, `sanity.cli.ts`, and the route file).

If you already have a Sanity project, the CLI connects to it. If not, it creates one.

## Manual installation

### Install peer dependencies

`sanity` and `styled-components` are peer dependencies required for the Studio. Most package managers (npm v7+, pnpm v8+) install them automatically when you install `next-sanity`. 

If you use yarn v1, run the following command:

**npm**

```shell
npx install-peerdeps --yarn next-sanity
```

**pnpm**

```shell
pnpm dlx install-peerdeps --yarn next-sanity
```

**yarn**

```shell
yarn dlx install-peerdeps --yarn next-sanity
```

**bun**

```shell
bunx install-peerdeps --yarn next-sanity
```

### Create `sanity.config.ts`

Create a Studio configuration file at the root of your Next.js project:

**sanity.config.ts**

```typescript
// sanity.config.ts

'use client'

import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'

export default defineConfig({
  basePath: '/studio',
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  plugins: [structureTool()],
  schema: { types: [] },
})
```

A few things to note about this configuration:

- `'use client'` is required. Sanity Studio is a client-side React application.
- `basePath` must match the route where you mount the Studio (see next section).
- `schema.types` is where you define your content model. Start empty and add types as you build.

### Create `sanity.cli.ts` (optional)

This enables `npx sanity` commands from within your Next.js project:

**sanity.cli.ts**

```typescript
// sanity.cli.ts

import { defineCliConfig } from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
    dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  },
})
```

With this in place, you can run commands like `npx sanity cors add` or `npx sanity schema extract` directly from your project.

## Create the Studio route

Create an App Router catch-all route for the Studio:

**src/app/studio/[[...tool]]/page.tsx**

```typescript
// src/app/studio/[[...tool]]/page.tsx

import { NextStudio } from 'next-sanity/studio'
import config from '../../../../sanity.config'

export const dynamic = 'force-static'

export { metadata, viewport } from 'next-sanity/studio'

export default function StudioPage() {
  return <NextStudio config={config} />
}
```

How this works:

- **[[...tool]]** is an optional catch-all segment that captures all Studio sub-routes, including structure, vision, and media.
- **dynamic = 'force-static'** tells Next.js to statically render this route. The Studio shell (HTML, CSS, JavaScript) is the same for all users. Only the data inside is dynamic, loaded client-side after the shell renders. Static rendering gives the fastest initial load.
- **metadata and viewport** exports from `next-sanity/studio` set mobile-friendly viewport settings and prevent search engines from indexing the Studio route.
- **NextStudio** wraps the Sanity Studio component in a Next.js-friendly layout with loading states and viewport handling.

## Customizing metadata

The default metadata exports handle the common case. To customize:

**src/app/studio/[[...tool]]/page.tsx**

```typescript
// src/app/studio/[[...tool]]/page.tsx

import type { Metadata, Viewport } from 'next'
import {
  metadata as studioMetadata,
  viewport as studioViewport,
} from 'next-sanity/studio'

export const metadata: Metadata = {
  ...studioMetadata,
  title: 'Loading Studio...',
}

export const viewport: Viewport = {
  ...studioViewport,
  interactiveWidget: 'resizes-content',
}
```

Spread the defaults and override specific fields. The defaults include `robots: 'noindex'` and mobile viewport settings that you'll want to keep.

## Advanced: StudioProvider and StudioLayout

If you need to inject custom navigation, wrap the Studio in additional context providers, or access Studio hooks outside of plugins, you can use `StudioProvider` and `StudioLayout` as children of `NextStudio`:

**src/app/studio/[[...tool]]/page.tsx**

```typescript
// src/app/studio/[[...tool]]/page.tsx

'use client'

import { NextStudio } from 'next-sanity/studio'
import { StudioProvider, StudioLayout } from 'sanity'
import config from '../../../../sanity.config'

export default function StudioPage() {
  return (
    <NextStudio config={config}>
      <StudioProvider config={config}>
        {/* Components here have access to the same React hooks as Studio plugins */}
        <StudioLayout />
      </StudioProvider>
    </NextStudio>
  )
}
```

How this works: `NextStudio` handles Next.js-specific layout concerns (viewport scaling, loading state). When you pass children, it delegates rendering to them instead of its default `StudioProvider`/`StudioLayout`. This lets you inject custom components between the provider and layout while keeping the Next.js wrapper benefits.

Most applications don't need this level of control. Use the standard `<NextStudio config={config} />` pattern from the section above unless you need to:

- Inject custom navigation or toolbar components
- Access Studio React hooks outside of plugins
- Wrap the Studio in additional context providers

## Pages Router projects

Even if the rest of your application uses Pages Router, embed the Studio on an App Router route. Next.js supports both routers in the same application. The Studio is a client-side React app that works best with App Router's layout system, and the `NextStudio` component is designed for App Router.

Your file structure would look like:

```text
src/
  app/
    studio/
      [[...tool]]/
        page.tsx        # Studio (App Router)
  pages/
    index.tsx           # Your site (Pages Router)
    posts/
      [slug].tsx
```

## Route separation with SanityLive

If you're using `<SanityLive />` and `<VisualEditing />` in your root layout, make sure they don't render on the Studio route. These components are for your content pages, not the Studio itself.

Use route groups to separate layouts:

```text
src/app/
  (app)/                    # content routes
    layout.tsx              # includes <SanityLive /> and <VisualEditing />
    page.tsx
    posts/
      [slug]/
        page.tsx
  (studio)/                 # Studio route
    studio/
      [[...tool]]/
        page.tsx            # no SanityLive, no VisualEditing
```

The `(app)` and `(studio)` groups create separate layout trees. The Studio route gets its own layout without the Live Content and Visual Editing components.

## Troubleshooting

**Studio shows a blank page or hydration error**

Make sure `sanity.config.ts` includes `'use client'` at the top. Without this directive, Next.js tries to render the Studio configuration on the server, which fails because the Studio is a client-side application.

**Studio loads but the URL doesn't match**

The `basePath` in `sanity.config.ts` must match the actual route where the Studio is mounted. If your Studio route is `/admin` instead of `/studio`, update `basePath` to `'/admin'`.

**Peer dependency errors during installation**

If your package manager reports missing peer dependencies for `sanity` or `styled-components`, install them explicitly. This is common with yarn v1, which does not auto-install peer dependencies. Run `npx install-peerdeps next-sanity` or install each package manually.

## Next steps

- [Visual Editing Guide](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router): set up Visual Editing and live preview.
- [Work-ready Next.js](https://www.sanity.io/learn/track/work-ready-next-js): Sanity Learn's multi-course track to level up your Next.js + Sanity skills.



# Query content

The `next-sanity` library wraps `@sanity/client` with helpers designed specifically for Next.js. Use it to write typed GROQ queries, fetch content in both App Router and Pages Router, and enable live content updates. It bundles:

- GROQ helpers that make TypeGen setup easier.
- Live content and visual editing tooling for fetching fresh content from different perspectives.

## Writing and typing GROQ queries

### `defineQuery`

Use `defineQuery` to write GROQ queries with TypeGen support and syntax highlighting:

**src/sanity/lib/queries.ts**

```typescript
// src/sanity/lib/queries.ts

import { defineQuery } from 'next-sanity'

export const POSTS_QUERY = defineQuery(
  `*[_type == "post" && defined(slug.current)][0...12]{
    _id, title, slug
  }`
)

export const POST_QUERY = defineQuery(
  `*[_type == "post" && slug.current == $slug][0]{
    title, body, mainImage
  }`
)
```

`defineQuery` enables two things:

- **Automatic type inference**: when you pass a `defineQuery` result to `client.fetch`, TypeScript infers the return type from the query. No manual type annotations needed.
- **Syntax highlighting**: with the [Sanity VS Code extension](https://marketplace.visualstudio.com/items?itemName=sanity-io.vscode-sanity) installed, GROQ inside `defineQuery` gets full syntax highlighting.

`next-sanity` also exports a `groq` template tag for backward compatibility, but `defineQuery` is recommended for new projects.

### TypeGen setup

[Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) generates TypeScript types for your schema and GROQ query results. If you used `sanity init` with an embedded Studio, TypeGen is ready to use. For manual setup, see the [TypeGen documentation](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen).

## Fetching content

### Choosing a fetch method

**Recommended: defineLive's sanityFetch.** If you're using the Live Content API (most apps should), import `sanityFetch` from your `live.ts` file. This function handles caching, revalidation, and live updates automatically. See the [Visual Editing](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router) or [Live Content guide](https://www.sanity.io/docs/developer-guides/live-content-guide).

**Alternative: manual sanityFetch helper.** For apps that don't use the Live Content API, you build a wrapper around `client.fetch` with explicit caching options. See [Caching and revalidation](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs).

**Basic: client.fetch directly.** Works for simple cases but gives you no caching control beyond Next.js defaults. See the [next-sanity client configuration](https://www.sanity.io/docs/nextjs/configure-sanity-client-nextjs) for additional settings.

### App Router

The following example uses `client.fetch` directly in a Server Component. This works for prototyping, but for production you should use one of the `sanityFetch` approaches described above:

**src/app/page.tsx**

```typescript
// src/app/page.tsx

import { client } from '@/sanity/lib/client'
import { POSTS_QUERY } from '@/sanity/lib/queries'

export default async function PostIndex() {
  const posts = await client.fetch(POSTS_QUERY)

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/posts/${post?.slug.current}`}>{post?.title}</a>
        </li>
      ))}
    </ul>
  )
}
```

For production, replace `client.fetch` with one of the `sanityFetch` approaches described above.

### Pages Router

Use `getStaticProps` to fetch data at build time. This example includes `revalidate: 60` to enable ISR, refreshing the data every 60 seconds:

**src/pages/index.tsx**

```typescript
// src/pages/index.tsx

import type { InferGetStaticPropsType } from 'next'
import { client } from '@/sanity/lib/client'
import { POSTS_QUERY } from '@/sanity/lib/queries'

export async function getStaticProps() {
  const posts = await client.fetch(POSTS_QUERY)
  return { props: { posts }, revalidate: 60 }
}

export default function PostIndex({
  posts,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/posts/${post?.slug.current}`}>{post?.title}</a>
        </li>
      ))}
    </ul>
  )
}
```

> [!NOTE]
> The Live Content API (`defineLive`, `SanityLive`) is App Router only. Pages Router apps can use `client.fetch` directly with ISR (`revalidate` in `getStaticProps`) for cache management.



# Rendering images in Next.js

The `next-sanity` library exports an `Image` component and `imageLoader` function that integrate [Sanity's image CDN](https://www.sanity.io/docs/apis-and-sdks/presenting-images) with the Next.js `next/image` component. Pass a Sanity CDN URL as the `src` prop and the component handles format negotiation, responsive sizing, and aspect ratio preservation automatically.

> [!NOTE]
> This API is currently in alpha and may change in future releases.

## Installation

If you don’t already have them, install `next-sanity` and `@sanity/image-url`.

**npm**

```shell
npx install next-sanity @sanity/image-url
```

**pnpm**

```shell
pnpm dlx install next-sanity @sanity/image-url
```

**yarn**

```shell
yarn dlx install next-sanity @sanity/image-url
```

**bun**

```shell
bunx install next-sanity @sanity/image-url
```

## Basic usage

The `Image` component accepts a Sanity CDN URL string as its `src` prop. Use `@sanity/image-url` to build the URL, [as shown below](https://www.sanity.io/docs/nextjs/next-sanity-image-component), from your image data, then pass it to `Image`:

```typescript
import { Image } from 'next-sanity/image'
import { urlFor } from '@/sanity/lib/image'

export function AuthorPhoto({ image }) {
  return (
    <Image
      src={urlFor(image).url()}
      alt="Author photo"
      width={400}
      height={300}
    />
  )
}
```

The component wraps `next/image` with a Sanity-aware loader, so you get all the performance benefits of `next/image` (lazy loading, `srcSet` generation, priority hints) with URLs that point to Sanity's CDN.

## How this differs from next/image

The `Image` component from `next-sanity/image` wraps `next/image`, so you get the same developer-facing features: lazy loading, `srcSet` generation, priority hints, and layout stability. The difference is in where and how image optimization happens.

With the `next-sanity/image` component, the built-in loader bypasses the Next.js optimization proxy entirely. Instead, it constructs a URL that points directly to Sanity's image CDN with the right [transformation parameters](https://www.sanity.io/docs/apis-and-sdks/image-urls). Sanity's CDN handles resizing, format conversion, and caching at the edge. This means:

- No `remotePatterns` configuration is needed in `next.config.js` for Sanity images.
- Format negotiation (WebP, AVIF) happens at the CDN level through the `auto=format` parameter, based on the browser's `Accept` header.
- Your Next.js server doesn't process the images, reducing its CPU and memory load.
- The loader automatically maintains aspect ratio across `srcSet` breakpoints by recalculating the `h` parameter proportionally for each width.
- The loader sets sensible defaults for the `fit` parameter: `fit=max` when only width is specified (prevents upscaling), `fit=min` when both width and height are present.

There are a couple of tradeoffs. The `Image` component restricts `src` to a string (it must be a valid Sanity CDN URL), while `next/image` also accepts static imports. It also disallows the `loader` prop since it ships with its own. If you need a custom loader or want to use the default Next.js optimization proxy, use `next/image` directly.

## How the loader works

The built-in `imageLoader` transforms the Sanity CDN URL for each entry in the generated `srcSet`. For every width that `next/image` requests, the loader:

1. Sets `auto=format` so the CDN returns the best format the browser supports (WebP, AVIF). See [Image transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls) for the full list of URL parameters.
2. Sets `fit=max` when only width is specified, or `fit=min` when both width and height are present. If the URL already contains a `fit` parameter, the loader respects it.
3. Recalculates `h` proportionally when both `w` and `h` are present, preserving the original aspect ratio at each breakpoint.
4. Passes the `quality` prop through as the `q` URL parameter.

This means your images are served in the optimal format and size without additional configuration.

## Props

`ImageProps` extends all `next/image` props except `loader` and changes `src` to accept only a string.

##### Image component props

| Prop | Type | Description |
| --- | --- | --- |
| src | string (required) | A valid Sanity image CDN URL. Build this with @sanity/image-url or construct it manually. |
| width | number | Width in pixels. Appended as w to the URL and used by the loader for aspect ratio calculations. |
| height | number | Height in pixels. Appended as h to the URL and used by the loader for aspect ratio calculations. |
| quality | number | Image quality (0-100). Passed through as q to the Sanity CDN. |
| loader | never | Not supported. The component throws a TypeError if you pass a custom loader. Use next/image directly if you need a custom loader. |

All other `next/image` props (`alt`, `priority`, `fill`, `sizes`, `placeholder`, `className`, etc.) are passed through to the underlying `next/image` component.

## Using imageLoader standalone

If you need more control over the `next/image` component but still want Sanity CDN URL handling, you can use the exported `imageLoader` directly with `next/image`:

```typescript
import NextImage from 'next/image'
import { imageLoader } from 'next-sanity/image'
import { urlFor } from '@/sanity/lib/image'

export function CustomImage({ image }) {
  return (
    <NextImage
      loader={imageLoader}
      src={urlFor(image).width(800).url()}
      alt="Product photo"
      width={800}
      height={600}
      sizes="(max-width: 768px) 100vw, 800px"
    />
  )
}
```

This gives you the same CDN-aware format negotiation and responsive sizing while letting you use other `next/image` features like custom `loader` chaining in a wrapper component.

## Building the source URL

The `Image` component expects a Sanity CDN URL string, not a Sanity image reference object. Use `@sanity/image-url` to build it. See [Presenting Images](https://www.sanity.io/docs/apis-and-sdks/presenting-images) for full setup instructions:

**sanity/lib/image.ts**

```typescript
// sanity/lib/image.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url'
import { client } from './client'

const builder = createImageUrlBuilder(client)

export function urlFor(source: SanityImageSource) {
  return builder.image(source)
}
```

The URL builder respects [crop and hotspot](https://www.sanity.io/docs/apis-and-sdks/presenting-images) settings when you pass the full image object (not just the asset reference). You can chain additional transformations before calling `.url()`:

```typescript
// Crop and hotspot are applied automatically
<Image
  src={urlFor(post.mainImage).width(1200).url()}
  alt={post.mainImage.alt || ''}
  width={1200}
  height={675}
  priority
/>
```

## Relationship to `@sanity/image-url`

These two tools serve different purposes and work together:

- `@sanity/image-url` builds a Sanity CDN URL string from image data. It handles crop, hotspot, and transformation parameters.
- `next-sanity/image` takes that URL string and integrates it with `next/image` so you get automatic `srcSet` generation, format negotiation, and responsive loading from the Sanity CDN.

You can use `@sanity/image-url` without `next-sanity/image` (for example, with a plain `<img>` tag or a different framework's image component). But when using Next.js, the `Image` component from `next-sanity/image` gives you the best of both: Sanity CDN transformations with `next/image` optimizations.

## Related resources

- [Presenting Images](https://www.sanity.io/docs/apis-and-sdks/presenting-images): configuring `@sanity/image-url`, crop and hotspot, responsive images, and performance tips.
- [Image transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls): full reference for Sanity CDN URL parameters (`w`, `h`, `fit`, `auto`, `q`, and more).
- [Image Metadata](https://www.sanity.io/docs/apis-and-sdks/image-metadata): LQIP placeholders, dimensions, palette, and other metadata available for Sanity images.
- [Image schema type](https://www.sanity.io/docs/studio/image-type): schema configuration for image fields, including crop and hotspot options.



# Add live content

The Live Content API lets you deliver live content experiences without the complexity and infrastructure requirements traditionally found in real-time apps.

The `next-sanity` library wraps the Live Content API for Next.js apps. The JavaScript client offers helper utilities to get you started, but you'll need to build additional functionality.

This guide shows two ways to add live content to an application: with `next-sanity` in a Next.js app, and with the JavaScript client in any other framework.

## Add live content with next-sanity

Enable live content with only a few lines of code with `next-sanity`.

#### Next.js + Sanity + Visual Editing
If you plan to set up Next.js, Sanity, Visual Editing, and the Live Content API, see the Next.js Visual Editing guide for a complete implementation.
[Set up Next.js, live content, and Visual Editing](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)

### Prerequisites

- A new or existing Sanity project.
- Add your frontend or deployment target's origin to the project's [CORS origins](https://www.sanity.io/docs/content-lake/cors). This is found in the project's API section at [sanity.io/manage](https://sanity.io/manage).
- A Next.js application built with the [app router architecture](https://nextjs.org/docs/app/getting-started/layouts-and-pages). The Live Content features in `next-sanity` do not support apps built with the pages router.
- This guide assumes `next-sanity` v13 or later, which requires Next.js 16, React 19.2 or later, and `@sanity/client` 7.26.1 or later.

### Install and configure the client

You can install, set up, and configure Sanity in your existing Next.js project with `init`:

**npm**

```shell
npx sanity@latest init
```

**pnpm**

```shell
pnpm dlx sanity@latest init
```

**yarn**

```shell
yarn dlx sanity@latest init
```

**bun**

```shell
bunx sanity@latest init
```

Alternatively, install the package or update it to the latest version:

**npm**

```shell
npm install next-sanity@latest
```

**pnpm**

```shell
pnpm add next-sanity@latest
```

**yarn**

```shell
yarn add next-sanity@latest
```

**bun**

```shell
bun add next-sanity@latest
```

Next, confirm that you have an existing Sanity client configured:

**src/sanity/lib/client.ts**

```typescript
import { createClient } from "next-sanity";

import { dataset, projectId } from "../env";

export const client = createClient({
  projectId,
  dataset,
  apiVersion: "2026-03-01",
  useCdn: true
});
```

### Create the live utilities

Create a live utility file and configure the `sanityFetch` helper and `SanityLive` component by passing in your local Sanity client and a token. `defineLive` requires a browser and server token to fetch draft content when using Draft Mode. If you aren't using Visual Editing or draft previews, set `serverToken: false` and `browserToken: false` to opt out and silence the development warnings:

**src/sanity/lib/live.ts**

```typescript
import { defineLive } from "next-sanity/live";
// import your local configured client
import { client } from "@/sanity/lib/client";

// set your viewer token
const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
  throw new Error("Missing SANITY_API_READ_TOKEN")
}

// export the sanityFetch helper and the SanityLive component
export const { sanityFetch, SanityLive } = defineLive({
  client,
  serverToken: token,
  browserToken: token,
})
```

> [!NOTE]
> Tokens
> Tokens passed to `defineLive` need [viewer access rights](https://www.sanity.io/docs/user-guides/roles) to fetch draft content.
> The token for `serverToken` and `browserToken` can be the same. The `browserToken` is only used when Draft Mode is enabled and initiated by Presentation Tool or Vercel Toolbar.

### Fetch your queries

Whenever you need to query data in your Sanity dataset, import the `sanityFetch` helper and call it as you would any Sanity client by passing in a GROQ query and any query parameters:

**app/page.tsx**

```typescript
import { sanityFetch } from "@/sanity/lib/live"
import { POST_QUERY } from "./queries"

const {data: post} = await sanityFetch({query: POST_QUERY, params: {}})
```

In this example, the `data` response is destructured to `post` and `sanityFetch` receives a GROQ query and an optional `params` object.

### Enable the SanityLive component

The final step to enable the Live Content API is adding the `SanityLive` React component. It listens for changes in your data and works with your `sanityFetch` queries to efficiently update content. Include it in your application so it renders on any page that needs live content.

> [!WARNING]
> Embedded studios
> This section adds the SanityLive component to the root layout. If you're using an embedded studio—one that renders on a route in your Next.js app—include the SanityLive and VisualEditing components only in your content layouts.
> Including `SanityLive` in your studio route can cause unexpected reloads.

In this example, it lives just before the closing body tag in the `RootLayout` component:

**app/layout.tsx**

```tsx
import { SanityLive } from "@/sanity/lib/live"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
      </body>
    </html>
  )
}
```

> [!NOTE]
> Make updates instant in next-sanity v13
> In `next-sanity` v13, `<SanityLive>` revalidates with a stale-while-revalidate profile by default, so a published change is *eventually consistent* — some connected visitors may need to navigate or refresh before they see it. To make updates instant for every visitor (and to invalidate caches that sit in front of Next.js, such as a CDN), pair `<SanityLive>` with a [Sync Tag Invalidate Function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) and set `waitFor="function"`. The quick start covers the function; the migration guide's [Opting in to guaranteed live content updates](https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v12-to-v13.md#opting-in-to-guaranteed-live-content-updates) section shows the matching Next.js revalidation route and `waitFor` wiring.

### Next steps

- To learn more about the `next-sanity` toolkit and how it fits together with Visual Editing and caching, see the [Next.js overview](https://www.sanity.io/docs/nextjs/introduction).
- Level up with [Work-ready Next.js](https://www.sanity.io/learn/track/work-ready-next-js) on Sanity Learn.
- Dive into [the Clean Next.js + Sanity starter](https://www.sanity.io/templates/nextjs-sanity-clean).
- For instant updates across CDNs and many statically generated routes, drive revalidation from a [Sync Tag Invalidate Function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) and set `waitFor="function"` on `<SanityLive>`. The quick start covers the function; the migration guide's [Opting in to guaranteed live content updates](https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v12-to-v13.md#opting-in-to-guaranteed-live-content-updates) section shows the matching revalidation route and `<SanityLive>` wiring.

## Create your own integration

If there isn't an official library for your framework that enables live content, you need to create your own integration to use the Live Content API. The Live Content API Examples repository on GitHub collects example projects and is a good starting point for custom implementations.

[Live Content API Examples](https://github.com/sanity-io/lcapi-examples)
A collection of example projects using live content

The minimal example in this section uses the [Sanity JavaScript client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started).

### Prerequisites

- API version `v2021-03-25` or later. Older versions omit `syncTags` from query responses and throw `The live events API requires API version 2021-03-25 or later.`
- The real dataset name. The Live Content API does not support dataset aliases.
- A new or existing Sanity project.
- Add your frontend or deployment target's origin to the project's [CORS origins](https://www.sanity.io/docs/content-lake/cors). This is found in the project's API section at [sanity.io/manage](https://sanity.io/manage).

### Install and configure the client

First, install the latest version of the client:

**npm**

```shell
npm install @sanity/client@latest
```

**pnpm**

```shell
pnpm add @sanity/client@latest
```

**yarn**

```shell
yarn add @sanity/client@latest
```

**bun**

```shell
bun add @sanity/client@latest
```

Configure your `@sanity/client` with your project settings and the latest API version:

**src/sanity/lib/client.ts**

```typescript
import { createClient } from "@sanity/client"

export const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  apiVersion: "2026-03-01",
  useCdn: true
})
```

### How it works

Here's a high-level overview of how the Live Content API works:

1. Every response from Content Lake includes *sync tags*. Your application stores the tags for the content it needs to keep up to date in real time.
2. It subscribes to a stream of live updates with the `client.live.events()` method, which returns an Observable that emits an event whenever content in the dataset changes.
3. When an event arrives, it checks whether any of the event tags match the stored sync tags.
4. If there's a match, it refetches the content, passing the event ID as the `lastLiveEventId` argument to `client.fetch` so the CDN returns the latest version of the content instead of stale data.

### Minimal example

Here is a minimal example running in the console. It keeps a single, predefined document in sync using sync tags:

**live-example.ts**

```typescript
import { createClient } from "@sanity/client"

// Create the client instance
const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  apiVersion: "2026-03-01",
  useCdn: true
})

const query = '*[_type == "post" && slug.current == $slug][0]'
const slug = "were-doing-it-live"

let syncTags = []

function render(lastLiveEventId?: string) {
  // Query the content lake
  client.fetch(
    query,
    { slug },
    { filterResponse: false, lastLiveEventId }
  ).then(
    (res) => {
      // Store the syncTags and "render" the data
      syncTags = res.syncTags
      const data = res.result
      console.log(data)
    })
}

// Kick off initial render
render()

// Subscribe to live updates
const subscription = client.live.events().subscribe(
  (event) => {
    // Check if incoming tags match saved sync tags
    if (event.type === "message" && event.tags.some((tag) => syncTags.includes(tag))) {
      // Refetch with ID to get latest data
      render(event.id)
    }
    if (event.type === "restart") {
      // A restart event is sent when the `lastLiveEventId` we've been given earlier is no longer usable
      render()
    }
})

// Later, unsubscribe when no longer needed (such as on unmount)
// subscription.unsubscribe()
```

In this example:

1. The example creates a Sanity client instance with the necessary configuration.
2. It defines a query to fetch posts and executes it, setting `filterResponse: false` to get the `syncTags` along with the result.
3. It stores the returned syncTags and renders the initial data.
4. It subscribes to live updates using `client.live.events()`.
5. Whenever an update event arrives, it checks whether any of the event's tags match the stored syncTags.
6. If there's a match, it refetches the data, passing the event ID as `lastLiveEventId` to get the latest version.
7. It updates the stored syncTags and re-renders with the fresh data.
8. Finally, it unsubscribes from the live updates when they're no longer needed.

This pattern keeps your application's content in sync with the latest changes in your Sanity dataset. For additional examples, including listening for drafts, see the [JavaScript client documentation](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started).

### Next steps

- Learn more about sync tags and the underpinnings of the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api).
- For reference details when interacting directly with the API, check the [Live reference docs](https://www.sanity.io/docs/http-reference/live).

## Troubleshooting

`client.live.events()` reports failures as an error on the observable rather than throwing, so pass an error handler to `subscribe` to see them at all.

### Origin not allowed by CORS

An unlisted origin makes the connection fail without a usable reason, so the client checks the project's CORS configuration and reports a `CorsOriginError`. In a browser, the message ends with a link that pre-fills the origin: `The current origin is not allowed to connect to the Live Content API. Add it here:` followed by the URL. On a server, where no origin is available, it reads `The current origin is not allowed to connect to the Live Content API. Change your configuration here:` followed by the project's API settings URL.

The stream errors and doesn't retry. The client only reports this error when it can confirm the rejection, so an ambiguous check surfaces the underlying connection error instead. Add the origin in the project's API settings at sanity.io/manage.

In a Next.js app, `SanityLive` logs a warning instead of failing the render: `Sanity Live is unable to connect to the Sanity API as the current origin - ORIGIN - is not in the list of allowed CORS origins for this Sanity Project.` Set `onError="throw"` to surface it to the nearest error boundary instead.

### Connection rejected by the API

A rejected token produces `EventSource connection failed` on the observable, with the HTTP status on the error's `status` property. Any 4xx other than 408 and 429 is fatal: the client stops and doesn't reconnect. A 5xx, a 408, or a 429 is retried, and the stream emits a `reconnect` event first.

The `status` property is only populated where the `eventsource` package provides the connection. Native browser and Node implementations expose no status, so the client can't tell a rejected token from a dropped network and retries instead. A silent reconnect loop with no error is the symptom of an authentication problem in those environments.

A token used to read drafts needs viewer rights or lower. Requesting drafts with no token throws before any request is made: `The live events API requires a token or withCredentials when 'includeDrafts: true'. Please update your client configuration. The token should have the lowest possible access role.`

### Respond to a restart event

A `restart` event means the `lastLiveEventId` you hold is no longer usable. Its payload carries only two fields, `type` and `id`, and no sync tags.

Handle it in three parts:

- Refetch every query, and don't pass the event's ID as `lastLiveEventId`.
- Discard the sync tags you've stored. They can no longer be matched against incoming events.
- Treat `reconnect` the same way. Both events invalidate buffered tags.

In a Next.js app, `SanityLive` calls `router.refresh()` on restart by default, so server components re-render with fresh data.

### Draft content missing from results

Querying with the `published` perspective returns published content only, and nothing tells you that's what happened. There's no error and no console message, and the response looks identical to one from a dataset with no drafts. Since API version `v2025-02-19`, `published` is the default.

Set `perspective: 'drafts'` and supply a token to read drafts. In Next.js, `defineLive` pins its internal client to `published`, so pass a `serverToken` to read drafts on the server and a `browserToken` for live preview in the browser. Without them, `defineLive` warns in development only.



# Caching and revalidation

If you're using `defineLive` from `next-sanity/live`, you probably don't need this article. `defineLive` handles caching, revalidation, and real-time updates automatically. [It's the recommended approach for most applications](https://www.sanity.io/docs/developer-guides/live-content-guide).

This article is for apps that need fine-grained control over caching, are building mostly-static sites, or don't use the Live Content API. It covers how to implement manual caching strategies with Sanity and Next.js.

> [!WARNING]
> Two functions named sanityFetch
> `defineLive` exports a `sanityFetch` that manages caching automatically. The manual `sanityFetch` helper described in this article is a different function with a different signature. Don't use both in the same project.

## Two caching layers

When your content lives in Sanity and your frontend runs on Next.js, there are two independent caching layers to reason about:

**Layer 1: the Sanity CDN.** When your client is configured with `useCdn: true`, API responses are cached at the edge. This is fast but introduces a short delay before new content is available. You control this with the `useCdn` option on your [Sanity client configuration](https://www.sanity.io/docs/nextjs/configure-sanity-client-nextjs).

**Layer 2: the Next.js data cache.** Next.js caches the results of `fetch` calls on the server. You control this with `revalidate` (time-based) and `tags` (on-demand) options passed to `fetch`. This cache persists across requests and, on some hosts like Vercel, survives redeployments.

These layers are independent. A page can show stale content because of either cache, or both. Understanding this prevents debugging headaches.

For a deeper look at how Next.js caching works, see the <span class="unknown__pt__mark__docsLinkAnnotation">Next.js caching documentation</span>.

## Choose your strategy

##### Caching strategies

| Strategy | Freshness | Complexity | Best for |
| --- | --- | --- | --- |
| Time-based | Delayed (you set the interval) | Low | Content that changes infrequently |
| Tag-based | On-demand (webhook/function-triggered) | Medium | Changes that affect many pages (authors, categories) |
| Path-based | On-demand (webhook/function-triggered) | Medium | Known URL-to-document mappings |

> [!WARNING]
> Tags and time-based revalidation are mutually exclusive
> The `sanityFetch` helper below disables time-based revalidation when you supply tags. If you pass `tags`, the cache lives indefinitely until a webhook busts it. If you pass `revalidate`, the cache expires on a timer. Pick one strategy per query. This is by design.

## The sanityFetch helper

This wrapper around `client.fetch` sets Next.js caching options for every query:

**src/sanity/lib/client.ts**

```typescript
// src/sanity/lib/client.ts

import { createClient, type QueryParams } from 'next-sanity'
import { apiVersion, dataset, projectId } from '../env'

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: true,
})

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,
    },
  })
}
```

How the helper works:

- **Default behavior:** every query is cached for 60 seconds (`revalidate: 60`), then Next.js fetches fresh data on the next request.
- **With tags:** time-based revalidation is disabled (`revalidate` is set to `false`). The cache lives indefinitely until you call `revalidateTag()` from a webhook handler.
- **With revalidate: false:** the cache lives indefinitely. Use this with tag-based or path-based revalidation. This is the preferred setting for static sites that want to handle revalidation manually.

> [!NOTE]
> `revalidate: 0` vs `revalidate: false`: these are different in Next.js. `0` means "revalidate on every request" (effectively no caching). `false` means "cache indefinitely until manually invalidated." Choose deliberately. See the <span class="unknown__pt__mark__docsLinkAnnotation">Next.js fetch API reference</span> for details.

## Time-based revalidation

Time-based revalidation is the simplest strategy. Set a `revalidate` interval and Next.js handles the rest.

**src/app/page.tsx**

```typescript
import { sanityFetch } from '@/sanity/lib/client'
import { defineQuery } from 'next-sanity'

const POSTS_QUERY = defineQuery(`*[_type == "post"] | order(publishedAt desc)`)

export default async function PostIndex() {
  const posts = await sanityFetch({
    query: POSTS_QUERY,
    revalidate: 3600, // revalidate at most once per hour
  })

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/posts/${post?.slug.current}`}>{post?.title}</a>
        </li>
      ))}
    </ul>
  )
}
```

Guidelines for choosing a revalidate value:

##### Revalidation intervals

| Value | Behavior | Use when |
| --- | --- | --- |
| 30–60 | Revalidate every 30–60 seconds | Content changes frequently (news, live scores) |
| 3600 | Revalidate once per hour | Content changes a few times per day |
| 86400 | Revalidate once per day | Content rarely changes (about pages, legal text) |
| false | Never revalidate automatically | You're using tag-based or path-based revalidation |

Time-based revalidation works well for most applications. If you need faster updates for specific content, combine it with path-based revalidation for those routes.

## Tag-based revalidation

Tag-based revalidation gives you fine-grained control. Instead of revalidating on a timer, you tag queries and invalidate specific tags when content changes.

### Tagging queries

Pass a `tags` array to `sanityFetch`:

```typescript
const posts = await sanityFetch({
  query: POSTS_QUERY,
  tags: ['post'],
})

const authors = await sanityFetch({
  query: AUTHORS_QUERY,
  tags: ['author'],
})

// This query depends on both types
const postsWithAuthors = await sanityFetch({
  query: POSTS_WITH_AUTHORS_QUERY,
  tags: ['post', 'author'],
})
```

When you tag a query, Next.js associates the cached response with those tags. Calling `revalidateTag('post')` invalidates all cached queries tagged with `'post'`, including `postsWithAuthors` above.

### Busting tags with webhooks

To trigger `revalidateTag()` when content changes in Sanity, set up a webhook handler or Sanity Function. See [Validating Sanity webhooks in Next.js](https://www.sanity.io/docs/nextjs/validating-sanity-webhooks-nextjs) for the complete implementation.

The short version: create an API route that receives webhook payloads from Sanity, validates the signature, and calls `revalidateTag(body._type)`.

## Path-based revalidation

Path-based revalidation lets you revalidate specific routes by URL path. Use it when you have a clear mapping between documents and routes.

When a Sanity document changes, a webhook or function sends the affected path to your Next.js API route, which calls `revalidatePath()`:

```typescript
// In your webhook handler:
revalidatePath('/posts/my-post')
```

This evicts the cached page at `/posts/my-post`. The next visitor gets a freshly rendered page.

To revalidate all routes at once:

```typescript
revalidatePath('/', 'layout')
```

This is a blunt instrument but useful as a fallback or for global content changes (site settings, navigation).

See [Validating Sanity webhooks in Next.js](https://www.sanity.io/docs/nextjs/validating-sanity-webhooks-nextjs) for the complete API route implementation, including GROQ projections that dynamically generate paths from document data.

## Debugging

When cached content isn't updating as expected, enable fetch logging in your Next.js configuration:

**next.config.ts**

```typescript
// next.config.ts

const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
}

export default nextConfig
```

This logs every `fetch` call with the full URL, whether the response was a cache HIT or MISS, and the `revalidate` and `tags` values applied. Look for:

- **Unexpected HITs:** the cache isn't being invalidated when you expect. Check that your webhook is firing and that tags match.
- **All MISSes:** nothing is being cached. Check that `revalidate` isn't set to `0` and that you're not accidentally passing conflicting cache options.
- **Stale data after webhook fires:** the Sanity CDN may still be serving old data. Pass `true` as the third argument to `parseBody` in your webhook handler to add a propagation delay. See [Validating Sanity webhooks in Next.js](https://www.sanity.io/docs/nextjs/validating-sanity-webhooks-nextjs) for details.

## Comparison with defineLive

##### Manual caching vs defineLive

|  | Manual caching (this article) | defineLive |
| --- | --- | --- |
| Freshness | Configurable (seconds to indefinite) | Real-time |
| Setup | sanityFetch helper + webhook/function handlers | defineLive + SanityLive component |
| Complexity | Medium (you manage caching strategy) | Low (automatic) |
| Visual Editing | Requires separate setup | Built-in support |
| Best for | Static sites, fine-grained control | Most applications |

If you started with manual caching and want to upgrade to real-time updates, see the [Live Content guide](https://www.sanity.io/docs/developer-guides/live-content-guide).

## Related resources

- [Configuring the Sanity client for Next.js](https://www.sanity.io/docs/nextjs/configure-sanity-client-nextjs): base client setup and useCdn guidance.
- [Validating Sanity webhooks in Next.js](https://www.sanity.io/docs/nextjs/validating-sanity-webhooks-nextjs): webhook handler implementation for on-demand revalidation.
- [Live Content guide](https://www.sanity.io/docs/developer-guides/live-content-guide): automatic caching and real-time updates with defineLive
- [Next.js caching documentation](https://nextjs.org/docs/app/guides/caching): how the Next.js data cache works



# Sanity Live with Next.js Cache Components

> [!NOTE]
> This guide requires next-sanity v13 and Next.js v16. We recommend Next.js v16.2 or later.

This guide shows how to configure next-sanity for `cacheComponents: true`. The important difference from traditional Sanity Live usage is that `sanityFetch` must run inside cached boundaries, while request-time values such as `draftMode()` and cookies must be resolved outside those boundaries and passed in as props.

> [!TIP]
> Automate the migration with an agent
> First install the skill: `npx skills add https://github.com/sanity-io/next-sanity --skill sanity-live-cache-components` and then give your agent this prompt: 
> `Use the /sanity-live-cache-components skill to migrate this app to use Cache Components. When verifying with next dev, test both draft mode enabled and draft mode disabled because each mode has different rendering rules.`

## Three-layer component pattern

With `cacheComponents: true`, Next.js does not allow request-time APIs like `draftMode()` or `cookies()` inside `use cache` boundaries. Sanity Live needs request-time data (the perspective and stega settings) to know what to fetch. The three-layer pattern bridges this constraint: a non-cached layer resolves request-time data and passes it as serializable props into a cached layer that performs the fetch.

Each route follows the same three layers:

- **Page or layout component:** branches on `draftMode()` when the route can be prerendered, and orchestrates the other layers.
- **Dynamic component:** runs outside `use cache`, and resolves `params`, `cookies()`, and `getDynamicFetchOptions()`.
- **Cached component:** has `use cache`, receives the serializable props from the dynamic layer, and calls `sanityFetch`.

**Three-layer skeleton**

```tsx
// Page (orchestrator)
export default function Page({params}) {
  return <DynamicWrapper params={params} />
}

// Dynamic (resolves request-time data)
async function DynamicWrapper({params}) {
  const dynamic = await getDynamicFetchOptions()
  const {slug} = await params
  return <CachedContent slug={slug} dynamic={dynamic} />
}

// Cached (performs sanityFetch)
async function CachedContent({slug, dynamic}) {
  'use cache'
  const {data} = await sanityFetch({query: POST_QUERY, params: {slug}, ...dynamic})
  return <article>{/* render */}</article>
}
```

The setup steps below configure each layer. The pattern returns in section 5 with concrete code for static routes, dynamic routes, metadata, and `loading.tsx`.

## Setup

Install next-sanity@13:

**npm**

```shell
npm install --save-exact next-sanity@^13
```

**pnpm**

```shell
pnpm add --save-exact next-sanity@^13
```

**yarn**

```shell
yarn add --exact next-sanity@^13
```

**bun**

```shell
bun add --exact next-sanity@^13
```

## 1. Configure next.config.ts

In your next.config.ts, enable `cacheComponents` and add the Sanity `cacheLife` preset. Sanity Live handles on-demand revalidation, so cached Sanity data should not rely on the default 15-minute time-based revalidation.

**next.config.ts**

```typescript
import type {NextConfig} from 'next'
import {sanity} from 'next-sanity/live/cache-life'

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {default: sanity},
} satisfies NextConfig

export default nextConfig
```

## 2. Configure the Sanity client

Projects typically have a `src/sanity/lib/client.ts` file. It should use a modern apiVersion, default to the published perspective, and configure `stega.studioUrl` for Visual Editing:

**src/sanity/lib/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!,
  useCdn: true,
  apiVersion: '2026-02-27',
  perspective: 'published',
  stega: {studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333'},
})
```

If this file already exists, extend it rather than overwriting it. Changing apiVersion or removing existing stega.* options can break an app.

## 3. Configure defineLive

Create a `live.ts` file next to `client.ts`. Use `strict: true` so TypeScript requires every `sanityFetch` call to pass perspective and stega, and every `<SanityLive />` render to pass `includeDrafts`. You also need helpers for the places where Sanity data is fetched outside normal React Server Component rendering.

**src/sanity/lib/live.ts**

```typescript
import {type QueryParams} from 'next-sanity'
import {defineLive, resolvePerspectiveFromCookies, type LivePerspective} from 'next-sanity/live'
import {cookies, draftMode} from 'next/headers'
import {client} from './client'

const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
  throw new Error('Missing SANITY_API_READ_TOKEN')
}

export const {sanityFetch, SanityLive} = defineLive({
  client,
  serverToken: token,
  // The browser token is exposed to browsers in draft/live preview.
  // It must be read-only and scoped to the minimum required permissions.
  browserToken: token,
  strict: true,
})

export interface DynamicFetchOptions {
  perspective: LivePerspective
  stega: boolean
}

export async function getDynamicFetchOptions(): Promise<DynamicFetchOptions> {
  const {isEnabled: isDraftMode} = await draftMode()
  if (!isDraftMode) {
    return {perspective: 'published', stega: false}
  }

  const jar = await cookies()
  const perspective = await resolvePerspectiveFromCookies({cookies: jar})
  return {perspective: perspective ?? 'drafts', stega: true}
}

// For usage within generateStaticParams
export async function sanityFetchStaticParams<const QueryString extends string>({
  query,
  params = {},
}: {
  query: QueryString
  params?: QueryParams
}) {
  'use cache'
  const {data} = await sanityFetch({query, params, perspective: 'published', stega: false})
  return {data}
}

// For usage within generateMetadata and generateViewport
export async function sanityFetchMetadata<const QueryString extends string>({
  query,
  params = {},
  perspective,
}: {
  query: QueryString
  params?: QueryParams
  perspective: LivePerspective
}) {
  'use cache'
  const {data} = await sanityFetch({query, params, perspective, stega: false})
  return {data}
}
```

## 4. Render <SanityLive /> in a root layout

Render `<SanityLive />` once in a root layout and pass `includeDrafts={isDraftMode}`. Render `<VisualEditing />` only in draft mode.

**src/app/layout.tsx**

```typescript
import {SanityLive} from '@/sanity/lib/live'
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'

export default async function RootLayout({children}: LayoutProps<'/'>) {
  const {isEnabled: isDraftMode} = await draftMode()
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive includeDrafts={isDraftMode} />
        {isDraftMode && <VisualEditing />}
      </body>
    </html>
  )
}
```

If the app has an embedded Sanity Studio route (for example `app/studio/[[...index]]/page.tsx`), put `<SanityLive />` in a route-group layout that the Studio route does not use, such as `src/app/(website)/layout.tsx`.

## 5. Fetching data with sanityFetch

Cache Components introduce a layered caching system, so you need to define cache boundaries yourself depending on your application needs and how dynamic or cacheable the data you are fetching is.

### Key difference from cacheComponents: false

When `cacheComponents: false`, `sanityFetch` can read `draftMode()` to set perspective and stega for you. When `cacheComponents: true`, Next.js does not allow request-time APIs like `draftMode()` and `cookies()` inside `use cache` boundaries. To handle this, use the [three-layer pattern](https://www.sanity.io/docs/nextjs/cache-components) mentioned at the beginning of this guide. 

Under the hood, `sanityFetch` automatically calls the `cacheTag()` API and the `cacheLife()` API, so you can focus on defining your query and params.

Keep these rules in mind:

- Any async function that calls `sanityFetch` should have a use cache or use cache: remote directive.
- Do not hardcode perspective: published or stega: false inside cached components that render page content. Resolve those values outside the cache boundary and pass them in as props.
- Do not take perspective or stega as server action input. Server action inputs are untrusted; resolve them inside the server action and pass them to a cached helper.
- In route.ts handlers, use stega: false unless the response is rendered into the same DOM as `<VisualEditing />`.

### Static routes

**src/app/page.tsx**

```typescript
import {draftMode} from 'next/headers'
import {defineQuery} from 'next-sanity'
import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {Suspense} from 'react'

const PRODUCTS_QUERY = defineQuery(
  `*[_type == "product" && defined(slug.current)][0...$limit]{_id,slug,title}`,
)

export default async function Page() {
  const {isEnabled: isDraftMode} = await draftMode()
  if (isDraftMode) {
    return (
      <Suspense fallback={<section>Loading&hellip;</section>}>
        <DynamicProductsList />
      </Suspense>
    )
  }
  return <CachedProductsList perspective="published" stega={false} />
}

async function DynamicProductsList() {
  const {perspective, stega} = await getDynamicFetchOptions()
  return <CachedProductsList perspective={perspective} stega={stega} />
}

async function CachedProductsList({perspective, stega}: DynamicFetchOptions) {
  'use cache'

  const {data: products} = await sanityFetch({
    query: PRODUCTS_QUERY,
    params: {limit: 10},
    perspective,
    stega,
  })

  return (
    <section>
      {products.map((product) => (
        <article key={product._id}>
          <a href={`/product/${product.slug}`}>{product.title}</a>
        </article>
      ))}
    </section>
  )
}
```

### Dynamic routes with params

In Next.js 16+, params is a Promise. For routes where params is used as input to `sanityFetch`, implement `generateStaticParams()` and use `sanityFetchStaticParams()`. The dynamic layer unwraps both params and the fetch options before passing plain, serializable values to the cached component:

**src/app/product/[slug]/page.tsx**

```typescript
import {draftMode} from 'next/headers'
import {defineQuery} from 'next-sanity'
import {
  getDynamicFetchOptions,
  sanityFetch,
  sanityFetchStaticParams,
  type DynamicFetchOptions,
} from '@/sanity/lib/live'
import {Suspense} from 'react'

const SLUGS_BY_TYPE_QUERY = defineQuery(`
  *[_type == $type && defined(slug.current)]{"slug": slug.current}
`)
const PRODUCT_QUERY = defineQuery(
  `*[_type == "product" && slug.current == $slug][0]{_id,slug,title,description}`,
)

export async function generateStaticParams() {
  const {data} = await sanityFetchStaticParams({
    query: SLUGS_BY_TYPE_QUERY,
    params: {type: 'product'},
  })
  return data
}

export default async function ProductPage({params}: PageProps<'/product/[slug]'>) {
  const {isEnabled: isDraftMode} = await draftMode()
  if (isDraftMode) {
    return (
      <Suspense fallback={<section>Loading product&hellip;</section>}>
        <DynamicProductPage params={params} />
      </Suspense>
    )
  }
  const {slug} = await params
  return <CachedProductPage slug={slug} perspective="published" stega={false} />
}

async function DynamicProductPage({params}: Pick<PageProps<'/product/[slug]'>, 'params'>) {
  const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
  return <CachedProductPage slug={slug} perspective={perspective} stega={stega} />
}

async function CachedProductPage({
  slug,
  perspective,
  stega,
}: Awaited<PageProps<'/product/[slug]'>['params']> & DynamicFetchOptions) {
  'use cache'

  const {data: product} = await sanityFetch({
    query: PRODUCT_QUERY,
    params: {slug},
    perspective,
    stega,
  })

  return (
    <article>
      <h1>{product?.title}</h1>
    </article>
  )
}
```

`PageProps<"/product/[slug]">` is provided by Next.js next typegen output, so the params are typed from the route segment without having to define a Props type by hand.

### Caching generateMetadata

Metadata should not use stega encoding, but it should still resolve perspective so Presentation Tool can preview draft content and content releases in a new preview window. Use `sanityFetchMetadata()` and pass the resolved perspective.

**src/app/product/[slug]/page.tsx**

```typescript
import type {Metadata, ResolvingMetadata} from 'next'
import {getDynamicFetchOptions, sanityFetchMetadata} from '@/sanity/lib/live'

export async function generateMetadata(
  {params}: PageProps<'/product/[slug]'>,
  parent: ResolvingMetadata,
): Promise<Metadata> {
  const [{slug}, {perspective}] = await Promise.all([params, getDynamicFetchOptions()])
  const {data: product} = await sanityFetchMetadata({
    query: PRODUCT_QUERY,
    params: {slug},
    perspective,
  })
  return {
    title: product?.title,
    description: product?.description ?? (await parent).description,
  }
}
```

### Routes with loading.tsx

If a dynamic route has a sibling `loading.tsx`, the route can rely on that fallback instead of adding its own Suspense boundary. In that case it can await params and `getDynamicFetchOptions()` directly in the page component before rendering a cached component:

**src/app/product/[slug]/page.tsx**

```typescript
export default async function ProductPage({params}: PageProps<'/product/[slug]'>) {
  const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
  return <CachedProductPage slug={slug} perspective={perspective} stega={stega} />
}
```

Without a sibling loading.tsx, keep request-time work in a dynamic component wrapped by Suspense.

## Migrating an existing Sanity Live setup

If the app is already using defineLive, this is a refactor, not a rewrite. The five-step sequence above still applies, but watch for these specific differences:

- Do not overwrite `client.ts` or `live.ts` if they exist. Append missing options. Preserve any existing token and stega.* settings.
- Search for hardcoded `perspective: published` and `stega: false` in `sanityFetch` call sites and refactor them to source perspective/stega via `getDynamicFetchOptions` and the three-layer pattern.
- Search for `sanityFetch` calls inside `generateStaticParams` and swap for `sanityFetchStaticParams`.
- Search for `sanityFetch` calls inside `generateMetadata`, sitemap.ts, and `opengraph-image.tsx` and swap for sanityFetchMetadata.
- Search for `sanityFetch` calls directly inside a use server function and split into a separate use cache helper.
- Verify there is exactly one `<SanityLive>` and one `<VisualEditing>` in the tree. Multiple renders are undefined behavior.

## Verify both modes

Run the app with next dev and test both draft mode enabled and draft mode disabled. `next build --debug-prerender` can catch prerendering issues, but it does not prove that draft mode, Presentation Tool perspective switching, or Visual Editing overlays work correctly.





# Validate webhooks

When content changes in Sanity, you can use [webhooks](https://www.sanity.io/docs/content-lake/webhooks) to revalidate cached pages in your Next.js application on demand. The `next-sanity` toolkit includes `parseBody`, a utility that validates webhook signatures and parses payloads, so you can build secure revalidation handlers with minimal code.

This guide covers two revalidation patterns:

- **Path-based revalidation**: revalidate specific routes when a document changes
- **Tag-based revalidation**: revalidate all pages that depend on a document type

Both patterns work with App Router and Pages Router API routes.

> [!NOTE]
> **Before you start:** this guide covers the mechanism for webhook-based revalidation. If you're deciding which caching strategy to use (time-based, tag-based, or path-based), see [Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs).

## Prerequisites

- A Next.js application with [next-sanity](https://www.sanity.io/docs/nextjs/introduction) configured.
- A Sanity project with permission to create webhooks.
- A shared secret for webhook signature validation.

### Set up the shared secret

Create a `SANITY_REVALIDATE_SECRET` environment variable with a random string. This secret must match in both your Sanity webhook configuration and your Next.js application.

**.env.local**

```sh
# .env.local
SANITY_REVALIDATE_SECRET=<your-random-secret-string>
```

Add the same value to your hosting provider's environment variables (Vercel, Netlify, etc.).

## The `parseBody` utility

`parseBody` from `next-sanity/webhook` handles signature validation and payload parsing in one call:

```typescript
import { parseBody } from 'next-sanity/webhook'

const { isValidSignature, body } = await parseBody<MyPayloadType>(
  request,
  secret,
  waitForContentLakeEvent
)
```

The function accepts three arguments:

| Argument | Type | Description |
| --- | --- | --- |
| request | Request | The incoming webhook request |
| secret | string | Your SANITY_REVALIDATE_SECRET value |
| waitForContentLakeEvent | boolean (optional) | When true, adds a short delay before returning. This gives the Content Lake time to propagate changes, preventing your revalidation from fetching stale data. Defaults to false. |

`parseBody` returns an object with:

- `isValidSignature`: `true` if the webhook request was signed with the correct secret
- `body`: the parsed webhook payload, typed as the generic you provide

> [!NOTE]
> Set `waitForContentLakeEvent` to `true` if your client uses `useCdn: true`. The CDN may serve stale data for a few seconds after a mutation. The delay ensures your revalidated pages fetch fresh content. See [Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs) for more on how the Sanity CDN and Next.js data cache interact.

## Path-based revalidation

Use path-based revalidation when you know which routes correspond to which documents. For example, a blog post at `/posts/my-post` maps directly to a Sanity document with `slug.current === 'my-post'`.

### Create the API route

**src/app/api/revalidate-path/route.ts**

```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 {
    if (!process.env.SANITY_REVALIDATE_SECRET) {
      return new Response(
        'Missing environment variable SANITY_REVALIDATE_SECRET',
        { status: 500 }
      )
    }

    const { isValidSignature, body } = await parseBody<WebhookPayload>(
      req,
      process.env.SANITY_REVALIDATE_SECRET,
      true // wait for Content Lake propagation
    )

    if (!isValidSignature) {
      return new Response(
        JSON.stringify({ message: 'Invalid signature', isValidSignature, body }),
        { status: 401 }
      )
    }

    if (!body?.path) {
      return new Response(
        JSON.stringify({ message: 'Bad Request', body }),
        { status: 400 }
      )
    }

    revalidatePath(body.path)

    return NextResponse.json({
      message: `Revalidated path: ${body.path}`,
      body,
    })
  } catch (err: unknown) {
    console.error(err)
    const message = err instanceof Error ? err.message : 'Unknown error'
    return new Response(message, { status: 500 })
  }
}
```

### Configure the webhook projection

The webhook needs to send a `path` value that matches your Next.js routes. Use a GROQ projection with the select() function to generate paths dynamically:

```groq
{
  "path": select(
    _type == "post" => "/posts/" + slug.current,
    "/" + slug.current
  )
}
```

Extend this pattern for your own document types and route structure:

```groq
{
  "path": select(
    _type == "post" => "/posts/" + slug.current,
    _type == "author" => "/authors/" + slug.current,
    _type == "category" => "/categories/" + slug.current,
    "/" + slug.current
  )
}
```

> [!NOTE]
> To revalidate all routes on demand, create an API route that calls `revalidatePath('/', 'layout')`. This is a blunt instrument, but useful as a fallback.

## Tag-based revalidation

Use tag-based revalidation when a single document change affects many pages. For example, updating an author's name should revalidate every post that displays it.

Tag-based revalidation has two parts:

- **Tagging queries**: when fetching data, you associate queries with tags (covered in [Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs))
- **Busting tags**: when content changes, a webhook calls `revalidateTag()` to invalidate all queries with that tag

This section covers the webhook side.

### Create the API route

**src/app/api/revalidate-tag/route.ts**

```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 = {
  _type: string
}

export async function POST(req: NextRequest) {
  try {
    if (!process.env.SANITY_REVALIDATE_SECRET) {
      return new Response(
        'Missing environment variable SANITY_REVALIDATE_SECRET',
        { status: 500 }
      )
    }

    const { isValidSignature, body } = await parseBody<WebhookPayload>(
      req,
      process.env.SANITY_REVALIDATE_SECRET,
      true // wait for Content Lake propagation
    )

    if (!isValidSignature) {
      return new Response(
        JSON.stringify({ message: 'Invalid signature', isValidSignature, body }),
        { status: 401 }
      )
    }

    if (!body?._type) {
      return new Response(
        JSON.stringify({ message: 'Bad Request', body }),
        { status: 400 }
      )
    }

    revalidateTag(body._type)

    return NextResponse.json({ body })
  } catch (err: unknown) {
    console.error(err)
    const message = err instanceof Error ? err.message : 'Unknown error'
    return new Response(message, { status: 500 })
  }
}
```

### How tags connect to queries

When you fetch data using the manual `sanityFetch` helper with tags:

```typescript
const posts = await sanityFetch({
  query: POSTS_QUERY,
  tags: ['post', 'author'],
})
```

Next.js associates the cached response with both the `post` and `author` tags. When the webhook fires for a document with `_type === 'post'`, calling `revalidateTag('post')` invalidates all cached queries tagged with `'post'`.

See [Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs) for the full `sanityFetch` helper implementation and tagging patterns.

### Configure the webhook projection

For tag-based revalidation, the projection is simple. You only need the document type:

```groq
{_type}
```

## Setting up the webhook in Sanity

Configure the webhook in your Sanity project dashboard:

| Field | Path-based | Tag-based |
| --- | --- | --- |
| URL | https://your-domain.com/api/revalidate-path | https://your-domain.com/api/revalidate-tag |
| Events | Create, Update, Delete | Create, Update, Delete |
| Filter | _type in ["post", "author"] | _type in ["post", "author"] |
| Projection | {"path": select(...)} | {_type} |
| Secret | Your SANITY_REVALIDATE_SECRET | Your SANITY_REVALIDATE_SECRET |
| HTTP method | POST | POST |

Adjust the filter to include the document types your application uses.

### Quick setup with webhook templates

Use these shareable templates to create pre-configured webhooks in your Sanity project:

- [Path-based revalidation webhook template](https://www.sanity.io/manage/webhooks/share?name=Path-based+Revalidation+Hook+for+Next.js&description=1.+Replace+URL+with+the+preview+or+production+URL+for+your+revalidation+handler+in+your+Next.js+app%0A2.%C2%A0Insert%2Freplace+the+document+types+you+want+to+be+able+to+make+tags+for+in+the+Filter+array%0A3.%C2%A0Make+a+Secret+that+you+also+add+to+your+app%27s+environment+variables+%28SANITY_REVALIDATE_SECRET%29%0A%0AFor+complete+instructions%2C+see+the+README+on%3A%0Ahttps%3A%2F%2Fgithub.com%2Fsanity-io%2Fnext-sanity&url=https%3A%2F%2FYOUR-PRODUCTION-URL.TLD%2Fapi%2Frevalidate-path&on=create&on=update&on=delete&filter=_type+in+%5B%22post%22%2C+%22home%22%2C+%22OTHER_DOCUMENT_TYPES%22%5D&projection=%7B%0A++%22path%22%3A+select%28%0A++++_type+%3D%3D+%22post%22+%3D%3E+%22%2Fposts%2F%22+%2B+slug.current%2C%0A++++slug.current%0A++%29%0A%7D&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=&headers=%7B%7D)
- [Tag-based revalidation webhook template](https://www.sanity.io/manage/webhooks/share?name=Tag-based+Revalidation+Hook+for+Next.js+13+&description=1.+Replace+URL+with+the+preview+or+production+URL+for+your+revalidation+handler+in+your+Next.js+app%0A2.%C2%A0Insert%2Freplace+the+document+types+you+want+to+be+able+to+make+tags+for+in+the+Filter+array%0A3.%C2%A0Make+a+Secret+that+you+also+add+to+your+app%27s+environment+variables+%28SANITY_REVALIDATE_SECRET%29%0A%0AFor+complete+instructions%2C+see+the+README+on%3A%0Ahttps%3A%2F%2Fgithub.com%2Fsanity-io%2Fnext-sanity&url=https%3A%2F%2FYOUR-PRODUCTION-URL.TLD%2Fapi%2Frevalidate-tag&on=create&on=update&on=delete&filter=_type+in+%5B%22post%22%2C+%22home%22%2C+%22OTHER_DOCUMENT_TYPE%22%5D&projection=%7B_type%7D&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=&headers=%7B%7D)

After importing a template, update the URL to point to your deployed application and set the secret.

## Security

Webhook endpoints are publicly accessible URLs. Follow these practices to keep them secure:

- **Always validate signatures.** Never skip the `parseBody` validation step, even in development. An unvalidated endpoint lets anyone trigger revalidation (or worse, if you add write operations later).
- **Use a strong secret.** Generate a random string of at least 32 characters. Tools like `openssl rand -base64 32` work well.
- **Keep the secret out of version control.** Use `.env.local` for local development and your hosting provider's secrets management for production.
- **Consider rate limiting.** In production, add rate limiting to your revalidation endpoints to prevent abuse. Most hosting providers offer this at the infrastructure level.

## Pages Router

Webhook revalidation works with Pages Router API routes. The Live Content API is App Router only, but webhook-based revalidation is available on both routers.

The key differences from the App Router examples above:

- API routes live in `pages/api/` instead of `src/app/api/`
- The handler receives `req` and `res` objects instead of a `Request`
- Use `res.revalidate()` instead of importing `revalidatePath` from `next/cache`

**pages/api/revalidate-path.ts**

```typescript
// pages/api/revalidate-path.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import { parseBody } from 'next-sanity/webhook'

type WebhookPayload = {
  path?: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' })
  }

  try {
    if (!process.env.SANITY_REVALIDATE_SECRET) {
      return res.status(500).json({
        message: 'Missing environment variable SANITY_REVALIDATE_SECRET',
      })
    }

    const { isValidSignature, body } = await parseBody<WebhookPayload>(
      req,
      process.env.SANITY_REVALIDATE_SECRET,
      true
    )

    if (!isValidSignature) {
      return res.status(401).json({
        message: 'Invalid signature',
        isValidSignature,
        body,
      })
    }

    if (!body?.path) {
      return res.status(400).json({ message: 'Bad Request', body })
    }

    await res.revalidate(body.path)

    return res.status(200).json({
      message: `Revalidated path: ${body.path}`,
      body,
    })
  } catch (err: unknown) {
    console.error(err)
    const message = err instanceof Error ? err.message : 'Unknown error'
    return res.status(500).json({ message })
  }
}
```

> [!NOTE]
> Pages Router doesn't support `revalidateTag`. For tag-based patterns, you'll need to map document types to paths and use `res.revalidate(path)` instead. This means tag-based revalidation in Pages Router effectively becomes path-based.

## Related resources

- [Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs): choosing a caching strategy and implementing the sanityFetch helper.
- [GROQ-powered webhooks](https://www.sanity.io/docs/content-lake/webhooks): configuring webhooks in the Sanity dashboard.
- [GROQ Functions Reference](https://www.sanity.io/docs/specifications/groq-functions): reference for select() and other functions used in webhook projections.
- [next-sanity overview](https://www.sanity.io/docs/nextjs/introduction): what the toolkit includes and how to get started.



# Visual Editing for App Router

This guide walks through the specific wiring that makes Sanity's visual editing work with a Next.js application.

By the end, editors will be able to open the Presentation Tool in the Studio, see the frontend in a live preview, click on any text element to jump to the corresponding field, and see changes reflected in real time as they type.

**What you'll set up:**

- A Sanity client configured for Content Source Map encoding.
- `defineLive` for real-time content fetching and live updates.
- Draft Mode routes to toggle between published and draft content.
- The Presentation Tool with document-to-URL mapping.
- Click-to-edit overlays powered by `<VisualEditing />`.

The guide assumes you already have document types defined in your Studio and pages that render them. The focus is purely on the integration layer: the files and configuration that connect the two apps.

## Prerequisites

- Node.js 20+.
- Next.js 16.x with the [App Router](https://nextjs.org/docs/app). This guide uses route handlers, `generateStaticParams`, `generateMetadata`, and Draft Mode, all of which are App Router features. It also expects that your app uses `next-sanity` v13.1.5 or later.
- A Sanity project with a dataset. [Create one](https://www.sanity.io/manage) if you don't have one.
- [An API token](https://www.sanity.io/docs/content-lake/http-auth) with **Viewer** permissions for that project. Create one under **API** → **Tokens** in your project settings.
- `http://localhost:3000` added as a [CORS origin](https://www.sanity.io/docs/content-lake/browser-security-and-cors) with **Allow credentials** checked.

You can create a basic Next.js app with the following command.

**npm**

```shell
# In a directory, outside your studio directory
npx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

**pnpm**

```shell
# In a directory, outside your studio directory
pnpm dlx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

**yarn**

```shell
# In a directory, outside your studio directory
yarn dlx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

**bun**

```shell
# In a directory, outside your studio directory
bunx create-next-app@latest frontend --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd frontend
```

You can create a new Studio with the following command.

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio
cd studio
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio
```

If you’re setting up a new Next.js app and Studio from scratch, we suggest following our [Next.js quick start](https://www.sanity.io/docs/next-js-quickstart). The schemas, routes, and file layout in this guide follow the structure set up in the quick start.

## How the pieces fit together

Before diving into the code, here's what happens at runtime when an editor opens the Presentation Tool:

1. The Studio loads the Next.js frontend inside an iframe. The URL it loads comes from the `origin` field in the Presentation Tool configuration.
2. The Studio hits the Draft Mode enable route on the frontend (`/api/draft-mode/enable`). This activates Next.js Draft Mode in the iframe session.
3. With Draft Mode active, `sanityFetch` returns strings with invisible characters embedded in them. These invisible characters (called "stega") encode Content Source Map data: which document and field each string came from, along with the Studio URL.
4. The `<VisualEditing />` component (which only renders during Draft Mode) reads those encoded strings from the DOM and draws click-to-edit overlays on every text element.
5. When an editor clicks an overlay, the Studio navigates to that document and field.
6. When an editor changes a field, the `<SanityLive />` component picks up the mutation and the frontend re-renders with the new content.

> [!NOTE]
> Contracts between the two apps.
> If you change one side, check the other.
> - The Studio's `previewMode.enable` path (`/api/draft-mode/enable`) must match an actual route in the Next.js app.
> - The URLs returned by `resolve.ts` (e.g., `/posts/${slug}`) must match actual routes in `web/src/app/`.
> - The `stega.studioUrl` in the Next.js client must point to the running Studio.
> - The Sanity project must have the frontend's origin in its CORS settings with **Allow credentials** enabled.

## Environment variables

The Next.js app needs three environment variables. The Studio doesn't need any since the project ID and dataset are hardcoded in `sanity.config.ts`. However, you can use [environment variables in Studio](https://www.sanity.io/docs/studio/environment-variables) if you need the flexibility.

**web/.env.local**

```bash
NEXT_PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=your-viewer-token
```

`NEXT_PUBLIC_SANITY_PROJECT_ID` and `NEXT_PUBLIC_SANITY_DATASET` are public because the Sanity client needs them in the browser for live subscriptions.

`SANITY_API_READ_TOKEN` is server-only and never exposed to the client bundle directly. It's passed to `defineLive`, which handles sharing it with the browser securely when Draft Mode is active.

## Studio setup

These files live in `studio/`. If you’re setting up a new Studio from scratch, these examples use the schema and conventions found in the [Next.js quick start](https://www.sanity.io/docs/next-js-quickstart).

### Presentation Tool configuration

The Presentation Tool is a Studio plugin that renders your frontend inside an iframe and enables the visual editing workflow. Configure it in `sanity.config.ts`:

**studio/sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {presentationTool} from 'sanity/presentation'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './src/schemaTypes'
import {resolve} from './src/presentation/resolve'

export default defineConfig({
  name: 'default',
  title: 'Blog Studio',

  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',

  plugins: [
    structureTool(),
    presentationTool({
      resolve,
      previewUrl: {
        origin: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
        },
      },
    }),
    visionTool(),
  ],

  schema: {
    types: schemaTypes,
  },
})
```

The important fields here:

- **resolve**: This defines the document location resolver. You’ll set this up in the next section.
- **previewUrl.origin**: The full URL of the Next.js app. The Presentation Tool loads this in the iframe. When the Studio and frontend are separate apps (as they are here), this is required. If you embedded the Studio inside the Next.js app at `/studio`, the origin would be implicit and you could omit it.
- **previewUrl.previewMode.enable**: The path (relative to `origin`) that the Studio calls to activate Draft Mode. The Studio makes a GET request to `http://localhost:3000/api/draft-mode/enable` with authentication parameters. This is what flips the switch that makes the frontend return draft content with stega encoding.

### Document locations

Document locations tell the Presentation Tool which frontend URLs correspond to which document types. This powers two things: when you select a document in the Studio, the iframe navigates to the right page; and documents show location badges linking to their frontend URLs.

**studio/src/presentation/resolve.ts**

```typescript
import {defineLocations, type PresentationPluginOptions} from 'sanity/presentation'

export const resolve: PresentationPluginOptions['resolve'] = {
  locations: {
    // The key is the document type name from your schema
    post: defineLocations({
      select: {
        title: 'title',
        slug: 'slug.current',
      },
      resolve: (doc) => ({
        locations: [
          {
            title: doc?.title || 'Untitled',
            href: `/posts/${doc?.slug}`,
          },
          {title: 'All posts', href: '/posts'},
        ],
      }),
    }),
  },
}
```

`select` uses GROQ-like field paths to pull data from the document. `resolve` receives that data and returns an array of `{title, href}` objects. The first location is treated as the primary one. You can add multiple locations if a document appears on several pages (for example, a post appears on its own page and on the posts index).

### CORS

The Sanity project needs `http://localhost:3000` added as a CORS origin with **Allow credentials** enabled. If you already added this in the prerequisites, you're set. If not, add it in your project settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**, or add it with the CLI.

**npm**

```shell
npx sanity cors add http://localhost:3000 --credentials
```

**pnpm**

```shell
pnpm dlx sanity cors add http://localhost:3000 --credentials
```

**yarn**

```shell
yarn dlx sanity cors add http://localhost:3000 --credentials
```

**bun**

```shell
bunx sanity cors add http://localhost:3000 --credentials
```

For production, you'd add your deployed frontend URL as well.

## Next.js setup

These files live in `frontend/`. If you’re setting up a new Next.js project from scratch, these examples use the schema and conventions found in the [Next.js quick start](https://www.sanity.io/docs/next-js-quickstart).

### The Sanity client

**frontend/src/sanity/lib/client.ts**

```typescript
import {createClient} from 'next-sanity'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET

if (!projectId) throw new Error('Missing NEXT_PUBLIC_SANITY_PROJECT_ID')
if (!dataset) throw new Error('Missing NEXT_PUBLIC_SANITY_DATASET')

export const client = createClient({
  projectId,
  dataset,
  apiVersion: '2026-02-01',
  useCdn: true,
  stega: {
    studioUrl: 'http://localhost:3333',
  },
})
```

Most of this is standard Sanity client setup. The critical field for visual editing is **stega.studioUrl**.

When Draft Mode is active, `sanityFetch` (which we'll set up next) asks the Content Lake for Content Source Maps alongside the query results. It then encodes these source maps as invisible characters into string values.

The `<VisualEditing />` overlay component reads these encoded strings from the DOM to create click-to-edit links. Without `stega.studioUrl`, it has the document and field information but doesn't know where to send the editor. The overlays render but don't connect to anything.

For production, you'd point this to your deployed Studio URL.

### The Live Content API

**frontend/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,
})
```

`defineLive` is the main integration point between Sanity and Next.js. It returns two things:

- **sanityFetch**: A server-side function you use in page components instead of `client.fetch()`. It handles caching, revalidation, stega encoding, and perspective switching (published vs. draft or version content) automatically based on whether Draft Mode is active.
- **SanityLive**: A React component that subscribes to real-time content updates. When an editor changes a field in the Studio, this component picks up the mutation and triggers a re-render.

The two tokens:

- **serverToken**: Used for server-side fetches. This is what lets `sanityFetch` read draft content when Draft Mode is active. Without it, the frontend can only return published content.
- **browserToken**: Shared with the browser during Draft Mode to enable live subscriptions. This is the token that powers real-time updates. It should have Viewer permissions only since it's exposed to the client.

> [!NOTE]
> Why have the same token twice?
> While most apps are fine with a shared “Viewer” role token, enterprise customers with custom roles may choose to narrow the read permissions of the browser token further.

### Fetching data in pages

Here's a page component that shows the three different fetch modes you'll use:

**frontend/src/app/posts/[slug]/page.tsx**

```typescript
import {notFound} from 'next/navigation'
import {sanityFetch} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'

// Update with your own queries
const POST_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
    _id,
    title,
    "slug": slug.current,
    publishedAt,
    body
  }
`)

const POST_SLUGS_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current)]{
    "slug": slug.current
  }`)

type Props = {
  params: Promise<{slug: string}>
}

// 1. Static params: published perspective, no stega
export async function generateStaticParams() {
  const {data} = await sanityFetch({
    query: POST_SLUGS_QUERY,
    perspective: 'published',
    stega: false,
  })
  return data
}

// 2. Metadata: stega disabled to keep invisible characters out of <title>
export async function generateMetadata({params}: Props) {
  const {data} = await sanityFetch({
    query: POST_QUERY,
    params: await params,
    stega: false,
  })
  return {title: data?.title ?? 'Post not found'}
}

// 3. Page component: default settings (stega active in Draft Mode)
export default async function PostPage({params}: Props) {
  const {data: post} = await sanityFetch({
    query: POST_QUERY,
    params: await params,
  })

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}
```

Three modes, three different configurations:

- **generateStaticParams**: Uses `perspective: 'published'` so it only generates pages for published posts (not drafts). Uses `stega: false` because these values are used as URL segments, not rendered text.
- **generateMetadata**: Uses `stega: false` because stega characters in `<title>` or `<meta>` tags corrupt your SEO. Invisible characters in a page title look fine in the browser tab but break search engine results.
- **The page component**: Uses default settings. When Draft Mode is off, it returns clean published content. When Draft Mode is on, it returns draft content with stega encoding, which is exactly what the overlays need.

The data returned by `sanityFetch` is fully typed only after you generate types with Sanity TypeGen. Run `npx sanity typegen generate` after changing queries.

### The root layout

**frontend/src/app/layout.tsx**

```typescript
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
import {SanityLive} from '@/sanity/lib/live'
import {DisableDraftMode} from '@/components/disable-draft-mode'

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
        {(await draftMode()).isEnabled && (
          <>
            <VisualEditing />
            <DisableDraftMode />
          </>
        )}
      </body>
    </html>
  )
}
```

Two components are doing the visual editing work here:

- **<SanityLive />** renders on every request, whether Draft Mode is active or not. It establishes a connection to the Content Lake and listens for content changes. When someone publishes a document, this component triggers revalidation so the page updates without a full deploy.
- **<VisualEditing />** renders only when Draft Mode is enabled. It scans the DOM for stega-encoded strings, decodes the Content Source Map data embedded in them (document ID, field path, Studio URL), and draws transparent overlays on top of each element. Clicking an overlay sends a message to the parent Studio window (via `postMessage`) telling it to navigate to that document and field.
- **<DisableDraftMode />** renders a button for users to manually disable draft mode. You’ll create this shortly.

### VisualEditing props

The `<VisualEditing />` component accepts optional props to control clipboard behavior and stega diagnostics. Both props require `@sanity/visual-editing` 5.5.0 or later, which is included with `next-sanity` v13.1.5 and later.

#### `keepStegaOnCopy`

Type: `boolean`. Optional. Default: `false` (stega is stripped from the clipboard by default).

By default, `<VisualEditing />` intercepts copy events and removes stega encoding from both `text/plain` and `text/html` clipboard payloads. This means editors copying text from the preview page won't get invisible stega characters in their clipboard. Pass `keepStegaOnCopy` to opt out of this behavior and preserve stega encoding in clipboard data.

#### `onSuspiciousStega`

Type: callback. Optional. Opt-in.

Reports stega found in unsafe DOM placements. When provided, `<VisualEditing />` audits the DOM for stega in locations where invisible characters can cause problems:

- Element attributes (`class`, `id`, `href`, `src`, `style`, `data-*`, etc.)
- Inside `<head>` (`title`, `meta[content]`, JSON-LD)
- Inside `<script>` or `<style>` text content
- In `textarea` form values
- In the page URL

Each report includes the `kind`, `element`, `attribute` (if applicable), `value`, and `cleaned`.

**frontend/src/app/layout.tsx**

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

> [!WARNING]
> Development and debugging only
> The `onSuspiciousStega` callback runs a full DOM audit using a TreeWalker and MutationObserver, which has a real performance cost. Use it during development and debugging to identify stega leaking into unsafe locations. Do not enable it in production.

#### Props reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| keepStegaOnCopy | boolean | false | Opt out of automatic stega stripping from clipboard on copy events. By default, stega encoding is removed from both text/plain and text/html clipboard payloads so editors don't copy invisible characters. |
| onSuspiciousStega | (reports: SuspiciousStegaReport[]) => void | undefined | Callback that receives reports of stega found in unsafe DOM placements (element attributes, <head>, <script>/<style> content, textarea values, page URL). Runs a full DOM audit. Use in development only. Reports may also include a sanity field with decoded node info. |

The `(await draftMode()).isEnabled` check is the gate. Outside of Draft Mode, the page renders clean published content with no overlays and no invisible characters. Inside Draft Mode, you get draft content, stega encoding, and click-to-edit overlays.

> [!TIP]
> Don't want Live Content?
> If you don’t want the Live Content API’s auto-refresh capabilities, perhaps if you have more granular caching and revalidation needs, see the [section below on replacing sanityFetch with your own helper](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router).

### Draft Mode routes

These two routes are the bridge between the Studio and the frontend.

**Enable route:**

**frontend/src/app/api/draft-mode/enable/route.ts**

```typescript
import {client} from '@/sanity/lib/client'
import {defineEnableDraftMode} from 'next-sanity/draft-mode'

export const {GET} = defineEnableDraftMode({
  client: client.withConfig({
    token: process.env.SANITY_API_READ_TOKEN || ''
  }),
})
```

When an editor opens the Presentation Tool, the Studio makes a GET request to this route with authentication parameters. `defineEnableDraftMode` handles the handshake: it verifies the request came from a legitimate Studio session (not a random visitor), then calls `draftMode().enable()` to activate Draft Mode for that browser session. From that point on, every `sanityFetch` call in the session returns draft content with stega encoding.

The `client.withConfig` part gives the handler an authenticated client to verify the request against the Sanity API.

**Disable route:**

**frontend/src/app/api/draft-mode/disable/route.ts**

```typescript
import {draftMode} from 'next/headers'
import {NextResponse} from 'next/server'

// set redirect to your preferred location
export async function GET() {
  ;(await draftMode()).disable()
  return NextResponse.redirect(
    new URL('/', 'http://localhost:3000')
  )
}
```

This turns off Draft Mode and redirects to the homepage. It's called by the "**Disable Draft Mode**" button (covered next).

### The "Disable Draft Mode" button

**frontend/src/components/disable-draft-mode.tsx**

```typescript
'use client'

import {useIsPresentationTool} from 'next-sanity/hooks'

export function DisableDraftMode() {
  const isPresentationTool = useIsPresentationTool()

  // Hide the button when inside the Presentation Tool
  if (isPresentationTool) return null

  return (
    <a
      href="/api/draft-mode/disable"
      className="fixed bottom-4 right-4 z-50 rounded-full bg-gray-900 px-4 py-2 text-sm text-white"
    >
      Disable Draft Mode
    </a>
  )
}
```

This component renders a floating button to exit Draft Mode, but only when the user is viewing the frontend directly (not inside the Presentation Tool's iframe). Inside the Presentation Tool, the Studio controls Draft Mode, so the button would be redundant.

`useIsPresentationTool` returns `true` when the frontend is loaded inside a Presentation Tool iframe and `false` when it's loaded directly in a browser tab. This is how you distinguish between the two contexts.

## Run both apps

With everything set up, you can now run both apps to test the functionality. If you’re using npm with two separate directories as described in this guide, run the `dev` command in each directory.

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun run dev
```

## The full flow

Now that you've seen every file, here's the complete sequence when an editor uses visual editing. This is the same flow described in "How the pieces fit together," but now you can trace each step back to the specific file that handles it:

1. The editor opens the **Presentation Tool** in the Studio (`sanity.config.ts`).
2. The Studio loads `http://localhost:3000` (the `origin`) in an iframe and uses `resolve.ts` to map the current document to a frontend URL.
3. The Studio hits `http://localhost:3000/api/draft-mode/enable` with authentication parameters (`enable/route.ts`).
4. The enable route verifies the request and activates **Draft Mode** in the iframe session.
5. The page re-renders. `sanityFetch` (`live.ts`) detects Draft Mode and returns draft content with **stega-encoded strings**: each string value has invisible characters that encode the document ID, field path, and Studio URL (`client.ts`).
6. `<VisualEditing />` (`layout.tsx`, only mounted during Draft Mode) reads the DOM, finds the stega-encoded strings, and renders transparent **click-to-edit overlays** on each text element.
7. The editor clicks an overlay. The overlay sends a `postMessage` to the parent Studio window with the document ID and field path. The Studio navigates to that field.
8. The editor changes a field. The mutation propagates through the Content Lake.
9. `<SanityLive />` (`layout.tsx`) picks up the mutation via its real-time subscription and triggers a re-render. The page updates with the new content.

## Next steps

- **Deploy to production.** Update `stega.studioUrl`, the Presentation Tool `origin`, and your CORS origins to point to your deployed URLs instead of `localhost`. It’s common to use environment variables for these values with local fallbacks.
- **Add more document types to resolve.ts.** Any document type that has a corresponding frontend route can get visual editing. Add entries to the `locations` object for each type.
- **Customize overlay behavior.** The `<VisualEditing />` component accepts props for filtering which elements get overlays. See the [next-sanity visual editing reference](https://reference.sanity.io/next-sanity/visual-editing/client-component/VisualEditingProps/) for details.

## Troubleshooting

### Visual Editing without the Live Content API

The instructions above rely on the Live Content API, but if your revalidation needs are different, you can substitute the live functionality with a custom `sanityFetch`, and remove the `<SanityLive />` component.

Remove `live.ts` and create `fetch.ts`.

You’ll also need a `token.ts` that exports the read token:

**frontend/src/sanity/lib/token.ts**

```typescript
export const token = process.env.SANITY_API_READ_TOKEN

if (!token) {
  throw new Error('Missing SANITY_API_READ_TOKEN')
}
```

**frontend/src/sanity/lib/fetch.ts**

```typescript
import {draftMode} from 'next/headers'
import {client} from './client'
import {token} from './token'

export async function sanityFetch<T>({
  query,
  params = {},
  revalidate = 60,
  tags = [],
  stega: stegaOverride,
  perspective: perspectiveOverride,
}: {
  query: string
  params?: Record<string, unknown>
  revalidate?: number | false
  tags?: string[]
  stega?: boolean
  perspective?: 'published' | 'drafts' | 'raw'
}): Promise<{data: T}> {
  const isDraftMode = (await draftMode()).isEnabled

  const perspective = perspectiveOverride ?? (isDraftMode ? 'drafts' : 'published')
  const stega = stegaOverride ?? isDraftMode
  const useCdn = !isDraftMode

  const data = await client
    .withConfig({useCdn, stega: stega ? {studioUrl: 'http://localhost:3333'} : false})
    .fetch<T>(query, params, {
      token: isDraftMode ? token : undefined,
      perspective,
      next: {
        revalidate: isDraftMode ? 0 : tags.length ? false : revalidate,
        tags: isDraftMode ? [] : tags,
      },
    })

  return {data}
}
```

Then, import this new `sanityFetch` instead of the `live.ts` one.

**frontend/src/app/posts/[slug]/page.tsx**

```tsx
import {notFound} from 'next/navigation'
import {sanityFetch} from '@/sanity/lib/fetch'

type Props = {
  params: Promise<{slug: string}>
}

/* ...omitted */

// Page component: default settings (stega active in Draft Mode)
export default async function PostPage({params}: Props) {
  const {data: post} = await sanityFetch({
    query: POST_QUERY,
    params: await params,
  })

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}
```

Pass in any overrides you need to handle revalidation as needed.

Next, remove `SanityLive` from the layout component.

**frontend/src/app/layout.tsx**

```tsx
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
import {DisableDraftMode} from '@/components/disable-draft-mode'

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body>
        {children}
        {(await draftMode()).isEnabled && (
          <>
            <VisualEditing />
            <DisableDraftMode />
          </>
        )}
      </body>
    </html>
  )
}
```

The VisualEditing and DisableDraftMode components will handle the rest. Learn more about [revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs) for more details on configuring a custom sanityFetch helper.

### Overlays appear but clicking does nothing

**Cause:** `stega.studioUrl` is missing from the Sanity client in `frontend/src/sanity/lib/client.ts`.

**Fix:** Add `stega: { studioUrl: 'http://localhost:3333' }` to `createClient`.

### Presentation Tool shows a blank iframe

**Cause:** `origin` is missing from the Presentation Tool config in `studio/sanity.config.ts`. This only happens when the Studio and frontend run as separate apps. When the Studio is embedded inside the Next.js app, the origin is implicit.

**Fix:** Add `origin: 'http://localhost:3000'` to `previewUrl` in the `presentationTool()` config.

### Page titles or meta tags contain garbled text

**Cause:** Stega encoding is active in `generateMetadata`. The invisible source map characters end up in `<title>` and `<meta>` tags. The page looks fine in the browser, but search engines see corrupted text.

**Fix:** Always pass `stega: false` when calling `sanityFetch` inside `generateMetadata`.

### Live preview doesn't update, 403 errors in browser console

**Cause:** The frontend's origin is missing from the Sanity project's CORS settings, so the browser can't reach the Content Lake.

**Fix:** Add `http://localhost:3000` (with **Allow credentials** checked) in your project's CORS settings at [sanity.io/manage](https://www.sanity.io/manage) under **API** → **CORS Origins**.

### String comparisons fail in Draft Mode

**Cause:** Stega encoding adds invisible characters to string values. An equality check like `align === 'center'` returns `false` even when the visible value is `"center"` because the encoded string contains extra characters.

**Fix:** Use `stegaClean()` to strip the encoding before comparing:

```typescript
import {stegaClean} from 'next-sanity'

const cleanAlign = stegaClean(align)
if (cleanAlign === 'center') {
  // ...
}
```

## Reference

### Key packages

| Package | Version | Purpose |
| --- | --- | --- |
| sanity | 6.x | Sanity Studio |
| next | 16.x | Next.js framework |
| next-sanity | 13.x | Sanity integration for Next.js |
| @portabletext/react | 6.x | Portable Text rendering |
| @sanity/image-url | 2.x | Image URL generation |

### File map

Every file involved in the visual editing integration, what it does, and what it depends on:

| File | Role | Depends on |
| --- | --- | --- |
| studio/sanity.config.ts | Configures the Presentation Tool with the frontend's origin and previewMode.enable path | studio/src/presentation/resolve.ts |
| studio/src/presentation/resolve.ts | Maps document types to frontend URLs for iframe navigation and location badges | Schema type names, frontend route structure in web/src/app/ |
| frontend/src/sanity/lib/client.ts | Sanity client with stega.studioUrl so overlays resolve back to the Studio | NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET |
| frontend/src/sanity/lib/token.ts | Exports the API read token for the Draft Mode enable route | SANITY_API_READ_TOKEN |
| frontend/src/sanity/lib/live.ts | defineLive returns sanityFetch (data fetching) and SanityLive (real-time subscriptions) | client.ts, SANITY_API_READ_TOKEN |
| frontend/src/app/layout.tsx | Root layout: renders <SanityLive /> always, <VisualEditing /> in Draft Mode only | live.ts, disable-draft-mode.tsx |
| frontend/src/app/api/draft-mode/enable/route.ts | Activates Draft Mode when called by the Presentation Tool | client.ts, SANITY_API_READ_TOKEN |
| frontend/src/app/api/draft-mode/disable/route.ts | Deactivates Draft Mode and redirects to homepage | Nothing |
| frontend/src/components/disable-draft-mode.tsx | "Disable Draft Mode" button, hidden when inside the Presentation Tool | Nothing |

### Import paths (next-sanity 13.x)

These changed significantly from earlier versions. If you're referencing older tutorials or blog posts, the paths below are the ones that work with v13:

| Export | Import from |
| --- | --- |
| createClient, defineQuery, groq, stegaClean | next-sanity |
| defineLive | next-sanity/live |
| VisualEditing | next-sanity/visual-editing |
| defineEnableDraftMode | next-sanity/draft-mode |
| useIsPresentationTool, useOptimistic | next-sanity/hooks |
| PortableText | @portabletext/react (not re-exported from next-sanity) |



# Visual Editing for Pages Router

Following this guide will enable you to:

- Render overlays in your application, allowing content editors to jump directly from Sanity content to its source in Sanity Studio.
- Edit your content and see changes reflected in an embedded preview of your application in Sanity’s Presentation Tool.
- Provide instant updates and seamless switching between draft and published content.

> [!WARNING]
> Gotcha
> This guide is for the Next.js Pages Router. See [the guide for the Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router).

## Prerequisites

- A Sanity project with [a hosted or embedded Studio](https://www.sanity.io/docs/studio/deployment).
- A Next.js application using Pages Router. Follow [this guide](https://nextjs.org/docs/pages/building-your-application) to set one up.

## Next.js application setup

The following steps should be performed in your Next.js application.

### Install dependencies

Install the dependencies that will provide your application with data fetching and Visual Editing capabilities.

**npm**

```shell
npm install next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

**pnpm**

```shell
pnpm add next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

**yarn**

```shell
yarn add next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

**bun**

```shell
bun add next-sanity @sanity/visual-editing @sanity/react-loader @sanity/preview-url-secret

```

## Add environment variables

Create a `.env` file in your application’s root directory to provide Sanity-specific configuration.

You can use [Manage](https://www.sanity.io/manage) to find your project ID and dataset, and to create a token with Viewer permissions which will be used to fetch preview content.

The URL of your Sanity Studio will depend on where it is [hosted](https://www.sanity.io/docs/studio/deployment) or [embedded](https://www.sanity.io/docs/studio/embedding-sanity-studio).

**.env**

```text
# Public
NEXT_PUBLIC_SANITY_PROJECT_ID="YOUR_PROJECT_ID"
NEXT_PUBLIC_SANITY_DATASET="YOUR_DATASET"
NEXT_PUBLIC_SANITY_STUDIO_URL="YOUR_STUDIO_URL"
# Private
SANITY_VIEWER_TOKEN="YOUR_VIEWER_TOKEN"

```

## Application setup

### Configure the Sanity client

Create a Sanity client instance to handle fetching data from Content Lake.

Configuring the `stega` option enables automatic overlays for basic data types when preview mode is enabled. You can read more about [how stega works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

**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: "2026-07-01",
  useCdn: true,
  token: process.env.SANITY_VIEWER_TOKEN,
  stega: {
    studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL,
  },
});

```

### Draft mode

Draft mode allows authorized content editors to view and interact with draft content. Presentation Tool and sharing communicate with your Next.js app to enable or disable draft mode.

Create an API endpoint (in `src/pages/api`) to enable draft mode when viewing your application in Presentation Tool.

**src/pages/api/enable-draft.ts**

```typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { client } from "@/sanity/client";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (!req.url) {
    return res.status(500).json({ message: "Missing request URL" });
  }

  const { isValid, redirectTo = "/" } = await validatePreviewUrl(
    client.withConfig({
      token: process.env.SANITY_VIEWER_TOKEN,
    }),
    req.url
  );

  if (!isValid) {
    return res.status(401).json({ message: "Invalid secret" });
  }

  // Enable Draft Mode
  res.setDraftMode({ enable: true });
  res.writeHead(307, { Location: redirectTo });
  res.end();
}

```

Similarly, create an API endpoint to disable draft mode.

**src/pages/api/disable-draft.ts**

```typescript
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handle(
  _req: NextApiRequest,
  res: NextApiResponse<void>,
): void {
  // Exit the current user from "Draft Mode".
  res.setDraftMode({ enable: false })

  // Redirect the user back to the index page.
  res.writeHead(307, { Location: '/' })
  res.end()
}
```

Create a new component with a link to the disable endpoint. We add conditional logic to only render this for content authors when viewing draft content in a non-Presentation context. The code in this example uses minimal styling, but you may wish to create a more suitable banner that fits your layout.

**src/components/DisableDraftMode.tsx**

```tsx
import { useEffect, useState } from "react";

export function DisableDraftMode() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    setShow(window.top === window);
  }, []);

  return show && <a href={"/api/disable-draft"}>Disable Draft Mode</a>;
}
```

### Enable Visual Editing

Create a Visual Editing wrapper component.

The `<VisualEditing>` component handles rendering overlays, enabling click to edit, and refreshing pages in your application when content changes. Render it alongside the `<DisableDraftMode>` component you created above.

> [!WARNING]
> Embedded studios
> The approach below adds the VisualEditing components to the App layout. If you’re using an embedded studio (one that renders on a route in your Next.js app), you should only include VisualEditing components in your content layouts.
> Our recommendation is that you create dedicated layout components for your content and studio routes.

We provide a basic refresh mechanism that will reload the page when changes are made in Presentation Tool. You can optionally use loaders to provide seamless updates.

**src/components/SanityVisualEditing.tsx**

```tsx
import { VisualEditing } from "@sanity/visual-editing/next-pages-router";
import { useLiveMode } from "@sanity/react-loader";
import { DisableDraftMode } from "@/components/DisableDraftMode";
import { client } from "@/sanity/client";

const stegaClient = client.withConfig({ stega: true });

export default function SanityVisualEditing() {
  useLiveMode({ client: stegaClient });

  return (
    <>
      <VisualEditing />
      <DisableDraftMode />
    </>
  );
}

```

#### <VisualEditing /> props

The `<VisualEditing />` component accepts the following optional props introduced in `@sanity/visual-editing` 5.5.0.

#### `keepStegaOnCopy` (boolean, optional)

Default: `false`. By default, `<VisualEditing />` intercepts copy events and automatically strips stega encoding from both `text/plain` and `text/html` clipboard payloads. This ensures that users copying text from the preview page do not get invisible stega characters in their clipboard. Pass `keepStegaOnCopy` to opt out of this behavior and preserve stega encoding in clipboard output.

#### `onSuspiciousStega` (callback, optional)

An opt-in callback that reports stega encoding found in unsafe DOM placements. When provided, `<VisualEditing />` audits the DOM for stega in locations where it should not appear, including:

- Element attributes such as `class`, `id`, `href`, `src`, `style`, `data-*`, and others
- Inside `<head>`: `title`, `meta[content]`, and JSON-LD script blocks
- Text content inside `<script>` or `<style>` elements
- Textarea form values
- The page URL

Each report includes the `kind`, `element`, `attribute` (if applicable), `value`, `cleaned`, and `sanity` (decoded edit information).

```tsx
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn(`Stega found in ${report.kind}`, report)
    }
  }}
/>
```

> [!WARNING]
> Development use
> The `onSuspiciousStega` callback audits the DOM using TreeWalker and MutationObserver. We recommend using it in development and debugging contexts rather than in production.

#### `onPerspectiveChange` (callback, optional)

Fires when the perspective changes in the Studio that is driving Visual Editing. The callback receives a `ClientPerspective`, which is an array of release IDs when an editor selects a release. Live updates in Presentation follow the selected perspective automatically. Applying it to the initial server-rendered payload means reading it per request, which the getStaticProps setup above cannot do.

In the root layout file, dynamically import and render the `<SanityVisualEditing>` wrapper component when draft mode is enabled.

**src/pages/_app.tsx**

```tsx
import type { AppProps } from "next/app";
import dynamic from "next/dynamic";

const SanityVisualEditing = dynamic(() => import("@/components/SanityVisualEditing"));

export default function App({ Component, pageProps }: AppProps) {
  const { draftMode } = pageProps;
  return (
    <>
      <Component {...pageProps} />
      {draftMode && <SanityVisualEditing />}
    </>
  );
}

```

### Set up loaders

Create a new file to configure loaders. Call `setServerClient`, with the client instance which should be used to fetch data on the server.

We also create a helper function to return fetch options based on the draft mode state, and export this alongside `loadQuery` for convenience.

**src/sanity/ssr.ts**

```tsx
import * as serverOnly from "@sanity/react-loader";
import { client } from "./client";
import { ClientPerspective } from "next-sanity";

const { loadQuery, setServerClient } = serverOnly;

setServerClient(
  client.withConfig({
    token: process.env.SANITY_VIEWER_TOKEN,
  })
);

const loadQueryOptions = (context: { draftMode?: boolean }) => {
  const { draftMode } = context;
  return draftMode
    ? {
        // Sets the perspective for the initial server-rendered payload only.
        // Once Presentation connects, useLiveMode applies the perspective
        // currently selected in the Studio, including a release.
        perspective: "drafts" as ClientPerspective,
        stega: true,
        useCdn: false,
      }
    : {};
};

export { loadQuery, loadQueryOptions };

```

### Render a page in preview mode

In `getStaticProps` use the `loadQuery` function created above. The `initial` data returned here is passed to `useQuery` in the page component.

When in Presentation Tool, `useQuery` will handle live updates as content is edited.

**src/pages/index.tsx**

```tsx
import { loadQuery, loadQueryOptions } from "@/sanity/ssr";
import { useQuery } from "@sanity/react-loader";
import type { GetStaticProps, InferGetStaticPropsType } from "next";

const query = `*[_type == "page"][0]{title}`;

export const getStaticProps = (async (context) => {
  const { draftMode = false } = context; 
  const options = loadQueryOptions({ draftMode });
  const initial = await loadQuery<{ title?: string }>(query, {}, options);
  return { props: { initial, draftMode } };
}) satisfies GetStaticProps;

export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;

export default function Page(props: PageProps) {
  const { initial } = props;
  const { data } = useQuery(query, {}, { initial });
  return <h1>{data.title}</h1>;
}

```

## Studio setup

To set up Presentation Tool in your Sanity Studio, import the tool from `sanity/presentation`, add it to your `plugins` array, and set `previewUrl` to the base URL of your application.

We similarly recommend using environment variables loaded via a `.env` file to support development and production environments.

**sanity.config.ts**

```tsx
import { defineConfig } from "sanity";
import { presentationTool } from "sanity/presentation";

export default defineConfig({
  // ... project configuration
  plugins: [
    presentationTool({
      previewUrl: {
        // Add a new ENV var to your Studio codebase if needed to accommodate live vs local preview.
        initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
        previewMode: {
          enable: "/api/enable-draft",
        },
      },
    }),
    // ... other plugins
  ],
});

```

## Optional extras

### Add data attributes for overlays

`useQuery` also returns an `encodeDataAttribute` helper method for generating `data-sanity` attributes. These attributes give you direct control over rendering [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) in your application, and are especially useful if not using stega encoding.

**src/pages/index.tsx**

```tsx
import { loadQuery, loadQueryOptions } from "@/sanity/ssr";
import { useQuery } from "@sanity/react-loader";
import type { GetStaticProps, InferGetStaticPropsType } from "next";

const query = `*[_type == "page"][0]{title}`;

export const getStaticProps = (async (context) => {
  const options = loadQueryOptions(context);
  const initial = await loadQuery<{ title?: string }>(query, {}, options);
  return { props: { initial } };
}) satisfies GetStaticProps;

export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;

export default function Page(props: PageProps) {
  const { initial } = props;
  const { data, encodeDataAttribute } = useQuery(query, {}, { initial });
  return <h1 data-sanity={encodeDataAttribute(["title"])}>{data.title}</h1>;
}

```

## Next steps

You now have a Next.js Pages Router application with click-to-edit overlays, live updates in Presentation Tool, and draft mode switching. To go deeper, learn [how stega encoding works](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega) or take direct control of [overlays](https://www.sanity.io/docs/visual-editing/visual-editing-overlays) in your application.



# Setting up your studio

## Create a new Studio with Sanity CLI

![Video](https://stream.mux.com/wIMs3CS7T4pP7hRArpQZsBZ01Be02vCjbK)

Run the command in your Terminal to initialize your project on your local computer.

See the documentation if you are [having issues with the CLI](https://www.sanity.io/docs/help/cli-errors).

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

## Run Sanity Studio locally

Inside the directory of the Studio, start the development server by running the following command.

**npm**

```shell
# in studio-hello-world 
npm run dev
```

**pnpm**

```shell
# in studio-hello-world 
pnpm run dev
```

**yarn**

```shell
# in studio-hello-world 
yarn run dev
```

**bun**

```shell
# in studio-hello-world 
bun run dev
```

## Log in to the Studio

**Open** the Studio running locally in your browser from [http://localhost:3333](http://localhost:3333).

You should now see a screen prompting you to log in to the Studio. Use the same service (Google, GitHub, or email) that you used when you logged in to the CLI.



# Defining a schema

## Create a new document type

![Video](https://stream.mux.com/IfVfAwxfwOKN2khdGCQ3cs5IuF1rYte1)

Create a new file in your Studio’s `schemaTypes` folder called `postType.ts` with the code below which contains a set of fields for a new `post` document type.

**/studio-hello-world/schemaTypes/postType.ts**

```
import {defineField, defineType} from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: {source: 'title'},
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
      initialValue: () => new Date().toISOString(),
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'image',
      type: 'image',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{type: 'block'}],
    }),
  ],
})
```

## Register the `post` schema type to the Studio schema

Now you can import this document type into the `schemaTypes` array in the `index.ts` file in the same folder.

**/studio-hello-world/schemaTypes/index.ts**

```
import {postType} from './postType'

export const schemaTypes = [postType]
```

## Publish your first document

When you save these two files, your Studio should automatically reload and show your first document type. Click the `+` symbol at the top left to create and publish a new `post` document.



# Querying content with GROQ

## Write your first GROQ query

![Video](https://stream.mux.com/Mc12Sdeu00ugrGuQyz00Du1G4AQZmT36UV)

Open **Vision** in your Studio's top nav bar and paste this query into the **Query** code block field.

**Vision**

```groq
*[_type == "post"]{
  _id,
  title,
  slug,
  publishedAt
}
```

- `*` represents all documents in a dataset as an array
- `[_type == "post"]` represents a **filter** to only return matching documents
- `{ _id, title, slug, publishedAt }` represents a **projection** which defines the attributes from those documents that you wish to include in the response.

## Run the query

Click **Fetch** to see the JSON output in **Results**. You should see the document you previously published in the results.

Queries run in Vision use your authenticated session, so you will see private documents – which have a `.` in the `_id` key, like `drafts.`. You will not see when queried from your front end in the next step.



# Displaying content in Nuxt.js

## Install a new Nuxt application

![Video](https://stream.mux.com/L02yip5K7fwXyG100zIGTpGi02ZOiktCSpV)

If you have an *existing* application, skip this first step and adapt the rest of the lesson to install Sanity dependencies to fetch and render content.

**Run** the following in a new tab or window in your Terminal (keep the Studio running) to create a new [Nuxt](https://nuxt.com/) application using the [Nuxt UI](https://ui.nuxt.com/) template for Tailwind CSS.

**npm**

```shell
# outside your studio directory
npm create nuxt@latest -- nuxt-hello-world -t ui -M ""
cd nuxt-hello-world
```

**pnpm**

```shell
# outside your studio directory
pnpm create nuxt@latest nuxt-hello-world -t ui -M ""
cd nuxt-hello-world
```

**yarn**

```shell
# outside your studio directory
yarn create nuxt@latest nuxt-hello-world -t ui -M ""
cd nuxt-hello-world
```

**bun**

```shell
# outside your studio directory
bun create nuxt@latest nuxt-hello-world -t ui -M ""
cd nuxt-hello-world
```

You should now have your Studio and Nuxt application in two separate, adjacent folders:

```sh
├─ /nuxt-hello-world
└─ /studio-hello-world
```

## Install Sanity dependencies

**Run** the following inside the `nuxt-hello-world` directory to:

- Install and configure the [Nuxt Sanity integration](https://nuxt.com/modules/sanity)
- Install `@sanity/image-url` for generating images from Sanity content

**npm**

```shell
npx nuxi@latest module add sanity
npm install @sanity/image-url @tailwindcss/typography
```

**pnpm**

```shell
pnpm dlx nuxi@latest module add sanity
pnpm add @sanity/image-url @tailwindcss/typography
```

**yarn**

```shell
yarn dlx nuxi@latest module add sanity
yarn add @sanity/image-url @tailwindcss/typography
```

**bun**

```shell
bunx nuxi@latest module add sanity
bun add @sanity/image-url @tailwindcss/typography
```

## Configure the Sanity client

**Update** the integration configuration with your project details. If you named your Studio directory something other than `studio-hello-world`, update `schemaTypesPath` so `typegen` can read your schema.

**/nuxt-hello-world/nuxt.config.ts**

```
export default defineNuxtConfig({
   modules: ['@nuxt/eslint', '@nuxt/ui', '@nuxtjs/sanity'],
  eslint: { ... },
  // 👇 Add these lines
  sanity: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'YOUR_DATASET',
    apiVersion: '2025-05-15',
    typegen: {
      enabled: true,
      schemaTypesPath: '../studio-hello-world/schemaTypes',
      queryPaths: ['./app/**/*.{ts,tsx,vue}']
    }
  },
});
```

## Start the development server

**Run** the following command and open [http://localhost:3000](http://localhost:3000) in your browser.

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun run dev
```

## Display content on the home page

Nuxt performs data fetching inside `script` tags at the top of `.vue` files

**Create** a route for a page with a list of posts fetched from your Sanity dataset, and visit [http://localhost:3000](http://localhost:3000)

**/nuxt-hello-world/app/pages/index.vue**

```tsx
<script setup lang="ts">
  const postsQuery = groq`*[
    _type == "post"
    && defined(slug.current)
  ]|order(publishedAt desc)[0...12]{_id, title, slug, publishedAt}`

  const { data: posts } = await useSanityQuery<PostsQueryResult>(postsQuery)
</script>

<template>
  <main class="container mx-auto min-h-screen max-w-3xl p-8">
    <h1 class="text-4xl font-bold mb-8">Posts</h1>
    <ul class="flex flex-col gap-y-4">
      <li v-for="post in posts" :key="post._id" class="hover:underline">
        <nuxt-link :to="`/${post.slug.current}`">
          <h2 class="text-xl font-semibold">{{ post.title }}</h2>
          <p>{{ new Date(post.publishedAt).toLocaleDateString() }}</p>
        </nuxt-link>
      </li>
    </ul>
  </main>
</template>

```

## Display individual posts

**Create** a new route for individual post pages.

The dynamic value of a slug when visiting `/[slug]` in the URL is used as a parameter in the GROQ query used by Sanity Client.

Notice that we’re using [Tailwind CSS Typography](https://github.com/tailwindlabs/tailwindcss-typography)’s `prose` class to style the post’s `body` content. We installed `@tailwindcss/typography` in the dependencies step. Enable it by adding `@plugin "@tailwindcss/typography";` to `app/assets/css/main.css` below the existing `@import "tailwindcss";` line.

**/nuxt-hello-world/app/pages/[slug].vue**

```tsx
<script setup lang="ts">
  import type { SanityDocument } from "@sanity/client";
  import {
    createImageUrlBuilder,
    type SanityImageSource,
  } from "@sanity/image-url";

  const POST_QUERY = groq`*[_type == "post" && slug.current == $slug][0]`;
  const { params } = useRoute();

  const { data: post } = await useSanityQuery<SanityDocument>(POST_QUERY, params);
  const { projectId, dataset } = useSanity().client.config();
  const urlFor = (source: SanityImageSource) =>
    projectId && dataset
      ? createImageUrlBuilder({ projectId, dataset }).image(source)
      : null;
</script>

<template>
  <main
    v-if="post"
    class="container mx-auto min-h-screen max-w-3xl p-8 flex flex-col gap-4"
  >
    <a href="/" class="hover:underline">&larr; Back to posts</a>
    <img
      v-if="post.image"
      :src="urlFor(post.image)?.width(550).height(310).url()"
      :alt="post?.title"
      class="aspect-video rounded-xl"
      width="550"
      height="310"
    />
    <h1 v-if="post.title" class="text-4xl font-bold mb-8">{{ post.title }}</h1>
    <div class="prose">
      <p v-if="post.publishedAt">
        Published: {{ new Date(post.publishedAt).toLocaleDateString() }}
      </p>
      <SanityContent v-if="post.body" :value="post.body" />
    </div>
  </main>
</template>

```





# Deploying Studio and inviting editors

## Deploy your Studio with Sanity

![Video](https://stream.mux.com/CvYhCQr8e1oZt98NW202BZLLNv376VVKc)

In your Studio directory (`studio-hello-world`) run the following command to deploy your Sanity Studio.

The first time you run this command, the CLI will prompt you to enter a **hostname**. This is the unique name for your Studio's URL (entering *my-app* will make your Studio available at *my-app*.sanity.studio).

**npm**

```shell
npm run deploy
```

**pnpm**

```shell
pnpm run deploy
```

**yarn**

```shell
yarn run deploy
```

**bun**

```shell
bun run deploy
```

## Invite a collaborator

Now that you’ve deployed your Studio, you can optionally invite a collaborator to your project. Navigate to your project in [Sanity Manage](https://www.sanity.io/manage), then select "Members". 

They will be able to access the deployed Studio, where you can collaborate together on creating content.





# APIs and SDKs

#### App SDK

[App SDK Quickstart](https://www.sanity.io/docs/app-sdk/sdk-quickstart)
Get up and running quickly with the Sanity App SDK by following this step-by-step guide!

[App SDK Reference](https://reference.sanity.io/_sanity/sdk-react/)
Reference documentation for App SDK. 

[App SDK Explorer](https://sdk-explorer.sanity.io)
Example interfaces built with the App SDK

#### Popular libraries

[@sanity/client](https://github.com/sanity-io/client)
Sanity's official JS/TS client

[next-sanity](https://github.com/sanity-io/next-sanity)
A full-featured collection of Next.js Sanity integrations

[Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify)
Sync your content between Sanity and Shopify

#### Schemas

[Introduction to schemas](https://www.sanity.io/docs/apis-and-sdks/introduction-to-schemas)
Learn how schemas define content structure across Sanity and design effective, evolving content models that grow with your business needs.

[Studio schema reference](https://www.sanity.io/docs/studio/schema-types)
A schema describes the types of documents and fields editors may author in a Sanity Studio workspace.

[Schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)
Deploy your schema into your dataset to enable deep integration between your content model and Sanity apps.

#### Asset API

[Presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images)
Presenting images through the Sanity image pipeline

[Image metadata](https://www.sanity.io/docs/apis-and-sdks/image-metadata)
This article takes a closer look at the types of metadata available for images and the values they might return.

[Asset CDN](https://www.sanity.io/docs/apis-and-sdks/asset-cdn)
Describes the CDN used for delivering assets

#### Command Line Interface

[Introduction to the CLI](https://www.sanity.io/docs/apis-and-sdks/cli)
Build, deploy, init plugin boilerplate, run scripts, and wrangle datasets and webhooks, all from the command line

[Importing data](https://www.sanity.io/docs/content-lake/importing-data)
How to go about importing data in bulk, including file and image assets.

[CLI configuration](https://www.sanity.io/docs/cli-reference/cli-config)
Wrangle datasets and webhooks, initialize plugin boilerplate code, build and deploy, all from the command line



# Introduction to schemas

Schemas are the foundation of how content is structured, stored, and presented in the Sanity ecosystem. This guide will help you understand what schemas are, how they work, and how to use them effectively in your Sanity projects.

## What is a schema?

The schema consists of simple JavaScript (or TypeScript) objects that define your content model. They describe the structure, relationships, and constraints of your content, allowing you to create a tailored content management experience.

While the Content Lake itself is schema-less (providing flexibility for content storage), the schema control how content is organized and can be used to:

- [Generate TypeScript types](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) for your projects
- Generate user-friendly content forms for [Sanity Studio](https://www.sanity.io/docs/studio/schemas-and-forms)
- Define metadata fields (Aspects) for assets in [the Media Library](https://www.sanity.io/docs/media-library)
- Enable apps like [Canvas](https://www.sanity.io/docs/canvas/configure-content-mapping) to transform free-form documents to structured content
- Power agent actions through well-defined content structures

## Where schemas live

Traditionally, schemas have been defined within Sanity Studio projects, and this remains the primary location for schema definitions. However, the Sanity ecosystem has evolved to use schemas in other contexts:

- **Sanity Studio**: The main place for defining document types and field structures.
- **Media Library**: Uses schemas (called Aspects) to define sets metadata fields for assets.
- **Content Mapping**: Uses a schema to map free-form content from Canvas to a structured document in Content Lake, so it can be edited in the Studio and other apps.
- **Agent Actions**: Schemas provide the structure needed for AI agents to work with your content.

As the Sanity Content Operating System evolves, schemas will remain a central component that bridges the gap between the schema-less Content Lake and the structured interfaces used to create and manage content.

## How to design schemas

Schemas in Sanity represent your content model(s), which should be a reflection of your organization's unique business reality. When designing your schema, it's crucial to align it with how your teams actually work and think about content, rather than forcing teams to adapt to rigid technical structures. A well-designed schema considers the mental models of content creators, the workflows they follow, and the relationships between different content types in your business domain. 

By mapping your schema to these real-world considerations, you create a more intuitive content management experience that reduces friction, improves adoption, and ultimately leads to better content outcomes. 

Remember that schemas can evolve over time as your business needs change—start with the core concepts that matter most to your teams, then iterate and expand as you learn more about how your content model performs in practice.

## Your schemas will change

Schemas naturally evolve as your business requirements change, content strategies mature, and new channels emerge. Sanity embraces this reality by providing robust tooling to manage schema migrations and content transformations. The Content Lake's schema-less architecture gives you the flexibility to modify your content model without rebuilding your entire database. 

The schema migration tooling allow you to programmatically transform existing content to match new schema structures and run your validation rules against your whole dataset. Rather than treating schema changes as exceptional events, Sanity's approach acknowledges them as a normal part of the content lifecycle.

## Anatomy of schemas

### Content model or schema

Your overall content model (or schema) is the complete collection of document types and field definitions that make up your content structure. Think of it as the blueprint for your content.

### Document types

Document types are collections of documents used to build standalone pieces of content. You can think of them as similar to tables in SQL databases. A document type:

- Consists of multiple fields
- Has revision history
- Can have copies to represent published, draft, and version states
- Can have indexed and queryable references between them

**Note**: Document types can be whatever you need them to be and don't have to map directly to "a page" or "a post". It can also be "project," "person," "product," and "place."

> [!WARNING]
> Don't use document types as field types
> A document type should not be used directly as a field type. If you want to link to a document, use a [reference](https://www.sanity.io/docs/studio/reference-type) field. If you want to embed fields inline, use an [object](https://www.sanity.io/docs/studio/object-type) instead.
> Document types carry system fields (`_id`, `_rev`, `_createdAt`, `_updatedAt`) that have no meaning when embedded inside another document. Using a document type as a field type can also cause issues with TypeGen.
> Sanity Studio displays a warning in the console if it detects this pattern in your schema.

### Field types

Field types are the building blocks of your schema that define what kind of data can be stored in each field. Sanity comes with a variety of built-in field types that you can use to model your content:

- **String**: For text content like titles, names, and descriptions
- **Number**: For numerical values
- **Boolean**: For true/false values
- **Date** and **DateTime**: For temporal data
- **Image** and **File**: For media assets
- **Reference**: For creating relationships between documents
- **Array**: For repeatable lists of other field types
- **Object**: For grouping related fields together
- **Block**: When used in an array, it gives you block content with Portable Text
- **Slug**: For URL-friendly strings
- **Geopoint**: For geographical coordinates

You can also create custom field types by combining existing types or extending them with custom validation and input components.

#### Field hoisting

While you can define custom field types "inline" in the document type definition's `fields` array, we recommend isolating them in their own files, importing them to the `schemas` array in the configuration, and then use them in your document types by referencing their `name` as the `type`.

> [!TIP]
> Standardize and reuse custom fields
> It's good practice to reuse and standardize custom fields. It makes it easier and more predictable to work on projects as they evolve, and what to expect in the data that comes out of your queries. You can also use GROQ projections to reshape data as you need it for specific contexts without having to change the content model.

## Example: Your first schema type

The easiest place to start with a schema is declaring it in the root configuration for your Sanity Studio project, typically in a file named `sanity.config.ts`.

While you can declare schemas inline in the configuration, the common practice is to organize them in external files and import them into the `schema.types` array.

Here's a simple example of a schema for a `person` document type:

**schemaType/person.ts**

```
import { defineType, defineField } from 'sanity'

export const personType = defineType({
  name: 'person',
  title: 'Person',
  type: 'document',
  fields: [
    defineField({
      name: 'name',
      title: 'Full name',
      type: 'string'
    }),
    defineField({
      name: 'portrait',
      title: 'Portrait',
      type: 'image'
    })
  ]
})
```

**schemaTypes/index.ts**

```
import { person } from './person'

export const schemaTypes = [person]
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './schemaTypes'

export default defineConfig({
  name: 'default',
  title: 'My Sanity Project',

  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',

  plugins: [structureTool()],

  schema: {
    types: schemaTypes,
  },
})
```

This schema type definition creates a "Person" document type with fields for a name (string) and portrait (image), which Sanity Studio will automatically transform into appropriate form inputs. In this example, we use the helper function `defineType` and `defineField` which are there to help you validate that the schema is correctly configured and will give you autocomplete for available options.

## Advanced schema features

### Schema deployment 

To make your schemas available across the Sanity ecosystem (for content mapping, agent actions, etc.), you need to deploy them:

1. Ensure your Studio is updated to the latest version
2. Create a deploy token with the appropriate permissions
3. Run the command: `npx sanity@latest schema deploy`

This stores your schemas as system documents (of type `_system.schema`) in your dataset at the workspace level. If you have multiple workspaces, each will have its own schema. These deployed schemas are essential for:

- Content mapping between Sanity Canvas and Content Lake so it can be edited in Sanity Studio
- Enabling agent actions to work with your content
- Other integrations that need to understand your content structure

### Schemas for Media Library (Aspects)

The Media Library uses schemas (called Aspects) to define metadata fields for assets. These aspects help organize and categorize assets across your organization.

## Conclusion

Schemas are a powerful tool in the Sanity ecosystem, providing structure and organization to your content while maintaining flexibility. Whether you're building a simple blog or a complex content platform, understanding how to effectively use schemas will help you create a tailored content management experience for your team.

By designing schemas that reflect your organization's business reality and leveraging features like schema deployment, you can create a cohesive content experience across all Sanity tools and integrations.

#### Related articles

[How GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work)
A tutorial on using the Sanity query language GROQ.

[Schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)
Deploy your schema into your dataset to enable deep integration between your content model and Sanity apps.

[Document](https://www.sanity.io/docs/studio/document-type)
Schema type reference for expressing documents.

[Schema](https://www.sanity.io/docs/studio/schema-types)
A schema describes the types of documents and fields editors may author in a Sanity Studio workspace.



# Naming things

Naming things can be hard. When you set up the Sanity Studio you will need to name two kinds of things – your **documents/types** and the **fields** they contain.

## Naming documents

There are few formal constraints for what characters the names of documents and types may contain, but for simplicity, you might want to stick with the convention for field names and only use:

- Letters (a-z / A-Z)
- Numbers
- Underscore

Naming types in a singular form will improve the readability of your queries and code. Let’s see what happens if we use plural type in plural "movies":

```javascript
{
  name: 'movies', // DON'T do this. It's better to name it "movie"
  type: 'document',
  fields: [
    {name: 'title', type: 'string'}
  ]
}
```

With this schema, your query for a list of movies will now look something like: `*[_type == 'movies']`. if you were to spell this query out, you could say *"give me all the documents of type 'movies'" *when it might make more sense to say *"give me all the documents of type 'movie'"*.

## Naming fields

The names of fields contained within documents and objects have some real formal requirements:

- Must not start with underscores (`_`), which are reserved for system fields.
- Must not start with a number; a field name has to begin with a letter.
- Should only contain the following characters:- Letters (a-z / A-Z)
- Numbers
- Underscores



So keep in mind that field names can't contain hyphens or emoji for that matter.

Apart from that you may do as you like, but we recommend using the plural form for arrays, like in this example from a minimal schema for a movie where the array name of `castMembers` is plural:

```javascript
export default {
  name: 'movie',
  title: 'Movie',
  type: 'document',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      required: true
    },
    {
      name: 'castMembers',
      title: 'Cast Members',
      type: 'array',
      of: [{type: 'castMember'}]
    }
  ]
}

```

## Choose a naming convention

Sanity doesn't enforce a casing convention. camelCase is what the Sanity documentation, starter templates, and most plugins use, but `snake_case`, `PascalCase`, and all-lowercase names are equally valid. The character rules above are the only naming rules the Studio's schema validator enforces. Pick one convention and apply it across the whole schema — mixed casing makes a dataset harder to query and harder to hand to a coding agent.

You might also want to consistently follow capitalization and naming conventions that you like and that fit with the languages you'll be using to consume the data. For example, if you want to use [dot-notation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Property_Accessors#dot_notation) in JavaScript, it is required that the key be a [valid identifier](https://developer.mozilla.org/en-US/docs/Glossary/Identifier). 

You should also consider that programming languages have reserved keywords (e.g. [class, import, or return in JavaScript](https://262.ecma-international.org/#prod-ReservedWord)) or common variables names in their environments (such as `global`, `window`, or `process`). If you discover such namespace collisions, you can use the [renameField migration](https://github.com/sanity-io/sanity-recipes/blob/master/snippets/renameField.js) script to rename those fields in your dataset. For broader schema and content migration tooling, see [Migrating your schema and content](https://www.sanity.io/docs/content-lake/schema-and-content-migrations).

If you're importing a schema authored under different rules — a community tool that emits kebab-case names, for example — convert those names before the schema will validate. The [content migration cheat sheet](https://www.sanity.io/docs/content-lake/content-migration-cheatsheet) has a migration that renames every hyphenated key in a dataset to camelCase.



# Attribute limit

## What is the attribute limit?

The attribute limit determines how many unique combinations of path and data type you can have in your dataset. Depending on what plan your project is on, your limit is one of the following:

- Free: 2,000 attributes
- Growth: 10,000 attributes
- Enterprise: custom number of attributes

> [!WARNING]
> Gotcha
> The attribute limit is a hard technical limit right now. For this reason, we do not currently offer a pay-as-you-go option for extra attributes.

## What counts as an attribute?

As shown above, an attribute is officially defined as *a unique combination of path and data type*. An alternative way to think about them is as the different paths through your content.

Let's take a basic data structure:

```json
{
  "sections": [
    {
      "heading":…,
      "body":…
    },
    {
      "heading":…,
      "body":…
    },
    {
      "callout": {
        "heading":…
      }
    }
  ]
}
```

This structure contains six unique paths or attributes:

1. `sections` -> an array
2. `sections[]` -> an object
3. `sections[].heading` -> a string
4. `sections[].body` -> a string
5. `sections[].callout` -> an object
6. `sections[].callout.heading` -> a string

Paths only count toward your attribute limit when they hold actual content. Solely changing your schema definitions will not affect the attribute count. Schema definitions define the structure of your content, a bit like a blueprint defines the structure of a building. Until you add or remove content using the Studio or the HTTP API, your attribute count will remain unchanged.

Each unique path is counted once, no matter how often it is used. Removing a path from your attribute count requires deleting every piece of content on that path across all documents.

In short, your attribute count:

- Goes up when you first add content on a path.
- Goes down when a path no longer holds any content.
- Stays the same regardless of whether a path is used once or many times.

## Best practices

When structuring your content, there are a few pitfalls to keep in mind to avoid hitting the attribute limit. Although this is not an exhaustive list, following the best practices below should go a long way in keeping your attribute count in check.

### Use arrays for page building

A common use case for Sanity is using structured content for [page building](https://www.sanity.io/docs/developer-guides/how-to-use-structured-content-for-page-building). In setting up a page builder, it may be tempting to use the block content type as the editor gives a lot of flexibility and allows adding any number of custom objects that can then be used inline.

However, a block content field has quite an extensive data structure by default:

- a `blockContent` array, with inside of it:
- `blocks` objects, with inside of them:
- `markDefs` and `children` arrays; the `children` array contains `span` objects, each with a `marks` array and a `text` field, while `markDefs` holds annotation objects (such as links)

This nested structure is further extended by any custom types you add to it, all with their own unique paths. A block content field with many custom objects may therefore lead to a hefty number of attributes.

Another issue with this approach is that people sometimes want to use block content fields *inside* of custom objects. This is likely to lead to even more attributes as a result of now having the above structure embedded in the same structure. Moreover, when the exact same block content component is used, allowing this type of nesting gives editors the freedom to nest to an arbitrarily deep level, which can then drag a project over the attribute limit.

To avoid any of these challenges and keep the attribute count as low as possible, we recommend using arrays for page building. In addition to fewer attributes, greater control over the exact content structure, and reduced risk of getting into nesting situations, this approach has the added advantage of not having to deal with serializers for complex custom objects. 

### Avoid excessive nesting and recursive data structures

Nesting compounds your attribute count because every additional level introduces a new set of unique paths for the same fields. Recursive structures are the extreme case: if the page builder described above uses the same block content configuration for block content fields inside its custom objects, editors can nest the entire page builder inside itself, and each level of nesting adds another full set of attributes. To stay in control, limit nesting to a fixed depth in your schema definitions, and avoid structures that can contain themselves, directly or indirectly.

### Focus on meaning, not presentation

Before responsive web design made its entrance and people started optimizing for different devices, it was customary to mix content with presentation. A headline could be blue, have font size 24px, line-height 30px, and a bottom padding of 10px. Although it may still be tempting today to offer that same level of control to editors, there are several downsides to this approach. For one, whenever you want to change your frontend's design, editors will have to review all relevant content.

Most importantly for this guide, adding all these presentational attributes is likely to boost your attribute count significantly as they would exist for nearly every piece of content.

Instead of mimicking CSS properties in your schema definitions, we recommend a separation of concerns. Leave the presentational aspects to wherever you implement your content and instead stick to semantics in your content structure. In other words, focus on the *meaning* of your content.

### Beware of multipliers in translation/localization

There is a variety of internationalization (i18n) and localization (l10n) approaches out there, some of which have a greater impact on your attribute count than others. For example, one approach suggests wrapping all your fields inside a language object, so you get the following structure:

```json
{
  "de": {
    ...
  },
  "en": {
    ...
  }
}
```

This multiplies the number of attributes by the number of languages added, as all fields get duplicated on a language path. Adding more than a few languages this way means trouble.

Instead of duplicating the fields inside a document, thereby creating all these extra paths, a more frugal approach is to duplicate the *document*. To differentiate between the different languages and more easily query for them, you can consider adding a (hidden) internationalization field to your document type, adding the language to the document ID, or both. As you will be reusing the same fields across different documents, adding an extra language no longer affects your attribute count at all.

## What to do if you hit the limit?

If you inadvertently hit the attribute limit on one of your datasets, you will see the following error when opening the Studio: `Total attribute count exceeds limit`.

### Export your data

Before deleting any content or changing your data structure, we highly recommend running a full export of your dataset to prevent any unintended data loss. To do so, you can run the [datasets export](https://www.sanity.io/docs/cli-reference/cli-datasets) command in your terminal. For example:

```sh
sanity datasets export production production.tar.gz
```

### Get unblocked

The first step after exporting your data is to get unblocked so you and other users on your project can work in the Studio again. In other words, the challenge is to get back below the attribute limit.

Perhaps there is a heavily nested structure with block content *and* translations that could be optimized. Or maybe you have singletons for different pages that could be folded into a single page type instead to further reduce the number of unique paths.

A final note is that it also helps to remove any unused content from schema revisions. For example, if you used to have a particular document type with a bunch of documents, but later removed that type, or even some fields within a type, make sure to clean up the content so there are no leftovers in the datastore that will count toward the attribute limit.

### Restructure your content

How to restructure your content depends on your content model and is therefore different per project. However, the two examples below show common ways to reduce the attribute count. Please note that in all cases, it is highly recommended to run a full dataset export *before *proceeding. 

For example, say you enrich product information with a separate string field for each specification:

```json
{
  "product": {
    "color": "Blue",
    "material": "Cotton",
    "weight": "230 g"
  }
}
```

This structure already uses four attributes (`product`, `product.color`, `product.material`, and `product.weight`), and every new specification adds another one. Restructuring the specifications into an array of name and value pairs caps the count:

```json
{
  "product": {
    "specifications": [
      { "name": "Color", "value": "Blue" },
      { "name": "Material", "value": "Cotton" },
      { "name": "Weight", "value": "230 g" }
    ]
  }
}
```

This version starts slightly higher at five attributes (`product`, `product.specifications`, `product.specifications[]`, `product.specifications[].name`, and `product.specifications[].value`), but the count stays the same no matter how many specifications you add, because each unique path is counted once regardless of how often it is used.

The same idea applies at the document level. If every page is its own document type with uniquely named fields, each page type introduces its own set of paths:

```json
[
  { "_type": "homePage", "homeHeading": "Welcome", "homeIntro": "…" },
  { "_type": "aboutPage", "aboutHeading": "About us", "aboutIntro": "…" }
]
```

These two documents use four attributes between them, and every new page type adds more. Folding them into a single shared page type keeps the paths constant:

```json
[
  { "_type": "page", "heading": "Welcome", "intro": "…" },
  { "_type": "page", "heading": "About us", "intro": "…" }
]
```

Both documents now share the same two attributes (`heading` and `intro`), so adding more pages no longer affects the count.

### Track your progress

To keep an eye on your attribute limit while restructuring your content, you can use this URL: `https://<projectId>.api.sanity.io/v1/data/stats/<datasetName>`

The attribute count is the value of `fields.count.value`, and the limit is inside `fields.count.limit`.

## Closing remarks

Although this guide was specifically about the attribute limit, the principles outlined above are best practices that are likely to lead to a more solid, flexible, and future-proof content model in any situation.

To keep going, learn more about [content modeling](https://www.sanity.io/guides/introduction-to-content-modeling), review the [datasets export command](https://www.sanity.io/docs/cli-reference/cli-datasets), or explore [localization approaches](https://www.sanity.io/docs/studio/localization) for handling multiple languages.



# Studio schema reference

The top level `schema` configuration accepts an object with two properties: `templates` and `types:`

- The `templates` property accepts an array of Initial Value Template configuration objects or a callback function returning the same.
- The `types` property accepts an array of schema definition objects or a callback function returning the same. 

In both cases, the callback function is called with the current value as the first argument and a context object as the second. Thus, you can access schema definitions and Initial Value Templates implemented by plugins.

#### Properties

**templates** (array | function)

An array of initial value templates, or a callback function that resolves to the same.

**types** (array | function)

An array of schema definitions or a callback function that resolves to the same.

The `templates` property is discussed in greater detail [in this article](https://www.sanity.io/docs/studio/initial-value-templates), and a reference article can be found [here](https://www.sanity.io/docs/studio/initial-value-templates-api). The rest of this article will deal with the default set of schema types supported in the Sanity Studio.

All schema types are listed below or in the documentation menu.

[Array](https://www.sanity.io/docs/studio/array-type)
Schema type for arrays of other types.

[Block](https://www.sanity.io/docs/studio/block-type)
Schema type for block which provides a rich text editor for block content.

[Boolean](https://www.sanity.io/docs/studio/boolean-type)
Schema type reference for expressing truthy values.

[Cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-references)
All you need to know about creating references across datasets.

[Date](https://www.sanity.io/docs/studio/date-type)
Schema type reference for the Date type.

[Datetime](https://www.sanity.io/docs/studio/datetime-type)
The schema type for expressing an exact date and time. 

[Document](https://www.sanity.io/docs/studio/document-type)
Schema type reference for expressing documents.

[File](https://www.sanity.io/docs/studio/file-type)
Schema type reference for the File type.

[Geopoint](https://www.sanity.io/docs/studio/geopoint-type)
Schema type reference for the geopoint type.

[Image](https://www.sanity.io/docs/studio/image-type)
Schema type for uploading, selecting, and editing images. 

[Number](https://www.sanity.io/docs/studio/number-type)
Schema type reference for the Number type.

[Object](https://www.sanity.io/docs/studio/object-type)
Schema type to create custom types to use in a document.

[Reference](https://www.sanity.io/docs/studio/reference-type)
A schema type for referencing other documents.

[Slug](https://www.sanity.io/docs/studio/slug-type)
A schema type for slugs is typically used to create unique URLs.

[String](https://www.sanity.io/docs/studio/string-type)
A schema type for strings and a selectable lists of strings.

[Span](https://www.sanity.io/docs/studio/span-type)
Schema type reference for the Span type.

[Text](https://www.sanity.io/docs/studio/text-type)
Schema type reference for the Text type.

[URL](https://www.sanity.io/docs/studio/url-type)
Schema type reference for the URL type.

[Global document reference](https://www.sanity.io/docs/studio/global-document-reference-type)
Reference documentation for the `globalDocumentReference` schema type.

## Properties

#### Properties

**type** (string, required)

Name of any valid schema type. This will be the type of the value in the data record.

**name** (string, required)

The field name. This will be the key in the data record.

**title** (string)

Human readable label for the field.

**hidden** (boolean | () => boolean)

Takes a static or a callback function that resolves to a boolean value and hides the given field based on it. You can use this property for conditional fields.

**readOnly** (boolean | ()=>boolean)

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description** (string)

Short description to editors how the field is to be used.

**deprecated** (object)

Marks a document type or a field as deprecated. This will render the field(s) as read-only with a visual deprecation message defined by the reason property.

Example: deprecated: { reason: 'no longer used' }

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**options** (object)

A unique set of options depending on the type. See the individual schema type references for available options.

**validation** (RuleBuilder)

Enables adding one or more validation rules to the field. See the validation guide for more details, the section below for common validation methods, and the individual schema type references for additional methods.

### Validation

#### Properties

**required()**

Ensures the field exists.

Example: (Rule) => Rule.required()

**either([rule, rule, ...])**

Accepts an array of rules. If any are truthy, the validation passes.

Example: (rule) => rule.either([rule.required().min(1), rule.custom((_, context) => context.document?.category !== 'bicycle')])

**all([rule, rule, ...])**

Accepts an array of multiple rules, all of which must be true for the validation to pass.

Example: (rule) => rule.all([rule.required(), rule.custom((value, context) => { ... })])

**custom(value, context)**

Allows for custom validation rules. Receives the field value and the context. Must return true if validation passes, or an error message if validation fails.

Example: rule => rule.custom(value => { ... })



**Note**: The properties listed above are common for all data types. For a more thorough description of how to use them, see the individual schema type references.


## Schema organization tips

The studio loads all schemas defined under `schema.types` in `studio.config.js`.

```javascript
//sanity.config.js
import {defineConfig} from 'sanity'

export default defineConfig({
  /* ... */
  schema: {
    types: [
      {
        title: "My Example Document Type",
        name: "exampleDocumentType",
        type: "document",
        fields: [
          {
            title: "Greeting",
            name: "greeting",
            type: "string"
          }
        ]
      }  
    ]
  }
})

```

To keep things organized, consider keeping the types array in a separate file and import it into `studio.config.js`. 

```javascript
//schemaTypes.js
export const schemaTypes = [
  {
    title: "My Example Document Type",
    name: "exampleDocumentType",
    type: "document",
    fields: [
      {
        title: "Greeting",
        name: "greeting",
        type: "string"
      }
    ]
  }  
]

//sanity.config.js
import {defineConfig} from 'sanity'
import {schemaTypes} from './schemaTypes'

export default defineConfig({
  /* ... */
  schema: {
    types: schemaTypes
  }
})

```

You should also consider using the [defineType](https://reference.sanity.io/sanity/index/defineType/), [defineField](https://reference.sanity.io/sanity/index/defineField/) and [defineArrayMember](https://reference.sanity.io/sanity/index/defineArrayMember/) helper functions when working with schemas. These will give you better IDE auto-suggestions and provide type-safety when used in TypeScript files. Using these functions is *completely optional.*

```javascript
import {defineType, defineField, defineArrayMember} from 'sanity'

export const someDocumentType = defineType({
  title: "Some Document Type",
  name: "exampleDocumentType",
  type: "document",
  fields: [
    defineField({
      title: "String array",
      name: "strings",
      type: "array",
      of: [
        defineArrayMember({ type: "string" })  
      ]
    })
  ]
})  

```

## Plugins

Plugins may also provide types. They will be available in the studio exactly like studio configured types. 

Using plugins to organize your code can be helpful as the studio codebase grows.

The official [@sanity/presets](https://www.npmjs.com/package/@sanity/presets) package (currently experimental) is one example. It ships ready-made schema types for pages, links, images, SEO metadata, and rich text.

```javascript
// pluginWithSchema.js
import {definePlugin, defineType, defineField} from 'sanity'

export const pluginWithSchema = definePlugin({
  name: 'plugin-with-schema',
  schema: {
    types: [
      defineType({
        title: "Plugin object",
        name: "exampleObject",
        type: "document",
        fields: [
          defineField({
            title: "Title",
            name: "title",
            type: "string"
          })
        ]
      })    
    ]
  }
})

//sanity.config.js
import {defineConfig} from 'sanity'
import {pluginWithSchema} from './pluginWithSchema'

export default defineConfig({
  /* ... */
  plugins: [pluginWithSchema()]
})

```



# Schema deployment

Since version [3.88.0](https://www.sanity.io/changelog/525de82a-dd7b-40c7-bf38-12248136c339), Sanity Studio has supported deploying a representation of your content model, in the form of a schema, to your dataset. The schema commands became generally available in that release. It enables integration between your studios and apps like [Dashboard](https://www.sanity.io/docs/dashboard) and [Canvas](https://www.sanity.io/docs/canvas).

> [!TIP]
> The [Sanity MCP Server](https://www.sanity.io/docs/ai/mcp-server) can also deploy your schema.

## What the schema commands do

The schema commands belong to the `sanity schemas` group. They let you deploy your schemas at the workspace level to the matching combination of dataset and project ID, which makes them available to Sanity apps and APIs. Requires `sanity` 3.88.0 or later.

If you aren't logged in with sufficient privileges, provide a deploy token. A deploy token is enough; these commands don't need a write token:

**CLI**

```sh
SANITY_AUTH_TOKEN=YOUR_DEPLOY_TOKEN npx sanity@latest schemas deploy
```

## Available commands

### `sanity schemas deploy`

Deploys schema documents to workspace datasets. If you've already run `sanity login`, you typically have deploy permission by default. In CI environments where `sanity login` hasn't been executed, you'll need to provide a deploy token.

**Options:**

- `--workspace <workspace_name>`: Deploy for a specific workspace. Essential for studios with multiple project IDs.
- `--tag <tag>`: Add a tag suffix to the schema ID, so you can test without overwriting an existing schema.
- `--verbose`: Show detailed deployment information, including the `schemaId`.

**Examples:**

**npm**

```shell
# Deploy all workspace schemas
npx sanity@latest schemas deploy

# Deploy the schema for a specific workspace
npx sanity@latest schemas deploy --workspace default
```

**pnpm**

```shell
# Deploy all workspace schemas
pnpm dlx sanity@latest schemas deploy

# Deploy the schema for a specific workspace
pnpm dlx sanity@latest schemas deploy --workspace default
```

**yarn**

```shell
# Deploy all workspace schemas
yarn dlx sanity@latest schemas deploy

# Deploy the schema for a specific workspace
yarn dlx sanity@latest schemas deploy --workspace default
```

**bun**

```shell
# Deploy all workspace schemas
bunx sanity@latest schemas deploy

# Deploy the schema for a specific workspace
bunx sanity@latest schemas deploy --workspace default
```

> [!WARNING]
> Deploying a schema is not registering a studio
> `sanity schemas deploy` uploads the workspace schema to your dataset. It does not write manifest files, and it does not tell Sanity where your studio is hosted.
> If you host the studio yourself, run `npx sanity@latest deploy --external --url https://example.com/studio` instead. That command registers the studio and deploys its schema in the same run, so a separate schema deployment isn't needed. Without it, Dashboard, Media Library, Canvas, and the App SDK have no registered studio to resolve workspaces from.
> See Register the studio and deploy the schema in [Hosting and deployment](https://www.sanity.io/docs/studio/deployment), and the [Deploy CLI command reference](https://www.sanity.io/docs/cli-reference/deploy), for details.

### `sanity schemas list`

Lists all schemas in the current dataset. Use it to find the `schemaId` that [Agent Actions](https://www.sanity.io/docs/agent-actions) needs.

**Options:**

- `--json`: Get the schema as JSON.
- `--id <schema_id>`: Fetch a single schema by ID.

**Examples:**

**npm**

```shell
# List all schemas
npx sanity@latest schemas list

# Get a specific schema
npx sanity@latest schemas list --id _.schemas.workspaceName

# Get schemas as JSON
npx sanity@latest schemas list --json
```

**pnpm**

```shell
# List all schemas
pnpm dlx sanity@latest schemas list

# Get a specific schema
pnpm dlx sanity@latest schemas list --id _.schemas.workspaceName

# Get schemas as JSON
pnpm dlx sanity@latest schemas list --json
```

**yarn**

```shell
# List all schemas
yarn dlx sanity@latest schemas list

# Get a specific schema
yarn dlx sanity@latest schemas list --id _.schemas.workspaceName

# Get schemas as JSON
yarn dlx sanity@latest schemas list --json
```

**bun**

```shell
# List all schemas
bunx sanity@latest schemas list

# Get a specific schema
bunx sanity@latest schemas list --id _.schemas.workspaceName

# Get schemas as JSON
bunx sanity@latest schemas list --json
```

### `sanity schemas delete`

Removes schema documents by id. Useful when you need to remove schemas from Canvas or Agent Actions.

**Options:**

- `--ids <schema_id_1,schema_id_2,...>`: Comma-separated list of schema IDs to delete.
- `--dataset <dataset_name>`: Delete schemas from a specific dataset.

**Examples:**

**npm**

```shell
# Delete a single schema
npx sanity@latest schemas delete --ids _.schemas.workspaceName

# Delete multiple schemas
npx sanity@latest schemas delete --ids _.schemas.workspaceName,_.schemas.otherWorkspace.tag.taggedSchema
```

**pnpm**

```shell
# Delete a single schema
pnpm dlx sanity@latest schemas delete --ids _.schemas.workspaceName

# Delete multiple schemas
pnpm dlx sanity@latest schemas delete --ids _.schemas.workspaceName,_.schemas.otherWorkspace.tag.taggedSchema
```

**yarn**

```shell
# Delete a single schema
yarn dlx sanity@latest schemas delete --ids _.schemas.workspaceName

# Delete multiple schemas
yarn dlx sanity@latest schemas delete --ids _.schemas.workspaceName,_.schemas.otherWorkspace.tag.taggedSchema
```

**bun**

```shell
# Delete a single schema
bunx sanity@latest schemas delete --ids _.schemas.workspaceName

# Delete multiple schemas
bunx sanity@latest schemas delete --ids _.schemas.workspaceName,_.schemas.otherWorkspace.tag.taggedSchema
```

The `--help` output for this command still shows IDs in the form `sanity.workspace.schema.workspaceName`. That form is rejected. Use `_.schemas.<workspaceName>` or `_.schemas.<workspaceName>.tag.<tag>` instead.

### `sanity schemas extract`

Extracts the studio schema as a single JSON file, `schema.json` in the project root by default. It is the prerequisite for `sanity typegen generate`, which reads that file. See [TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen). It does not write manifest files.

**Options:**

- `--path <path>`: Destination for the extracted schema file. Defaults to `schema.json` in the project root, or to `schemaExtraction.path` from `sanity.cli.ts` when that is set.
- `--watch`: Re-run the extraction as the schema changes.
- `--workspace <name>`: The workspace to generate a schema for.
- `--enforce-required-fields`: Treat fields marked as required as non-optional. Defaults to `false`.
- `--force`: Overwrite an existing schema file without prompting. Without it, an existing file prompts for confirmation, and fails outright under `--unattended`.
- `--format <groq-type-nodes>`: Output format. `groq-type-nodes` is both the default and the only available format.
- `--watch-patterns <glob>`: Additional glob patterns to watch. Can be specified multiple times.

**Examples:**

**npm**

```shell
# Extract the schema to schema.json in the project root
npx sanity@latest schemas extract

# Extract to a specific path
npx sanity@latest schemas extract --path ./src/sanity/schema.json

# Extract and re-run on schema changes
npx sanity@latest schemas extract --watch
```

**pnpm**

```shell
# Extract the schema to schema.json in the project root
pnpm dlx sanity@latest schemas extract

# Extract to a specific path
pnpm dlx sanity@latest schemas extract --path ./src/sanity/schema.json

# Extract and re-run on schema changes
pnpm dlx sanity@latest schemas extract --watch
```

**yarn**

```shell
# Extract the schema to schema.json in the project root
yarn dlx sanity@latest schemas extract

# Extract to a specific path
yarn dlx sanity@latest schemas extract --path ./src/sanity/schema.json

# Extract and re-run on schema changes
yarn dlx sanity@latest schemas extract --watch
```

**bun**

```shell
# Extract the schema to schema.json in the project root
bunx sanity@latest schemas extract

# Extract to a specific path
bunx sanity@latest schemas extract --path ./src/sanity/schema.json

# Extract and re-run on schema changes
bunx sanity@latest schemas extract --watch
```

## Manifest file structure

The extracted manifest follows this structure:

```typescript
interface CreateManifest {
  version: number        // Current version: 2
  createdAt: string      // ISO timestamp
  workspaces: ManifestWorkspaceFile[]
}

interface ManifestWorkspaceFile {
  name: string
  title?: string
  subtitle?: string
  basePath: string
  dataset: string
  projectId: string
  schema: string        // filename with serialized schema
  tools: string         // filename
  icon: string | null
}
```

## How manifest files are produced

Two commands write manifest files:

1. `sanity deploy`: writes manifest files into your build output when deploying to Sanity hosting. It skips that step for `--external` deployments.
2. `sanity manifest extract`: writes manifest files to a directory you choose with `--path`. This is the only way to produce them without deploying to Sanity hosting.

## More schema commands

Two more commands belong to this group:

### `sanity schemas validate`

Validates schema types in a workspace. `sanity schemas deploy` runs the same validation before deploying and reports the same output, so a schema error here also blocks a deployment.

**Options:**

- `--workspace <name>`: The workspace to validate.
- `--format <pretty|ndjson|json>`: Output format. Default: `pretty`.
- `--level <error|warning>`: Minimum reporting level. Default: `warning`.

### `sanity manifest extract`

Extracts the studio configuration as one or more JSON manifest files. This is the only command that writes manifest files without deploying to Sanity hosting, and it is intended for use with Create.

**Options:**

- `--path`: destination directory for the manifest files. Default: `dist/static`.

## Related commands

- `sanity deploy` - Deploys a studio to Sanity hosting and includes schema deployment in the process. Use `sanity deploy --external` to register a studio you host yourself.
- `sanity typegen generate` - Creates TypeScript types from schema types and GROQ queries

## Manifests, permissions, and registration

1. You don't need to keep your manifest in version control since it's derived from your codebase.
2. For embedded studios, the manifest has to be served at `<studio-url>/static/create-manifest.json`. Write it there with `npx sanity@latest manifest extract --path`. No other command writes those files.
3. Serving the manifest at your own domain does not register the studio. An externally hosted studio has to be registered with `npx sanity deploy --external` before Dashboard and Media Library can resolve its workspaces.
4. All schema commands need the `deployStudio` grant on `sanity.project`. A deploy token carries it; a write token isn't required.

## Errors you might see

- `Failed to deploy schemas:` The deployment failed. The underlying reason follows on the next line of output.
- `Failed to deploy 1/3 schemas. Successfully deployed 2/3 schemas.` Some workspaces deployed and others didn't. The output names which ones failed.
- `No permissions to write schema for workspace "..." in dataset "...". For multi-project workspaces, set SANITY_AUTH_TOKEN environment variable to a token with access to the workspace projects.` The token lacks the `deployStudio` grant, or it doesn't cover every project a multi-project workspace spans.



# Aspects schema for Media Library

Aspects are sets of properties that describe an asset, and are defined like Studio schemas. Asset managers can apply aspects to assets in the library, with mutations, or programmatically during upload. The information stored in aspects is specific to the Media Library. For local metadata, use fields in your studio projects.

In this guide, you'll create a new aspect and deploy it to your Media Library.

Prerequisites:

- `sanity` v3.85.1 or later

## Configure your aspect directory

In a project with a `sanity.cli.ts` file, edit the configuration to include a `mediaLibrary.aspectsPath`:

**sanity.cli.ts**

```ts
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production'
  },
  mediaLibrary: {
    aspectsPath: 'aspects',
  },
  autoUpdates: true,
})
```

The `aspectsPath` value is relative to the location of the `sanity.cli.ts` file.

> [!NOTE]
> Aspects require a CLI configuration file
> To deploy aspects, you need a `sanity.cli.ts` configuration connected to a project. We recommend setting up a configuration file manually, or working directly in an existing Sanity Studio project.

## Define a new aspect

In the directory you set as `aspectsPath`, generate a new aspect with the Sanity CLI:

**npm**

```shell
npx sanity@latest media create-aspect
```

**pnpm**

```shell
pnpm dlx sanity@latest media create-aspect
```

**yarn**

```shell
yarn dlx sanity@latest media create-aspect
```

**bun**

```shell
bunx sanity@latest media create-aspect
```

This command prompts you for a title and a name, then creates a new aspect definition file in your aspects directory. Aspect names must be unique. Whatever you enter is normalized to camel case, so `copyright-info` becomes `copyrightInfo`, and the file is written as `copyrightInfo.ts`.

Aspects can be a single field or an object containing multiple fields. They can contain strings, objects, arrays, or nearly any [Studio schema type](https://www.sanity.io/docs/studio/schema-types).

> [!NOTE]
> Aspect schema limitations
> Aspects support most schema types including strings, numbers, booleans, dates, objects, and arrays. However, you can't use executable code in aspect definitions. This includes:
> - Custom validation functions
> - Custom input or preview components
> - Callback functions such as `hidden`, `readOnly`, and `initialValue`
> - The preview `prepare` function
> - Functions in `options` or other configuration properties
> Aspects also don't support the `image`, `file`, `reference`, `crossDatasetReference`, or `document` types.

The CLI creates an object-type aspect with a single string field, like this example, where the name is `copyright`:

**copyright.ts**

```ts
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'string',
      title: 'Plain String',
      type: 'string',
    }),
  ],
})

```

Modify the aspect with more fields. This example updates the existing string field and adds a `date` type field:

**copyright.ts**

```ts
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
})

```

Once deployed, the aspect appears in your Media Library like this:

![The Aspects panel in Media Library, showing the Copyright aspect with its Copyright Holder and Date fields.](https://cdn.sanity.io/images/3do82whm/next/49ac62823d932a41274fce626ea3b92e1e2e02eb-882x806.png)

You can see more aspect examples in the [aspect patterns cheat sheet](https://www.sanity.io/docs/media-library/aspect-patterns).

### Make an aspect public

To query the aspect value from your dataset without authentication, mark the aspect as public:

**copyright.ts**

```ts
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
  public: true
})
```

When you mark an aspect definition as public, you can resolve its value from your dataset with `media::aspect(MEDIA_REF, "ASPECT")`, where `MEDIA_REF` is the asset reference and `ASPECT` is the aspect name:

**aspect.groq**

```groq
*[_type == "post"][0...10] {
  _id,
  title,
  mainImage {
    asset,
    "copyright": media::aspect(media, "copyright")
  }
}
```

## Deploy an aspect

With your aspect defined, it's time to deploy it to your Media Library.

Run the following to deploy a single aspect. Replace `copyright` with your aspect name:

**npm**

```shell
npx sanity@latest media deploy-aspect copyright
```

**pnpm**

```shell
pnpm dlx sanity@latest media deploy-aspect copyright
```

**yarn**

```shell
yarn dlx sanity@latest media deploy-aspect copyright
```

**bun**

```shell
bunx sanity@latest media deploy-aspect copyright
```

If you make additional changes to the aspect, you can update it by running the `deploy-aspect` command again.

To deploy every aspect in your aspects directory, run `npx sanity@latest media deploy-aspect --all`.

## Delete an aspect

To delete an aspect from your library, run the following command, replacing `copyright` with the name of your aspect:

**npm**

```shell
npx sanity@latest media delete-aspect copyright
```

**pnpm**

```shell
pnpm dlx sanity@latest media delete-aspect copyright
```

**yarn**

```shell
yarn dlx sanity@latest media delete-aspect copyright
```

**bun**

```shell
bunx sanity@latest media delete-aspect copyright
```

This deletes the aspect from your library, but doesn't remove the local definition file.



# Get started

The @sanity/client library is the official JavaScript client for interacting with Sanity's APIs. It provides a type-safe way to query and mutate your content from any JavaScript environment, including browsers, Node.js, Deno, Bun, and edge runtimes.

With @sanity/client, you can:

- [Query content](https://www.sanity.io/docs/apis-and-sdks/js-client-querying) using GROQ to fetch exactly the data you need.
- [Mutate documents](https://www.sanity.io/docs/apis-and-sdks/js-client-mutations) by creating, updating, patching, and deleting content programmatically.
- [Upload assets](https://www.sanity.io/docs/apis-and-sdks/js-client-assets) like images and files to your Sanity project.
- [Listen to real-time updates](https://www.sanity.io/docs/apis-and-sdks/js-client-realtime) and react to changes in your content as they happen.

## Requirements

To use the client, you need:

- A JavaScript runtime: Node.js 22.12 or later, modern browsers, Bun, Deno, or edge runtimes.
- A Sanity project with a project ID and dataset name.
- An API token (optional, required only for authenticated requests like mutations or accessing private datasets).
- TypeScript projects: `moduleResolution` set to `node16`, `nodenext`, or `bundler` in `tsconfig.json`.

These requirements apply to `@sanity/client` v8 and later. If you can't move to a newer Node.js version, stay on v7. The [v8 migration guide](https://github.com/sanity-io/client/blob/main/docs/MIGRATE-v7.md) covers each breaking change.

> [!TIP]
> If you want to use the client library with a framework, we have optimized versions for [Next.js](https://github.com/sanity-io/next-sanity), [Astro](https://github.com/sanity-io/sanity-astro), or [SvelteKit](https://github.com/sanity-io/sanity-sveltekit).

## Installation

Install `@sanity/client` using your preferred package manager:

**npm**

```shell
npm install @sanity/client
```

**pnpm**

```shell
pnpm add @sanity/client
```

**yarn**

```shell
yarn add @sanity/client
```

**bun**

```shell
bun add @sanity/client
```

## Basic client setup

Create a client instance using the `createClient()` function with your project configuration:

**sanity.ts**

```typescript
import {createClient} from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})
```

## Query with the client

Retrieving your content from Content Lake is often done with `client.fetch`.

**sanity.ts**

```typescript
import {createClient} from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

const QUERY = `*[_type == "post"]`

try {
  const posts = await client.fetch(QUERY)
  console.log(posts)
} catch (error) {
  console.error('Query failed:', error.message)
}
```

You can learn more about fetching content in our [querying content guide](https://www.sanity.io/docs/apis-and-sdks/js-client-querying).

## Common configuration options

The client accepts several configuration options to customize its behavior. Here are the most commonly used options:

#### Properties

**projectId** (string, required)

Your Sanity project ID. You can find this at sanity.io/manage.

**dataset** (string, required)

The dataset name. Common values are production, staging, or development.

**useCdn** (boolean)

When set to true, queries use Sanity’s global CDN for faster response times and lower latency. Set to false when you need the freshest data or are performing mutations. Default is true.

**apiVersion** (string, required)

Specifies which version of the Sanity API to use. Set to a YYYY-MM-DD format. This ensures your application continues to work as expected when the API evolves. Learn more about API versions.

**token** (string)

An authentication token for accessing private datasets or performing mutations. You can create tokens in your project settings.

Additional configuration options can be found in the [client reference documentation](https://reference.sanity.io/_sanity/client/index/ClientConfig/).

## Build on configuration with `withConfig`

You can extend the functionality of an existing client and change some values as needed with `withConfig`.

**sanity.ts**

```typescript
import {createClient} from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: false, // set to false when used with token
  apiVersion: '2026-03-01',
  token: 'your-auth-token'
})

const draftClient = client.withConfig({
  perspective: 'drafts'
})

```

## Use within Studio

You can use the client within Studio components by importing [useClient](https://reference.sanity.io/sanity/index/useClient/). It comes pre-configured with settings passed down from the containing Studio components. Note that this method requires that you set an API version.

```typescript
import {useClient} from 'sanity'

const client = useClient({
  apiVersion: '2026-03-01'
})
```

## Environment-specific examples

The `@sanity/client` library works across different JavaScript environments. Here are examples for common setups:

**ESM (ES Modules)**

```javascript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

// Query for documents
const posts = await client.fetch('*[_type == "post"]')
console.log(posts)
```

**CommonJS**

```javascript
const {createClient} = require('@sanity/client')

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

// Query for documents
client.fetch('*[_type == "post"]').then((posts) => {
  console.log(posts)
})
```

**TypeScript**

```typescript
import {createClient, type SanityClient} from '@sanity/client'

interface Post {
  _id: string
  _type: 'post'
  title: string
  slug: {current: string}
}

const client: SanityClient = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

// Query with type safety
const posts = await client.fetch<Post[]>('*[_type == "post"]')
console.log(posts)
```

**Next.js App Router**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  useCdn: false, // Server components can use fresh data
  apiVersion: '2026-03-01',
})

export default async function Page() {
  const posts = await client.fetch('*[_type == "post"]')
  
  return (
    <div>
      {posts.map((post) => (
        <article key={post._id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </div>
  )
}
```

**Bun**

```javascript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

const posts = await client.fetch('*[_type == "post"]')
console.log(posts)
```

**Deno**

```typescript
import {createClient} from 'npm:@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

const posts = await client.fetch('*[_type == "post"]')
console.log(posts)
```

**Edge Runtime**

```javascript
import {createClient} from '@sanity/client'

export const config = {
  runtime: 'edge',
}

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

export default async function handler(request) {
  const posts = await client.fetch('*[_type == "post"]')
  return new Response(JSON.stringify(posts), {
    headers: {'content-type': 'application/json'},
  })
}
```

**Browser ESM CDN**

```html
<!DOCTYPE html>
<html>
<head>
  <title>Sanity Client Example</title>
</head>
<body>
  <div id="posts"></div>
  
  <script type="module">
    import {createClient} from 'https://esm.sh/@sanity/client'
    
    const client = createClient({
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'YOUR_DATASET',
      useCdn: true,
      apiVersion: '2026-03-01',
    })
    
    const posts = await client.fetch('*[_type == "post"]')
    document.getElementById('posts').innerHTML = posts
      .map(post => `<h2>${post.title}</h2>`)
      .join('')
  </script>
</body>
</html>
```

`@sanity/client` v8 and later ship as ES modules only. `require()` still works on Node.js 22.12 or later through Node's built-in `require(esm)` support, but `import` is preferred.



# Querying content

The Sanity client provides several ways to fetch content from your dataset: GROQ queries for flexible filtering and projections, direct document lookups by ID, and perspective controls for switching between published, draft, and release versions.

This article covers how to query with `client.fetch()`, use query parameters, control document versions with perspectives, and fetch documents by ID.

## Prerequisites

Before querying content, make sure you have `@sanity/client` installed and configured. See [Getting started with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) for setup instructions. To query draft or version documents, you also need an API token with read access.

## Querying with client.fetch()

The `fetch()` method executes a [GROQ query](https://www.sanity.io/docs/content-lake/groq-introduction) against your dataset and returns the results. It accepts the query string as the first argument, an optional parameters object as the second, and an optional options object as the third.

**fetch-basics.js**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2026-03-01',
})

try {
  // Fetch all documents of a given type
  const posts = await client.fetch('*[_type == "post"]')

  // Fetch a single document by slug
  const post = await client.fetch(
    '*[_type == "post" && slug.current == $slug][0]',
    {slug: 'hello-world'}
  )

  // Fetch with a projection to select specific fields
  const authors = await client.fetch(`
    *[_type == "author"] {
      name,
      "postCount": count(*[_type == "post" && references(^._id)])
    }
  `)
} catch (error) {
  console.error('Query failed:', error.message)
}
```

Replace `YOUR_PROJECT_ID` with your Sanity project ID, which you can find in your project's management dashboard.

### Using query parameters

Query parameters prevent injection attacks and make queries reusable. Prefix parameters with `$` in the GROQ query and pass their values as the second argument to `fetch()`.

**query-parameters.ts**

```typescript
// String parameter
const result = await client.fetch(
  '*[_type == $type]',
  {type: 'post'}
)

// Multiple parameters with ordering and limit
const filtered = await client.fetch(
  '*[_type == $type && publishedAt > $date] | order(publishedAt desc) [0...$limit]',
  {
    type: 'post',
    date: '2024-01-01',
    limit: 10,
  }
)

// Array parameter
const categorized = await client.fetch(
  '*[_type == "post" && category->slug.current in $slugs]',
  {slugs: ['technology', 'design', 'development']}
)
```

## Controlling document versions with perspectives

Perspectives control which version of a document your query returns. This is how you switch between showing published content on your production site and showing [draft or release versions](https://www.sanity.io/docs/content-lake/documents) in preview environments. You can set a default perspective when creating the client or override it per query.

### Published perspective

The `published` perspective returns only published documents, excluding drafts and version documents. This is the default and is what you typically use for production sites.

**perspective-published.ts**

```typescript
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  perspective: 'published', // default
  useCdn: true,
  apiVersion: '2026-03-01',
})

// Only published documents are returned
const posts = await client.fetch('*[_type == "post"]')

// Override perspective for a single query
const publishedPosts = await client.fetch(
  '*[_type == "post"]',
  {},
  {perspective: 'published'}
)
```

### Drafts perspective

The `drafts` perspective returns draft versions when they exist, falling back to published versions otherwise. Use this for preview environments where editors need to see their unpublished changes. It requires an API token and should not use the CDN, since drafts are not cached.

**perspective-drafts.ts**

```typescript
// Configure client for preview
const previewClient = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  perspective: 'drafts',
  useCdn: false, // Drafts are not cached on the CDN
  token: process.env.SANITY_API_TOKEN,
  apiVersion: '2026-03-01',
})

// Returns the draft if it exists, otherwise the published version
const posts = await previewClient.fetch('*[_type == "post"]')
```

### Raw perspective

The `raw` perspective returns every version of every matching document as separate entries: published, drafts, and release versions all appear in the results. This is useful when you need to compare versions or build custom editorial tooling that shows all document states at once.

**perspective-raw.ts**

```typescript
const rawClient = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  perspective: 'raw',
  useCdn: false,
  token: process.env.SANITY_API_TOKEN,
  apiVersion: '2026-03-01',
})

// Returns both published and draft versions as separate documents
const allVersions = await rawClient.fetch('*[_type == "post"]')
// Result may include: [{_id: 'post-123', ...}, {_id: 'drafts.post-123', ...}]
```

### Release perspectives

Beyond the built-in perspectives, you can query content as it would appear if a specific release were published. This uses a perspective stack, where release versions take priority over drafts, which take priority over published documents. See [perspectives](https://www.sanity.io/docs/content-lake/perspectives) for details on configuring perspective stacks with release IDs.

**perspective-release.ts**

```typescript
// Preview how content will look when a release is published
const releaseClient = client.withConfig({
  perspective: ['release-id', 'drafts'], // published is automatically added to the end
  useCdn: false,
  token: process.env.SANITY_API_TOKEN,
})

const posts = await releaseClient.fetch('*[_type == "post"]')
```

## Fetching documents by ID

When you already have a document's ID, use `getDocument()` for a single document or `getDocuments()` for multiple documents in one request. These methods return the full document without needing a GROQ query.

**get-documents.ts**

```typescript
// Fetch a single document by ID
const post = await client.getDocument('post-123')
// Returns null if the document doesn't exist

// Fetch multiple documents in one request
const docs = await client.getDocuments(['post-123', 'post-456', 'author-789'])
// Returns an array in the same order as the input IDs
// Missing documents appear as null: [{ _id: 'post-123', ... }, null, { _id: 'author-789', ... }]

// Both methods accept an options object
const draft = await client.getDocument('post-123', {
  includeAllVersions: true,
  tag: 'post-detail',
})
```

Use `getDocuments()` when you have a list of known IDs to resolve, such as IDs stored in an external system. For fetching related documents within your dataset, a GROQ query with `references()` or joins is usually more efficient since it avoids multiple round trips.

## Visual editing

When visual editing is enabled, the client can enrich query responses with Content Source Maps, which are metadata that tracks where each piece of content came from. This powers click-to-edit overlays in Sanity Studio, allowing editors to select content on a preview page and jump directly to the corresponding field.

[Content Source Maps](https://www.sanity.io/docs/visual-editing/content-source-maps)
Learn how the Content Lake enriches queries with metadata about where each value originates.

[Stega-encoding](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega)
Learn how invisible metadata is embedded into strings to enable click-to-edit overlays.

[Visual editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
Get an overview of visual editing features and framework-specific setup guides.

## Next steps

Now that you can fetch content, you may want to learn how to modify it. See [Creating mutations with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-mutations) for creating, patching, and deleting documents, or [Creating transactions with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-transactions) for atomic multi-document operations. For request tagging, cancellation, and other configuration, see [Advanced client patterns](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced).



# Creating and updating documents

The `@sanity/client` library provides methods for creating and modifying documents in your dataset. This guide covers the create, createOrReplace, createIfNotExists, and patch methods with practical examples for each.

> [!TIP]
> Validation is client-side only
> Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See [Schema validation and the Content Lake](https://www.sanity.io/docs/content-lake/schema-validation-and-the-content-lake) for details.

## Prerequisites

All mutation methods require an authenticated client with a write token and return promises that resolve with the created or updated document. See [Getting started with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) for setup instructions.

## Creating documents with client.create()

The `create()` method creates a new document in your dataset. Sanity automatically generates a unique `_id` for the document unless you provide one.

**create-document.ts**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: false,
  token: 'your-token',
  apiVersion: '2026-03-01'
})

// Create a new document
try {
  const newPost = await client.create({
    _type: 'post',
    title: 'Getting started with Sanity',
    slug: {
      _type: 'slug',
      current: 'getting-started'
    },
    publishedAt: new Date().toISOString()
  })

  console.log('Created document:', newPost._id)
} catch (error) {
  console.error('Failed to create document:', error.message)
}
```

You can also specify a custom document ID:

**create-with-id.ts**

```typescript
const newPost = await client.create({
  _id: 'post-123',
  _type: 'post',
  title: 'My custom ID post'
})
```

## Creating or replacing with client.createOrReplace()

The `createOrReplace()` method creates a new document or completely replaces an existing one if a document with the specified `_id` already exists. This is useful for idempotent operations where you want to ensure a specific document state.

**create-or-replace.ts**

```typescript
// This will create the document if it doesn't exist,
// or replace it entirely if it does
const post = await client.createOrReplace({
  _id: 'post-123',
  _type: 'post',
  title: 'Updated title',
  slug: {
    _type: 'slug',
    current: 'updated-slug'
  },
  publishedAt: new Date().toISOString()
})

console.log('Document created or replaced:', post._id)
```

> [!WARNING]
> The `createOrReplace()` method replaces the entire document. Any fields not included in the new document will be removed. Use `patch()` if you want to update specific fields while preserving others.

## Creating if not exists with client.createIfNotExists()

The `createIfNotExists()` method creates a document only if no document with the specified `_id` exists. If the document already exists, the operation does nothing and returns the existing document.

**create-if-not-exists.ts**

```typescript
// This will only create the document if it doesn't exist
const post = await client.createIfNotExists({
  _id: 'post-123',
  _type: 'post',
  title: 'My post',
  slug: {
    _type: 'slug',
    current: 'my-post'
  }
})

// If the document already exists, it returns the existing document
// without modifying it
```

This method is particularly useful for initialization scripts or ensuring default documents exist without overwriting user modifications.

## Patching documents with client.patch()

The `patch()` method lets you update specific fields in an existing document without replacing the entire document. You can chain multiple operations together to perform complex updates.

**patch-document.ts**

```typescript
// Update specific fields in a document
const updatedPost = await client
  .patch('post-123')
  .set({title: 'Updated title'})
  .commit()

console.log('Updated document:', updatedPost)
```

The `commit()` method executes the patch operation. You can chain multiple patch operations before calling `commit()`.

### Setting fields with .set()

The `set()` method sets or overwrites field values. You can set multiple fields at once or use dot notation to set nested fields.

**set-fields.ts**

```typescript
// Set multiple fields
const result = await client
  .patch('post-123')
  .set({
    title: 'New title',
    publishedAt: new Date().toISOString(),
    'author.name': 'Jane Doe'
  })
  .commit()

// Set nested fields using dot notation
const nested = await client
  .patch('post-123')
  .set({'metadata.views': 100})
  .commit()
```

### Setting only if missing with .setIfMissing()

The `setIfMissing()` method sets field values only if the fields don't already exist or are `null`. This is useful for setting default values without overwriting existing data.

**set-if-missing.ts**

```typescript
// Set default values only if they don't exist
const result = await client
  .patch('post-123')
  .setIfMissing({
    views: 0,
    likes: 0,
    publishedAt: new Date().toISOString()
  })
  .commit()

// If 'views' already has a value, it won't be changed
// If 'views' is null or doesn't exist, it will be set to 0
```

### Removing fields with .unset()

The `unset()` method removes fields from a document. You can remove multiple fields by passing an array of field paths.

**unset-fields.ts**

```typescript
// Remove a single field
const result = await client
  .patch('post-123')
  .unset(['draft'])
  .commit()

// Remove multiple fields at once
const multiUnset = await client
  .patch('post-123')
  .unset(['draft', 'internalNotes', 'metadata.temp'])
  .commit()
```

### Incrementing and decrementing with .inc() and .dec()

The `inc()` and `dec()` methods increment or decrement numeric field values. These operations are atomic and useful for counters, view counts, or other numeric tracking.

**increment-decrement.ts**

```typescript
// Increment a field by 1
const result = await client
  .patch('post-123')
  .inc({views: 1})
  .commit()

// Increment multiple fields by different amounts
const bulkInc = await client
  .patch('post-123')
  .inc({views: 10, likes: 5})
  .commit()

// Decrement a field
const decremented = await client
  .patch('post-123')
  .dec({stock: 1})
  .commit()
```

### Conditional patches with .ifRevisionId()

The `ifRevisionId()` method ensures that a patch only applies if the document's current revision matches the specified revision ID. This prevents race conditions and ensures you're updating the version of the document you expect.

**conditional-patch.ts**

```typescript
// First, fetch the document to get its current revision
const post = await client.getDocument('post-123')

try {
  // Only apply the patch if the revision hasn't changed
  const result = await client
    .patch('post-123')
    .ifRevisionId(post._rev)
    .set({title: 'Updated title'})
    .commit()

  console.log('Updated document:', result)
} catch (error) {
  // If the document was modified by another process,
  // the patch fails with a revision mismatch error
  console.error('Patch failed:', error.message)
}
```

This is particularly useful in collaborative environments where multiple users or processes might be updating the same document.

## Working with arrays

The patch API provides several methods for manipulating array fields, letting you add and remove items without replacing the entire array.

### Inserting items with .insert()

The `insert()` method adds items to an array at a specific position. You can insert items before or after existing items, or at the beginning or end of the array.

**insert-array-items.ts**

```typescript
// Insert at the beginning of an array
const result = await client
  .patch('post-123')
  .insert('before', 'tags[0]', ['featured'])
  .commit()

// Insert at the end of an array
const result2 = await client
  .patch('post-123')
  .insert('after', 'tags[-1]', ['trending'])
  .commit()

// Insert an object into an array (objects require a _key property)
const result3 = await client
  .patch('post-123')
  .insert('after', 'sections[-1]', [
    {_key: 'section-abc', _type: 'textSection', heading: 'New section'}
  ])
  .commit()
```

### Appending items with .append()

The `append()` method adds items to the end of an array. This is a convenient shorthand for inserting after the last item.

**append-array-items.ts**

```typescript
// Append items to an array
const result = await client
  .patch('post-123')
  .append('tags', ['javascript', 'tutorial'])
  .commit()

// Append a single item
const result2 = await client
  .patch('post-123')
  .append('categories', ['development'])
  .commit()
```

### Prepending items with .prepend()

The `prepend()` method adds items to the beginning of an array.

**prepend-array-items.ts**

```typescript
// Prepend items to an array
const result = await client
  .patch('post-123')
  .prepend('tags', ['featured', 'important'])
  .commit()

// The new items will appear at the start of the array
```

### Deleting array elements

You can remove items from arrays using the `unset()` method with array index notation. You can also use array filters to remove items that match specific criteria.

**delete-array-items.ts**

```typescript
// Remove an item by index
const result = await client
  .patch('post-123')
  .unset(['tags[2]'])
  .commit()

// Remove items matching a condition
const result2 = await client
  .patch('post-123')
  .unset(['tags[@ == "deprecated"]'])
  .commit()

// Remove all items with a specific value
const result3 = await client
  .patch('post-123')
  .unset(['categories[_ref == "cat-123"]'])
  .commit()
```

The `@` symbol represents the current array item in filter expressions. You can use comparison operators to match items based on their values or properties.

## Chaining multiple operations

You can chain multiple patch operations together to perform complex updates in a single transaction. All operations are applied atomically when you call `commit()`.

**chaining-operations.ts**

```typescript
// Perform multiple operations in one transaction
const result = await client
  .patch('post-123')
  .set({title: 'Updated title', publishedAt: new Date().toISOString()})
  .setIfMissing({views: 0})
  .inc({views: 1})
  .append('tags', ['updated'])
  .unset(['draft', 'internalNotes'])
  .commit()

// All operations succeed or fail together
```

This ensures data consistency and reduces the number of API calls needed to update a document.

## Actions

In addition to the client’s helper functions, you can also create, edit, and delete documents with the Actions.

**actions.ts**

```
import {createDraftId} from '@sanity/id-utils'

await client.action({
  actionType: 'sanity.action.document.edit',
  publishedId: documentId,
  draftId: createDraftId(documentId),
  patch: {
    set: {
      title: 'new title'
    }
  }
});
```

Actions allow you to use the same approach used by Studio to manipulate documents. Learn more in our [guide to mutating documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions).

## Next steps

For additional settings and patterns to use alongside mutations, check the [advanced client patterns guide](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced).



# Working with assets and images

The `@sanity/client` library provides methods for uploading, querying, and deleting [assets](https://www.sanity.io/docs/content-lake/assets) in your Sanity project. This guide covers working with assets in Content Lake and introduces Media Library for video workflows.

> [!NOTE]
> Before you begin
> This guide assumes you have a configured Sanity client with a write token. See [Getting started with @sanity/client ](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started)for setup instructions. [Media Library](https://www.sanity.io/docs/media-library/introduction) sections require an active Media Library on your organization.

## Uploading assets to Content Lake

The `client.assets.upload()` method handles uploads to Content Lake. It accepts a type (`'image'` or `'file'`), a body (file stream, buffer, Blob, or File object), and an optional configuration object. The method returns a promise that resolves to the created asset document.

### Uploading from Node.js

In Node.js environments (build scripts, migrations, server-side processing), you can upload from file streams or buffers.

**upload-image-node.ts**

```typescript
import {createClient} from '@sanity/client'
import {createReadStream} from 'fs'
import {basename} from 'path'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-03-01',
  token: '<your-write-token>',
  useCdn: false
})

const filePath = './images/hero.jpg'

try {
  const imageAsset = await client.assets.upload(
    'image',
    createReadStream(filePath),
    {filename: basename(filePath)}
  )

  console.log('Uploaded image:', imageAsset._id)
  console.log('URL:', imageAsset.url)
} catch (error) {
  console.error('Upload failed:', error.message)
}
```

After uploading, you typically reference the asset from a document. Use `patch()` to set the asset reference on an existing document. For Media Library assets, see [Link assets to documents](https://www.sanity.io/docs/media-library/link-media-assets).

**attach-asset-to-document.ts**

```typescript
// Upload and attach an image to a document in one step
const imageAsset = await client.assets.upload(
  'image',
  createReadStream('./images/hero.jpg'),
  {filename: 'hero.jpg'}
)

await client
  .patch('post-123')
  .set({
    mainImage: {
      _type: 'image',
      asset: {
        _type: 'reference',
        _ref: imageAsset._id
      }
    }
  })
  .commit()

console.log('Image attached to document')
```

### Uploading from the browser

In browser environments, pass a `File` or `Blob` object to the upload method.

**upload-from-file-input.ts**

```typescript
// Upload a file from an <input type="file"> element
const input = document.querySelector('input[type="file"]') as HTMLInputElement

input.addEventListener('change', async () => {
  const file = input.files?.[0]
  if (!file) return

  try {
    const asset = await client.assets.upload('image', file, {
      filename: file.name
    })
    console.log('Uploaded file:', asset._id)
  } catch (error) {
    console.error('Upload failed:', error.message)
  }
})
```

### Specifying image metadata to extract

When uploading images, use the `extract` option to control which [metadata](https://www.sanity.io/docs/apis-and-sdks/image-metadata) is processed during upload. Available values include:

- `palette`: Extracts dominant colors from the image.
- `location`: Extracts GPS coordinates from EXIF data, if available.
- `exif`: Extracts EXIF metadata like camera settings and timestamps.
- `blurhash`: Generates a compact placeholder representation.

**upload-with-metadata.ts**

```typescript
const imageAsset = await client.assets.upload(
  'image',
  createReadStream('./images/photo.jpg'),
  {
    filename: 'photo.jpg',
    extract: ['palette', 'exif', 'location', 'blurhash']
  }
)

console.log('Palette:', imageAsset.metadata.palette)
console.log('Dimensions:', imageAsset.metadata.dimensions)
```

## Querying assets

Assets in Content Lake are documents with the types `sanity.imageAsset` and `sanity.fileAsset`. You can query them with GROQ like any other document. For rendering images on your front end, see [Presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images). You can also manage assets through the Assets HTTP API.

**query-assets.ts**

```typescript
// Fetch all image assets
const images = await client.fetch('*[_type == "sanity.imageAsset"]')

// Fetch all file assets
const files = await client.fetch('*[_type == "sanity.fileAsset"]')

// Follow a reference to get asset metadata from a document
const posts = await client.fetch(`
  *[_type == 'post'] {
    title,
    mainImage {
      asset-> {
        _id,
        url,
        metadata {
          dimensions,
          palette
        }
      }
    }
  }
`)
```

## Deleting assets from Content Lake

Delete an asset by passing its document ID to `client.delete()`. This permanently removes the asset and its associated data.

**delete-asset.ts**

```typescript
try {
  await client.delete('image-abc123-300x200-jpg')
  console.log('Asset deleted')
} catch (error) {
  if (error.statusCode === 409) {
    console.error('Asset is still referenced by other documents')
  } else {
    console.error('Delete failed:', error.message)
  }
}
```

> [!WARNING]
> References are not removed automatically
> Deleting an asset does not remove references to it from your documents. Update or remove those references separately to avoid broken links.

## Working with Media Library

[Media Library](https://www.sanity.io/docs/media-library/introduction) is a specialized storage and delivery system for managing assets across your organization. It provides features like adaptive streaming, automatic transcoding, and thumbnail generation for video content. Unlike Content Lake assets, Media Library assets are stored separately and managed through a dedicated API.

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

### Configuring the client for Media Library

To query or interact with Media Library from `@sanity/client`, configure the client with a `resource` property pointing to your Media Library instance.

**media-library-client.ts**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  apiVersion: '2026-03-01',
  useCdn: false,
  token: 'your-write-token',
  resource: {
    type: 'media-library',
    id: 'your-media-library-id'
  }
})
```

Or, if you have a configured client and want to use the same configuration, use `withConfig`.

**media-library-client.ts**

```typescript
import {client} from 'lib/client' // import your configured client

const mlClient = client.withConfig({
  token: 'your-write-token',
  resource: {
    type: 'media-library',
    id: 'your-media-library-id'
  }
})
```

You can find your Media Library ID in the Sanity management console under your organization settings. See [Configure your library](https://www.sanity.io/docs/media-library/configure-library) for setup details.

### Querying Media Library assets

With a Media Library-configured client, you can query assets using GROQ.

**query-media-library.ts**

```typescript
// Query all assets in your Media Library
const assets = await client.fetch('*[_type == "sanity.asset"]')

// Query only image assets
const images = await client.fetch(
  '*[assetType == "sanity.imageAsset"]'
)

// Query recent assets
const recentAssets = await client.fetch(
  '*[_type == "sanity.asset" && _createdAt > $date]',
  {date: '2026-01-01'}
)
```

### Uploading to Media Library

Upload assets to Media Library using the `client.assets.upload()` method on a Media Library-configured client.

**upload-to-media-library.ts**

```typescript
import fs from 'node:fs'

// Upload an image
const imageAsset = await client.assets.upload(
  'image',
  fs.createReadStream('photo.jpg'),
  {
    filename: 'photo.jpg',
    title: 'Product Photo'
  }
)

console.log('Uploaded:', imageAsset._id)

// Upload a video
const videoAsset = await client.assets.upload(
  'file',
  fs.createReadStream('promo.mp4'),
  {filename: 'promo.mp4'}
)

console.log('Uploaded video:', videoAsset._id)
```

### Deleting Media Library assets

Media Library uses the same mutation API as Content Lake for deletions. To ensure both the published and draft versions are removed, use a transaction.

**delete-media-library-asset.ts**

```typescript
const assetId = '36fOGtOJOadpl4F9xpksb9uKjYp'

// Delete both the asset and its draft
try {
  await client
    .transaction()
    .delete(assetId)
    .delete(`drafts.${assetId}`)
    .commit()

  console.log('Asset deleted from Media Library')
} catch (error) {
  console.error('Delete failed:', error.message)
}
```

### Getting video playback information

For video assets, use `client.mediaLibrary.video.getPlaybackInfo()` to retrieve playback URLs, thumbnails, and metadata like duration and aspect ratio. See [Working with video](https://www.sanity.io/docs/media-library/working-with-video) for a complete guide to video delivery.

**get-video-playback.ts**

```typescript
// Fetch a document with a video reference
const doc = await client.fetch(
  `*[_type == 'videoPage'][0]{ title, video }`
)

// Get playback info from the video asset reference
const playbackInfo = await client.mediaLibrary.video.getPlaybackInfo(
  doc.video.asset
)

console.log('Stream URL:', playbackInfo.stream.url)
console.log('Thumbnail:', playbackInfo.thumbnail.url)
console.log('Duration:', playbackInfo.duration)
console.log('Aspect ratio:', playbackInfo.aspectRatio)
```



# Creating transactions

When you need to create, update, and delete multiple documents as a single unit, transactions ensure that all changes succeed or none are applied. This prevents partial updates that could leave your content in an inconsistent state.

This article covers how to create and commit transactions, chain multiple operations, use inline patch syntax, and build standalone transactions without a client instance.

> [!TIP]
> Validation is client-side only
> Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See [Schema validation and the Content Lake](https://www.sanity.io/docs/content-lake/schema-validation-and-the-content-lake) for details.

## Prerequisites

Before using transactions, make sure you have `@sanity/client` installed and configured with write access. See [Getting started with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) for setup instructions.

## When to use transactions

Use transactions when your mutations depend on each other. For example, if you create a new category and assign it to a post in the same operation, a transaction guarantees both changes apply together. If the category creation fails, the post won't be left referencing a category that doesn't exist.

For independent mutations that don't depend on each other, individual `create()`, `patch()`, and `delete()` calls are simpler and sufficient.

## Creating and committing a transaction

Call `client.transaction()` to start a new transaction, chain your mutation operations, and call `.commit()` to execute them. All operations are applied atomically. If any operation fails, none of the changes take effect.

**transaction.js**

```typescript
try {
  const result = await client
    .transaction()
    .create({_type: 'product', title: 'New Widget'})
    .patch('product-abc', (patch) => patch.set({featured: true}))
    .delete('product-discontinued-xyz')
    .commit()

  console.log('Transaction completed:', result.transactionId)
} catch (error) {
  console.error('Transaction failed, no changes applied:', error.message)
}
```

Replace `product-abc` and `product-discontinued-xyz` with your actual document IDs.

## Chaining related operations

Transactions are most useful when operations depend on each other. This example creates a new category, assigns it to a post, and removes an old category in one atomic operation:

**chain-operations.js**

```typescript
try {
  const result = await client
    .transaction()
    .create({
      _type: 'category',
      _id: 'category-technology',
      title: 'Technology',
    })
    .patch('post-getting-started-with-sanity', (patch) =>
      patch.set({category: {_ref: 'category-technology'}})
    )
    .delete('category-legacy-tech')
    .commit()

  // Each result corresponds to the operations in order
  console.log('Created category:', result.results[0])
  console.log('Updated post:', result.results[1])
  console.log('Deleted old category:', result.results[2])
} catch (error) {
  console.error('Transaction failed:', error.message)
}
```

## Using inline patch syntax

Instead of using a callback function for patches, you can pass an object describing the patch operations directly. This is often more concise when you're setting multiple fields at once:

**inline-patch.js**

```typescript
const result = await client
  .transaction()
  .patch('post-getting-started-with-sanity', {
    set: {title: 'Updated title', publishedAt: new Date().toISOString()},
    inc: {views: 1},
    unset: ['draft'],
  })
  .patch('author-jane-doe', {
    set: {lastActive: new Date().toISOString()},
    inc: {postCount: 1},
  })
  .commit()
```

Inline patches support `set`, `unset`, `setIfMissing`, `inc`, `dec`, `insert`, and `append`.

## Building transactions without a client instance

You can construct patches and transactions independently using the `Patch` and `Transaction` classes from `@sanity/client`. This is useful when you want to build mutation logic in one place and execute it later, or pass it between functions:

**clientless.js**

```typescript
import {Patch, Transaction} from '@sanity/client'

// Build a standalone patch
const patch = new Patch('post-getting-started-with-sanity')
  .set({title: 'Updated title'})
  .inc({views: 1})

// Build a standalone transaction
const transaction = new Transaction()
  .create({_type: 'post', title: 'Hello World'})
  .patch('post-getting-started-with-sanity', (p) => p.set({updated: true}))
  .delete('post-old-draft')

// Execute either one later with a configured client
await client.mutate(patch)
await client.mutate(transaction)
```

## Next steps

For mutation options like visibility modes, dry runs, and request cancellation, see [Advanced client patterns](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced). To learn more about how transactions relate to the underlying mutation API, see the [Transactions](https://www.sanity.io/docs/content-lake/transactions) reference.



# Deleting documents

The `@sanity/client` library provides methods for deleting documents individually by ID or in bulk using a query. This guide covers both approaches.

## Prerequisites

Deleting requires an authenticated client with a write token. See [Getting started with @sanity/client](https://www.sanity.io/docs/js-client-getting-started) for setup instructions.

## Delete by ID

Use `client.delete()` to remove a single document by its ID:

**delete-by-id.js**

```typescript
// Delete a single document
const result = await client.delete('post-123')
console.log('Deleted document:', result.documentId)

// Delete a draft document
await client.delete('drafts.post-456')

// Delete with error handling
try {
  await client.delete('post-789')
  console.log('Document deleted successfully')
} catch (error) {
  if (error.statusCode === 404) {
    console.log('Document not found')
  } else {
    console.error('Delete failed:', error.message)
  }
}
```

## Delete by query

For bulk deletions, query for documents and delete them in a transaction. This ensures all deletions succeed or fail together:

**delete-by-query.js**

```typescript
// Query for documents to delete
const query = '*[_type == "post" && publishedAt < $cutoffDate]'
const params = {cutoffDate: '2020-01-01'}

const docsToDelete = await client.fetch(query, params)

if (docsToDelete.length === 0) {
  console.log('No documents to delete')
} else {
  // Build transaction with all deletions
  const transaction = docsToDelete.reduce(
    (tx, doc) => tx.delete(doc._id),
    client.transaction()
  )

  // Execute the transaction
  const result = await transaction.commit()
  console.log(`Deleted ${result.results.length} documents`)
}
```

> [!WARNING]
> Always test your query before deleting. Use client.fetch() to verify which documents match your query, then proceed with deletion. Deleted documents cannot be recovered unless you have backups.

The bulk deletion example uses `client.transaction()` to delete all matching documents atomically. Learn more about transactions in [Transactions](https://www.sanity.io/docs/js-client-transactions).



# Create content releases

Content Releases let you group document changes and publish them together. The `@sanity/client` library provides helper methods on the `client.releases` namespace for managing releases, along with top-level methods for working with document versions.

These methods require an authenticated client with a write token. See [Getting started with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) for setup instructions. The examples on this page assume a configured `client` and require `@sanity/client` 7.8.0 or later.

> [!TIP]
> For a conceptual overview of how Content Releases work, see the [Content Releases user guide](https://www.sanity.io/docs/user-guides/content-releases). For the underlying HTTP endpoints, see the [Content Releases API](https://www.sanity.io/docs/content-lake/content-release-document-flow).

## Create a release

Use `client.releases.create()` to create a new release. The method returns an object containing the `releaseId`, which you use to add document versions to the release.

**create-release.ts**

```typescript
const {releaseId} = await client.releases.create({
  metadata: {
    title: 'Spring product launch',
    releaseType: 'scheduled',
  },
})

console.log('Created release:', releaseId)
```

The `releaseType` can be `scheduled`, `asap`, or `undecided`, and defaults to `undecided` when omitted. Sanity generates the `releaseId` for you unless you pass one explicitly.

## Add document versions to a release

Use `client.createVersion()` to add a document version to a release. The most common case is versioning an existing published document. The example below uses `baseId`, which tells Sanity to copy the current published content into the version for you. To set the version's content explicitly instead, for example for a document that doesn't exist in published form yet, pass an inline `document`. See [Choosing between baseId and inline document](https://www.sanity.io/docs/apis-and-sdks/js-client-releases).

**create-version.ts**

```typescript
await client.createVersion({
  releaseId,
  publishedId: 'product-123',
  baseId: 'product-123',
})
```

This snapshots the current published `product-123` into the release as a versioned document with the ID `versions.<releaseId>.product-123`. The published document remains unchanged until the release is published.

### Choosing between `baseId` and inline `document`

When you create a version of an existing published document, prefer `baseId`. Sanity copies the current published content into the version for you, so you don't have to fetch and repackage it client-side. `baseId` is the source of the version's content; `publishedId` is the logical document the version refers to. In the common case where you are versioning a document's own published edition, both values are the same. You can also pass `ifBaseRevisionId` to make the action fail if the base document has changed since you read it.

Pass an inline `document` when there is no published edition to copy from, for example a product you are introducing in this release:

**create-version-inline.ts**

```typescript
await client.createVersion({
  releaseId,
  publishedId: 'product-new-summer-hat',
  document: {
    _type: 'product',
    title: 'Summer sun hat',
    price: 24.99,
  },
})
```

`_type` is required. The version's `_id` is derived from the release and published IDs, so you don't need to set it. Calling `createVersion()` with an inline `document` logs a console warning recommending `baseId`, which you can disregard when the document has no published edition to copy from.

## Mark a document for unpublishing

Use `client.unpublishVersion()` to mark a document for removal when the release runs. The document stays published until the release is executed.

**unpublish-version.ts**

```typescript
await client.unpublishVersion({
  releaseId,
  publishedId: 'product-456',
})
```

## Get a release and its documents

Retrieve a release's metadata with `client.releases.get()`, and list its documents with `client.releases.fetchDocuments()`.

**get-release.ts**

```typescript
// Get the release metadata
const release = await client.releases.get({releaseId})

if (!release) {
  throw new Error(`Release ${releaseId} not found`)
}

console.log(release.metadata.title) // 'Spring product launch'
console.log(release.state) // 'active'

// List all documents in the release
const {result: documents} = await client.releases.fetchDocuments({releaseId})
console.log(`Release contains ${documents.length} documents`)
```

`get()` returns `undefined` when no release matches the ID, so guard the result before reading from it. The release's `state` is one of `active`, `scheduling`, `scheduled`, `publishing`, `published`, `archiving`, `archived`, or `unarchiving`.

## Schedule a release

Schedule a release to publish at a specific time with `client.releases.schedule()`. Pass an ISO 8601 date string as the `publishAt` value.

**schedule-release.ts**

```typescript
// Schedule the release for one hour from now
const publishAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()

await client.releases.schedule({
  releaseId,
  publishAt,
})

console.log(`Release scheduled for ${publishAt}`)
```

## Publish a release

To publish a release immediately instead of scheduling it, use `client.releases.publish()`. This is how you run a release created with the `asap` release type.

**publish-release.ts**

```typescript
await client.releases.publish({releaseId})

console.log('Release published')
```

The new content is queryable as soon as the action returns. For larger releases, replacing the `versions.<releaseId>.*` documents with their published counterparts can take longer, and both the version and published documents are locked until that finishes.

## Delete a release after publishing

After a release has been published, you can clean it up with `client.releases.delete()`. Delete accepts releases in the `published` or `archived` state, so check the release state first. To remove a release that is still `active`, use `client.releases.archive()`, which also deletes its document versions.

**delete-release.ts**

```typescript
const release = await client.releases.get({releaseId})

if (release?.state === 'published' && !release.error) {
  await client.releases.delete({releaseId})
  console.log('Release deleted')
}
```

## Full example: create and schedule a release

Here's a complete workflow that creates a release, adds document versions, and schedules it to publish:

**full-release-workflow.ts**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-03-01',
  useCdn: false,
  token: process.env.SANITY_TOKEN,
})

// 1. Create a release
const {releaseId} = await client.releases.create({
  metadata: {
    title: 'Spring product launch',
    releaseType: 'scheduled',
  },
})

// 2. Snapshot an existing product into the release
await client.createVersion({
  releaseId,
  publishedId: 'product-123',
  baseId: 'product-123',
})

// 3. Mark an old product for removal
await client.unpublishVersion({
  releaseId,
  publishedId: 'product-old-winter-coat',
})

// 4. Verify the release contents
const {result: documents} = await client.releases.fetchDocuments({releaseId})
console.log(`Release contains ${documents.length} document(s)`)

// 5. Schedule the release
await client.releases.schedule({
  releaseId,
  publishAt: '2026-04-01T09:00:00.000Z',
})

console.log('Release scheduled for April 1')
```

## Release actions with the Actions API

The mutating helper methods shown above use the `client.action()` method under the hood. If you need more control, you can dispatch release actions directly. This lets you archive, unarchive, and unschedule releases, as well as create, discard, replace, and unpublish individual document versions, among other operations.

For example, to archive and then unarchive a release:

**release-actions.ts**

```typescript
// Archive a release
await client.action({
  actionType: 'sanity.action.release.archive',
  releaseId: 'spring-launch',
})

// Unarchive it later
await client.action({
  actionType: 'sanity.action.release.unarchive',
  releaseId: 'spring-launch',
})
```

You can also manage individual document versions through actions:

**version-actions.ts**

```typescript
// Create a version of a document in a release
await client.action({
  actionType: 'sanity.action.document.version.create',
  publishedId: 'product-123',
  document: {
    _id: 'versions.spring-launch.product-123',
    _type: 'product',
  },
})

// Discard a version
await client.action({
  actionType: 'sanity.action.document.version.discard',
  versionId: 'versions.spring-launch.product-123',
})

// Replace a version's contents
await client.action({
  actionType: 'sanity.action.document.version.replace',
  document: {
    _id: 'versions.spring-launch.product-123',
    _type: 'product',
    title: 'Revised spring jacket',
    price: 79.99,
  },
})
```

For the full list of available action types and their options, see [Mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions).

## Next steps

- [Content Releases user guide](https://www.sanity.io/docs/user-guides/content-releases): Learn how releases work in Sanity Studio.
- [Content Releases API](https://www.sanity.io/docs/content-lake/content-release-document-flow): HTTP endpoint reference for the releases API.
- [Mutate documents with actions](https://www.sanity.io/docs/content-lake/dispatch-actions): Dispatch release and version actions directly through the Actions API.
- [Release Actions](https://www.sanity.io/docs/studio/release-actions): Add custom release actions to the Studio.



# Listening to content updates

Whether you are building a live dashboard, a preview environment for editors, or a site that stays current without manual redeployment, the Sanity JavaScript client gives you two approaches for keeping content in sync: the Live Content API and query listeners.

[The Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) is the recommended approach for most applications. It scales well, works with CDN caching, and is designed for production use. [Query listeners](https://www.sanity.io/docs/content-lake/realtime-updates) provide a lower-level event stream that includes mutation details and previous document revisions.

## Prerequisites

This guide assumes you have already installed and configured `@sanity/client`. See [Getting started with @sanity/client](https://www.sanity.io/docs/js-client-getting-started) for setup instructions.

## Real-time updates with the Live Content API

The Live Content API uses a tag-based system to notify your application when relevant content changes. Instead of streaming full documents on every mutation, it sends lightweight sync tag events. Your application stores these tags and uses them to determine when to refetch data.

This approach works well with CDN caching and scales to high traffic volumes. If you are building a website or application that displays content to end users, use the Live Content API.

#### Using a framework like Next.js?
The next-sanity library offers a turnkey integration with the Live Content API that handles sync tags, revalidation, and draft mode automatically.
[Live Content API overview](https://www.sanity.io/docs/content-lake/live-content-api)

### How it works

The Live Content API follows a three-step pattern:

1. Fetch content with `client.fetch()` and store the sync tags returned in the response.
2. Subscribe to live events using `client.live.events()`.
3. When an event arrives whose tags match your stored tags, refetch the content to get the latest version.

The following example demonstrates this pattern. It fetches a single document, stores the sync tags, and refetches whenever a matching live event arrives.

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-03-01',
  useCdn: true,
})

const query = '*[_type == "post" && slug.current == $slug][0]'
const params = {slug: 'hello-world'}

// Store sync tags from the initial fetch
let syncTags: string[] = []

async function render(lastLiveEventId?: string) {
  const response = await client.fetch(query, params, {
    // Required: returns the full response object including syncTags
    filterResponse: false,
    lastLiveEventId,
  })

  syncTags = response.syncTags
  const data = response.result
  console.log(data)
}

// Initial fetch
render()

// Subscribe to live events
const subscription = client.live.events().subscribe({
  next: (event) => {
    if (
      event.type === 'message' &&
      event.tags.some((tag) => syncTags.includes(tag))
    ) {
      // A matching tag means our content changed, so refetch
      render(event.id)
    }

    if (event.type === 'restart') {
      // Restart events mean we should refetch without an event ID
      render()
    }
  },
  error: (err) => {
    console.error('Live event stream error:', err)
  },
})

// Unsubscribe when no longer needed
// subscription.unsubscribe()
```

Setting `filterResponse: false` is essential. Without it, `fetch()` returns only the query result and you will not receive the `syncTags` needed to connect your fetched content to the live event stream.

### Event types

The `client.live.events()` method returns an Observable that emits the following event types:

- `message`: Carries sync tags that you compare against your stored tags. If any match, your content has changed.
- `restart`: The event stream has reset. Refetch all content without passing a `lastLiveEventId`.
- `welcome`: Connection established successfully.
- `reconnect`: The client reconnected after a temporary disconnection.
- `goaway`: The connection was rejected, for example because connection limits were reached. Consider falling back to polling.

### Listening for draft changes

To receive live updates for draft content, pass `includeDrafts: true` to the `events()` method. The client must be configured with an authentication token that has at minimum a viewer role.

```typescript
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-03-01',
  useCdn: false,
  token: process.env.SANITY_API_TOKEN,
})

const subscription = client.live
  .events({includeDrafts: true})
  .subscribe((event) => {
    if (event.type === 'message') {
      // Check tags and refetch as needed
    }
  })
```

## Listening to queries with client.listen()

The `listen()` method opens a server-sent event (SSE) stream that notifies your application whenever documents matching a GROQ query are created, updated, or deleted. Unlike the Live Content API, listeners give you direct access to the mutation data and the affected document, making them useful for backend workflows and editorial tools.

For frontend applications serving content to end users, the Live Content API is a better fit. It handles CDN caching efficiently and scales to high connection counts. Use `client.listen()` when you need mutation-level detail or are building server-side processes that react to content changes.

```typescript
const query = '*[_type == "comment" && authorId != $ownerId]'
const params = {ownerId: 'bikeOwnerUserId'}

const subscription = client.listen(query, params).subscribe((update) => {
  const comment = update.result
  console.log(`${comment.author} commented: ${comment.text}`)
})

// Unsubscribe when no longer needed
subscription.unsubscribe()
```

The `listen()` method returns an Observable. Call `.subscribe()` to start receiving events, and `.unsubscribe()` to stop. By default, each event includes a `result` property with the document after the mutation is applied. For delete mutations, `result` is not present.

### Understanding listener events

Each event includes a `transition` field that tells you what happened:

- `appear`: A document now matches the query, either because it was created or updated to match.
- `update`: An already-matching document was modified.
- `disappear`: A document no longer matches, either because it was deleted or modified to fall out of scope.

```typescript
client.listen('*[_type == "post"]').subscribe((update) => {
  switch (update.transition) {
    case 'appear':
      console.log('New post:', update.result)
      break

    case 'update':
      console.log('Post updated:', update.result)
      break

    case 'disappear':
      console.log('Post removed:', update.documentId)
      break
  }
})
```

### Listener options

The third argument to `listen()` accepts several options that control what data each event includes.

```typescript
const subscription = client.listen(
  '*[_type == "post"]',
  {},
  {
    // Include the document before the mutation was applied
    includePreviousRevision: true,

    // Include the raw mutations that caused the change
    includeMutations: true,

    // Set to false to omit the result document (saves bandwidth)
    includeResult: true,

    // Control visibility: 'query' (default), 'sync', or 'async'
    visibility: 'query',

    // Filter which event types to receive
    events: ['welcome', 'mutation', 'reconnect'],

    // Tag for request logs
    tag: 'post-listener',
  }
).subscribe((update) => {
  if (update.previous) {
    console.log('Before:', update.previous.title)
    console.log('After:', update.result.title)
  }
})
```

Setting `includeResult: false` reduces bandwidth when you only need to know that a change occurred. Combining `includePreviousRevision: true` with `includeMutations: true` gives you a complete before-and-after picture along with the specific operations that were applied.

## Choosing between the Live Content API and listeners

Use the Live Content API when you are building a website or application that displays content to users. It works with the CDN, scales efficiently, and integrates with framework-specific libraries like next-sanity. If you need the content on screen to update when an editor publishes, this is the right choice.

Use `client.listen()` when you need detailed mutation data, previous document revisions, or are building server-side automation such as triggering external systems on content changes. Listeners give you fine-grained control over the event stream at the cost of managing more complexity yourself.

## Common issues

**Sync tags are undefined or empty.** Make sure you pass `filterResponse: false` to `client.fetch()`. Without this option, the response only contains the query result and the sync tags are not included.

**Live events are not arriving.** Verify that your API version is `2021-03-25` or later and that your frontend domain is listed in the project's CORS origins at [sanity.io/manage](https://www.sanity.io/manage).

**Listener does not return draft documents.** Draft documents are only visible to authenticated clients. Pass a token when creating the client and set `useCdn: false`.

**Receiving a goaway event.** This means the live connection was closed, usually because connection limits were reached. Implement a polling fallback: periodically call `client.fetch()` on a timer instead of relying on the event stream.

## Next steps

- Read the [Live Content API overview](https://www.sanity.io/docs/content-lake/live-content-api) to understand how sync tags, caching, and usage limits work together.
- Follow the [live content guide](https://www.sanity.io/docs/developer-guides/live-content-guide) for framework-specific setup, including Next.js with next-sanity.
- Explore the [Live Content API reference](https://www.sanity.io/docs/http-reference/live) for the full HTTP API details and event stream specification.
- Browse the [live content examples on GitHub](https://github.com/sanity-io/client#live-content) for custom integration patterns beyond Next.js.



# Request tags for filtering logs

Request tags help you identify and filter API requests in your project's request logs. This is valuable for debugging, monitoring performance, and understanding how your application uses the API.

You can set tags at the client level or per request:

**example.ts**

```typescript
import {createClient} from '@sanity/client'

// Set default tag on client
const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-03-01',
  requestTagPrefix: 'myapp', // Prefix for all requests
})

// Tag individual queries
const posts = await client.fetch(
  '*[_type == "post"]',
  {},
  {tag: 'homepage-posts'}
)
// Request appears in logs as: myapp.homepage-posts

// Tag getDocument requests
const post = await client.getDocument('post-123', {
  tag: 'post-detail'
})

// Tag listeners
const subscription = client.listen(
  '*[_type == "post"]',
  {},
  {tag: 'realtime-posts'}
).subscribe(update => {
  // ...
})
```

Tags appear in your project's API request logs in the Sanity management console, where you can filter and analyze request patterns, identify slow queries, and debug issues.



# Advanced client patterns

This guide covers advanced patterns for working with `@sanity/client`, including mutation options that control how changes are applied, the retries the client performs on your behalf, and techniques for canceling in-flight requests.

These patterns apply to any mutation method: `create()`, `patch()`, `delete()`, and `transaction().commit()`. See [Creating and updating documents](https://www.sanity.io/docs/apis-and-sdks/js-client-mutations) and [Creating transactions](https://www.sanity.io/docs/apis-and-sdks/js-client-transactions) for the basics.

## Mutation options

Control how mutations execute with options for visibility, dry runs, and array key generation. These options apply to any mutation method, not only transactions.

### Visibility: sync, async, and deferred

The visibility option controls when mutations become visible to queries:

- `sync`: The mutation completes and indexes before returning. Queries immediately see the changes. This is the default behavior.
- `async`: The mutation returns immediately but indexes in the background. Queries may not see changes right away.
- `deferred`: The mutation queues for later processing. Use this for non-critical updates or bulk operations.

**visibility.ts**

```typescript
// Sync: wait for indexing (default)
await client.create(
  {_type: 'post', title: 'Hello'},
  {visibility: 'sync'}
)

// Async: return immediately, index in background
await client.create(
  {_type: 'post', title: 'Hello'},
  {visibility: 'async'}
)

// Deferred: queue for later processing
await client.create(
  {_type: 'analytics', event: 'page_view'},
  {visibility: 'deferred'}
)

// Use async for bulk operations
const transaction = client.transaction()
for (const item of largeDataset) {
  transaction.create({_type: 'product', ...item})
}
await transaction.commit({visibility: 'async'})
```

### Dry run mode

Test mutations without applying changes using `dryRun: true`. The API validates the mutation and returns what would happen, but does not modify any documents:

**dry-run.ts**

```typescript
// Test a mutation without applying it
const result = await client.create(
  {_type: 'post', title: 'Test Post'},
  {dryRun: true}
)

console.log('Would create:', result)
// Document is not actually created

// Test a transaction
const txResult = await client
  .transaction()
  .create({_type: 'author', name: 'Jane'})
  .patch('post-123', (p) => p.set({author: 'jane-id'}))
  .commit({dryRun: true})

console.log('Transaction would execute:', txResult.results)
// No changes are applied
```

### Auto-generate array keys

By default, the client automatically generates `_key` values for array items. Disable this with `autoGenerateArrayKeys: false` if you want to provide your own keys:

**array-keys.ts**

```typescript
// Default: keys are auto-generated
await client.create({
  _type: 'post',
  title: 'Hello',
  tags: [
    {_type: 'tag', name: 'javascript'},
    {_type: 'tag', name: 'sanity'}
  ]
})
// Each tag gets a unique _key automatically

// Provide your own keys
await client.create(
  {
    _type: 'post',
    title: 'Hello',
    tags: [
      {_key: 'js-tag', _type: 'tag', name: 'javascript'},
      {_key: 'sanity-tag', _type: 'tag', name: 'sanity'}
    ]
  },
  {autoGenerateArrayKeys: false}
)
```

## Automatic retries

`@sanity/client` retries some failed requests on its own. Retries are on by default, so you don't need to add your own backoff around a query.

The client retries a request when the response status is `429 Too Many Requests`, `502 Bad Gateway`, or `503 Service Unavailable`, and the request is one of these:

- A `GET` or `HEAD` request, such as `getDocument()`.
- A query to the `/data/query` endpoint, including a query long enough that the client sends it as a `POST`.

The client also retries DNS `ENOTFOUND` failures on idempotent requests, meaning `GET` and `HEAD`, using the same backoff. A hostname that never resolves, such as a mistyped project ID, takes the full retry sequence to fail rather than failing at once. Set `maxRetries: 0` to fail immediately instead. This applies to `@sanity/client` v8 and later.

> [!WARNING]
> Mutations are not retried
> Retries cover reads only. `create()`, `patch()`, `delete()`, `transaction().commit()`, document and release actions, and asset uploads all go to endpoints the client does not retry, so a `429` on any of them reaches your code immediately.
> Back those requests off yourself, or send them through a rate-limited queue. [Importing data](https://www.sanity.io/docs/content-lake/importing-data) shows a queue that stays under the mutation rate limit.
> A request whose body is a stream is never retried either, because the body has already been read.

### Retry defaults

Two [ClientConfig](https://reference.sanity.io/_sanity/client/index/ClientConfig/) options control retries:

- `maxRetries`: how many times to retry a failed request. Defaults to `5`. Set it to `0` to turn retries off.
- `retryDelay`: a function that receives the attempt number, starting at `0`, and returns how long to wait in milliseconds. Defaults to exponential backoff with jitter: `100 * 2 ** attemptNumber` plus a random 0 to 100 milliseconds.

With the defaults, the waits are roughly 100, 200, 400, 800, and 1,600 milliseconds, each plus up to 100 milliseconds of jitter. A request that exhausts all five retries fails after about 3.1 to 3.7 seconds.

**sanityClient.ts**

```typescript
import {createClient} from '@sanity/client'

// Retry more patiently: eight attempts, backing off from 250ms
export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2025-02-19',
  useCdn: true,
  maxRetries: 8,
  retryDelay: (attemptNumber) => 250 * 2 ** attemptNumber + Math.random() * 100,
})

// Turn retries off entirely
export const noRetryClient = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2025-02-19',
  maxRetries: 0,
})
```

## Canceling requests

Cancel in-flight requests using AbortController or by unsubscribing from Observables. This is useful for cleaning up requests when components unmount or when user actions make a request obsolete.

### Using AbortController

Pass an AbortSignal to any client method to enable cancellation:

**abort-controller.ts**

```typescript
const controller = new AbortController()
const {signal} = controller

// Start a mutation
const mutationPromise = client.create(
  {_type: 'post', title: 'Hello'},
  {signal}
)

// Cancel the mutation after 1 second
setTimeout(() => {
  controller.abort()
  console.log('Mutation canceled')
}, 1000)

try {
  await mutationPromise
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Request was aborted')
  }
}

// Use with transactions
const txController = new AbortController()

const transaction = client
  .transaction()
  .create({_type: 'post', title: 'Post 1'})
  .create({_type: 'post', title: 'Post 2'})

try {
  await transaction.commit({signal: txController.signal})
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Transaction was aborted')
  }
}
```

### Unsubscribing from Observables

When using the Observable API, unsubscribe to cancel the request:

**observables.ts**

```typescript
const subscription = client
  .observable
  .create({_type: 'post', title: 'Hello'})
  .subscribe({
    next: (result) => console.log('Created:', result),
    error: (error) => console.error('Error:', error),
    complete: () => console.log('Complete')
  })

// Cancel by unsubscribing
setTimeout(() => {
  subscription.unsubscribe()
  console.log('Unsubscribed from mutation')
}, 1000)

// Use with transactions
const txSubscription = client.observable
  .transaction()
  .create({_type: 'post', title: 'Post 1'})
  .create({_type: 'post', title: 'Post 2'})
  .commit()
  .subscribe({
    next: (result) => console.log('Transaction result:', result),
    error: (error) => console.error('Transaction error:', error)
  })

// Unsubscribe to cancel
txSubscription.unsubscribe()
```

### React cleanup example

This example shows how to cancel requests when a React component unmounts:

**useCreatePost.tsx**

```typescript
import {useEffect, useState} from 'react'
import {type SanityDocument} from '@sanity/client'
import {client} from './sanityClient'

function useCreatePost(postData: Record<string, unknown>) {
  const [result, setResult] = useState<SanityDocument | null>(null)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const controller = new AbortController()

    client
      .create(postData, {signal: controller.signal})
      .then(setResult)
      .catch((err: Error) => {
        if (err.name !== 'AbortError') {
          setError(err)
        }
      })

    // Cleanup: abort request if component unmounts
    return () => controller.abort()
  }, [postData])

  return {result, error}
}
```

## Reference documentation

For the complete set of mutation options, error types, and client configuration, see the reference documentation for [SanityClient](https://reference.sanity.io/_sanity/client/index/SanityClient/) and [ClientConfig](https://reference.sanity.io/_sanity/client/index/ClientConfig/).



# Introduction

**npm**

```shell
npx sanity@latest --help
```

**pnpm**

```shell
pnpm dlx sanity@latest --help
```

**yarn**

```shell
yarn dlx sanity@latest --help
```

**bun**

```shell
bunx sanity@latest --help
```

The `sanity` Command Line Interface (CLI) is a collection of tools for managing, developing, debugging, and deploying your Sanity Studio projects as well as running scripts to migrate or manipulate your data.

To make sure you always have the latest version we suggest running entirely using `npx sanity@latest [command]`, which bypasses the need to install the CLI globally. Additional package managers like pnpm are also supported.

[Reference: Command Line Interface](https://www.sanity.io/docs/cli-reference/cli-config)
Learn about all the commands available in the sanity CLI

> [!WARNING]
> You'll need Node.js and NPM
> Whether you choose to install the `sanity` CLI or use it only with `npx`, you will need node and npm installed on your system.
> [How to install node and npm?](https://nodejs.org/)

## The CLI configuration file

You can add project-specific CLI configuration by adding a file named `sanity.cli.js` (`.ts`) in your project‘s root folder. 

### Minimal example

```javascript
// sanity.cli.js
import { defineCliConfig } from "sanity/cli";

export default defineCliConfig({
  api: {
    projectId: "YOUR_PROJECT_ID",
    dataset: "YOUR_DATASET",
  }
});

```

### Advanced example

You can embed further settings in your CLI configuration file, including specifying the local server port for `sanity dev`, GraphQL deployments, and extending the Vite configuration.

```javascript
// sanity.cli.js
import { defineCliConfig } from "sanity/cli";

export default defineCliConfig({
  api: {
    projectId: "YOUR_PROJECT_ID",
    dataset: "YOUR_DATASET",
  },
  server: {
    hostname: "localhost",
    port: 3333,
  },
  graphql: [{
    tag: "default",
    playground: true,
    generation: "gen3",
    nonNullDocumentFields: false,
  }],
  vite: (config) => config,
});

```

## Uninstall Sanity CLI

If you previously installed `sanity` globally and experience unexpected version collisions, you can uninstall it with the following commands:

```sh
# Remove the CLI globally
npm uninstall --global sanity

# Alternatively
yarn global remove sanity
pnpm remove --global sanity
```



# Authentication

The Sanity CLI authenticates against your account through a browser-based OAuth flow, and stores the resulting session token in a single configuration file on your machine. This page covers the auth surface end-to-end: signing in, managing tokens, signing out, switching accounts, where the token is stored, and signing in through an SSO provider.

## Sign in with sanity login

Run `npx sanity login` to open a browser window where you can choose an identity provider, sign in, and return to the CLI with an active session. After a successful login the CLI writes the session token to your local configuration file.

**npm**

```shell
# Open the browser-based login flow
npx sanity login

# Sign in with a specific identity provider directly
npx sanity login --provider google

# Sign in with SSO using your organization slug
npx sanity login --sso my-org

# Print the login URL without opening a browser (useful on headless machines)
npx sanity login --no-open

# Authenticate non-interactively by piping a token to stdin
echo "$SANITY_AUTH_TOKEN" | npx sanity login --with-token
```

**pnpm**

```shell
# Open the browser-based login flow
pnpm dlx sanity login

# Sign in with a specific identity provider directly
pnpm dlx sanity login --provider google

# Sign in with SSO using your organization slug
pnpm dlx sanity login --sso my-org

# Print the login URL without opening a browser (useful on headless machines)
pnpm dlx sanity login --no-open

# Authenticate non-interactively by piping a token to stdin
echo "$SANITY_AUTH_TOKEN" | npx sanity login --with-token
```

**yarn**

```shell
# Open the browser-based login flow
yarn dlx sanity login

# Sign in with a specific identity provider directly
yarn dlx sanity login --provider google

# Sign in with SSO using your organization slug
yarn dlx sanity login --sso my-org

# Print the login URL without opening a browser (useful on headless machines)
yarn dlx sanity login --no-open

# Authenticate non-interactively by piping a token to stdin
echo "$SANITY_AUTH_TOKEN" | npx sanity login --with-token
```

**bun**

```shell
# Open the browser-based login flow
bunx sanity login

# Sign in with a specific identity provider directly
bunx sanity login --provider google

# Sign in with SSO using your organization slug
bunx sanity login --sso my-org

# Print the login URL without opening a browser (useful on headless machines)
bunx sanity login --no-open

# Authenticate non-interactively by piping a token to stdin
echo "$SANITY_AUTH_TOKEN" | npx sanity login --with-token
```

Useful flags: `--sso <org-slug>` for organizations that sign in through an identity provider, `--sso-provider <name>` to target a specific provider, `--provider <name>` to skip the picker for a known account, `--with-token` to read a token from stdin, and `--no-open` to print the login URL instead of launching a browser. See the [login reference](https://www.sanity.io/docs/cli-reference/login) for a full list of options.

## Sign out with sanity logout

Run `npx sanity logout` to invalidate the active session on the server and clear the local token. If the server reports the session was already invalid, the CLI still clears the local token and exits successfully.

### Cannot delete session for robot user error

If `npx sanity logout` returns *Cannot delete session for robot user - use delete token endpoint*, your CLI is configured with a robot token (a long-lived API token) rather than a user session. Robot tokens do not have a server-side logout, so they have to be revoked through the tokens API instead:

**npm**

```shell
# List robot tokens to find the ID you want to revoke
npx sanity tokens list

# Revoke the robot token by ID
npx sanity tokens delete <token-id>
```

**pnpm**

```shell
# List robot tokens to find the ID you want to revoke
pnpm dlx sanity tokens list

# Revoke the robot token by ID
pnpm dlx sanity tokens delete <token-id>
```

**yarn**

```shell
# List robot tokens to find the ID you want to revoke
yarn dlx sanity tokens list

# Revoke the robot token by ID
yarn dlx sanity tokens delete <token-id>
```

**bun**

```shell
# List robot tokens to find the ID you want to revoke
bunx sanity tokens list

# Revoke the robot token by ID
bunx sanity tokens delete <token-id>
```

## Manage robot tokens with sanity tokens

Use the `npx sanity tokens` command to manage robot tokens (long-lived API credentials) from the CLI. The command has three subcommands:

- `npx sanity tokens list`: list robot tokens in your project, with their IDs and labels.
- `npx sanity tokens add`: create a new robot token with a label and a role.
- `npx sanity tokens delete <id>`: revoke a robot token by ID.

For the full flag reference and command output, see [Tokens CLI command reference](https://www.sanity.io/docs/cli-reference/tokens).

## Switch to a different account

To sign in as a different user, run `npx sanity login` again. The CLI invalidates your previous session, clears the local token, and writes the new session token in one step. You do not need to run `npx sanity logout` first.

## Where the CLI stores your token

The Sanity CLI stores your session token in a single JSON file at `~/.config/sanity/config.json`. This path is the same on macOS, Linux, and Windows. The CLI does not use OS-specific configuration directories.

The file is a JSON object. After signing in, it contains your `authToken` alongside the CLI's telemetry-consent record. Other transient fields (for example, `telemetryDisclosed`) may also be present:

```json
{
  "authToken": "sk...",
  "telemetryConsent": "..."
}
```

> [!NOTE]
> To use a different config location (for testing, dev environments, or staging), set the `SANITY_CLI_CONFIG_PATH` environment variable to your chosen path. The CLI also reads `~/.config/sanity-staging/` when configured to use the staging environment.

## Sign in through SSO

If your organization signs in through an identity provider, pass `--sso <org-slug>` to route the login flow to your IdP. The org slug is the short identifier used in your organization's Sanity URL.

**npm**

```shell
# Sign in through your organization's SSO
npx sanity login --sso my-org

# Target a specific SSO provider configured for the organization
npx sanity login --sso my-org --sso-provider okta
```

**pnpm**

```shell
# Sign in through your organization's SSO
pnpm dlx sanity login --sso my-org

# Target a specific SSO provider configured for the organization
pnpm dlx sanity login --sso my-org --sso-provider okta
```

**yarn**

```shell
# Sign in through your organization's SSO
yarn dlx sanity login --sso my-org

# Target a specific SSO provider configured for the organization
yarn dlx sanity login --sso my-org --sso-provider okta
```

**bun**

```shell
# Sign in through your organization's SSO
bunx sanity login --sso my-org

# Target a specific SSO provider configured for the organization
bunx sanity login --sso my-org --sso-provider okta
```



# Importing content

> [!NOTE]
> Media Library available
> This guide outlines details for importing documents, including images and files, into a dataset. The Media Library allows images and files to be used in any dataset in your organization.
> For details on importing assets to a centralized library, review our guide on [importing assets](https://www.sanity.io/docs/media-library/importing-assets).

The recommended way of importing data is to use the [Command Line Interface](https://www.sanity.io/docs/apis-and-sdks/cli). You can run  `sanity datasets import --help` for a quick summary of syntax and options. Your other option is to use one of our client libraries and handle it yourself. 

> [!TIP]
> Validation is client-side only
> Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See [Schema validation and the Content Lake](https://www.sanity.io/docs/content-lake/schema-validation-and-the-content-lake) for details.

> [!WARNING]
> Avoid unexpected webhook and function invocations
> Consider disabling any webhooks and functions you might have that could cause high volumes of traffic to the receiving endpoint on importing data.

## Import using the CLI

The Sanity import tool operates on [newline-delimited JSON](https://github.com/ndjson/ndjson-spec) (NDJSON) files. Basically, each line in a file is a valid JSON-object containing a document you want to import.

Documents should follow the structure of your [data model](https://www.sanity.io/docs/studio/connected-content) – most importantly, the requirement of a `_type` attribute. The `_id` field is optional – but helpful – in case you want to make references or be able to re-import your data replacing data from an old import. `_id`s in Sanity are [usually a GUID](https://www.sanity.io/docs/content-lake/ids), but any string containing only letters, numbers, hyphens, and underscores are valid.

During import, all references are automatically set to *weak*, then flipped to *strong* after all documents are in place. This ensures that you can import documents that reference other documents in any order you like.

[Assets (images and files)](https://www.sanity.io/docs/content-lake/assets) are stored using references in Sanity. To make it easy to import these and refer to them within your documents, you can use a special `_sanityAsset` property where you would normally put a `_ref`. For instance, let's say you want your document to end up like this:

```javascript
{
  "_id": "movie_123",
  "_type": "movie",
  "title": "Rogue One",
  "poster": {
    "_type": "image",
    "asset": {
      "_ref": "image_234",
      "_type": "reference"
    }
  }
}
```

This is what your ready-to-import document should look like:

```javascript
{
  "_id": "movie_123",
  "_type": "movie",
  "title": "Rogue One",
  "poster": {
    "_type": "image",
    "_sanityAsset": "image@file:///local/path/to/rogue-one-poster.jpg",
  }
}
```

However, ndjson uses the newline character as delimiter (NDJSON == Newline Delimited JSON), therefore your ndjson file must be structured with one document on each line, like this:

```json
{"_id": "movie_123", "_type": "movie", "title": "Rogue One", "poster": {"_type": "image", "_sanityAsset": "image@file:///local/path/to/rogue-one-poster.jpg"}}
{"_id": "another_movie", "_type": "movie"}
{"_id": "yet_another_movie", "_type": "movie"}

```

Note that you need to prefix the asset URL with a type declaration – either `image@` or `file@`.

If your asset is on the Internet use `image@https://example.com/path/to/rogue-one-poster.jpg` instead of `image@file:///local/path/to/rogue-one-poster.jpg`.

> [!TIP]
> File URIs are absolute so include the entire path.

Once you have prepared your ndjson file, you can run the import using the Sanity CLI.

> [!NOTE]
> What should I import?
> In some cases you will want to import your ndjson file, such as when you've exported your dataset, made changes to the ndjson file, and are importing it back into the same dataset.
> In other cases you will want to compress your dataset back into a tarball / tar file (`.tar`, `.tar.gz`, or `.tgz`), which includes the ndjson file and your assets. You might take this approach when [migrating data](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) to a new dataset, as you'll want to maintain references to assets.
> If you're getting an import error like `Error: Error while fetching asset from "file://./images/<image-name>.<ext>": File does not exist at the specified endpoint`, you can either (1) make the filenames absolute or (2) import a tarball (including assets) rather than an ndjson file.

**npm**

```shell
npx sanity@latest datasets import <file> <targetDataset>
```

**pnpm**

```shell
pnpm dlx sanity@latest datasets import <file> <targetDataset>
```

**yarn**

```shell
yarn dlx sanity@latest datasets import <file> <targetDataset>
```

**bun**

```shell
bunx sanity@latest datasets import <file> <targetDataset>
```



### Changes to the _updatedAt field

When you import documents that reference assets or other documents, Sanity initially preserves the value of the `_updatedAt` field of these documents.

However, references in documents are first imported as [weak references](https://www.sanity.io/docs/studio/reference-type), and strengthened later in the import process. To strengthen references, patch mutations are submitted for the containing documents.

**These patches run in new transactions, which sets _updatedAt to the time the patch executes successfully**. Documents without references will keep their original `_updatedAt`.

### Handling existing documents

The import will fail if an incoming document already exists in the dataset. A couple of options allow you to amend this:

- `--replace` Overwrite existing documents. If you specify `_id` in the imported data, this flag can be very useful. It will let you reimport stuff that you got wrong in an earlier pass.
- `--missing` Only create documents which don't exist, leave the rest alone.

The import will also fail if an asset is unavailable. This typically happens if the file isn't at the given path on your local system or the asset URL returns 404. You can tell the import *not* to fail on a missing asset by passing the `--allow-failing-assets` option.

> [!TIP]
> Protip
> Check out our [reference-type docs](https://www.sanity.io/docs/studio/reference-type) page for more ways on how to reference different documents.

## Import using a client library

If you prefer not to use our CLI import tool, you may of course do the import yourself with help from one of our client libraries.

There are some common pitfalls to keep in mind:

### Concurrency

While you may have thousands of documents to import, you shouldn't trigger thousands of requests in parallel. The API allows 25 mutations per second per IP, and requests over that limit return `429 Too Many Requests`. `@sanity/client` [retries rate-limited queries automatically, but not mutations](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced). Use a queue with a reasonably low concurrency to keep your import below the [API rate limit](https://www.sanity.io/docs/content-lake/technical-limits):

```javascript
const {default: PQueue} = require('p-queue')
const queue = new PQueue({
  concurrency: 1,
  interval: 1000 / 25
})

queue.add(() => client.create(...))
queue.add(() => client.patch('id').inc('visits').commit())
```

### API usage limits

Importing large data sets can quickly cause a lot of requests, especially if you import a single document per request. It is usually a good idea to send [multiple mutations within a single transaction](https://www.sanity.io/docs/js-client).

### Mutation size limits

While it's a good idea to do multiple mutations per transaction, you need to make sure that the size of the request is [within our limits](https://www.sanity.io/docs/content-lake/technical-limits), in terms of byte size.

### Mutation visibility

A Sanity client will use the visibility mode of `sync` by default, which means that it will wait for the documents to be searchable before returning. This should not be necessary when importing large datasets, so we recommend you use `deferred`. If you have a lot of documents, it can take a little while for them to be searchable, but the import job will move along much faster.

### References

If you are referring to one document from another, they either need to be imported in the right order, or the reference needs to be flagged as *weak* by setting the `_weak` property to `true`. After importing, you probably want to remove the weak property in order to prevent referenced documents from being deleted.

> [!WARNING]
> Gotcha
> When a weak reference is desired, you should use the `weak` property when [defined in the schema](https://www.sanity.io/docs/studio/reference-type) but `_weak` when set up using a client. Using the `weak` property with the client will likely return the error: `key "weak" not allowed in ref`.
> `weak` in the schema, `_weak` in the JSON.

### Assets

Since assets (e.g., files and images) in Sanity are stored using references, you'll need to upload the assets first and put the returned document ID in your reference.

With this in mind, do check out our [client libraries](https://www.sanity.io/docs/client-libraries) documentation to see how to perform mutations.



# Reference

The `sanity` Command Line Interface (CLI) is a handy tool for managing your Sanity projects in your terminal. Note that there are some commands that can only be run in a project folder and global ones.

[Learn more about the Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli)
Learn how to set up and configure the Sanity CLI

## Configuration file

The Sanity CLI can read configuration from a `sanity.cli.js` (`.ts`) file in the same folder that the command is run in. It will fall back on the configuration in the `sanity.config.ts` file.

Use `defineCliConfig` from `sanity/cli` to configure the CLI with TypeScript type-checking:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: '<YOUR_PROJECT_ID>',
    dataset: '<YOUR_DATASET>',
  },
  server: {
    hostname: 'localhost',
    port: 3333,
  },
})
```

See the properties table below for all available options.

#### Properties

**api** (CliApiConfig)

Defines the projectId and dataset that the CLI should connect to and run its commands on.

**deployment** ({ appId?: string, autoUpdates?: boolean})

appId: The ID of your studio or app. Generated when deploying your studio or app for the first time.

autoUpdates: Enable auto-updates for studios.

**graphql** (GraphQLAPIConfig[])

Defines the GraphQL APIs that the CLI can deploy and interact with.

**mediaLibrary** ({ aspectsPath?: string })

aspectsPath: The path to the Media Library aspects directory. When using the CLI to manage aspects, this is the directory they will be read from and written to.

**project** ({ basePath?: string })

Contains the property basePath which lets you change the top-level slug for the Studio. You typically need to set this if you embed the Studio in another application where it is one of many routes. Defaults to an empty string.

**reactCompiler** (boolean | ReactCompilerConfig)

Allows customization of the underlying React compiler config.

**reactStrictMode** (boolean)

Wraps the Studio in <React.StrictMode> root to aid in flagging potential problems related to concurrent features (startTransition, useTransition, useDeferredValue, Suspense). Defaults to true in development. To opt out, set reactStrictMode: false. Can also be controlled by setting SANITY_STUDIO_REACT_STRICT_MODE="true"|"false".

**server** ({ hostname?: string, port?: number })

Defines the hostname and port that the development server should run on. hostname defaults to localhost, and port to 3333.

**vite** (any)

Exposes the default Vite configuration for the Studio so it can be changed and extended.

**typegen** (TypeGenConfig)

Configures automatic TypeScript type generation during sanity dev and sanity build. Properties include enabled, path, generates, and overloadClientMethods. See Sanity TypeGen for details.

**schemaExtraction** (Object)

Configures automatic schema extraction during sanity dev and sanity build. Properties: enabled, path, enforceRequiredFields, watchPatterns, and workspace.

**app** (AppConfig)

Configuration for App SDK applications. Properties: organizationId (required), entry (default: './src/App.tsx'), and visibility (Dashboard visibility; default or unlisted; defaults to default).

> [!WARNING]
> Gotcha
> If you run `sanity --help` outside a folder with a project configuration file and without a specified `projectId` flag, you will only see the subset of commands that aren't project-specific.

## GraphQLAPIConfig

#### Properties

**id** (string)

ID of GraphQL API. Only (currently) required when using the --api flag for sanity graphql deploy, in order to only deploy a specific API.

**workspace** (string)

Name of workspace containing the schema to deploy

Optional, defaults to default (e.g., the one used if no name is defined).

**source** (string)

Name of source containing the schema to deploy, within the configured workspace

Optional, defaults to default (e.g., the one used if no name is defined).

**tag** (string)

API tag for this API. Allows deploying multiple different APIs to a single dataset.

Optional, defaults to default

**playground** (boolean)

Whether or not to deploy a "GraphQL Playground" to the API URL. This is an HTML interface that allows running queries and introspecting the schema from the browser. Note that this interface is not secured in any way, but as the schema definition and API route is generally open, this does not expose any more information than is otherwise available. It only makes it more discoverable.
Optional, defaults to true.

**generation** ('gen3' | 'gen2' | 'gen1')

Generation of API to auto-generate from schema. New APIs should use the latest (gen3).

Optional, defaults to gen3

**nonNullDocumentFields** (boolean)

Define document interface fields (_id, _type, etc.) as non-nullable. If you never use a document type as an object (within other documents) in your schemas, you can (and probably should) set this to true. Because a document type could be used inside other documents, it is by default set to false, as in these cases these fields can be null.

Optional, defaults to false

**filterSuffix** (string)

Suffix to use for generated filter types.

Optional, defaults to Filter.

## Commands

```text
USAGE
  $ npx sanity [COMMAND]

TOPICS
  api            Make an authenticated HTTP request to a Sanity API
  backups        Manage dataset backups
  blueprints     Local Blueprint and remote Stack management commands
  cors           Manage CORS origins for your project
  datasets       Manage datasets in your project
  docs           Browse and search Sanity documentation
  documents      Manage documents in a dataset
  functions      Sanity Function development and management commands
  graphql        Manage GraphQL APIs for your project
  hooks          Manage webhooks for your project
  manifest       Extract studio configuration as JSON manifests
  mcp            Configure Sanity MCP server for AI agents
  media          Manage media assets and aspect definitions
  migrations     Run and manage content migrations
  openapi        Manage OpenAPI specifications
  organizations  Manage your organizations
  projects       Manage Sanity projects
  schemas        Manage and validate schemas
  skills         Install Sanity agent skills for AI agents
  telemetry      Manage telemetry consent
  tokens         Manage API tokens for your project
  typegen        Generate TypeScript types for schema and GROQ
  users          Manage project users and invitations

COMMANDS
  api       Make an authenticated HTTP request to a Sanity API
  build     Build Sanity Studio into a static bundle
  codemod   Updates Sanity Studio codebase with a code modification script
  debug     Print diagnostic info for troubleshooting
  deploy    Builds and deploys Sanity Studio or application to Sanity hosting
  dev       Start a local development server with live reloading
  doctor    Run diagnostics on your Sanity project
  exec      Executes a script within the Sanity Studio context
  help      Display help for sanity.
  init      Initialize a new Sanity Studio, project and/or app
  install   Install dependencies for the Sanity Studio project
  learn     Open Sanity Learn in your browser
  login     Log in to your Sanity account
  logout    Log out of the current session
  manage    Open project settings in your browser
  preview   Start a local server to preview a production build
  undeploy  Removes the deployed Sanity Studio/App from Sanity hosting
  versions  Show installed package versions

```

> [!NOTE]
> CLI option flag order
> For commands with option flags, add the option flag to the end after any arguments. When adding option flags to both commands and subcommands, make sure the command flags are before the subcommand. For example: 
> `sanity COMMAND [args] [--command-flags] SUBCOMMAND [args] --[subcommand-flags]`
> You can always run `sanity COMMAND --help` for usage tips and examples.

## Changing <hostname>.sanity.studio

To change the host name of your Sanity-hosted Studio (e.g., `https://<oldHostName>.sanity.studio` to `https://<newHostName>.sanity.studio`), please see [Undeploying the Studio](https://www.sanity.io/docs/studio/deployment).

## Debugging `sanity` commands

Not to be confused with [sanity debug](https://www.sanity.io/docs/cli-reference/debug), which returns information about your Sanity environment, you can use the `DEBUG` environment variable with your `sanity` commands to get more verbose results and troubleshoot potential issues.

For full debugger results, use a wildcard on its own (`DEBUG=* sanity <command>`). For more targeted results, you can specify a namespace followed by a wildcard (`DEBUG=sanity* sanity <command>` or `DEBUG=sanity:cli* sanity <command>`).

> [!NOTE]
> Example
> Least verbose
> `sanity dataset import production.tar.gz dev`
> More verbose, returning all debuggers in the `sanity` namespace
> `DEBUG=sanity* sanity dataset import production.tar.gz dev`
> Most verbose, returning **all** debuggers
> `DEBUG=* sanity dataset import production.tar.gz dev`

Results can also be excluded by using a `-` prefix. `DEBUG=sanity*,-sanity:export* sanity dataset export production production.tar.gz` would return all debuggers in the `sanity` namespace except for `sanity:export` debuggers (e.g., `sanity:cli` and `sanity:client`) during export of the `production` dataset.

## Authorizing the CLI

In most cases, you'll use `sanity login` to authenticate with the Sanity API. When you need to run the CLI unattended, like in a CI/CD environment, set the `SANITY_AUTH_TOKEN` environment variable to a token. You can generate tokens in the [project management dashboard](https://sanity.io/manage).



# Managing backups

Sanity offers a backup feature that provides a robust solution for disaster recovery and content history auditing, ensuring your data's safety and integrity. With the ability to restore your production environment seamlessly and to inspect historical data states, this feature is a powerful tool for maintaining data continuity and compliance. Don't have a plan that supports backups? You can manually export your data with the [CLI's datasets command](https://www.sanity.io/docs/cli-reference/cli-datasets).

Backups work at the dataset level. To recover individual deleted documents, see [Find and restore deleted documents](https://www.sanity.io/docs/developer-guides/find-and-restore-deleted-documents).

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

## Core concepts

### Backup contents

A "backup" in the context of this article is an archived snapshot of the state of your dataset (documents and assets) at a specific time. You can use it to audit the history of your content or roll it back to a known safe state in case of data loss or unintended changes.

Each backup contains all documents and assets from your dataset in their state from when the backup job ran. This includes hidden documents used for settings and configuration by the Studio and installed plugins. [Comments](https://www.sanity.io/docs/studio/comments) and [document history](https://www.sanity.io/docs/http-reference/history) (the timeline shown when you select "Review changes" in the Studio) are not included in the backup.

When you download a backup, the resulting file contains an archive with all your documents exported into a single [NDJSON](https://github.com/ndjson/ndjson-spec) file alongside your files and images in separate folders, neatly collected into a single gzip-compressed archive file with a `.tar.gz` file type, colloquially known as a "tarball."

```bash
production-backup-2024-02-23-a9bfa2d7-9ba1-42cc-beb2-f9f448bec656/
├── data.ndjson
├── files
│   └── file.txt
└── images
    └── image.png
```

### Backup frequency and retention time

Once you enable the backup service, as described in the next section of this article, Sanity will perform a backup of your dataset daily.

> [!WARNING]
> Gotcha
> The backup service runs at set regular intervals, so your initial backup may take up to 24 hours to become available.

Your backups are managed by Sanity in an offsite third-party storage location for data redundancy and security. Daily backups are stored for 365 days. Weekly backups are stored for an additional two years on top of the one year of daily backups.

### Deleted datasets

If you delete a dataset, then no new backups will be created. Any existing backups will continue to be accessible. If you later create a new dataset with the same name, then:

1. You will need to actively enable backups for this new dataset to start the backup service up again.
2. Backups for the older, deleted dataset will no longer be accessible directly through the CLI. Contact support if you require them.

## Enabling and disabling backups

Enabling and disabling the backup service is mainly done with the Sanity CLI.

### Prerequisites

- The relevant project is on a supported plan.
- The backup feature is enabled for the project.
- The Sanity CLI is up to date (v3.31.0 or later is required).
- The user has administrator permissions for the project.

### Enable backups

To enable backups for a dataset, use the `sanity backups enable` CLI command in your project folder:

```sh
sanity backups enable [DATASET_NAME]
```

You should see a confirmation message in your CLI, and your first backup should be available within 24 hours.

### Disable backups

To disable backups for a dataset, use the `sanity backups disable` CLI command in your project folder:

```sh
sanity backups disable [DATASET_NAME]
```

No further backups will be scheduled. Your existing backups will continue to exist and be available for downloading.

## Common commands

### List available backups

To list all available backups for a dataset, use the following CLI command in your project folder:

```sh
sanity backups list [DATASET_NAME]
```

Running this command will list the available backups for the dataset in question.

```bash
┌──────────┬─────────────────────┬─────────────────────────────────────────────────┐
│ RESOURCE │ CREATED AT          │ BACKUP ID                                       │
├──────────┼─────────────────────┼─────────────────────────────────────────────────┤
│ Dataset  │ 2024-02-21 16:57:34 │ 2024-02-21-cf51334d-4caa-4487-a746-75a49b078e82 │
│ Dataset  │ 2024-02-22 02:40:30 │ 2024-02-22-c66adb69-cbed-4e4f-88a2-f97b5feeb464 │
│ Dataset  │ 2024-02-23 01:43:28 │ 2024-02-23-e1b1dcd3-fa9b-45a2-ab58-9f1c1eae45c7 │
└──────────┴─────────────────────┴─────────────────────────────────────────────────┘
```

By default, this command will list the 30 most recent backups. You can use the `--limit` parameter to increase the listing threshold to a maximum of 100, or you can use the `--after` and `--before` parameters to target a specific time period from which to list backups.

```sh
sanity backups list production --after 2024-01-10 --before 2024-01-31 --limit 10
```

### Download backups

To download a specific backup for a dataset, use the following CLI command in your project folder:

```sh
sanity backups download [DATASET_NAME] --backup-id [BACKUP_ID] --out [FILE_NAME]
```

`[BACKUP_ID]` needs to match the ID of an existing backup, and `[FILE_NAME]` should be a valid file name for the resulting downloaded file. A more realistic example is shown below.

```sh
sanity backups download production --backup-id 2024-02-23-a9bfa2d7-9ba1-42cc-beb2-f9f448bec656 --out backup_2024.tar.gz
```

If you don't specify the file name in the `--out` flag, the backup file will follow the `[dataset name]-backup-[backup ID].tar.gz` convention.

To learn about all the options for this command, refer to the CLI reference article, or run `sanity backups download --help` in the CLI.

### Restore from a backup

You can use the `sanity datasets import` CLI command to restore from a downloaded backup. Include the `--replace` flag so the backup overwrites what is currently in the dataset. Without it, the import fails for every document whose ID already exists in the target dataset.

```sh
sanity datasets import ~/Downloads/backup_2024.tar.gz production --replace
```

> [!WARNING]
> Gotcha
> If you are importing a backup into a different dataset than the one the backup originated from, you will have to use the `--allow-assets-in-different-dataset` option on import. Read about this and other parameters and options available for this command in the relevant [CLI reference article](https://www.sanity.io/docs/cli-reference/cli-datasets), or by running `sanity datasets import --help` in the CLI.

Downloaded backups are structured to be ready for importing, both in their original compressed file state and in their decompressed file structure.

#### Restoring is not a point-in-time reset

An import only writes the documents contained in the backup. `--replace` overwrites documents that share an `_id` with a document in the backup. It never removes documents that exist in the target dataset but not in the backup, so drafts and documents created after the backup was taken survive the restore.

To return a dataset to exactly the state captured in a backup, you need one of two additional steps:

1. Delete the dataset and recreate it with the same name before importing. Read the Deleted datasets section of this article first, because backups taken under the deleted dataset are no longer accessible through the CLI and you have to enable backups again for the new dataset. [Restore a deleted dataset from a backup](https://www.sanity.io/docs/content-lake/restore-deleted-dataset) covers the full procedure.
2. Keep the dataset and delete the leftover documents with a [content migration](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) that compares the document IDs currently in the dataset against the IDs in the backup's `data.ndjson` file.

## Conclusion

The backup feature from Sanity offers a solution for securing your content, providing data redundancy, means of compliance, and peace of mind. Contact your account manager to have backups enabled for your enterprise project, or visit our [pricing page](https://www.sanity.io/pricing) to learn more about Sanity's enterprise plan offerings.



# Generating types

If you use TypeScript for your frontend or web application, you will want to type the content from the Sanity Content Lake API. With the Sanity TypeGen tooling, you can generate type definitions from the schema types in your Studio and the results of your GROQ queries in your frontends and applications.

Typing your content is useful for:

- Catching bugs and errors caused by wrongly handled data types, like forgetting to check for `null` or a `undefined` property
- Autocomplete of fields available in the result of your GROQ query
- Making it easier to refactor integration code when you make changes to the schema

This article will present the different aspects of type generation and walk you through workflows depending on your project structure (separate repositories, monorepos, and embedded Studio).

[Course: Typed content with Sanity TypeGen](https://www.sanity.io/learn/course/typescripted-content/introduction)
Introduction course to typed content on Sanity Learn

## Requirements

- Sanity Studio v5.10.0 or later
- A Sanity Studio project with a schema
- GROQ queries assigned to variables and using the [groq template string helper](https://github.com/sanity-io/sanity/tree/main/packages/groq)

## Overview

You can use Sanity TypeGen to generate types for your Sanity Studio schema and for the return value of a GROQ query run against documents made from that schema.

Types from your schema can be useful for cases where GROQ isn't used, such as [Studio customization](https://www.sanity.io/docs/customization) and [schema change management](https://www.sanity.io/docs/content-lake/schema-and-content-migrations).

The most common use case is generating types for GROQ queries. TypeGen works by "overlaying the schema types" over the GROQ query to determine what types the returned data will have.

### Using GraphQL?

If you primarily use [the Sanity GraphQL API](https://www.sanity.io/docs/content-lake/graphql), we recommend using established GraphQL TypeScript tooling, like [GraphQL Code Generator](https://the-guild.dev). You can use your GraphQL API URL as the configuration setting for `schema`.

## Minimal example

Sanity TypeGen needs to access a static representation of your Studio schema to generate types. You can use the `sanity schema extract` command to create the `schema.json` file the TypeGen command requires:

```sh
$ cd ~/my-studio-folder
$ sanity schema extract # outputs a `schema.json` file
✔ Extracted schema

$ sanity typegen generate
✔ Config loaded from ./sanity.cli.ts
✔ Schema loaded from ./schema.json
✔ Successfully generated types to /Users/dev/my-studio-folder/sanity.types.ts in 225ms
  └─ 2 queries and 2 schema types
  └─ found queries in 1 files after evaluating 1 files
  └─ formatted the generated code with prettier
```

## Types from schemas

Take this schema type for "event" documents:

> [!NOTE]
> TypeGen preserves casing in type names and properly quotes non-identifier field names. For example, field names like `my-field` are correctly quoted as `'my-field': string` in the generated types.

Input

**./src/schema/event.ts**

```typescript
export const event = defineType({
  name: 'event',
  type: 'document',
  title: 'Event',
  fields: [
    defineField({
      name: 'name',
      type: 'string',
      title: 'Event name',
      validation: rule => rule.required()
    }),
    defineField({
      name: 'description',
      type: 'text',
      title: 'Event description'
    })
  ]
})
```

Generated types

**sanity.types.ts**

```typescript
export type Event = {
  _id: string;
  _type: 'event';
  _createdAt: string;
  _updatedAt: string;
  _rev: string;
  name?: string;
  description?: string
}
```

### Supported schema types

Nearly all schema types and all permutations of schema types are supported:

- Document types
- Literal fields (boolean, string, text, number, geopoint, date, dateTime)
- Object types
- Array of types
- Portable Text (Block content)
- References
- Image and file assets

Unsupported schema that will be typed as `unknown`:

- Cross-dataset references

> [!NOTE]
> ☝ Is missing support for certain schema types blocking you? Let us know!

### Supported schema features

Since Studio schemas are defined in JavaScript, it can get gnarly to represent and generate statically TypeScript definitions from specific configuration options. However, the following schema configuration options are supported.

#### Required field validation and non-optional fields

If you add `validation: rule => rule.required()` to a field, you might want to translate required rules into non-optional types depending on your use case. You do this by adding the `--enforce-required-fields` flag when extracting the schema:

```sh
$ npx sanity schema extract --enforce-required-fields
✔ Extracted schema, with enforced required fields
$ npx sanity typegen generate
✔ ...
```

> [!WARNING]
> Gotcha
> If you have enabled previews of unpublished content, then remember that values might be `undefined` or `null` even though the field is set as required. Validation is only checked for published documents, and draft documents are allowed to be in an "invalid" state.

"Built-in" fields required for documents in the Sanity Content Lake will also be set to required in the TypeScript definition: `_id`, `_type`, `_createdAt`, `_updatedAt`, `_rev`.

#### Literals and `options.list`

The string schema type supports adding a list of predefined values in `options.list`. This gets generated into a literal type in the TypeScript definition:

Input

**./src/schema/event.ts**

```typescript
export const event = defineType({
  name: 'event',
  type: 'document',
  title: 'Event',
  fields: [
    defineField({
      name: 'name',
      type: 'string',
      title: 'Event name',
      validation: rule => rule.required()
    }),
    defineField({
      name: 'description',
      type: 'text',
      title: 'Event description'
    }),
    defineField({
      name: 'format',
      type: 'string',
      title: 'Event format',
      options: {
        list: ['in-person', 'virtual'],
        layout: 'radio',
      },
    }),
  ]
})

```

Generated types

**sanity.types.ts**

```typescript
export type Event = {
  _id: string;
  _type: 'event';
  _createdAt: string;
  _updatedAt: string;
  _rev: string;
  name: string;
  description?: string;
  format?: 'in-person' | 'virtual';
}
```

## Types from GROQ queries

Sanity TypeGen can also generate TypeScript definitions for GROQ query *results*. This is useful since GROQ is a query language that lets you specify which fields to return in projections and re-shape that data to fit your needs.

The CLI command requires that a GROQ query is:

- Assigned to a variable (it does *not* need to be exported)
- Uses the `groq` [template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), or `defineQuery`, from the [groq](https://www.npmjs.com/package/groq) package (also exported by [next-sanity](https://github.com/sanity-io/next-sanity))
- Validates as a GROQ expression

The `typegen` requires all queries to have a unique name. This also means that no inline queries are included in the generated types.

```tsx
// ✅ Will be included
async function getStuff() {
	const myUniquelyNamedQuery = groq`*[_type == 'post']{ slug, title }`
	const result = await client.fetch(myUniquelyNamedQuery)
	return result
}

async function getMoreStuff() {
	const myUniquelyNamedQuery = defineQuery(`*[_type == 'post']{ slug, title }`)
	const result = await client.fetch(myUniquelyNamedQuery)
	return result
}

// ❌ Will not be included
async function getInlineStuff() {
	const result = await client.fetch(groq`*[_type == 'post']{ slug, title }`)
	return result
}
```

### Supported GROQ features

Since GROQ is so versatile, we are still working on identifying edge cases, and some functions are not yet supported.

> [!NOTE]
> Unsupported expressions
> Unsupported GROQ expressions will be typed as `unknown` by TypeGen.

Supported features:

- Data types: Null, Boolean, Number, String, Array, Object
- Selectors: Everything (`*`), this (`@`), attribute filters (`[name == "string"]`), parent (`^`)
- Functions: `coalesce()`, `select()`, `dateTime::now()`, `global::now()`, `round()`, `upper()`, `lower()`, `select()` (and `=>`), and array functions
- Compounds: parenthesis, traversals (`[]`), pipe function calls (`|`)
- Operators: and (`&&`), or (`||`), not (`!=`), equality (`==`), comparison (`<, <=, >, >=`), plus (`+`), minus (`--`), unaries (`++` `--`), star (`*`), slash (`/`), percent (`%`), star star (`**`)

> [!NOTE]
> ☝ Is missing support for certain GROQ features blocking you? Let us know in [the community](https://snty.link/community)!

### Supported file types

TypeGen parses queries from multiple file types:

- TypeScript (.ts, .tsx)
- JavaScript (.js, .jsx)
- Astro (.astro)
- Svelte (.svelte)
- Vue (.vue)

SvelteKit's `defineQuery` function is fully supported for automatic type inference.

### Automatic Sanity Client type inference

By using `defineQuery` when writing your GROQ queries the Sanity Client will automatically return types when the query is used with `fetch`, after running `sanity typegen generate`.

#### Example

**sanity.queries.ts**

```typescript
import { defineQuery } from 'groq'

export const postsQuery = defineQuery(`*[_type == "event"]{title}`)

// data.ts
import { createClient } from '@sanity/client'
import { postsQuery } from './sanity.queries.ts'

const client = createClient({...})

export function getPosts() {
  return client.fetch(postsQuery) // <- the returned type here is automatically inferred
}

```

> [!WARNING]
> Gotcha
> For TypeScript to return the query types generated by TypeGen the generated `sanity.types.ts` needs to be included in the pattern configured in the `includes` array in `tsconfig.json`.

> [!NOTE]
> Opt out of automatic type inference
> You can opt out by setting `overloadClientMethods` to `false` in your `sanity.cli.ts`.

### Ignoring individual queries

You can instruct the type generator to skip generating query types for individual queries by having **@sanity-typegen-ignore** in a leading comment before the query, similar to how ESLint and TypeScript can be instructed.

### Minimal example

Input

**sanity.queries.ts**

```typescript
import groq from 'groq'
// import { groq } from 'next-sanity'

const postQuery = groq`*[_type == "event"]{title}`

const authorQuery = groq`*[_type == "author" && name == $name][0]{_type, name, description}`

// this query wont get generated types because of the instruction below
// @sanity-typegen-ignore
const anotherQuery = groq`*[_type == "another"][0]`
```

Generated types

**sanity.types.ts**

```typescript
// Variable: postQuery
// Query: *[_type == "event"]{title}
export type postQueryResult = Array<{
  title: string | null
}>

// Variable: authorQuery
// Query: *[_type == "author" && name == $name][0]{_type, name, description}
export type authorQueryResult = {
  _type: 'author'
  name: string | null
  description: string | null
} | null
```

## Type utilities

TypeGen provides utility types to help you work with generated types in complex schemas.

### Get utility

The Get utility extracts deeply nested properties from your types, supporting up to 20 levels of nesting. This is especially useful for page builder schemas with complex nested structures.

```typescript
import type { Get } from '@sanity/codegen'
import type { Page } from './sanity.types'

// Extract a deeply nested type
type HeroSection = Get<Page, 'sections', number, 'hero'>
```

This replaces verbose type manipulation with NonNullable and index access.

### FilterByType utility

The FilterByType utility filters specific types from union types using the _type discriminator. This is useful when working with arrays of different block types.

```typescript
import type { FilterByType } from '@sanity/codegen'
import type { PageBuilder } from './sanity.types'

// Extract only hero blocks from a union
type HeroBlock = FilterByType<PageBuilder, 'hero'>

// Works with multiple types
type ContentBlocks = FilterByType<PageBuilder, 'hero' | 'textBlock' | 'imageGallery'>
```

## Type generation workflows

Generating types from your schemas and GROQ queries using the Sanity CLI in the root Sanity Studio folder for the relevant project. First, you'll run a command to extract the structure of your schemas into a format suited for further processing and then convert it into a handy JSON file. Then you'll run another command to generate and output type definitions based on that same JSON file.

### Automatic type generation

Automatic type generation is the recommended workflow. Enable it in your sanity.cli.ts configuration, and types regenerate automatically during development.

**sanity.cli.ts**

```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'

export default defineCliConfig({
  typegen: {
    enabled: true,
  },
})
```

With enabled set to true, types regenerate automatically when you run sanity dev or sanity build. You can still use manual commands when needed.

> [!NOTE]
> Understanding configuration options
> `typegen.enabled: true` activates automatic type generation during `sanity dev` or `sanity build`. Similarly, `schemaExtraction.enabled: true` activates automatic schema extraction (generating schema.json).
> **In the Studio:** You typically want schema extraction enabled. Enable typegen too if you need types in your Studio code (for custom components, etc.).
> **In separate frontend repositories:** Use `sanity typegen generate --watch` directly, since you're not using `sanity dev/build` to build your frontend.

For separate frontend repositories, use watch mode: sanity typegen generate --watch to keep types in sync as your schema changes.

### General workflow

1. Extract current schema with `sanity schema extract`
2. Generate types from schemas and queries with `sanity typegen generate`

### Extracting Studio schema to `schema.json`

The first step towards generating type definitions based on your schemas is to extract the entire schema structure into a single JSON file for the `typegen` command to ingest.

👉 In your Sanity project root (wherever your `sanity.config.ts` lives), run the following CLI command:

```sh
$ npx sanity schema extract
✔ Extracted schema

# If you have multiple workspaces defined, specify which one to use:
$ npx sanity schema extract --workspace=commerce
✔ Extracted schema
```

The CLI tool will pick up the schema definition from your project configuration, and generate a representation of your complete schema structure in a new file named `schema.json` unless otherwise specified. You are now ready to proceed to the next step.

🔗 Learn how to override the default output file and more in the CLI reference docs.

### Generate `sanity.types.ts` from `schema.json`

Once you have extracted your schema as described in the previous section, you are all set to generate some types.

👉 Still in your Sanity project root, run the following command in your CLI:

```sh
$ npx sanity typegen generate
✔ ...
```

The CLI tool will look for the `schema.json` file you created in the previous step and will create a new file by default named `sanity.types.ts` containing all the type declarations for your schema and for any GROQ query found in the default source file path, which is `./src`.

The `generate` command can be configured by adding a property named `typegen` to your `sanity.cli.ts/js` containing a configuration object with the following shape:

**sanity.cli.ts**

```typescript
import { defineCliConfig } from 'sanity/cli';

export default defineCliConfig({
  // ...rest of config
  typegen: {
    path: "./src/**/*.{ts,tsx,js,jsx}", // glob pattern to your typescript files. Can also be an array of paths
    schema: "schema.json", // path to your schema file, generated with 'sanity schema extract' command
    generates: "./sanity.types.ts", // path to the output file for generated type definitions
    overloadClientMethods: true, // set to false to disable automatic overloading the sanity client
  },
})
```

The example above is shown with the default values. Note that the `path` can be defined as either an array of strings or a single string.

> [!NOTE]
> Deprecated typegen config file
> Type generation was previously configured in a separate configuration file (typically `sanity-typegen.json`). This has been deprecated and we advise everyone to move their configuration into the main CLI configuration (typically found in `sanity.cli.ts`).

### Watch mode

Both `sanity schema extract` and `sanity typegen generate` features a `--watch` flag (from v5.8.0) to watch for changes in your files and generate the schema and type files as you change things in your project.

The schema extraction watch mode watches for changes in your Studio and generates the `schema.json` file.

The type generation watches the configured schema path and files you've configured your type generation to look for.

```sh
$ cd ~/frontend
$ sanity typegen generate --watch
✓ Config loaded from ./sanity.cli.ts
✔ Schema loaded from ./schema.json
✔ Successfully generated types to /Users/dev/frontend/sanity.types.ts in 328ms
  └─ 0 queries and 18 schema types
  └─ found queries in 0 files after evaluating 1 file
  └─ formatted the generated code with prettier
[5:42:03 PM] change: src/queries.ts
✔ Schema loaded from ./schema.json
✔ Successfully generated types to /Users/dev/frontend/sanity.types.ts in 414ms
  └─ 1 query and 18 schema types
  └─ found queries in 1 file after evaluating 1 file
  └─ formatted the generated code with prettier
```

### Generated files and version control

Commit `schema.json` and `sanity.types.ts` to your repository. Both files are generated, but tracking them means every developer, CI job, and type check starts from the same types without running the CLI first.

Sanity's starter templates commit these files. They also regenerate them before the `dev` and `build` scripts run, so the committed types stay in step with the schema:

**package.json**

```json
{
  "scripts": {
    "typegen": "sanity schema extract && sanity typegen generate",
    "predev": "npm run typegen",
    "prebuild": "npm run typegen"
  }
}
```

Teams working in the same schema often hit merge conflicts in `sanity.types.ts`. Because the file is regenerated in full on every run, you never have to resolve one by hand. Take either side of the conflict, then regenerate:

**CLI**

```sh
$ npx sanity schema extract
$ npx sanity typegen generate
```

> [!NOTE]
> Ignoring the generated files
> You can add `schema.json` and `sanity.types.ts` to `.gitignore` instead. Every environment that builds or type-checks your code then has to run `sanity schema extract` and `sanity typegen generate` first, including CI and any teammate who has cloned the repository.

### Example: embedded Studio

A common pattern is to embed Sanity Studio in another application, keeping everything in a single repository. You can find several example repositories that follow this convention in the templates section of the Sanity Exchange. This is the happiest of paths since your Studio and application are sharing a single project root. Likely, the only adjustment you might need to make is to specify the path to your queries (unless it's in a sub-directory of `./src` in which case the default settings have you fully covered!)

### Example: monorepo

Another common way of structuring a Sanity-powered project is to create a "monorepo" within which your Studio and your consuming application live separately side by side, possibly in sub-repositories of their own. Depending on the needs and preferences of your project you could use the configuration options of each CLI command to output the generated files into your consuming application, or you could keep the generated files in the Studio folder and put it on the application to find them by traversing the monorepo.

**npm**

```shell
// Use the --path flag to output schema.json elsewhere

npx sanity schema extract --path ../../my-cool-app/sanity-schemas.json
```

**pnpm**

```shell
// Use the --path flag to output schema.json elsewhere

pnpm dlx sanity schema extract --path ../../my-cool-app/sanity-schemas.json
```

**yarn**

```shell
// Use the --path flag to output schema.json elsewhere

yarn dlx sanity schema extract --path ../../my-cool-app/sanity-schemas.json
```

**bun**

```shell
// Use the --path flag to output schema.json elsewhere

bunx sanity schema extract --path ../../my-cool-app/sanity-schemas.json
```

**sanity.cli.ts**

```typescript
import { defineCliConfig } from 'sanity/cli';

export default defineCliConfig({
  // ...rest of config
  typegen: {
    path: "../../my-cool-app/src/**/*.{ts,tsx,js,jsx}", // glob pattern to your typescript files
    schema: "../../my-cool-app/sanity-schemas.json", // path to your schema file, generated with 'sanity schema extract' command
    generates: "../../my-cool-app/sanity.types.ts" // path to the output file for generated type definitions
  },
})
```

### Example: separate repos

Use this recipe when your consumer (frontend or app) lives in a different repository from your Studio. The consumer repo runs TypeGen against a `schema.json` file extracted from the Studio.

#### In the Studio repo: extract the schema

Run `sanity schema extract` and write the output to a path your frontend can read:

**npm**

```shell
npx sanity schema extract --path ../my-frontend/schema.json
```

**pnpm**

```shell
pnpm dlx sanity schema extract --path ../my-frontend/schema.json
```

**yarn**

```shell
yarn dlx sanity schema extract --path ../my-frontend/schema.json
```

**bun**

```shell
bunx sanity schema extract --path ../my-frontend/schema.json
```

For automated workflows, run this command in CI on every Studio deploy, or publish `schema.json` through a shared internal package.

#### In the frontend repo: install and configure

Install the required packages. The full `sanity` package is required for the CLI commands. Add it to `devDependencies` so it stays out of your runtime bundle:

**npm**

```shell
npm install --save-dev sanity
```

**pnpm**

```shell
pnpm add --save-dev sanity
```

**yarn**

```shell
yarn add --dev sanity
```

**bun**

```shell
bun add --dev sanity
```

Create `sanity.cli.ts` at the root of your frontend repo and configure the `typegen` block:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  typegen: {
    path: './src/**/*.{ts,tsx,js,jsx}',
    schema: './schema.json',
    generates: './sanity.types.ts',
  },
})
```

Generate types. Use watch mode in development to regenerate types as your queries change:

**npm**

```shell
# Development: regenerate on save
npx sanity typegen generate --watch

# CI: run once
npx sanity typegen generate
```

**pnpm**

```shell
# Development: regenerate on save
pnpm dlx sanity typegen generate --watch

# CI: run once
pnpm dlx sanity typegen generate
```

**yarn**

```shell
# Development: regenerate on save
yarn dlx sanity typegen generate --watch

# CI: run once
yarn dlx sanity typegen generate
```

**bun**

```shell
# Development: regenerate on save
bunx sanity typegen generate --watch

# CI: run once
bunx sanity typegen generate
```

> [!NOTE]
> The standalone `sanity-typegen.json` config file is deprecated. In v5, configuration lives in `sanity.cli.ts`. The integrated TypeGen feature and the `--watch` flag both require Sanity 5.8.0 or higher. Version 5.10.0 or higher is recommended (TypeGen GA release).



# Programmatic control

Content Releases let you organize and schedule updates across multiple documents. You can plan, preview, and validate significant changes in advance, then publish them together.

This document explores interacting with Content Releases using Sanity's APIs. For details on using Content Releases in Sanity Studio, or customizing the experience, follow these links:

[Content Releases user guide](https://www.sanity.io/docs/user-guides/content-releases)
Create, schedule, and publish releases from Sanity Studio.

[Content Releases configuration](https://www.sanity.io/docs/studio/content-releases-configuration)
Configure Content Releases in Sanity Studio

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

> [!NOTE]
> Scheduled Drafts is also available
> For teams on Growth or above plans, or that don’t need to schedule groups of documents to go out at once, the [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) feature is available.

APIs that interact with Content Releases require API version `v2025-02-19` or later. A single release can contain a maximum of 1,000 documents, and the combined JSON of all documents in a release cannot exceed 100 MB. Asset files linked from those documents don't count toward the size limit.

## Releases and document versions

Releases are Sanity documents with a type of `system.release`. The top-level `state` property holds the release state, and `metadata` holds the user-supplied fields such as `metadata.title`, `metadata.description`, `metadata.releaseType`, and `metadata.intendedPublishAt`.

> [!TIP]
> Protip
> If you use [content resources and custom roles](https://www.sanity.io/docs/user-guides/roles), you can restrict access for:
> 1. Editing documents *in* releases by using a filter like `_id in path("versions.**")` for any release or `_id in path("versions.rA29bfjqa.**")` for documents in a specific release.
> 2. Performing release actions such as creating, publishing and archiving releases by using a filter like `_id in path("_.releases.**")` for any release or `_id == "_.releases.rA29bfjqa"` for a specific release.

Releases and documents are connected by a document ID system similar to the `drafts.` syntax. For releases, document IDs start with the `versions.` prefix. For example:

- The published version: `movie_70981`
- A release version: `versions.RELEASE_NAME.movie_70981`

Releases have a name, not to be confused with the user-supplied title. This name matches the end of the `_id`. When you create a release through the API, the `releaseId` you supply becomes the name; `client.releases.create()` generates one for you if you omit it. For example, a release name of `rSC2jjcUJ` results in an `_id` of `_.releases.rSC2jjcUJ`.

## Release states

The current status of a release is known as the release `state`. Releases begin in the `active` state. This information is available on the `state` property in documents with a `_type` of `system.release`.

<div style="display:none">Unknown block type "mermaidDiagram", specify a component for it in the `components.types` option</div>A release may have the following states (`state`):

- `active`: The general state of a release that is not within one of the other states. *This is the default state of a new release*.
- `scheduled`: A state resulting from calling the `sanity.action.release.schedule` action on the release or scheduling the release in Studio.
- `published`: A state resulting from either calling the `sanity.action.release.publish` action, publishing the release in Studio, or when a scheduled release is published due to reaching its `publishAt` time.
- `archived`: A state resulting from calling the `sanity.action.release.archive` action or archiving the release in Studio.

There is no `deleted` state. The `sanity.action.release.delete` action, or deleting the release in Studio, removes the release document. You can only delete a release that is `published` or `archived`.

> [!WARNING]
> Gotcha
> When `scheduled`, any version documents that are part of the release are locked. To mutate these documents, either in Studio or programmatically, the release must have a `state` of `active`.

Additional transient states exist to indicate the asynchronous points when releases move between states:

- `scheduling`/`unscheduling`: Intermediate states that exist when moving to or from the `scheduled` state.
- `archiving`/`unarchiving`: Intermediate states that exist when moving to or from the `archived` state.
- `publishing`: Intermediate state that exists before reaching the `published` state. A scheduled release also transitions through `publishing`.

### State transitions

Releases begin in `active`. Every other state is reached through one of these transitions:

- Scheduling or publishing an `active` release moves it to `scheduling`, then `scheduled`.
- Unscheduling a `scheduled` release moves it to `unscheduling`, then back to `active`.
- A `scheduled` release moves to `publishing` when its publish time arrives, then to `published`.
- Archiving an `active` release moves it to `archiving`, then `archived`.
- Unarchiving an `archived` release moves it to `unarchiving`, then back to `active`.
- Deleting a `published` or `archived` release removes the release document.

If publishing or archiving fails, the release returns to `active` and the `error` property holds the reason. Large releases publish, archive, and unarchive in batches, so they can stay in `publishing`, `archiving`, or `unarchiving` across several updates.

A scheduled release stores its publish time in the top-level `publishAt` property. This is distinct from `metadata.intendedPublishAt`, which records the time an editor picked in Studio. Where both are set, `publishAt` takes precedence.

Even releases set for immediate publishing move through the scheduling and scheduled states. They do not stay there; they immediately move on to publishing. Keep this in mind if you listen for state changes on release documents.

## Query releases and versions

Releases are Sanity documents and respect the existing query and mutation APIs. The Content Releases API cheat sheet provides examples of querying and interacting with releases and their documents.

[Content Releases API cheat sheet](https://www.sanity.io/docs/apis-and-sdks/content-releases-cheat-sheet)
Common patterns for querying and interacting with releases and their documents

## Additional resources

[Actions API reference](https://www.sanity.io/docs/http-reference/actions)
Reference documentation for the Actions HTTP endpoint, including the release actions.

[GROQ functions](https://www.sanity.io/docs/specifications/groq-functions)
GROQ queries can use the releases::all(), sanity::partOfRelease(), and sanity::versionOf() functions to retrieve release information.

[@sanity/id-utils](https://github.com/sanity-io/id-utils)
This utility library helps parse and convert between the various ID formats.



# Cheat sheet

Interfacing with [Content Releases](https://www.sanity.io/docs/content-lake/content-release-document-flow) is similar to interfacing with other documents and relationships in the Content Lake. 

**Prerequisites:**

- The examples below use a variety of APIs to cover common patterns. As releases aren't public, make sure your requests are [authenticated](https://www.sanity.io/docs/content-lake/http-auth) and match the correct [URL format](https://www.sanity.io/docs/content-lake/http-urls) for each API. For example, using Sanity clients or other integrations, you'll need to configure them with an appropriate token and permissions to view unpublished content.
- Release APIs and features are available in API version `2025-02-19` and later unless otherwise noted.
- Many query interactions on this page assume a perspective that can view release information. You should set the perspective to `raw` or a unique release stack when viewing release information.
- For examples that use the `@sanity/client` actions and shorthand methods, version 7.13.0 or higher is recommended.

## Create a new release

As releases are Sanity documents (`system.release`), you can interact with them the same way you would mutate any other document. The Sanity client also offers helpers methods. For example:

```
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: true,
    apiVersion: '2025-02-19',
    token: '<token>',
})

const {releaseId} = await client.releases.create({
  metadata: {
    title: 'New bike release',
    releaseType: 'asap'
  }
})
```

For a full list of available metadata properties, see the type definitions in your editor or the `sanity.action.release.create` [reference documentation](https://www.sanity.io/docs/http-actions#b6800cddd015).

## Modify release information

You can modify an existing releases metadata by passing a patch in along with the releaseId. For example:

```
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: true,
    apiVersion: '2025-02-19',
    token: '<token>',
})

const release = await client.releases.edit({
  releaseId: 'r123456', // your releaseId
  patch: {
      set: {
        metadata: {
          releaseType: 'asap',
        },
      },
    },
})
```

You can also edit releases using the [Mutate API](https://www.sanity.io/docs/http-reference/mutation).

## Get all releases for a project and dataset

Access releases by querying the documents with the `releases::all()` GROQ function. This is the preferred method for retrieving a list of releases.

### JS Client

Use the client's fetch method to query for releases.

Input

```typescript
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: true,
    apiVersion: '2025-02-19',
    token: '<token>',
    perspective: 'raw'
})

const query = "releases::all()"
const params = {}
client.fetch(query, params).then((data)=>{
  console.log(data)
})
```

Response

```json
[
    {
      "_createdAt": "2024-11-26T21:30:57Z",
      "finalDocumentStates": null,
      "_updatedAt": "2024-12-17T16:33:26Z",
      "_type": "system.release",
      "name": "rHw6FBu82",
      "_id": "_.releases.rHw6FBu82",
      "state": "active",
      "metadata": {
        "releaseType": "scheduled",
        "title": "End of year release",
        "intendedPublishAt": "Mon Dec 30 2024"
      },
      "publishAt": "2024-12-30T08:00:00Z",
      "_rev": "JmI5JuFTDPq3paS6p09Jmu",
      "userId": "paATypsg4"
    },
    {
      "publishAt": null,
      "_rev": "1kbjGQwz5Z0FmijO2l7Lwl",
      "finalDocumentStates": [
        {
          "id": "versions.rglJO3Sfg.movie_70981",
          "_key": "1kbjGQwz5Z0FmijO2l7M0E"
        }
      ],
      "_id": "_.releases.rglJO3Sfg",
      "state": "published",
      "metadata": {
        "title": "Quick fixes",
        "releaseType": "asap"
      },
      "_createdAt": "2024-11-26T22:01:56Z",
      "_type": "system.release",
      "name": "rglJO3Sfg",
      "_updatedAt": "2024-12-02T17:32:59Z",
      "userId": ""
    },
    {
      "_createdAt": "2024-11-26T18:34:14Z",
      "name": "rqZSzJ1uS",
      "finalDocumentStates": [
        {
          "id": "versions.rqZSzJ1uS.movie_10681"
        }
      ],
      "userId": "paATypsg4",
      "_id": "_.releases.rqZSzJ1uS",
      "state": "published",
      "_updatedAt": "2024-12-05T17:22:00Z",
      "metadata": {
        "releaseType": "scheduled",
        "description": "Experimental updates for testing",
        "title": "Experimental updates",
        "intendedPublishAt": "2024-12-05T17:22:00.000Z"
      },
      "publishAt": "2024-12-05T17:22:00Z",
      "_rev": "5dKCVUpSDccCmU1E23aUAc",
      "_type": "system.release"
    },
  // ...
  ],
```

### Query API

You access releases by querying for documents with the `releases::all()` GROQ function using the [Query API](https://www.sanity.io/docs/http-reference/query). This is the preferred method for retrieving a list of releases.

Use the following GROQ query in the API request:

```groq
releases::all()
```

Input

```sh
curl "https://<project-id>.api.sanity.io/2025-02-19/data/query/<dataset-name>?query=<GROQ-QUERY>" \
    --H "Authorization: Bearer <token>" \
```

Example response

```json
{
  "query": "*[releases::all()]",
  "result": [
    {
      "_createdAt": "2024-11-26T21:30:57Z",
      "finalDocumentStates": null,
      "_updatedAt": "2024-12-17T16:33:26Z",
      "_type": "system.release",
      "name": "rHw6FBu82",
      "_id": "_.releases.rHw6FBu82",
      "state": "active",
      "metadata": {
        "releaseType": "scheduled",
        "title": "End of year release",
        "intendedPublishAt": "Mon Dec 30 2024"
      },
      "publishAt": "2024-12-30T08:00:00Z",
      "_rev": "JmI5JuFTDPq3paS6p09Jmu",
      "userId": "paATypsg4"
    },
    {
      "publishAt": null,
      "_rev": "1kbjGQwz5Z0FmijO2l7Lwl",
      "finalDocumentStates": [
        {
          "id": "versions.rglJO3Sfg.movie_70981",
          "_key": "1kbjGQwz5Z0FmijO2l7M0E"
        }
      ],
      "_id": "_.releases.rglJO3Sfg",
      "state": "published",
      "metadata": {
        "title": "Quick fixes",
        "releaseType": "asap"
      },
      "_createdAt": "2024-11-26T22:01:56Z",
      "_type": "system.release",
      "name": "rglJO3Sfg",
      "_updatedAt": "2024-12-02T17:32:59Z",
      "userId": ""
    },
    {
      "_createdAt": "2024-11-26T18:34:14Z",
      "name": "rqZSzJ1uS",
      "finalDocumentStates": [
        {
          "id": "versions.rqZSzJ1uS.movie_10681"
        }
      ],
      "userId": "paATypsg4",
      "_id": "_.releases.rqZSzJ1uS",
      "state": "published",
      "_updatedAt": "2024-12-05T17:22:00Z",
      "metadata": {
        "releaseType": "scheduled",
        "description": "Experimental updates for testing",
        "title": "Experimental updates",
        "intendedPublishAt": "2024-12-05T17:22:00.000Z"
      },
      "publishAt": "2024-12-05T17:22:00Z",
      "_rev": "5dKCVUpSDccCmU1E23aUAc",
      "_type": "system.release"
    },
  ],
  "syncTags": [
    "s1:r6H+EQ"
  ],
  "ms": 3
}
```

To view only active releases, and exclude archived releases, adjust your GROQ query to compare the `state` property.

```text
releases::all()[state == 'active']
```

## Get all documents from a release

Query all documents associated with a release.

### JS client

```
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: true,
    apiVersion: '2025-02-19',
    token: '<token>',
})

const release = await client.releases.get({
  releaseId: 'r123456',
})
```

This is the equivalent of using the `sanity::partOfRelease` GROQ function.

### `sanity::partOfRelease` GROQ function

The sanity::partOfRelease GROQ function accepts a release name and returns all documents associated with the release.

> [!TIP]
> Release ID vs. release name
> Release names are the final piece of a release ID. For example, a release with an id of `_.releases.rEGM2JqQ3` has a name of `rEGM2JqQ3`. Use just the name final portion of the ID when referencing releases by name.

Use the function in a GROQ query and pass the release name string.

```typescript
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: false,
    apiVersion: '2025-02-19',
    token: '<token>',
    perspective: 'raw'
})

const query = "*[sanity::partOfRelease(<release-name>)] { _id }"
const params = {}
client.fetch(query, params).then((data)=>{
  console.log(data)
})
```

## Get all versions of a document

Query all versions (published, drafts, and release versions) of a document.

### `sanity::versionOf` GROQ function

The `sanity::versionOf` GROQ function accepts a document ID and returns all versions of a document.

Use the function in a GROQ query and pass the document ID string.

```typescript
import { createClient } from "@sanity/client";
import { getPublishedId } from "sanity"

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: false,
    apiVersion: '2025-02-19',
    token: '<token>',
    perspective: 'raw'
})

const query = "*[sanity::versionOf($publishedId)] { _id }"
const params = {
  publishedId: getPublishedId(documentId)
}
client.fetch(query, params).then((data)=>{
  console.log(data)
})
```

> [!WARNING]
> Gotcha
> The function expects a published document ID. For example: `abc123` is acceptable, but `drafts.abc123` and `versions.r1324.abc123` are not. You can use the `getPublishedId` helper imported from `sanity` to derive it from any Id.

The function also works with the Query API and anywhere that supports GROQ functions.

### Doc API

Use the document ID, along with the [Doc API endpoint](https://www.sanity.io/docs/http-reference/doc) to retrieve all versions of a specific document. The `includeAllVersions` boolean query parameter returns all versions for the document.

Input

```sh
GET /vX/data/doc/production/movie_70981?includeAllVersions=true
```

Example response

```json
{
  "documents": [
    {
      "_createdAt": "2018-06-13T08:57:45Z",
      "_id": "movie_70981",
      "_rev": "1kbjGQwz5Z0FmijO2l7Lwl",
      "_type": "movie",
      "_updatedAt": "2024-12-02T17:32:59Z",
      // ...
    },
    {
      "_createdAt": "2018-06-13T08:57:45Z",
      "_id": "drafts.movie_70981",
      "_rev": "3276f9d1-0343-4b78-a79e-8c6561942f1b",
      "_type": "movie",
      "_updatedAt": "2024-12-02T17:14:27Z",
      // ...
    },
    {
      "_createdAt": "2018-06-13T08:57:45Z",
      "_id": "versions.rHw6FBu82.movie_70981",
      "_rev": "a000fa99-072c-434c-8669-825103a111b7",
      "_type": "movie",
      "_updatedAt": "2024-11-27T18:36:19Z",
      // ...
    }
  ],
  "omitted": []
}

```

## Use releases in perspective queries

Content Releases use a layering system that layers document versions atop one another, allowing you to create a custom perspective stack. You can learn more about layering in the [Content Releases User Guide](https://www.sanity.io/docs/user-guides/content-releases). 

Perspective in addition to accepting the `raw`, `published`, and `drafts` states, also accepts a comma-separated list of release names. Releases take priority from left to right.

For example, in the perspective `a,b,c` you would see changes in `a` take priority over `b` and `c`, and changes in `b` take priority over `c`.

The `published` perspective is automatically added to the end, so even if a release only contains changes to one document, the response will include all matching published documents in addition to the release changes.

### Query API

To query against a list of releases, use the `perspective` query parameter and order the release names by priority from left to right. For example: `?perspective=a,b,c`.

Using a GROQ query such as `*[_type == 'movie'] { _id }` will return all document IDs that match the releases layer.

Input

```sh
GET https://<projectId>.api.sanity.io/vX/data/query/<dataset>?query‌‌‌‌‌‌=<GROQ-QUERY>&perspective=<release-name1>,<release-name2>
```

Example response

```json
{
  "query": "*[_type == 'movie']{ _id }",
  "result": [
    { "_id": "a306e7cf-ea18-4a43-8ce2-0586073c41c8" },
    { "_id": "movie_10681" },
    { "_id": "movie_118340" },
    { "_id": "movie_126889" },
    { "_id": "movie_157336" },
    { "_id": "movie_17654" },

  ],
  "syncTags": ["s1:+jIWIw"],
  "ms": 5
}

```

### JavaScript Client

In addition to the `raw`, `published`, and `drafts` values, the client also accepts an array of release name strings. You can add `drafts` to the end of the array to include drafts that aren't part of any release.

Edit the perspective value to insert your release names.

```typescript
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: false, // Don't use the CDN for draft/release previewing
    apiVersion: '2025-02-19',
    token: '<token>',
    perspective: ['<release-name-1>', '<release-name-2>']
})

const query = "*[_type == 'movie']"
const params = {}
client.fetch(query, params).then((data)=>{
  console.log(data)
})
```

Another common pattern is to extend your client configuration for releases and draft preview by creating a new client from the existing one.

```tsx
import { createClient } from "@sanity/client";

const client = createClient({
    projectId: '<project-id>',
    dataset: '<dataset>',
    useCdn: true,
    apiVersion: '2024-08-01',
    token: '<token>',
    perspective: 'published' //default
})

const previewClient = client.withConfig({
  useCdn: false,
  apiVersion: '2025-02-19',
  perspective: ['<release name>', 'drafts']
})
```

## Trigger webhook or functions by release state

The Release documents can be queried and trigger assigned webhooks. 

The most useful way of triggering webhooks might be off the release `state`. 

A release may have the following states:

- `active`: The general state of a release that is not within one of the other states.
- `scheduled`: A state resulting from calling the `sanity.action.release.schedule` action on the release.
- `published`: A state resulting from either calling the `sanity.action.release.publish` action, or when a scheduled release is published due to reaching its `publishAt` time.
- `archived`: A state resulting from calling the `sanity.action.release.archive` action.
- `deleted`: A state resulting from calling the `sanity.action.release.delete` action on an archived release.

Additional transient states exist to indicate the asynchronous points when releases move between states:

- `scheduling`/`unscheduling`: Intermediate states which will exist when moving to/from the `scheduled` state.
- `archiving`/`unarchiving`: Intermediate states which will exist when moving to/from the `archived` state.
- `publishing`: Intermediate state which will exist before reaching the `published` state. Note that a scheduled release will also transition through `publishing`.

### Functions

You can create [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction) that activate when you create a release, or when it moves through different states.

**Every release creation or update**

```
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'release-function',
      event: {
        on: ['create', 'update'],
        filter: '_type == "system.release"'
      }
    }),
  ],
})‌
```

**State changed**

```
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: 'release-function',
      event: {
        on: ['update'],
        filter: '_type == "system.release" && delta::changedAny(state) && state == "published"'
      }
    }),
  ],
})‌
```

### Webhook

To create a webhook that listens to all new releases, define a webhook rule as follows:

```json
"rule":{
  "on":["create"],
  "filter":"_type == 'system.release'"
}
```

Additionally filters can enable triggering only on releases of a particular state. In this example only releases that have transitioned into a `published` state will trigger the webhook:

```json
"rule": {
  "on": ["update"],
  "filter": "_type == 'system.release' && delta::changedAny(state) && state == 'published'"
}
```

## Find documents scheduled for deletion

Sometimes you have a release that will delete documents. You can query for a list of scheduled-for-deletion docs with GROQ. Replace the `$releaseName` parameter in the following example with your own release name.

```
releases::all()[name == $releaseName] {
  _id,
  "docs": *[sanity::partOfRelease(^.name) && _system.delete == true]._id
}
```

## Patch all versions of a document

If you need to make an update to all versions of a document, you can use the mutations API along with the `sanity::versionOf` GROQ function.

**JS Client**

```
await client
  .patch({query: `*[sanity::versionOf('your-document-id')]`})
  .set({
    title: "example"
  })
  .commit()
```

**HTTP**

```text
curl --location 'https://<project-id>.api.sanity.io/v2025-03-01/data/mutate/<dataset>' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <token>' \
--data '{ 
  "mutations": [
    {"patch": {
        "query": "*[sanity::versionOf('\''{doc-id}'\'')]",
        "set": {
            "field": "value"   
        }
    }
    }
  ]
}'
```

## Query and sort releases for perspective-building

Sometimes you want to build your own perspective stack outside of a preview / visual editing context. You can do so by querying, filtering, and then sorting the releases.

This example:

- Queries releases by their type.
- Orders the results by descending date, so the furthest in the future show first. For scheduled releases, this uses the scheduled date or the target release date. For other types, it uses the _createdAt datetime value.
- Builds a perspective stack that you can use to query content. Note that using the full stack like this will essentially act as "raw", with some slight layering differences depending on the ordering.

In practice, you should adjust the criteria for which releases to include.

**Example**

```
// make sure your client uses the `raw` perspective and a supported API version.
import { client } from "./client.ts";

type Release = { name: string };

const RELEASES_QUERY = `{
  "undecided": *[_type == "system.release" && state == "active" && metadata.releaseType == "undecided"] | order(_createdAt desc) {
    _id,
    _createdAt,
    name,
    metadata {
      title
    }
  },
  "asap": *[_type == "system.release" && state == "active" && metadata.releaseType == "asap"] | order(_createdAt desc) {
    _id,
    _createdAt,
    name,
    metadata {
      title
    }
  },
  "scheduled": *[_type == "system.release" && state in ["active", "scheduled"] && metadata.releaseType == "scheduled"]  | order(coalesce(publishAt, metadata.intendedPublish), desc) {
    _id,
    _createdAt,
    name,
    publishAt,
    metadata {
      title,
      intendedPublishAt
    },
  }
}`;

const releasesByType = await client.fetch(RELEASES_QUERY);
const { undecided, asap, scheduled }: { undecided: Release[]; asap: Release[]; scheduled: Release[] } = releasesByType;

const perspectiveStack = [
  'drafts', 
  ...asap.map(release => release.name), 
  ...scheduled.map(release => release.name), 
  ...undecided.map(release => release.name)
  // published is automatically applied to perspective stacks
];

// use your new perspective to create a new client
const pespectiveClient = client.withConfig({ perspective: perspectiveStack });
```

Perspectives take priority from left to right. Learn more in the [perspective documentation](https://www.sanity.io/docs/content-lake/perspectives).

## Get perspective stack in custom components

If you're building with Studio, you can use the [usePerspective hook](https://reference.sanity.io/sanity/index/usePerspective/) to retrieve the active perspective stack.

```
import { usePerspective } from 'sanity'

function MyComponent() {
 const { perspectiveStack } = usePerspective()
  // ...
}
```

## Create a document version in a release

Use `client.createVersion()` to add a version of an existing published document to a release. Prefer `baseId` so Sanity copies the current published content for you. See [Content releases and versions with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-releases) for the full guide.

**create-version.ts**

```typescript
await client.createVersion({
  releaseId,
  publishedId: 'product-123',
  baseId: 'product-123',
})
```



# Presenting images

Sanity provides a powerful image pipeline that gives you access to a globally-distributed asset CDN. This allows you to request images in various sizes, crops, and formats on demand, with assets automatically cached close to your users for optimal performance.

This guide explains how to effectively present and transform images from your Sanity Content Lake in your front-end applications. For information about how assets are stored or uploaded, see the [Assets documentation](https://www.sanity.io/docs/content-lake/assets).

Prerequisites:

- A Sanity project with images uploaded to the Content Lake.
- Understanding of your front-end framework of choice.
- Familiarity with your schema.

## Understanding image representation in Sanity

Before implementing images in your front-end, it's important to understand how Sanity handles images. In Sanity:

- The `image` type represents an image used as a field in a document or embedded in block text.
- Images may contain additional fields like caption, credits, and specific crop and hotspot information.
- Each `image` references an `asset` which contains the actual image data.
- One `asset` can be shared by multiple `image` instances, allowing editors to reuse assets with different crops, captions, etc.

> [!TIP]
> Media Library
> You may have images in your dataset, images in your organization's Media Library, or a mix of the two. In all scenarios, you should request the assets from your project's dataset. They'll still pull the latest updates from Media Library, but will ensure you're using the correct asset pipeline.

## Get the image URL

The simplest way to display images from Sanity is to get the base URL of the image from the asset, then transform it. Here's how to query for an image URL:

**GROQ**

```groq
*[_type == 'person']{
  name,
  "imageUrl": image.asset->url
}
```

**Result**

```json
{
  "name": "Sean Gunn",
  "imageUrl": "https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg"
}
```

> [!WARNING]
> Gotcha
> Be aware, that **requesting un-optimised images** directly via the image URL, as detailed here,** can lead to overages in your usage. **

With the base URL, you can now append options in order to constrain size, crop the image, blur it or perform other operations on it. Here are some examples:

**Auto format**

```text
// Use the browser headers to supply the most optimized format.
// ?auto=format
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?auto=format
```

**Fixed height**

```text
// Resized to have a height of 200
// ?h=200
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?h=200
```

**Resize, but never upscale**

```text
// Set width to 800px, but never make a sub-800px wide image scale up.
// ?w=800&fit=max
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?w=800&fit=max
```

**Crop to rectangle**

```text
// Crop to a rectangle from the image (x, y, width, height)
// ?rect=70,20,120,150
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?rect=70,20,120,150
```

**Crop to rectangle + fixed height**

```text
// Crop to a rectangle from the image (x, y, width, height) and constrain height to 64
// ?rect=70,20,120,150&h=64
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?rect=70,20,120,150&h=64
```

**Blur the image**

```text
// Blur the image
?blur=50
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?blur=50
```

> [!WARNING]
> Gotcha
> Small images get scaled up to the width or height you specify. To avoid this use `&fit=max`.

See the [image url reference documentation](https://www.sanity.io/docs/apis-and-sdks/image-urls) for the full list of parameters.

## Use the image URL builder

For JavaScript projects, Sanity provides the `@sanity/image-url` package that generates image URLs while respecting crop and hotspot settings.

> [!TIP]
> image-url returns a URL
> `@sanity/image-url` offers helpers to build URLs, but in the end it returns a normal URL. This lets you use it for any component that expects a URL, even framework-specific `Image` components.

It uses a method-chaining style syntax that lets you define image transformations.

### Install the package

**NPM**

```sh
npm install @sanity/image-url
```

**PNPM**

```sh
pnpm add @sanity/image-url
```

> [!NOTE]
> Older versions of `@sanity/image-url` used a different import format and export naming scheme. Update to the latest version if you notice errors when importing the library.

### Configure the builder

Next, configure the builder by passing a configured `@sanity/client` to the `imageUrlBuilder`. This example exports a reusable `urlFor` function. Check the `ImageComponent.tsx` example to see how to use it. 

**sanityImageUrl.ts**

```
// sanityImageUrl.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url'

import { client } from './client' // see example client config

// Create an image URL builder using the client
const builder = createImageUrlBuilder(client)

// Export a function that can be used to get image URLs
export function urlFor(source: SanityImageSource) {
  return builder.image(source)
}
```

**ImageComponent.tsx (Example)**

```tsx
import { urlFor } from './sanityImageUrl'
import type { SanityImageObject } from '@sanity/image-url'

// Use the urlFor function to build URLs
function ImageComponent({image}: {image: SanityImageObject}) {
  return (
    <img 
      src={urlFor(image)
        .width(300)
        .height(200)
        .url()}
      
      // Depending on your schema, you may need to adjust the alt text location
      // and update types to match your schema.
      alt={(image.alt) || 'Image'}
    />
  )
}

export default ImageComponent
```

**client.ts**

```
import { createClient } from "@sanity/client"

// Configure the client
export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2023-05-03',
  useCdn: true
})
```

The builder accepts an `image` source, an `asset` source, or a `string` containing the asset's ID. If you supply the image or asset source, the builder will automatically default to respecting any crop and hot-spot settings.

Learn more about the `@sanity/image-url` in the [library documentation](https://github.com/sanity-io/image-url).

## The crop and hot-spot

Sanity Studio allows editors, [if enabled in the schema](https://www.sanity.io/docs/studio/image-type), to define crop regions and hotspots for images. The crop describes which part of the image the editor wants to allow to be used, while the hot-spot specifies what area to preserve when the image needs to be cropped additionally in a front end.

![Hotspot and crop UI](https://cdn.sanity.io/images/3do82whm/next/3ad04e1303b079d952cc710601b1d5117a950733-1652x1114.png)

When an editor sets these, the image record might look like this:

**Image shape**

```javascript
{
  image: {
    _type: "image",
    asset: {
      _ref: "image-G3i4emG6B8JnTmGoN0UjgAp8-300x450-jpg",
      _type: "reference"
    },
    // The crop is specified in fractions of the image dimensions
    // and measured from the edge of the image. This image is cropped
    // from the bottom at 44% of the image height. The other dimensions
    // are left un-touched.
    crop: {
      bottom: 0.44,
      left: 0,
      right: 0,
      top: 0
    },
    // The hot-spot position x, y is in fractions of the image dimensions.
    // This hot-spot is centered at 43% of the image width from the left,
    // 26% of the image height from the top. The width and height is 
    // in the same unit system. This hot spot is 44% of the image width wide,
    // 65% of the image height tall, this rectangle is centered on the x,y
    // coordinate given.
    hotspot: {
      height: 0.44,
      width: 0.65,
      x: 0.43,
      y: 0.26
    }
  }
}
```

**GROQ example**

```groq
*[_type == "person"][0]{
  image{
    _type,
    asset,
    crop,
    hotspot
  }
}
```

The editor expects your front end to respect these settings. You can manually build the URL using the crop and hotspot data along with the pipeline's [transformation options](https://www.sanity.io/docs/apis-and-sdks/image-urls), but the `@sanity/image-url` library handles this automatically for you. 

For example, using the `urlFor` helper from the earlier code example, you can render an image in two sizes. Note that `urlFor` expects the image field (`person.image` in the example), not just the asset, as it needs the crop and hotspot data.

```tsx
import {urlFor} from './sanityImageUrl'

function ProfileImage({person}) {
  // The image builder automatically applies crop and hotspot settings
  return (
    <img 
      src={urlFor(person.image)
        .width(800)
        .height(600)
        .url()}
      alt={person.image.alt || `Portrait of ${person.name}`}
    />
  )
}

// For square thumbnails
function AvatarImage({person}) {
  return (
    <img 
      src={urlFor(person.image)
        .width(200)
        .height(200)
        .url()}
      alt={person.image.alt || `Avatar of ${person.name}`}
      className="avatar"
    />
  )
}
```

The URL builder applies the editor's crop settings, then uses the hotspot to determine a focal point if additional cropping is needed based on the requested dimensions.



## Creating downloadable image links

Sometimes you want to change the headers on the URL so that when clicked, the file will download instead of displaying in the browser. You can do this with both the urlFor helper from the previous examples and with URL parameters.

```
// Using the URL builder
const downloadUrl = urlFor(image)
  .width(1200)
  .url() + '&dl=filename.jpg'

// Raw URL equivalent
const baseUrl = 'https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg'
const downloadUrlRaw = `${baseUrl}?w=1200&dl=filename.jpg`;

// Use the default filename by omitting a filename
const defaultFilename = `${baseUrl}?dl=`
```

Using `dl=` without a filename will use the original filename from time of upload, assuming `storeOriginalFilename` wasn't disabled in the schema. Otherwise, it will use the asset ID.

## Vanity filenames

Sometimes you want to display an image normally, but adjust the pre-set filename that users see when they download or copy an image. You can append `/your-new-filename.jpg` with the text and extension of your choice to update the name. For example:

```text
https://cdn.sanity.io/images/y856rro4/production/6005c6a1da9e27b033589ef439f8bb8f38420933-5152x7728.jpg/vanity-filename.jpg
```



## Performance considerations

To maximize performance, reuse image transformations across your front end. This ensures that cached assets are reused effectively.

Pair image transformations with responsive images. For example:

**@sanity/image-url**

```tsx
function ResponsiveImage({image}) {
  return (
    <img 
      src={urlFor(image).width(800).url()}
      srcSet={[
        `${urlFor(image).width(400).url()} 400w`,
        `${urlFor(image).width(800).url()} 800w`,
        `${urlFor(image).width(1200).url()} 1200w`,
      ].join(', ')}
      sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
      alt={image.alt || ''}
    />
  )
}
```

## Additional resources

[Image transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls)
Reference documentation for image transformations.

[Assets in Content Lake](https://www.sanity.io/docs/content-lake/assets)
Learn more about how Sanity stores your assets in Content Lake.

[@sanity/image-url](https://github.com/sanity-io/image-url)
Sanity's helper library for working with image URLs.

[International Image Interoperability Framework (IIIF) API reference](https://www.sanity.io/docs/apis-and-sdks/iiif-api-reference)
The International Image Interoperability Framework (IIIF) provides a standardized way of delivering and describing images shared on the web. This is the reference documentation for how you can interact with the asset pipeline using IIIF.



# Image transformations

## The anatomy of the image URL

This article provides a detailed rundown of all the options for transforming images with Sanity. You can find a general introduction to our image pipeline and tools in [Presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images).


Let's start by dissecting this Sanity image URL:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg
```

- `https://cdn.sanity.io/images/` is the common base for all Sanity image URLs. 
- `zp7mbokg` is the project ID 
- `production` is the dataset name
- `G3i4emG6B8JnTmGoN0UjgAp8` is the asset ID and the asset metadata document `_id`
- `300x450` is the width and height of the original image
- `jpg` is the file format of the *original* asset file

The image URLs can always be found in the asset metadata document referred to in an asset reference. Still, you don't have to fetch this document as the asset document ID contains all the information and represents a stable, documented interface you can trust.

The asset ID corresponding to the URLs above looks like this: `"image-G3i4emG6B8JnTmGoN0UjgAp8-300x450-jpg"`.  It provides the name, dimensions, and format. Given the project ID and dataset name, you have every piece you need to assemble the URLs without fetching the asset document:

```text
https://cdn.sanity.io/images/<project id>/<dataset name>/<asset name>-<original width>x<original height>.<original file format>
```

> [!TIP]
> Prettier image file names
> While the naming format described above contains lots of info about the original asset, it does leave something to be desired for readability and memorability when read with human eyes. If you'd like to specify a more legible file name you can do this by appending `/vanity-name.png` after the actual file name provided by Sanity. (Substituting both name and extension to fit your actual case, of course.)

This represents the base URL. If you fetch this, you will be served the original asset. This potentially uses a lot of bandwidth as content managers are advised to upload full-resolution assets. With the Sanity image pipeline, you can scale, crop, and process images on the fly based on URL parameters. E.g. by appending `?h=200` to the base URL, you instruct Sanity to scale the image to be 200 pixels tall:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?h=200
```

You can specify any number of parameters. This will extract a rectangle from the image starting at 70 pixels from the left and 20 pixels from the top at a width of 120 pixels and a height of 150 pixels, scale it to 200 pixels tall, and blur it:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg?rect=70,20,120,150&h=200&blur=10
```

Even though the Sanity image backend is fast, you get a tremendous performance boost if your front end limits the number of sizes and crops you ask for. Sanity will cache the result in the global CDN, and if we see the same URLs again, we serve the same data directly from the edge cache closest to the user.

> [!WARNING]
> Gotcha
> Non-integer values for parameters expecting integers may cause performance issues or timeouts. It is recommended that you always use integer values when the parameter calls for it (e.g., `w` and `h`), including when returning calculated values.
> `&h=200` - Correct
> `&h=200.0` - May be problematic

## Supported image types

While the [Image schema type](https://www.sanity.io/docs/image-type) supports a wide range of [image formats](https://www.sanity.io/docs/content-lake/assets), transformations are limited to JPEG, PNG, WebP, PJPG, TIFF, AVIF, and GIF. For all other formats, you should convert the image to one of the supported file types before performing additional transformations.

> [!TIP]
> Protip
> The image pipeline supports transforming animated GIFs up to a maximum size of 256 megapixels, calculated as (width x height x frame count) / 1,000,000. If an animated file exceeds this limit, only the first frame is returned. See [Technical limits](https://www.sanity.io/docs/content-lake/technical-limits) for more about asset limits.

## The URL parameters

> [!WARNING]
> Gotcha
> Small images get scaled up to the width or height you specify. To avoid this use `&fit=max`.

#### Properties

**auto** (string)

Set auto=format to automatically return an image in in the most optimized format supported by the browser as determined by its Accept header. To achieve the same result in a non-browser context, use the fm parameter instead to specify the desired format, for example fm=webp.

**bg** (string)

Fill in any transparent areas in the image with a color. The string must be resolve to a valid hexadecimal color (RGB, ARGB, RRGGBB, or AARRGGBB). E.g. bg=ff00 for red background with no transparency.

**blur** (integer)

Blur 1-2000.

**crop** (string)

Use with fit=crop to specify how cropping is performed:

top, bottom, left and right: The crop starts from the edge specified. crop=top,left will crop the image starting in the top left corner.

center: Will crop around the center of the image

focalpoint: Will crop around the focal point specified using the fp-x and fp-y parameters.

entropy: Attempts to preserve the "most important" part of the image by selecting the crop that preserves the most complex part of the image.

**dl** (string)

Configures the headers so that opening this link causes the browser to download the image rather than showing it. The browser will suggest to use the file name you provided.

**dlRaw** (string)

As dl but requests the original file/image asset. Requires authentication.

**dpr** (number)

Specifies device pixel ratio scaling factor. From 1 to 3.

**fit** (string)

Affects how the image is handled when you specify target dimensions.

clip: The image is resized to fit within the bounds you specified without cropping or distorting the image.

crop: Crops the image to fill the size you specified when you specify both w and h

fill: Like clip, but any free area not covered by your image is filled with the color specified in the bg parameter.

fillmax: Places the image within box you specify, never scaling the image up. If there is excess room in the image, it is filled with the color specified in the bg parameter.

max: Fit the image within the box you specify, but never scaling the image up.

scale: Scales the image to fit the constraining dimensions exactly. The resulting image will fill the dimensions, and will not maintain the aspect ratio of the input image.

min: Resizes and crops the image to match the aspect ratio of the requested width and height. Will not exceed the original width and height of the image.

**flip** (string)

Flipping. Flip image horizontally, vertically or both. Possible values: h, v, hv

**fm** (string)

Convert image to jpg, pjpg, png, or webp.

Note that avif is not a valid option for this parameter as AVIF transformations are generated asynchronously. See the AVIF format details below.

This property also accepts a value of json, which does not convert the image but returns information about the image including width, height, frame count, content length, and content type.

**fp-x** (coordinate)

Focal Point X. Specify a center point to focus on when cropping the image. Values from 0.0 to 1.0 in fractions of the image dimensions. (See crop)

**fp-y** (coordinate)

Focal Point Y. Specify a center point to focus on when cropping the image. Values from 0.0 to 1.0 in fractions of the image dimensions. (See crop)

**frame** (integer)

The frame of an animated image. The only valid value is 1, which is the first frame.

**h** (integer)

Height of the image in pixels. Scales the image to be that tall.

**invert** (boolean)

Invert the image.

**max-h** (integer)

Maximum height. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**max-w** (integer)

Maximum width in the context of image cropping. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**min-h** (integer)

Minimum height. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**min-w** (integer)

Minimum width. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying fit=crop.

**or** (integer)

Orientation. Possible values: 0, 90, 180 or 270.Rotate the image in 90 degree increments.

**pad** (integer)

The number of pixels to pad the image.  Applies to both width and height.

**q** (integer)

Quality 0-100. Specify the compression quality (where applicable). Defaults are 75 for JPG and WebP.

**rect** (coordinates)

Crop the image according to the provided coordinate values (left, top, width, height). 

left: Number of pixels from the left of the image

top: Number of pixels from the top of the image

width: Width, in pixels, of the crop from the left value

height: Height, in pixels, of the crop from the top value

**sat** (number)

Saturation. The asset pipeline only supports sat=-100, which renders the image with grayscale colors.

**sharp** (integer)

Sharpen 0-100.

**w** (integer)

Width of the image in pixels. Scales the image to be that wide.

**cs** (string)

Output the image in a specifying color space.

Supported color spaces:

origin: render the image in the original color space

srgb: The default, output the image in web-friendly sRGB.

cmyk: Output the image in the CMYK color space

b-w: Output will be black and white.

## Vanity filenames

In addition to the query parameters, you can also append a `/my-filename.jpg` style vanity filename to the end of the URL. This will set the filename if users save the image. For example:

```text
https://cdn.sanity.io/images/zp7mbokg/production/G3i4emG6B8JnTmGoN0UjgAp8-300x450.jpg/easier-to-read-name.jpg
```

## AVIF transformations

Images that have the query parameter `auto` set to `format` and are requested from a browser that supports the AVIF format will potentially get an AVIF returned. 

There are a few exceptions/quirks:

The first few requests for an AVIF may get the "second best option" (WebP if supported, otherwise PNG/JPG depending on the source image). Subsequent requests will eventually get an AVIF back. This is done to ensure a speedy response, since encoding AVIFs is a slow process. 

Image requests made prior to the AVIF rollout may already be cached in our CDN and will not return an AVIF response until they expire/fall out of the cache. In other words: if you are not seeing AVIF images being returned, don't worry — they should eventually return AVIF. 

You can use `curl` to verify the behavior:

```sh
# Replace the URL with an actual URL from your project.
# Remember to include `?auto=format`!
curl -sS -I \
  -H 'accept: image/avif,image/webp,image/*' \
  'https://cdn.sanity.io/images/:projectId/:dataset/:filename?auto=format' \
  | grep 'content-type:'
```

On the first request, you will likely see `image/webp` returned. After waiting 30 seconds, run the same command again, and you should see `image/avif`. If you don't, wait a little longer and retry. If you still do not see AVIF, ensure that the accept header includes `image/avif` (before other formats) and that the query parameters includes `auto=format`.

> [!WARNING]
> Gotcha
> Because AVIF transformations are generated asynchronously, you cannot explicitly request AVIF transformations using the `fm` query parameter. Instead, use the `accept` header as described above.

## Troubleshooting images for social and Open Graph previews

When a social platform or chat app renders a link, its crawler fetches the URL in your `og:image` and `twitter:image` tags. Those crawlers are less tolerant of modern image formats than browsers are, so a preview card with no image is often a format problem rather than a missing tag.

With `auto=format`, the Image API picks the format from the `Accept` header of whoever requests the URL: a browser that advertises AVIF gets AVIF, and a client that sends `Accept: */*` gets the source format. AVIF transformations are also generated asynchronously, so the first few requests for one return the second-best format. A crawler that fetches an image once can end up with a different format than the one you see in your browser.

To make crawler-facing URLs predictable, pin the format with `fm`. It takes precedence over `auto`, so you can keep `auto=format` for your on-page images and set `fm=jpg` only in the URLs your metadata tags point to:

```text
https://cdn.sanity.io/images/PROJECT_ID/DATASET/ASSET_ID-2400x1260.jpg?w=1200&h=630&fit=crop&fm=jpg
```

Set `w` and `h` to the dimensions the platform expects, and add `fit=crop` so the image fills them exactly. This example targets a 1200x630 card, a widely supported size, but each platform documents its own requirements.

> [!WARNING]
> Gotcha
> Platforms cache preview data, including failed fetches, so an existing post can keep showing the old result after you fix the image. Changing the URL parameters produces a new URL, which the platform fetches as a new image.

## Read more

[Client library for generating urls](https://github.com/sanity-io/image-url)





# Image metadata

The `metadata` option for image fields controls which types of metadata Sanity extracts or generates from uploaded images and saves alongside the asset. 

Image assets in your Content Lake may include a range of helpful metadata. 

- **Always included:** Essential facts about your image, including height, width, aspect ratio, and information about transparency.
- **Included by default:** Useful information generated from the image on upload: minified placeholders and palette values.
- **Excluded by default: **Potentially private information about the place and circumstances under which the image was created, held in the `exif`, `image`, and `location` values.

An example of an image field with every metadata option specified looks as follows:

```javascript
{
  name: 'metaImage',
  title: 'Image with metadata',
  type: 'image',
  options: {
    metadata: [
      'blurhash',   // Default: included
      'thumbhash',  // Default: included
      'lqip',       // Default: included
      'palette',    // Default: included
      'image',      // Default: not included
      'exif',       // Default: not included
      'location',   // Default: not included
    ],
  },
},
```

There are three additional metadata options that are always included and cannot be disabled: `dimensions`, `hasAlpha`, and `isOpaque`. Specifying an invalid option in the `metadata` array (including any of those three terms) will throw an error.

The metadata fields fall into one of three "default behaviors": **always included**, **included by default**, and **excluded by default**. We'll look at each default setting and the metadata fields that adhere to it.

> [!WARNING]
> Gotcha
> Some metadata is computed synchronously on upload, while other values are added *asynchronously*. If your query for image metadata returns unexpectedly empty, wait a moment and try again.

> [!WARNING]
> Gotcha
> Metadata is applied to an image asset when the image is uploaded and based on the schema settings at that time. If a `metadata` array is set to include `exif` or `location` data, **changing the schema later will not remove those details**. If removing those details is desired, you can do so with a script or using the [Media browser plugin](https://www.sanity.io/plugins/sanity-plugin-media), among other options. Likewise, adding options to the `metadata` array will not add those details to images previously uploaded.

## Alpha channel, opaqueness, and dimensions

> [!NOTE]
> Always included
> These values are *always available*, and you do not need to ask for them. In fact, they are not [valid options](https://www.sanity.io/docs/studio/image-type) in the `options.metadata` array, so including them will throw an error.

### `hasAlpha`

`hasAlpha` will return `true` if the image has an alpha channel, even if unused.

### `isOpaque`

`isOpaque` returns `true` if the image is fully opaque (i.e., has no transparency).

### `dimensions`

The `dimensions` object contains the numeric values `aspectRatio`, `height`, and `width`, which together describe the physical features of the image. A photo taken in portrait mode might yield the following payload:

```json
{
  "dimensions" : {
    "_type": "sanity.imageDimensions",
    "aspectRatio": 0.75,
    "height": 4032,
    "width": 3024
  }
}
```

## Placeholders and colors

> [!NOTE]
> Included by default
> These values are *available by default.* If you don't ask for any metadata at all (that is, if you don't specify a `metadata` array), you will get these values. **Beware though:** If you *do* specify a `metadata` array and explicitly leave these out, they will not be returned.

### `lqip`, `blurHash`, and `thumbHash`

Sanity will generate low-fidelity representations of your images automatically. These are useful for creating placeholders for loading images in your frontend. These downsampled previews come in three different flavors: LQIP, BlurHash, and ThumbHash.

**LQIP** (Low-Quality Image Preview) is a 20-pixel-wide version of your image (height is set according to aspect ratio) in the form of a base64-encoded string and can be used as-is in your frontend, as shown below. A typical value for `lqip` might look like this:

```json
"lqip": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAbCAYAAAB836/YAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGE0lEQVRIiV2W6VNb1xnGbw1oQ/sCkgABWgAZEPsiFoFALJIQi9gECASCYtmsNjaYFAzjOCYkxonjpu6Stc20+dbMtDP50D/u1zkXTNJ++M3Rvfe9z/OeM6P3uZJapUCgUSso1CjRFirR61QY9WpMBjVmgwazUSOv4tqgU6PXquS6Qo0CtVqBSlWAUlmAQpGPJItolOgKVeh1alnIYtRgMxVSZNZSbNHdIq6tpkJZXNT9v7BaVYCk16ox6tSY9BosxkJsZi12qx6HTY+zyEBpsZEyuwmX3USZ3SjfK7bq5DpRb9RrfiWsRLIYtFiNWorMOuxWA84iI6XyyyZcDjMVJVbcpTbcThOVDgMuh0l+LuqKLXqsJh0mQ6F8FDqtCsluMeCwGSkpFgJWKkpsuMuK8ZXbqa504veUUON24iu14C0x4i2z4XYVy0aldjMOm0FuxnzTrSREymURO74KJzWeUuqqymn0u2mp89IaqKKlzkNjVQkNPgf1VS5qfS7ZTBi7nFacRabrbs06JF9FCdXuUmp95TT4PbJAV0stoY4A4WAjke5mBoKNdDf5CDZ4CDbX0NZQTYPfjd9bhrfcIe9KdCuOQWq466G5zkdHo5/e9gCRnhbGBoMkR0PMxsMsTESYGwsT7W1mKFhPtL9druluraOlvor66gq5IU+5ncqyIqSetnr6g40Mh9oYH+phPjHA2lyc+8tJdtdmOdhcYCczTSoWYmakm+WpYbkmPtAp76CjyU9TnZf6mgruVrmQ4oNBJkd6SI0PsjYXY2d1huOtZT58vMnl8RavTnY528uSnR4hOzPCo415tjPTLCdHmBzuZai3ld6OAMGWu7Q3VSMtTkbIzI6SS09wsJni/GGW16c7/OniiO9en/K3N+d8frbP9sok+2vTfPTkHmf76+ytzbI6E2U62kd8sJPh/jYioRak3y6NsbU6xZPcwrXYsx2+/vSYH798zj+/uuRf313xzdUJR7kFTnczfPniMZ892+V0d1XeTWYmyvz4ADNj/STjIaTd9SQHuTlO91a4fHqPdx8d8Pe35/z09SU///AZ//nxLX99c8bR/QU+PFjnm1cf8O7lIReHOQ5zi/LO1uajrMyNkJ4ZRnq4OcPRgxRn+xk+Oc7xx5eP+cfvz/npqwv+/f0rfv7hNX++eMR2OsrJgxR/+fgJf3hxwMXhJk/vL7KdmWIzPcbGUpzsYhRpd2OKg3uz/G57kRePs7w52+Hbq2O+vXrKuxd7fH91yNvTLHupXk42E3xxmuP5foaDbJKH2Wm2M5NspuNsLMVkpM3lOA8yCfayUzzNzfP80Sqfn23z8dE6O+kYJ/eTfHGU4nI7xqf7Sc63ZslM9LEQ6ya3GCeXTpCdH2V1foS11AjS4lSYdHKQ1dkh7i3GeJhN8mw3zQcPFlhMhJgZauXR0gAvtxKcbMRYigcZ7W5gLtrDxnyM1dlRFicHSE2EZaTYQBvxwXYmhjuZjfeykhwktxRnJzPBytQgsb5mUtEgO+lRslP9xEJNJAbaWZqIsDQZYSYWYmK4i0Skk7FIJ1JXq5/uVvG3q2WgK0C0v5Wp4S5SiX6WJgdZGA+zPBVhfW6UtBCI9jI92ktypId4uIOhnmbCwQb6OgOEOuuR6qtdCAI15TTVVtIe8NHd4ifcGWA01EJisJPJ4W6Z8UiQeLid4Z5m+jrq6Wr20y6mUb2X5jovTXUepMpSK9fY8LiKqap0cNdbSqBaGLhpC3jpaKyis7GajsZq2gI+mmvdNNRUUOsrw+8ppcYjJpZTRvolM8TYN1AiRr7DTLnTcmviqxDD1kFVhUMevF5XMe6yIipLxEC2yLUup1l+TzLoVAjkXLlJOJEVFtN1vhRb9dhv8uV/sIlJr5fzx25935QWSa3KR60ukJNLpJ8Im/dJJlaRE7KhXkTqdSIKrDerRTQhx60IOjWSoiAPhSIPpTIflbLgJmPzUSpuUObL8ai5MdWJ3NYqZROB+C3QCQqVSHl5vyE//w4FBXm3iGvBL8/uIIyvxfNvxbUa8XEguDYUyILvyc+7FhHcuSPd8v6eEFYq8m5Ff418dKp8/gutMmaHeMkQagAAAABJRU5ErkJggg=="
```

And can be used like this:

```html
<!-- 
  The LQIP value is actual image data
  encoded into a base64-string which can be 
  used directly as the src property of an img tag!
  Remember to set the height and width 
  properties, though, or it'll be very small
-->
<img
  height="100"
  width="100"
  src="data:image/png;base64,iVBORw0KGgo[...50 lines of this stuff omitted for brevity...]Jggg=="
/>
```

**BlurHash** is a more [advanced method](https://blurha.sh/) of creating a lightweight image preview that can give a superior result and comes in a more concise format. The trade-off is that you'll need to decode the value using a [helper library](https://github.com/woltapp/blurhash) before use. A `blurHash` value might look something like this:

```json
"blurHash": "d79Z$I-o4:IoxaofR*WC00Io?GxtM{Rkt7s:~VxaNGRk"
```

Example of use in a JavaScript project: 

```javascript
import { decode } from "blurhash";

const pixels = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 32, 32);

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
document.body.append(canvas);
```

**ThumbHash** is a [similar approach](https://evanw.github.io/thumbhash/) to BlurHash that also encodes the approximate aspect ratio of the image and supports transparency. Sanity stores the value as a base64-encoded string. As with BlurHash, you'll need to decode the value using a [helper library](https://github.com/evanw/thumbhash) before use. A `thumbHash` value might look something like this:

```json
"thumbHash": "tigGFISGr2Wbhdc+d5r0MEUPUw=="
```

Example of use in a JavaScript project:

```javascript
import { thumbHashToDataURL } from "thumbhash";

// The stored value is base64-encoded, so decode it to bytes first
const binary = atob("tigGFISGr2Wbhdc+d5r0MEUPUw==");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));

const image = new Image();
image.src = thumbHashToDataURL(bytes);
document.body.append(image);
```

### `palette`

Sanity will generate a color palette by analyzing your image. Along with the dominant swatches, a collection of suggestions for colors that contrast nicely with them is returned, as well as a numeric indication of how prominently each color is represented in the image. A palette object might look like:

```json
{
  "_type": "sanity.imagePalette",
  "darkMuted": {
    "_type": "sanity.imagePaletteSwatch",
    "background": "#653a2d",
    "foreground": "#fff",
    "population": 3.8,
    "title": "#fff"
  },
  "darkVibrant": {
    "_type": "sanity.imagePaletteSwatch",
    "background": "#c4850b",
    "foreground": "#fff",
    "population": 0.08,
    "title": "#fff"
  },
  "dominant": {
    "_type": "sanity.imagePaletteSwatch",
    "background": "#d5c3ba",
    "foreground": "#000",
    "population": 7.17,
    "title": "#fff"
  },
  "lightMuted": {
		// [...] truncated for brevity
  },
  "lightVibrant": {
		// [...] truncated for brevity
  },
  "muted": {
		// [...] truncated for brevity
  },
  "vibrant": {
		// [...] truncated for brevity
  }
}
```

> [!TIP]
> Protip
> If `lqip`, `blurHash`, `thumbHash`, or `palette` values are absent from your image asset, it's likely that at the time the image was uploaded, a `metadata` array was specified and the value in question was not included in the array.

## Camera and location

> [!NOTE]
> Excluded by default
> These values are *not included* in your image metadata unless a `metadata` array is specified and these values are specifically requested. This is because camera and location data generally contain private or identifying information.

### `image`

This field contains basic information about the image such as camera make and model, resolution, and orientation. For more detailed information, use the `exif` field. The following is an example readout:

```json
{
  "_type": "sanity.imageExifTags",
  "Make": "Apple",
  "Model": "iPhone 6",
  "Orientation": 1,
  "XResolution": 72,
  "YResolution": 72,
  "ResolutionUnit": 2,
  "Software": "Photos 1.0",
  "ModifyDate": "Sat Feb 28 2015 17:13:57 GMT-0800 (PST)",
  "ExifOffset": 198,
  "GPSInfo": 1008
}
```

### `exif`

Short for [Exchangeable Image File](https://en.wikipedia.org/wiki/Exif) format, this field contains information about the image file itself and the conditions under which it was produced, typically camera settings. Exactly what data is contained here depends on the origins of the file. Below is an example readout of the Exif object for a photo taken with an iPhone camera:

```json
{
  "_type": "sanity.imageExifMetadata",
  "ApertureValue": 1.6959938128383605,
  "BrightnessValue": 1.7619172145845785,
  "DateTimeDigitized": "2020-03-19T12:25:17.000Z",
  "DateTimeOriginal": "2020-03-19T12:25:17.000Z",
  "ExposureBiasValue": 0,
  "ExposureMode": 0,
  "ExposureProgram": 2,
  "ExposureTime": 0.020833333333333332,
  "FNumber": 1.8,
  "Flash": 16,
  "FocalLength": 4.25,
  "FocalLengthIn35mmFormat": 26,
  "ISO": 250,
  "LensMake": "Apple",
  "LensModel": "iPhone 11 Pro back triple camera 4.25mm f/1.8",
  "LensSpecification": [
    1.5399999618512084,
    6,
    1.8,
    2.4
  ],
  "MeteringMode": 5,
  "PixelXDimension": 4032,
  "PixelYDimension": 3024,
  "SceneCaptureType": 0,
  "SensingMethod": 2,
  "ShutterSpeedValue": 5.586024712398807,
  "SubSecTimeDigitized": "900",
  "SubSecTimeOriginal": "900",
  "SubjectArea": [
    2323,
    710,
    1410,
    1412
  ],
  "WhiteBalance": 0
}
```

### `location`

This field, as you might expect, returns geographical data, usually representing the coordinates where the photo was taken. It conforms to the specification of the [geopoint](https://www.sanity.io/docs/studio/geopoint-type) schema type, and might look like this:

```json
{
  "_type": "geopoint",
  "alt": 168.32554596241746,
  "lat": 59.948811111111105,
  "lng": 10.867780555555557
}
```





# International Image Interoperability Framework (IIIF) API reference

The Sanity asset pipeline supports the [International Image Interoperability Framework API (IIIF)](https://iiif.io/). The URL schema for IIIF supported APIs looks like this: `{scheme}://{server}{/prefix}/{identifier}`

For the Sanity asset pipeline, that translates to:

`https://cdn.sanity.io/images/{projectId}/{dataset}/iiif/{identifier}`

You can consult the IIIF Image API 2.0 specification to find all the identifiers and different ways of querying images in your dataset.

## Examples

### General image info

If you go to [https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/info.json](https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/info.json) it will return this JSON structure:

```json
{
  "@context": "http://iiif.io/api/image/2/context.json",
  "@id": "https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg",
  "protocol": "http://iiif.io/api/image",
  "profile": ["http://iiif.io/api/image/2/level2.json"],
  "width": 500,
  "height": 750,
  "sizes": [
    { "width": 50, "height": 75 },
    { "width": 200, "height": 300 },
    { "width": 600, "height": 900 },
    { "width": 1200, "height": 1800 },
    { "width": 2000, "height": 3000 }
  ],
  "tiles": [{ "width": 512, "scaleFactors": [1, 2, 4, 8, 16] }]
}

```

### Default, full-size

Identifier: `/full/full/0/default.jpg`

[https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/full/full/0/default.jpg](https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/full/full/0/default.jpg)

![The late, great actor Alan Rickman smiling awkwardly at the camera](https://cdn.sanity.io/images/3do82whm/next/d798944dd22b8ecf96704607b8e7d7d09ea828fd-500x750.png)
*Alan Rickman in full proportions*

### Square crop, 75% size, gray color, png format

Identifier: `square/pct:25/0/gray.png`

[https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/square/pct:75/0/gray.png](https://cdn.sanity.io/images/zp7mbokg/production/iiif/0078ltwW67gQ_k61DhalsnpQPP0RXS14878ui-500x750.jpg/square/pct:75/0/gray.png)

![Alan Rickman in a square crop and grey color](https://cdn.sanity.io/images/3do82whm/next/e995baaa8b4536f9bccec63a9f1ec842caa30be2-375x375.png)
*Alan Rickman in a square crop and grey color*





# Asset CDN

Sanity offers a global content delivery network (CDN) for serving assets, at cdn.sanity.io. This is based on [Google's global CDN](https://cloud.google.com/cdn/). Note that this is a different system from our [API CDN](https://www.sanity.io/docs/content-lake/api-cdn).

Assets are uploaded content such as images, videos, and other files - see [separate article](https://www.sanity.io/docs/content-lake/assets) for details. These assets can only be accessed by clients via our asset CDN, optionally with processing by our [image pipeline](https://www.sanity.io/docs/apis-and-sdks/image-urls). When an asset is first requested, it is processed by our backend systems and then cached by the CDN on servers located near end-users. Subsequent requests are then served from the cache, ensuring fast response times and a better user experience.

Assets are cached indefinitely. The asset URL includes a SHA-1 hash of the asset contents, so any content changes will generate a new URL, thus avoiding the need to invalidate the cached entries. We only invalidate caches when a dataset/project is deleted.

Image responses larger than 10 MB currently cannot be cached in the CDN, and are instead returned from the backend servers. However, for all other file types (including videos) we support caching of responses up to 5 TB.

Clients can use standard cache headers such as `Cache-Control`, `If-Modified-Since`, `If-None-Match`, and `Accept-Encoding` to control cache behavior - for details, see the [Google Cloud CDN documentation](https://cloud.google.com/cdn/docs/caching).

## Considerations if you run your own CDN or proxy in front of the asset CDN

If you run your own CDN or reverse proxy in front of `cdn.sanity.io`, it requests assets from the asset CDN like any other client. Asset URLs contain a SHA-1 hash of the asset contents, so an edited asset gets a new URL and your cache never serves stale content for it. The following behaviors need attention when you cache asset responses yourself:

- **Format negotiation**: With `auto=format`, the Image API picks the format from the `Accept` header of the requesting client. Forward that header to the asset CDN and key your own cache on it. Without it, the Image API returns the source format, and a cache that ignores it serves one format to every client.
- **AVIF encoding**: The first few requests for an AVIF-eligible image return the second-best format, because encoding AVIF is slow. A long-lived cache entry created from that first response keeps your users on the second-best format. See [AVIF transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls).
- **Deleted assets**: Deleting an asset does not clear caches that already hold it. Revalidate against the asset CDN, or purge your own cache when you [delete an asset](https://www.sanity.io/docs/content-lake/manage-assets).
- **Private assets**: Switching a Media Library asset from public to private does not clear caches. The asset CDN can keep serving cached responses for up to 30 days after the change, and your own cache extends that window. See [Asset visibility](https://www.sanity.io/docs/media-library/asset-visibility).
- **Container URLs**: A [Container URL](https://www.sanity.io/docs/media-library/container-urls) stays the same while the asset behind it changes version or visibility, so a content change does not produce a new URL. Use a short cache lifetime for these URLs, or purge them when the underlying asset changes.
- **Signed URLs**: The asset CDN validates the signature on a signed URL and serves only the transformations that the URL pins. Pass these requests through to the asset CDN instead of answering them from your own cache.





# Introduction

The [Sanity Connect application for Shopify](https://apps.shopify.com/sanity-connect) is used to synchronize content between a Sanity dataset and your Shopify store. This gives you flexibility to use the tools that are right for your needs. You can take a headless approach using Shopify's Hydrogen framework and Next.js, or you can sync data into Shopify's platform and use Liquid or the Storefront API.

## Requirements

To take advantage of Sanity Connect, you will need:

- A Shopify store
- A Sanity project and dataset

If you are starting with a new Sanity dataset, you can create the dataset and a pre-configured Studio instance using:

**npm**

```shell
npm create sanity@latest -- --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

**pnpm**

```shell
pnpm create sanity@latest --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

**yarn**

```shell
yarn create sanity@latest --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

**bun**

```shell
bun create sanity@latest --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

## Installation

To install Sanity Connect in your Shopify store and connect it to a project:

1. Find [Sanity Connect on the Shopify App Store](https://apps.shopify.com/sanity-connect) and push the “**Install**” button.
2. If you have multiple Shopify accounts, you need to choose the one that contains the store you want to add the app to.
3. After choosing the store, Shopify will show you the permissions Sanity Connect needs to work and its data policies. You can push the Install app button to continue.
4. The app will ask you to connect to your Sanity account. If you don't have one, you can choose to **Create new account**.
5. When you're logged in, you will need to connect your shop with a project on Sanity. You can choose between existing projects or create a new one (for free).
6. Select an organization to list its projects, then select the project and dataset you want to sync to.
7. You are now ready to configure the app.

> [!WARNING]
> Gotcha
> Once you choose Start synchronizing now, the app will add product documents to your Content Lake. It can be wise to test it against a non-production dataset if you haven't tried it before.

You might also want to consider using our [Shopify asset plugin](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-shopify-assets), which allows you to select assets from your Shopify store in the context of your Sanity Studio, allowing you to serve assets from the Shopify CDN in your frontends.

## Settings

You can configure how and when Sanity Connect should synchronize products to your Content Lake, and whether content should be synchronized back to your Shopify store. You can change these options at any time.

![Settings panel showing synchronization options during initial setup](https://cdn.sanity.io/images/3do82whm/next/8c633519b6b003dd7a95026d8e8c13df9df5b809-1274x1346.png)
*Synchronization activated at initial setup*

![Settings panel showing synchronization options after initial setup](https://cdn.sanity.io/images/3do82whm/next/f8a87a923c694905c6c8293814534ea436df29fc-1282x1438.png)
*Synchronization activated after initial setup*

### Sync content from Sanity to Shopify

This setting allows you to sync any custom fields and document types you've created in Sanity back into Shopify. Your custom content will sync as Shopify metafields and metaobjects.

For a deeper dive, review our documentation on [displaying Sanity content within Shopify](https://www.sanity.io/docs/developer-guides/displaying-sanity-content-in-shopify).

This is the outbound direction — Sanity content becoming Shopify metafields. To bring Shopify's own metafields into Sanity, see Import Shopify metafields below.

### How to synchronize

Sanity Connect offers two ways to synchronize content from Shopify into your Content Lake: direct sync and custom sync.

**Direct sync**

This will synchronize all products, product variants, and collections as documents to your Content Lake. You can check the [reference](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify-reference) to preview the data model for these documents.

> [!WARNING]
> Gotcha
> Synced documents created by Sanity Connect will count towards your Sanity document usage limit. One document will be created for every product, product variant, and collection in your storefront.

**Custom sync**

This option will let you enter an endpoint that receives updates from Shopify and syncs data to your Content Lake. Typically that will be a serverless function handler where you can reshape the data and do other business logic as part of the sync.

You may, for example, want to reduce document usage by syncing products but not variants, or sync variants as objects on a product document rather than individual variant documents.

We have further documentation on [custom sync handlers](https://www.sanity.io/docs/developer-guides/custom-sync-handlers-for-sanity-connect) including an example serverless function.

### When to synchronize

**Sync data automatically:** Automatically sync whenever you save products. Note: The sync will update the Shopify information for both published and draft documents. An update is typically available in your Content Lake after a couple of seconds.

**Sync manually:** There will be no automatic sync, and you'll have to go into the Sanity Connect settings to trigger a synchronization manually.

Sanity Connect will do an initial synchronization once you choose one of these options.

> [!WARNING]
> Sanity Connect will not sync versions
> Content Release document versions are not supported at this time. Sanity Connect will only sync published and draft documents.

> [!NOTE]
> Automatic drift correction
> Sanity Connect runs a daily reconciliation check against Shopify for every connected shop. Each run fetches only what has changed since the last one, repairs any differences in your dataset, and catches deletions that a webhook may have missed. There is nothing to configure. The Logs tab in the Sanity Connect app shows recent runs and a plain-language explanation if one fails. [Learn how drift correction works →](https://www.sanity.io/docs/apis-and-sdks/automatic-drift-correction-in-sanity-connect)

### Sync collections

The Sanity Connect app can optionally sync collections data. This will sync data and properties about your collection, but it will not sync the product membership of your collections.

### Import Shopify metafields

Sanity Connect can import your Shopify metafields onto the synced product and collection documents as a read-only `store.metafields` array. Custom data you already keep in Shopify — specifications, care instructions, or an external ID — becomes queryable in GROQ alongside the rest of the product. Shopify remains the source of truth: each sync overwrites the array, so edits made in Sanity are replaced.

Choose which data comes across in the Metafields tab, at the namespace level. Selecting a namespace imports every metafield in it; clearing your selection turns import off. Changes apply to each document as it next syncs — run a Resync to apply them everywhere at once, whether you are adding metafields or removing them.

The Metafields tab hosts both directions: importing Shopify metafields into Sanity, and syncing your Sanity fields out to Shopify as metafields. They are configured independently. The outbound section appears only if you have enabled syncing content from Sanity to Shopify.

Variant metafields and metaobjects are not supported for import. See the [reference](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify-reference) for the data shape.

## Set up your Studio

You can install a production-ready reference Studio that's set up with a great editor experience by running this command in your local shell. Replace the `PROJECT_ID` and `DATASET_NAME` placeholders with the actual values from the project your Shopify store is connected to:

**npm**

```shell
npx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

**pnpm**

```shell
pnpm dlx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

**yarn**

```shell
yarn dlx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

**bun**

```shell
bunx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

You'll find comprehensive documentation for this studio in its `README.md`.

![Screenshot of Shopify reference studio](https://cdn.sanity.io/images/3do82whm/next/58ebc2e9801b90061c4184d22ff0d267f534a25e-720x427.png)
*The Shopify reference studio*

### Integrate with an existing Studio

If you've already set up a Studio instance, you can follow the patterns exposed in this [example Studio setup](https://github.com/sanity-io/cli/tree/main/packages/%40sanity/cli/templates/shopify). This repository showcases the same Studio customizations that are implemented when creating a new Studio with the `shopify` template.

## Further reading

[Sanity Studio for Shopify](https://github.com/sanity-io/cli/tree/main/packages/%40sanity/cli/templates/shopify)

[Shopify asset selection for Sanity Studio](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-shopify-assets)



# Custom sync handlers

A custom sync handler allows you to provide an endpoint which receives updates from Shopify and passes data into your Content Lake. Typically, this will be a serverless function where you can reshape the data from Shopify and apply business logic before it is passed to your Content Lake.

## Prerequisites

- Sanity Connect installed in your Shopify store and connected to a Sanity project and dataset, with custom sync selected as the sync method. See [Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify).
- A Sanity API token with write access, so your handler can create and update documents on your behalf. See [Authentication and tokens](https://www.sanity.io/docs/content-lake/http-auth).
- A publicly reachable HTTPS endpoint to deploy your handler to, typically a serverless function. It must respond within 10 seconds.

## When to use a custom sync handler

There are a number of scenarios where you may choose to implement a custom sync handler. Common examples include:

- Where you need to apply additional logic to the data, for example, querying additional APIs to retrieve data that Sanity Connect does not sync.
- You may want to reduce your document usage on Sanity by only syncing selected products, or syncing variants as an object on product documents rather than variant documents.
- Where you want to amend the default manner in which Sanity Connect handles a product being deleted on Shopify (by setting `isDeleted` to `true`) to fully delete the document from your Content Lake.

## How custom sync handlers work

When enabled, the custom sync handler will send a payload on every update from Shopify as a POST request. You can write your custom business logic in your endpoint and [update](https://www.sanity.io/docs/content-lake/transactions) your Content Lake accordingly in the function, or respond with a set of documents which Sanity Connect will update for you.

Sanity Connect expects a response header with `content-type: application/json` and will regard a `200` status code as a success. Any other status code will be considered a failure.

You can find the [shape of the payload your handler](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify-reference) will receive in our Sanity Connect reference.

When you have selected [metafield namespaces to import](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify), those metafields arrive on the `Product` and `Collection` objects in the payload as a `metafields` array. You do not need to call the Shopify API to fetch them.

> [!WARNING]
> Gotcha
> The request has a 10s timeout and your handler needs to reply before that. Requests that fail with a 5xx status code will be retried up to 10 times; other error responses are not retried.
> If your handler needs more time to complete updates (for example if it calls a third-party API), a common pattern would be to store the payload in a queue for background processing, and respond `200 OK` immediately to acknowledge receipt of the payload.

> [!WARNING]
> Gotcha
> This operation will be batched when manually syncing, especially when dealing with larger catalogs.

> [!WARNING]
> Gotcha
> Changes in product inventory (through sales) will also trigger updates to your custom handler.
> Make sure to tailor your custom handler to account for how our [API CDN invalidates cache](https://www.sanity.io/docs/content-lake/api-cdn) on writes to non-draft documents, especially if operating on a high-traffic store with fast-moving content.

## Example custom sync handler function

Below is an example of a barebones custom function that will:

- Create/update/delete products (including drafts) in the Content Lake on Shopify product operations
- Only deal with products (variants are included as objects within products)
- Manual sync will create and update products on your dataset, but will not delete products that have since been removed.

For a more complete example, refer to [this gist](https://gist.github.com/snorrees/1ca7c3191d62ede6b9b5d0a1822d7103#file-requirements-md).

```javascript
import {createClient} from "@sanity/client";

// Document type for all incoming synced Shopify products
const SHOPIFY_PRODUCT_DOCUMENT_TYPE = "shopify.product";

// Prefix added to all Sanity product document ids
const SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX = "product-";

// Enter your Sanity Studio details here.
// You will also need to provide an API token with write access in order for this
// handler to be able to create documents on your behalf.
// Read more on auth, tokens, and securing them: https://www.sanity.io/docs/http-auth
const sanityClient = createClient({
  apiVersion: "2025-07-01",
  dataset: process.env.SANITY_DATASET,
  projectId: process.env.SANITY_PROJECT_ID,
  token: process.env.SANITY_ADMIN_AUTH_TOKEN,
  useCdn: false,
});

/**
 * Sanity Connect sends POST requests and expects both:
 * - a 200 status code
 * - a response header with `content-type: application/json`
 * 
 * Remember that this may be run in batches when manually syncing.
 */
export default async function handler(req, res) {
  // Next.js will automatically parse `req.body` with requests of `content-type: application/json`,
  // so manually parsing with `JSON.parse` is unnecessary.
  const { body, method } = req;

  // Ignore non-POST requests
  if (method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  try {
    const transaction = sanityClient.transaction();
    switch (body.action) {
      case "create":
      case "update":
      case "sync":
        await createOrUpdateProducts(transaction, body.products);
        break;
      case "delete":
        const documentIds = body.productIds.map((id) =>
          getDocumentProductId(id)
        );
        await deleteProducts(transaction, documentIds);
        break;
    }
    await transaction.commit();
  } catch (err) {
    console.error("Transaction failed: ", err.message);
  }

  res.status(200).json({ message: "OK" });
}

/**
 * Creates (or updates if already existing) Sanity documents of type `shopify.product`.
 * Patches existing drafts too, if present.
 *
 * All products will be created with a deterministic _id in the format `product-${SHOPIFY_ID}`
 */
async function createOrUpdateProducts(transaction, products) {
  // Extract draft document IDs from current update
  const draftDocumentIds = products.map((product) => {
    const productId = extractIdFromGid(product.id);
    return `drafts.${getDocumentProductId(productId)}`;
  });

  // Determine if drafts exist for any updated products
  const existingDrafts = await sanityClient.fetch(`*[_id in $ids]._id`, {
    ids: draftDocumentIds,
  });

  products.forEach((product) => {
    // Build Sanity product document
    const document = buildProductDocument(product);
    const draftId = `drafts.${document._id}`;

    // Create (or update) existing published document
    transaction
      .createIfNotExists(document)
      .patch(document._id, (patch) => patch.set(document));

    // Check if this product has a corresponding draft and if so, update that too.
    if (existingDrafts.includes(draftId)) {
      transaction.patch(draftId, (patch) =>
        patch.set({
          ...document,
          _id: draftId,
        })
      );
    }
  });
}

/**
 * Delete corresponding Sanity documents of type `shopify.product`.
 * Published and draft documents will be deleted.
 */
async function deleteProducts(transaction, documentIds) {
  documentIds.forEach((id) => {
    transaction.delete(id).delete(`drafts.${id}`);
  });
}

/**
 * Build Sanity document from product payload
 */
function buildProductDocument(product) {
  const {
    featuredImage,
    id,
    options,
    productType,
    priceRange,
    status,
    title,
    variants,
  } = product;
  const productId = extractIdFromGid(id);
  return {
    _id: getDocumentProductId(productId),
    _type: SHOPIFY_PRODUCT_DOCUMENT_TYPE,
    image: featuredImage?.src,
    options: options?.map((option, index) => ({
      _key: String(index),
      name: option.name,
      position: option.position,
      values: option.values,
    })),
    priceRange,
    productType,
    status,
    title,
    variants: variants?.map((variant, index) => {
      const variantId = extractIdFromGid(variant.id);
      return {
        _key: String(index),
        compareAtPrice: Number(variant.compareAtPrice || 0),
        id: variantId,
        inStock: variant.inventoryPolicy === "continue" || variant.inventoryQuantity > 0,
        inventoryPolicy: variant.inventoryPolicy,
        option1: variant?.selectedOptions?.[0]?.value,
        option2: variant?.selectedOptions?.[1]?.value,
        option3: variant?.selectedOptions?.[2]?.value,
        price: Number(variant.price || 0),
        sku: variant.sku,
        title: variant.title,
      };
    }),
  };
}

/**
 * Extract ID from Shopify GID string (all values after the last slash)
 * e.g. gid://shopify/Product/12345 => 12345
 */
function extractIdFromGid(gid) {
  return gid?.match(/[^\/]+$/i)[0];
}

/**
 * Map Shopify product ID number to a corresponding Sanity document ID string
 * e.g. 12345 => product-12345
 */
function getDocumentProductId(productId) {
  return `${SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX}${productId}`;
}
```



# Reference

You will find all data synced from Shopify under the `store` property of each document. Typically, you want to set these fields as `readOnly` or `hidden` in your Sanity Studio schemas.

## Sanity publish state

All products sync from Shopify into Sanity, and we attempt to keep the `Status` in Shopify linked to the publishing state in Sanity.

- If a product is "Draft" in Shopify- The document is created as unpublished in Sanity.
- Any changes to the product cascade to the unpublished draft in Sanity.


- If a product is "Active" in Shopify- The document is created as published in Sanity.
- Any changes to the product cascade to the published document as well as any unpublished draft.



If a product is "Archived" or switched to the "Draft" status, then we attempt to unpublish the matched document in Sanity. This operation will fail if the published Sanity document is referenced by another document in your dataset. We allow the operation to fail, and we will attempt to unpublish the document again on the next sync.

Because "Draft" products are not published, we do not support syncing custom fields as [Shopify metafields](https://www.sanity.io/docs/developer-guides/displaying-sanity-content-in-shopify) on draft products. These custom fields will sync once the product is switched to "Active".

## Product document

This is an example of a product document. Note the array of references to variant documents.

```json
{
  "_createdAt": "2022-05-18T07:45:26Z",
  "_id": "shopifyProduct-7696133062907",
  "_rev": "sERZ3ZJ9MtNiP4BmT5zftt",
  "_type": "product",
  "_updatedAt": "2022-08-31T21:41:10Z",
  "body": [],
  "store": {
    "createdAt": "2022-05-12T17:39:51+01:00",
    "shopifyTriggeredAt": "2022-05-12T17:39:51+01:00",
    "descriptionHtml": "",
    "gid": "gid://shopify/Product/7696133062907",
    "id": 7696133062907,
    "isDeleted": false,
    "metafields": [
      {
        "_key": "custom.care_instructions",
        "namespace": "custom",
        "key": "care_instructions",
        "type": "multi_line_text_field",
        "value": "Hand wash only"
      },
      {
        "_key": "specs.dimensions",
        "namespace": "specs",
        "key": "dimensions",
        "type": "dimension",
        "value": {"value": 24.5, "unit": "CENTIMETERS"}
      }
    ],
    "options": [
      {
        "_key": "Color",
        "_type": "option",
        "name": "Color",
        "values": [
          "Blue",
          "Ecru",
          "Pink"
        ]
      }
    ],
    "previewImageUrl": "https://cdn.shopify.com/s/files/1/0639/3285/8619/products/Green_1.jpg?v=1655598944",
    "priceRange": {
      "maxVariantPrice": 25.5,
      "minVariantPrice": 25
    },
    "productType": "",
    "shop": { "domain": "your-store.myshopify.com" },
    "slug": {
      "_type": "slug",
      "current": "soap-dish"
    },
    "status": "active",
    "tags": "",
    "title": "AUTOGRAF Soap Dish",
    "variants": [
      {
        "_key": "c8b492e1-3c24-527d-bffd-accc634177c7",
        "_ref": "shopifyProductVariant-43068621422843",
        "_type": "reference",
        "_weak": true
      },
      {
        "_key": "9128c62c-f887-594c-b9b8-ddaaf850ce84",
        "_ref": "shopifyProductVariant-43068621455611",
        "_type": "reference",
        "_weak": true
      },
      {
        "_key": "5d861cdf-bcfe-5781-81dd-d62db159442b",
        "_ref": "shopifyProductVariant-43068621488379",
        "_type": "reference",
        "_weak": true
      }
    ],
    "vendor": "Lucy Holdberg"
  }
}
```

## Variant document

This is an example of a variant document.

```json
{
  "_createdAt": "2022-05-27T08:49:54Z",
  "_id": "shopifyProductVariant-43068621422843",
  "_rev": "sERZ3ZJ9MtNiP4BmT5zftt",
  "_type": "productVariant",
  "_updatedAt": "2022-08-31T21:32:01Z",
  "store": {
    "compareAtPrice": 35,
    "createdAt": "2022-05-27T09:49:52+01:00",
    "gid": "gid://shopify/ProductVariant/43068621422843",
    "id": 43068621422843,
    "inventory": {
      "isAvailable": true,
      "policy": "CONTINUE"
    },
    "isDeleted": false,
    "option1": "Blue",
    "option2": "",
    "option3": "",
    "previewImageUrl": "https://cdn.shopify.com/s/files/1/0639/3285/8619/products/Blue_1.jpg?v=1655598950",
    "price": 25.5,
    "productGid": "gid://shopify/Product/7696133062907",
    "productId": 7696133062907,
    "shop": { "domain": "your-store.myshopify.com" },
    "sku": "AGSD_BLUE",
    "status": "active",
    "title": "Blue",
    "barcode": "12345"
  }
}
```

## Collection document

This is an example of a collection document:

```json
{
  "_createdAt": "2022-06-07T10:00:11Z",
  "_id": "shopifyCollection-396461834491",
  "_rev": "0penztPZlC32Cv2tesREk7",
  "_type": "collection",
  "_updatedAt": "2022-08-26T15:07:57Z",
  "store": {
    "createdAt": "2022-08-26T15:07:56.895Z",
    "shopifyTriggeredAt": "2022-08-26T15:07:56.895Z",
    "descriptionHtml": "",
    "disjunctive": false,
    "gid": "gid://shopify/Collection/396461834491",
    "id": 396461834491,
    "imageUrl": "https://cdn.shopify.com/s/files/1/0639/3285/8619/collections/BLOMST_print.jpg?v=1655599663",
    "isDeleted": false,
    "metafields": [
      {
        "_key": "custom.landing_copy",
        "namespace": "custom",
        "key": "landing_copy",
        "type": "multi_line_text_field",
        "value": "Limited print run"
      }
    ],
    "rules": [
      {
        "_key": "7803ad21-682e-56b6-ae2a-4d380d0d120c",
        "_type": "object",
        "column": "TYPE",
        "condition": "Poster",
        "relation": "CONTAINS"
      }
    ],
    "shop": { "domain": "your-store.myshopify.com" },
    "slug": {
      "_type": "slug",
      "current": "prints"
    },
    "sortOrder": "BEST_SELLING",
    "title": "Prints"
  }
}
```

Below are the data types for the properties of a collection document:

```typescript
export type ShopifyDocumentCollection = {
  _id: `shopifyCollection-${string}` // Shopify collection ID
  _type: 'collection'
  store: {
    id: number
    gid: `gid://shopify/Collection/${string}`
    createdAt: string
    shopifyTriggeredAt?: string
    isDeleted: boolean
    descriptionHtml: string
    imageUrl?: string
    rules?: {
      _key: string
      _type: 'object'
      column: Uppercase<string>
      condition: string
      relation: Uppercase<string>
    }[]
    disjunctive?: boolean
    slug: {
      _type: 'slug'
      current: string
    }
    sortOrder: string
    title: string
    updatedAt?: string
    shop: {
      domain: string
    }
    metafields?: Metafield[]
  }
}

// Imported Shopify metafields. The same shape is used in the custom webhook
// sync payload below.
export type Metafield = {
  _key: string // `${namespace}.${key}`
  namespace: string
  key: string
  type: string
  value: unknown
}
```

## Metafields

When you select metafield namespaces to import, those metafields appear on product and collection documents as a `store.metafields` array. Each entry is keyed by `namespace.key` and carries the namespace, key, Shopify type, and value.

Values are deserialized from Shopify's stored representation, so structured types surface as real arrays, objects, and numbers rather than JSON-encoded strings.

Imported metafields are owned by Shopify and read-only in Sanity — each sync overwrites the array, so set it `readOnly` in your Studio schema. Import is namespace-scoped: selecting a namespace imports every metafield in it, and nothing outside your selection is imported. Variant metafields and metaobjects are not supported.

## Custom webhook sync payload

If you use the custom webhook sync, your handler will receive the shape described by `Product` (and `Collection` if enabled) below. You can still use JavaScript or any other programming language in your custom handler even though we describe the payload using [TypeScript](https://www.typescriptlang.org/) syntax.

```typescript
export type Product = {
  id: `gid://shopify/Product/${string}`
  title: string
  description: string
  descriptionHtml: string
  featuredImage?: ProductImage
  handle: string
  images: ProductImage[]
  options: ProductOption[]
  priceRange: ProductPriceRange
  productType: string
  tags: string[]
  variants: ProductVariant[]
  vendor: string
  status: 'active' | 'archived' | 'draft' | 'unlisted' | 'unknown'
  publishedAt: string
  createdAt: string
  updatedAt: string
  shop: {
    domain: string
  }
  metafields?: Metafield[]
}
export type ProductImage = {
  id: `gid://shopify/ProductImage/${string}`
  altText?: string
  height?: number
  width?: number
  src: string
}
export type ProductOption = {
  id: `gid://shopify/ProductOption/${string}`
  name: string
  position: number
  values: string[]
}
export type ProductPriceRange = {
  minVariantPrice?: number
  maxVariantPrice?: number
}
export type ProductVariant = {
  id: `gid://shopify/ProductVariant/${string}`
  title: string
  compareAtPrice?: number
  barcode?: string
  inventoryPolicy: string
  inventoryQuantity: number
  position: number
  sku: string
  taxable: boolean
  price: string
  createdAt: string
  updatedAt: string
  image?: ProductImage
  product: {
    id: `gid://shopify/Product/${string}`
    status: 'active' | 'archived' | 'draft' | 'unlisted' | 'unknown'
  }
  selectedOptions: {
    name: string
    value: string
  }[]
  shop: {
    domain: string
  }
}
export type Collection = {
  id: `gid://shopify/Collection/${string}`
  createdAt: string
  handle: string
  descriptionHtml: string
  image?: CollectionImage
  rules?: {
    column: string
    condition: string
    relation: string
  }[]
  disjunctive?: boolean
  sortOrder: string
  title: string
  updatedAt: string
  shop: {
    domain: string
  }
  metafields?: Metafield[]
}
export type Metafield = {
  _key: string
  namespace: string
  key: string
  type: string
  value: unknown
}
export type CollectionImage = {
  altText: string
  height?: number
  width?: number
  src: string
}

// When products are created, updated or manually synced
export type payloadProductsSync = {
  action: 'create' | 'update' | 'sync'
  products: Product[]
}

// When products are deleted
export type payloadProductsDelete = {
  action: 'delete'
  productIds: number[]
}

// When collections are created, updated or manually synced
export type payloadCollectionsSync = {
  action: 'create' | 'update' | 'sync'
  collections: Collection[]
}

// When collections are deleted
export type payloadCollectionsDelete = {
  action: 'delete'
  collectionIds: number[]
}

export type requestPayload = payloadProductsDelete | payloadProductsSync | payloadCollectionsDelete | payloadCollectionsSync
```



# Get started

Sanity Connect for Salesforce Commerce Cloud (SFCC) synchronizes your B2C Commerce product catalog into Sanity, where your team can enrich it with editorial content such as rich text, media, localized copy, and custom fields, without touching SFCC directly. The enriched content is then available to any frontend that can query Sanity's APIs, including the SFCC PWA Kit.

The connector has three parts:

- **SFCC Cartridge:** runs inside your B2C Commerce instance and pushes product and category data into Sanity
- **Sanity Studio plugin** (`@sanity/sfcc`): adds SFCC-aware document schemas, structure, and UI to your Studio
- **PWA Kit integration**: utilities and patterns for fetching Sanity-enriched content from your composable storefront, including live preview of draft changes

## How it works

The SFCC Cartridge hooks into B2C Commerce's job framework to perform an initial full catalog sync and then keeps Sanity up to date as products and categories change. Each SFCC product and category becomes a document in Sanity, carrying read-only commerce fields (like ID, name, prices, variants) alongside editable fields that your editors control to enrich your content.

On the storefront side, any frontend can fetch this enriched content from Sanity at runtime, merging it with product data returned by the Salesforce Commerce API.

The PWA Kit is a common choice for composable storefronts built on SFCC, but Next.js, Nuxt, Astro, Remix, and other frameworks work equally well.

## Requirements

Before you begin, make sure you have:

1. A Sanity project with an API token with **Editor** write access. [Create a new project](https://www.sanity.io/get-started)
2. A Salesforce B2C Commerce instance with access to Business Manager and permissions to manage cartridge paths, import metadata, configure services, and manage jobs.
3. Your Sanity project details: Project ID, dataset name and API token.

> [!TIP]
> Protip
> Always install and validate in a sandbox before deploying to staging or production.

## Part 1: Install the SFCC Cartridge

The `int_sanity_connect` cartridge runs inside your B2C Commerce environment and synchronizes catalog data, categories and products, to Sanity using the SFCC Jobs framework and Sanity's API.

### 1.1 Add the cartridge to your codebase

Clone the connector repository and place the `int_sanity_connect` cartridge into your SFCC codebase alongside your other cartridges:

**Terminal**

```sh
git clone https://github.com/sanity-io/sanity-sfcc.git
```

Your cartridge directory should look something like this:

```text
/cartridges
   /app_storefront_base
   /int_sanity_connect
```

### 1.2 Deploy to your instance

Deploy the cartridge using your standard deployment process – CI/CD pipeline, WebDAV upload, or UX Studio. Confirm the cartridge is present in the instance after deployment before proceeding.

### 1.3 Update the cartridge path

1. Log into **Business Manager**.
2. Navigate to **Administration → Sites → Manage Sites**.
3. Select your site and open the **Settings** tab.
4. Locate the **Cartridges** field and add the cartridge to the path, for example: `int_sanity_connect:app_storefront_base`.
5. Select **Apply**.
6. Clear the cache: **Administration → Sites → Manage Sites → Clear Cache**.

> [!WARNING]
> Cartridge order matters
> If the cartridge extends or overrides logic, position it accordingly relative to other cartridges in the path.

### 1.4 Import metadata

The cartridge ships with required metadata in the `/metadata` folder, which registers the site preferences, custom object types, services, and jobs used by the connector.

#### Step 1 – Prepare the ZIP file

Zip the `/metadata` folder from the repository.

#### Step 2 – Upload the ZIP

1. In Business Manager, navigate to **Administration → Site Development → Site Import & Export**.
2. Under the **Import** section, select **Choose File** and select your ZIP.
3. Select **Upload**.

#### Step 3 – Run the import

1. Select the uploaded ZIP file.
2. Click **Import**.
3. Monitor the import status. You should see **Status: Finished** with no errors in the logs.

### 1.5 What gets created after import

After a successful import, the following are available in Business Manager:

- **Site preferences**: a **Sanity** group under **Merchant Tools → Site Preferences → Custom Preferences** holding all cartridge configuration values.
- **Custom object type**: `sanityProductFeedLastSyncInfo`, which stores the timestamp of the last successful product sync for use by the delta job.
- **Service**: `sanity.http.api` under **Administration → Operations → Services**.
- **Jobs**: `FULL_Sanity_Export_Categories_and_Products` and `DELTA_Sanity_Export_Categories_and_Products` under **Administration → Operations → Jobs**.

See the Sanity Connect for SFCC reference for the full list of site preferences, attribute mapping configuration, and job step parameters.

### 1.6 Configure the site preferences

Navigate to **Merchant Tools → Site Preferences → Custom Preferences → Sanity** and fill in the values for your environment. The three fields you must set to get syncing are:

##### Required preferences

| Preference ID | Description |
| --- | --- |
| sanityProjectId | Your Sanity project ID |
| sanityDataset | Your target dataset, e.g., production |
| sanityBearerToken | Your Sanity API token, with write access |

Also ensure that `isSanityIntegrationEnabled` is set to `true` to activate the integration. For the full list of available preferences, see the reference doc.

**Keep your bearer token secure.** The `sanityBearerToken` field is stored as a Password type and is masked in Business Manager. Use a dedicated token for the cartridge and rotate it via [sanity.io/manage](https://www.sanity.io/manage) if it is ever exposed.

### 1.7 Verify the service

Navigate to **Administration → Operations → Services** and confirm the `sanity.http.api` service is listed. If it is not visible, re-import the metadata ZIP and check the import logs for errors.

### 1.8 Full catalog sync

The `FULL_Sanity_Export_Categories_and_Products` job performs a complete sync of all categories and products from your SFCC catalog to Sanity. Run this job manually for your initial import and whenever you need to resync the full catalog.

The job contains multiple steps, one per locale, so if your storefront supports multiple locales, confirm that each locale has a corresponding step configured before running. See the job step parameters reference for the full list of options.

To run the job: navigate to **Administration → Operations → Jobs**, open `FULL_Sanity_Export_Categories_and_Products`, and select **Run Now**. Monitor the execution status and check the logs to confirm no errors and that records were sent to Sanity.

> [!WARNING]
> The full sync creates one Sanity document per product, variant, and category. This counts towards your Sanity plan's document limit. Check your usage at [sanity.io/manage](https://www.sanity.io/manage).

### 1.9 Delta sync (scheduled)

The `DELTA_Sanity_Export_Categories_and_Products` job sends only products modified since the last successful sync, using the timestamp stored in the `sanityProductFeedLastSyncInfo` custom object. All categories are always included in each delta run.

**This job should be set up on a recurring schedule** to keep Sanity up to date as catalog changes happen in SFCC. Configure the schedule under the job's **Schedule & History** tab in Business Manager.

## Part 2: Set up your Sanity Studio

The `@sanity/sfcc` package provides schema building blocks, desk structure helpers, document actions, and UI components for working with SFCC-synced data in Sanity Studio. Rather than registering document types for you, it gives you the pieces to compose your own `product` and `category` document types – so you can add whatever editorial fields your team needs alongside the read-only SFCC data.

### 2.1 Install the plugin

In your Studio project:

**npm**

```shell
npm install @sanity/sfcc sanity-plugin-internationalized-array
```

**pnpm**

```shell
pnpm add @sanity/sfcc sanity-plugin-internationalized-array
```

**yarn**

```shell
yarn add @sanity/sfcc sanity-plugin-internationalized-array
```

**bun**

```shell
bun add @sanity/sfcc sanity-plugin-internationalized-array
```

`sanity-plugin-internationalized-array` is required because the synced SFCC store fields use its `internationalizedArrayString` and `internationalizedArrayText` types for localized product and category data.

### 2.2 Configure the plugins

Add `sfccPlugin()` and `internationalizedArray()` to your `sanity.config.ts`. Configure `internationalizedArray` with the languages your SFCC instance supports. These should match the locales configured in the cartridge job steps:

**sanity.config.ts**

```
import { sfccPlugin } from '@sanity/sfcc'
import { defineConfig } from 'sanity'
import { internationalizedArray } from 'sanity-plugin-internationalized-array'
import { structureTool } from 'sanity/structure'

export default defineConfig({
  // ...
  plugins: [
    structureTool({ structure }), // see step 4
    sfccPlugin(),
    internationalizedArray({
      languages: [
        { id: 'en_US', title: 'English' },
        { id: 'fr', title: 'French' },
      ],
      defaultLanguages: ['en_US'],
      fieldTypes: ['string', 'text'],
    }),
  ],
  schema: {
    types: [productType, categoryType], // see step 3
  },
})
```

### 2.3 Define your document types

Create your `product` and `category` document types using the building blocks exported from `@sanity/sfcc`. Each type should include the relevant store field (which holds all the read-only synced SFCC data), the preview config, and the offline banner – then add your own fields alongside them:

**schema.ts**

```
import { PackageIcon } from '@sanity/icons/Package'
import { TagIcon } from '@sanity/icons/Tag'
import {
  sfccCategoryPreview,
  sfccCategoryStoreField,
  sfccProductPreview,
  sfccProductStoreField,
  sfccRenderMembers,
} from '@sanity/sfcc'
import { defineField, defineType } from 'sanity'

export const productType = defineType({
  name: 'product',
  title: 'Product',
  type: 'document',
  icon: TagIcon,
  renderMembers: sfccRenderMembers,
  fields: [
    // Add your own enrichment fields here
    defineField({
      name: 'promotionalContent',
      title: 'Promotional Content',
      type: 'array',
      of: [{ type: 'block' }],
    }),
    // Read-only synced SFCC data
    sfccProductStoreField,
  ],
  preview: sfccProductPreview,
})

export const categoryType = defineType({
  name: 'category',
  title: 'Category',
  type: 'document',
  icon: PackageIcon,
  renderMembers: sfccRenderMembers,
  fields: [
    defineField({
      name: 'name',
      title: 'Name',
      type: 'string',
    }),
    sfccCategoryStoreField,
  ],
  preview: sfccCategoryPreview,
})
```

### 2.4 Set up the studio structure

![A content management system displaying product listings, variants, and detailed attributes (ID, color, size) for a "Classic Blouse."](https://cdn.sanity.io/images/3do82whm/next/684ffd3ab7a5ab10256312189531209f8ae1b966-3636x1988.png)

Use the exported structure builders to organize products and categories in the Studio sidebar, with Master/Simple products grouped alongside their variants:

**structure.ts**

```
import { categoryStructure, productStructure } from '@sanity/sfcc'
import { type StructureResolver } from 'sanity/structure'

export const structure: StructureResolver = (S, context) =>
  S.list()
    .title('Content')
    .items([
      categoryStructure(S, context),
      productStructure(S, context),
      S.divider(),
      ...S.documentTypeListItems().filter((item) => {
        const id = item.getId()
        return id ? !['category', 'product'].includes(id) : false
      }),
    ])
```

### 2.5 What the plugin provides

`sfccPlugin()` itself registers two behaviours for documents of type `product` and `category`:

- The **duplicate** action is removed. These documents are managed by the SFCC sync process, not created manually.
- The **delete** action is replaced with a custom version that, for products, also deletes all associated variant documents in a single transaction.
- Both types are hidden from the **Create new document** menu for the same reason.

The individual building block exports, like `sfccProductStoreField`, `sfccCategoryStoreField`, `sfccRenderMembers`, the preview configs, and the structure builders, are what you compose into your own document types as shown in steps 2.3 and 2.4 above.

## Part 3: Integrate your storefront

Sanity's APIs are framework-agnostic. You can query enriched content from any frontend using GROQ over HTTP. The connector repository includes a **PWA Kit demo application** that shows one way to approach this integration, and it's a useful reference regardless of which framework you're using.

> [!NOTE]
> This is an example, not a template.
> The demo is built on the `@salesforce/retail-react-app` extensibility framework using the PWA Kit overrides pattern. Study it as a reference for key patterns, but don't treat it as a drop-in starting point for production. [Browse the demo source](https://github.com/sanity-io/sanity-sfcc)

### 3.1 The `sanity/` module

All Sanity-related code in the demo is consolidated under `overrides/app/sanity/`:

**/sanity folder**

```text
overrides/app/sanity/
  lib/
    client.js      – Browser-side Sanity client (CDN-enabled, no token)
    server.js      – Server-side published and preview clients
    queries.js     – All GROQ queries, defined with defineQuery()
    queryStore.js  – SSR query store, useSanityQuery hook
    image.js       – Image URL builder and responsive image helper
    utils.js       – Link resolution and marketing tile insertion
  components/
    SanityLink.jsx           – Internal/external link component
    visual-editing.jsx       – Presentation tool visual editing integration
    draft-mode-indicator.jsx – Preview mode banner
```

The key architectural decisions in this structure are worth understanding before adapting for your own frontend:

**Server/browser client separation.** `server.js` creates two clients: a published client (CDN, no token) and a preview client (no CDN, token-authenticated). The browser client in `client.js` never receives the API token. All client-side GROQ queries are proxied through a `/api/sanity/query` server endpoint that selects the right client based on cookie state.

**Centralised queries.** `queries.js` defines all GROQ queries in one place using `defineQuery()` from the `groq` package. Reusable fragments handle image and link projections so queries return pre-shaped data that components can use directly.

**Feature flag.** A `SANITY_INTEGRATION_ENABLED` environment variable gates all Sanity rendering. Setting it to `false` disables Sanity content completely without code changes.

### 3.2 Wiring into `ssr.js`

The demo's `overrides/app/ssr.js` adds three things on top of the standard PWA Kit server:

**Preview endpoints.** `GET /api/preview/enable` validates the secret from the Sanity Presentation tool (using `@sanity/preview-url-secret`), sets an HTTP-only signed cookie with the preview perspective, and redirects. `GET /api/preview/disable` clears the cookie.

**Preview middleware.** On every request, the middleware reads the `__sanity_preview` cookie. If valid, it sets `res.locals.isPreview`, `res.locals.previewPerspective`, and `res.locals.sanityPreviewToken`. Downstream code uses these to swap the Sanity client.

**GROQ proxy endpoint.** `POST /api/sanity/query` executes GROQ queries server-side, selecting the published or preview client based on `res.locals`. This keeps the API token out of the browser entirely.

The server also adds `*.sanity.io` to the Content Security Policy for images, connections, frames, and frame ancestors (to allow the Presentation tool to embed the storefront).

### 3.3 Wiring into `_app-config/index.jsx`

`overrides/app/components/_app-config/index.jsx` is the PWA Kit app wrapper, and is where the demo wires in the per-request Sanity client swap and the visual editing components:

**overrides/app/components/_app-config/index.jsx**

```jsx
// Swap the Sanity server client per-request during SSR
if (res?.locals?.isPreview && res.locals.sanityPreviewToken) {
    setServerClient(createPreviewClient(res.locals.sanityPreviewToken, res.locals.previewPerspective))
} else if (typeof window === 'undefined') {
    setServerClient(createPublishedClient())
}
```

It also mounts the two Sanity UI components:

**overrides/app/components/_app-config/index.jsx**

```jsx
<DraftModeIndicator />
{typeof window !== 'undefined' && window.self !== window.top && <SanityVisualEditing />}
```

`DraftModeIndicator` shows a fixed banner when the `__sanity_preview` cookie is active (but not inside the Presentation tool iframe). `SanityVisualEditing` enables stega encoding and live mode only when the app is running inside the Presentation tool.

### 3.4 Fetching Sanity data in a page

Page components use the `useSanityQuery` hook from `queryStore.js`. On the server it calls `loadQuery` directly; on the client it proxies through `/api/sanity/query`. The result is passed to `useQuery` from `@sanity/react-loader`, which enables live updates when the Presentation tool is active.

The home page is the simplest example:

**overrides/app/pages/home/index.jsx**

```jsx
import {useSanityQuery} from '../../sanity/lib/queryStore'
import {HOMEPAGE_QUERY} from '../../sanity/lib/queries'

const {data: sanityHomepage} = useSanityQuery(HOMEPAGE_QUERY, {id: 'home-page'})
const hero = sanityHomepage?.hero
const marketingTiles = sanityHomepage?.marketingTiles

// Rendering is gated by the feature flag and the presence of data
{isSanityIntegrationEnabled && hero && <HeroBanner heroData={hero} />}
{isSanityIntegrationEnabled && marketingTiles && <MarketingTilesCarousel tiles={marketingTiles} />}
```

The category and product detail pages follow the same pattern, using `CATEGORY_QUERY` and `PRODUCT_QUERY` respectively.

### 3.5 Environment variables

The demo uses the following environment variables. The `SANITY_STUDIO_*` variables are set in `.env`. `SANITY_API_READ_TOKEN` must be set as a server-side environment variable only and never committed to source control.

##### PWA Kit environment variables

| Variable | Purpose |
| SANITY_STUDIO_PROJECT_ID | Sanity project ID |
| SANITY_STUDIO_DATASET | Sanity dataset name |
| SANITY_STUDIO_API_VERSION | Sanity API version date string, e.g. 2025-05-30 |
| SANITY_INTEGRATION_ENABLED | Feature flag – set to false to disable all Sanity rendering |
| SANITY_API_READ_TOKEN | Server-only viewer token for preview/draft mode – never expose to the browser |

> [!WARNING]
> Cookie configuration required for preview mode
> Preview mode relies on the `__sanity_preview` cookie being set and read server-side. Two settings need to be in place:
> **Local development:** set `localAllowCookies: true` in the `options` object in `ssr.js`.
> **MRT deployment:** enable the equivalent cookie-handling setting in the Managed Runtime dashboard for your environment. Without this, MRT will strip cookies and preview mode will not function.

## Further reading

- [Sanity Connect for SFCC – reference](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-sfcc-configuration-reference)
- [PWA Kit overview](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/pwa-kit-overview.html)
- [B2C Commerce job framework](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/b2c-jobs.html)
- [@sanity/sfcc on npm](https://www.npmjs.com/package/@sanity/sfcc)
- [Connector repository](https://github.com/sanity-io/sanity-sfcc)



# Configuration reference

This page documents all configuration options for Sanity Connect for Salesforce Commerce Cloud. For installation and setup instructions, see the [main guide](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-salesforce-commerce-cloud).

## Site preferences

After importing the cartridge metadata, a **Sanity** custom preference group is available under **Merchant Tools → Site Preferences → Custom Preferences**. Each preference has a built-in description in Business Manager.

##### Preferences

| Preference ID | Type | Notes |
| --- | --- | --- |
| isSanityIntegrationEnabled | Boolean | Master on/off switch for the integration |
| sanityApiHostUrl | String | Default: api.sanity.io |
| sanityBearerToken | Password | API token with write access; masked in Business Manager |
| sanityProjectId | String | Found in sanity.io/manage |
| sanityDataset | String | e.g., staging, development, production |
| sanityVersion | String | e.g., v2025-11-28 |
| sanityApiQueryParams | String | e.g., returnIds=true&autoGenerateArrayKeys=true&visibility=deferred&tag=sanity.sfcc |
| isExternalImage | Boolean | Enable if your catalog uses externally hosted product images |
| sfccToSanityCategoryAttributeMappings | JSON | See Category attribute mappings below |
| sfccToSanityProductAttributeMappings | JSON | See Product attribute mappings below |

## Attribute mappings

The two mapping preferences (`sfccToSanityCategoryAttributeMappings` and `sfccToSanityProductAttributeMappings`) are JSON configuration objects that define which SFCC attributes are pushed to Sanity and what they are named in Sanity documents.

They are designed to be extended without code changes – to add a custom attribute, add a new key to the JSON and redeploy.

Each attribute entry follows this shape:

```json
"<sfccAttributeName>": {
  "sanityName": "<sanityFieldName>",
  "localized": true | false,
  "dataType": "String" | "Boolean" | "Date" | "Image" | "HTML" | "Array" | "enum"
}
```

### Category attribute mappings

Default value for `sfccToSanityCategoryAttributeMappings`:

**sfccToSanityCategoryAttributeMappings**

```json
{
  "ID": {
    "sanityName": "categoryId",
    "localized": false,
    "dataType": "String"
  },
  "creationDate": {
    "sanityName": "creationDate",
    "localized": false,
    "dataType": "Date"
  },
  "description": {
    "sanityName": "description",
    "localized": true,
    "dataType": "String"
  },
  "displayName": {
    "sanityName": "displayName",
    "localized": true,
    "dataType": "String"
  },
  "online": {
    "sanityName": "online",
    "localized": false,
    "dataType": "Boolean"
  },
  "onlineFrom": {
    "sanityName": "onlineFrom",
    "localized": false,
    "dataType": "Date"
  },
  "onlineTo": {
    "sanityName": "onlineTo",
    "localized": false,
    "dataType": "Date"
  },
  "thumbnail": {
    "sanityName": "thumbnailImage",
    "localized": false,
    "dataType": "Image"
  }
}
```

### Product attribute mappings

Default value for `sfccToSanityProductAttributeMappings`:

**sfccToSanityProductAttributeMappings**

```json
{
  "ID": {
    "sanityName": "productId",
    "localized": false,
    "dataType": "String"
  },
  "brand": {
    "sanityName": "brand",
    "localized": false,
    "dataType": "String"
  },
  "color": {
    "sanityName": "color",
    "localized": false,
    "dataType": "String"
  },
  "creationDate": {
    "sanityName": "creationDate",
    "localized": false,
    "dataType": "Date"
  },
  "isNew": {
    "sanityName": "isNew",
    "localized": false,
    "dataType": "Boolean"
  },
  "isSale": {
    "sanityName": "isSale",
    "localized": false,
    "dataType": "Boolean"
  },
  "lastModified": {
    "sanityName": "lastModified",
    "localized": false,
    "dataType": "Date"
  },
  "longDescription": {
    "sanityName": "longDescription",
    "localized": true,
    "dataType": "HTML"
  },
  "manufacturerName": {
    "sanityName": "manufacturerName",
    "localized": false,
    "dataType": "String"
  },
  "manufacturerSKU": {
    "sanityName": "manufacturerSKU",
    "localized": false,
    "dataType": "String"
  },
  "name": {
    "sanityName": "name",
    "localized": true,
    "dataType": "String"
  },
  "onlineFlag": {
    "sanityName": "onlineFlag",
    "localized": false,
    "dataType": "Boolean"
  },
  "onlineFrom": {
    "sanityName": "onlineFrom",
    "localized": false,
    "dataType": "Date"
  },
  "onlineTo": {
    "sanityName": "onlineTo",
    "localized": false,
    "dataType": "String"
  },
  "pageDescription": {
    "sanityName": "pageDescription",
    "localized": true,
    "dataType": "String"
  },
  "pageKeywords": {
    "sanityName": "pageKeywords",
    "localized": true,
    "dataType": "String"
  },
  "pageTitle": {
    "sanityName": "pageTitle",
    "localized": true,
    "dataType": "String"
  },
  "pageURL": {
    "sanityName": "pageURL",
    "localized": true,
    "dataType": "String"
  },
  "refinementColor": {
    "sanityName": "refinementColor",
    "localized": false,
    "dataType": "enum"
  },
  "searchable": {
    "sanityName": "searchable",
    "localized": false,
    "dataType": "Boolean"
  },
  "searchableIfUnavailable": {
    "sanityName": "searchableIfUnavailable",
    "localized": false,
    "dataType": "Boolean"
  },
  "shortDescription": {
    "sanityName": "shortDescription",
    "localized": true,
    "dataType": "HTML"
  },
  "size": {
    "sanityName": "size",
    "localized": false,
    "dataType": "String"
  },
  "productType": {
    "sanityName": "productType",
    "localized": false,
    "dataType": "String"
  },
  "variationAttributes": {
    "sanityName": "variationAttributes",
    "localized": false,
    "dataType": "Array"
  },
  "image": {
    "sanityName": "productImage",
    "localized": false,
    "dataType": "String"
  }
}
```

## Job step parameters

Both sync jobs (`FULL_Sanity_Export_Categories_and_Products` and `DELTA_Sanity_Export_Categories_and_Products`) use the same step-level parameters. Each locale your storefront supports requires its own job step.

### Category sync step

##### Category sync step

| Parameter | Description | Values |
| --- | --- | --- |
| isEnabled | Enable or disable this step for the given locale | true / false |
| locale | Locale to sync | Valid BM locale string, e.g. en_US, fr, de |
| sendLocalizedAttributesOnly | Send only localized attributes when enabled | true / false |

### Product sync step

##### Product sync step

| Parameter | Description | Values |
| --- | --- | --- |
| isEnabled | Enable or disable this step for the given locale | true / false |
| locale | Locale to sync | Valid BM locale string, e.g. en_US, fr, de |
| sendLocalizedAttributesOnly | Send only localized attributes when enabled | true / false |
| ingestionStrategy | Full catalog sync or delta-only | FULL / DELTA |
| includeMasterProductsOutOfStock | Include out-of-stock master products | true / false |

## Further reading

- [Sanity Connect for SFCC: installation guide](https://www.sanity.io/sanity-for-salesforce-commerce-cloud)
- [@sanity/sfcc on npm](https://www.npmjs.com/package/@sanity/sfcc): full plugin API and exports reference
- [Connector repository](https://github.com/sanity-io/sanity-sfcc)
- [B2C Commerce custom preferences](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/b2c-custom-preferences.html)
- [B2C Commerce job framework](https://developer.salesforce.com/docs/commerce/b2c-commerce/guide/b2c-jobs.html)



# Content Agent API

Build chat interfaces, automate content workflows, and create custom tools that read and write Sanity content through natural language. The [content-agent npm package](https://npmx.dev/package/content-agent) is a [Vercel AI SDK](https://sdk.vercel.ai/) provider that handles streaming, authentication, and thread management.

The package supports two interaction modes: **threads** for stateful, multi-turn conversations (`.agent()`) and **one-shot prompts** for stateless single-turn tasks (`.prompt()`). Both work with the standard Vercel AI SDK functions like `generateText` and `streamText`.

For the full API reference, see the [content-agent](https://reference.sanity.io/content-agent/) [reference docs](https://reference.sanity.io/content-agent/).

#### Related

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[Build with AI](https://www.sanity.io/docs/ai)
AI-powered tools to enhance your content and development workflows.

## Prerequisites

Before you start, you need:

- A Sanity project with a [deployed schema](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)
- A project-level API token with **Editor** role or above. Create one in [sanity.io/manage](https://sanity.io/manage) under Your Project → API → Tokens.
- Your organization ID (visible in your project settings)
- Node.js 18+
- A Sanity Studio (v5.1.0+) opened at least once after deployment. This registers the Studio with the Content Agent service.

> [!NOTE]
> Content Agent calls consume AI credits
> Every Content Agent API call uses [AI credits](https://www.sanity.io/docs/platform-management/how-ai-credits-work). Costs vary by operation: read-only queries cost less than write operations. Monitor your usage in your project settings.

## Quick start

First, install the packages:

**npm**

```shell
npm install content-agent ai
```

**pnpm**

```shell
pnpm add content-agent ai
```

**yarn**

```shell
yarn add content-agent ai
```

**bun**

```shell
bun add content-agent ai
```

### Generating text

This example sends a single prompt to the Content Agent and prints the response. It uses `generateText` from the Vercel AI SDK.

**quick-start.ts**

```
import { createContentAgent } from 'content-agent'
import { generateText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('my-thread')

const result = await generateText({
  model,
  prompt: 'What blog posts do I have?',
})

console.log(result.text)
```

### Streaming

Use `streamText` to display results as they arrive.

**quick-start-stream.ts**

```
import { createContentAgent } from 'content-agent'
import { streamText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const { textStream } = streamText({
  model: contentAgent.agent('my-thread'),
  prompt: 'Summarize my latest content',
})

for await (const text of textStream) {
  process.stdout.write(text)
}
```

## Installation and setup

### Install the packages

The [content-agent](https://npmx.dev/package/content-agent) package is available on npm.

**npm**

```shell
npm install content-agent ai
```

**pnpm**

```shell
pnpm add content-agent ai
```

**yarn**

```shell
yarn add content-agent ai
```

**bun**

```shell
bun add content-agent ai
```

The `content-agent` package is a [Vercel AI SDK](https://sdk.vercel.ai/) provider. The `ai` package is a peer dependency required for `generateText`, `streamText`, and other Vercel AI SDK functions.

### Create the provider

**provider.ts**

```
import { createContentAgent } from 'content-agent'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})
```

For all provider options, see the [createContentAgent](https://reference.sanity.io/content-agent/createContentAgent/) [reference](https://reference.sanity.io/content-agent/createContentAgent/).

### Authentication

All API requests require a **project-level** API token with the **Editor** role or above. Create one from sanity.io/manage → Your Project → API → Tokens. Organization-level tokens and viewer tokens will not work.

> [!WARNING]
> Keep tokens secure
> Don't expose authentication tokens in client-side code. For browser-based apps, proxy requests through your own backend.

> [!WARNING]
> Common authentication errors
> - `SIO-401-ANF` ("Session not found"): You are likely using an organization token or a robot token instead of a project-level API token.
> - `projectUserNotFoundError`: The token does not belong to the target project. Verify you created the token under the correct project in sanity.io/manage.
> - `NO_COMPATIBLE_APPLICATIONS`: No registered Studio found. Open your Sanity Studio in a browser at least once to connect it to the Content Agent service.

## Applications

Each application key uniquely identifies a deployed Sanity Studio workspace. Since multiple studios can share the same project ID and dataset, the application key targets the right one.

Use `.applications()` to list available studios for the authenticated user, then pass the key to `.agent()` or `.prompt()`:

**list-apps.ts**

```
const apps = await contentAgent.applications()

const app = apps.find((a) => a.title === 'My Studio')

const model = contentAgent.agent('my-thread', {
  application: { key: app.key },
})
```

## Configuration

The `config` object controls agent behavior. Pass it as part of the options to `.agent()` or `.prompt()`. For the full type definition, see the [Config](https://reference.sanity.io/content-agent/Config/) [reference](https://reference.sanity.io/content-agent/Config/).

**config.ts**

```
const model = contentAgent.agent('my-thread', {
  config: {
    capabilities: { read: true, write: false },
  },
})
```

Here are three common patterns:

**config-patterns.ts**

```
// Read-only: the agent can query but not modify content
config: { capabilities: { read: true, write: false } }

// Scoped: limit to specific document types
config: {
  capabilities: { read: true, write: false },
  filter: { read: '_type in ["post", "author"]' },
}

// Release-scoped: read and write within a specific release
config: {
  capabilities: { read: true, write: true },
  perspectives: { read: ['myRelease'], write: 'myRelease' },
}

```

For full details on each option, see the subsections below.

### Capabilities

Capabilities control what the agent can do. Configure `read` and `write` independently. Each accepts `true` (standard preset), `false` (no access), or an object with a preset name. For the full type definition, see the [Capabilities](https://reference.sanity.io/content-agent/Capabilities/) [reference](https://reference.sanity.io/content-agent/Capabilities/).

| Preset | Read features | Write features |
| --- | --- | --- |
| false | No access | No access |
| { preset: 'minimal' } | Document queries, web search | Simple mutations |
| true or { preset: 'standard' } | Document queries, sets (bulk analysis), web search | Simple and bulk mutations |

> [!NOTE]
> Drafts only
> The agent can't write to published documents directly. It can only create or update draft and versioned documents.

**capabilities.ts**

```
// Read-only with all read tools
const readOnly = {
  capabilities: { read: true, write: false },
}

// Minimal read (basic queries, no bulk analysis)
const minimalRead = {
  capabilities: { read: { preset: 'minimal' }, write: false },
}

// Full read, minimal write
const readWriteMinimal = {
  capabilities: { read: true, write: { preset: 'minimal' } },
}
```

Use `capabilities.features` to toggle individual features on or off, overriding the preset defaults:

**no-web-search.ts**

```
// Standard read but disable web search
const noWebSearch = {
  capabilities: {
    read: true,
    write: false,
    features: { webSearch: false },
  },
}

```

### Filters

Use GROQ boolean expressions to control which documents the agent can see and modify. For the full type definition, see the [Filter](https://reference.sanity.io/content-agent/Filter/) [reference](https://reference.sanity.io/content-agent/Filter/).

**filters.ts**

```
const model = contentAgent.agent('my-thread', {
  config: {
    filter: {
      // Only these document types are visible
      read: '_type in ["post", "author", "category"]',
      // Only posts can be modified
      write: '_type == "post"',
    },
  },
})

```

### Perspectives

Perspectives control which document versions the agent reads from and writes to. Values are Sanity perspective IDs: `"drafts"`, `"published"`, `"raw"`, or a release ID.

**perspectives.ts**

```
// Only read published documents
const publishedOnly = {
  perspectives: { read: ['published'] },
}

// Lock to a specific release for both reading and writing
const releaseScoped = {
  perspectives: { read: ['myRelease'], write: 'myRelease' },
}
```

When you set `read`, the agent's query tools are restricted to the listed perspectives. When you set `write`, new documents are created in the specified perspective (for example, `"drafts"` creates `drafts.*` IDs).

### User message context

The `userMessageContext` field passes contextual information that the agent appends to each user message. Each key becomes an XML tag with the value as content.

**msg-context.ts**

```
const config = {
  userMessageContext: {
    'slack-channel': '#marketing',
    'slack-user': '@john.doe',
  },
}
// Renders as: <slack-channel>#marketing</slack-channel>

```

### Custom instructions

The `instruction` field adds custom instructions to the agent's system prompt.

**instruction.ts**

```
const config = {
  instruction:
    'You are a Slack bot helping users manage blog content. Always respond in a friendly, concise tone.',
}
```

## Custom tools

You can extend the agent with your own tools using the [Vercel AI SDK tool pattern](https://sdk.vercel.ai/). Pass custom tools when calling `generateText` or `streamText`. The package forwards tool schemas to the agent and runs execution locally on your server.

**tool.ts**

```
import { generateText, tool } from 'ai'
import { z } from 'zod'

const model = contentAgent.agent('my-thread', {
  application: { key: '<your-application-key>' },
  config: { capabilities: { read: true, write: false } },
})

const { text } = await generateText({
  model,
  prompt: 'What is the weather in San Francisco?',
  tools: {
    getWeather: tool({
      description: 'Get the current weather for a location',
      parameters: z.object({
        location: z.string().describe('City name'),
      }),
      execute: async ({ location }) => {
        return { temperature: 72, condition: 'sunny' }
      },
    }),
  },
})

```

Custom tools run alongside the agent's built-in tools. The agent decides when to call them based on the message and the tool descriptions you provide.

## Examples

### Read-only document explorer

Restrict the agent to querying documents without making changes.

**read-only-explorer.ts**

```
import { createContentAgent } from 'content-agent'
import { generateText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('explorer-thread', {
  application: { key: '<your-application-key>' },
  config: {
    capabilities: {
      read: { preset: 'standard' },
      write: false,
    },
    filter: {
      read: '_type in ["post", "author", "page"]',
    },
  },
})

const { text } = await generateText({
  model,
  prompt: 'Show me all posts published this month',
})

console.log(text)

```

### Chat with user context

Pass contextual information about the current environment or workflow to the agent.

**chat.ts**

```
import { createContentAgent } from 'content-agent'
import { streamText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('slack-bot-thread', {
  application: { key: '<your-application-key>' },
  config: {
    instruction: 'You are a Slack bot helping users manage content.',
    userMessageContext: {
      'slack-channel': '#content-team',
      'slack-user': '@john.doe',
    },
    capabilities: {
      read: true,
      write: false,
    },
  },
})

const { textStream } = streamText({
  model,
  prompt: 'What content needs review this week?',
})

for await (const chunk of textStream) {
  process.stdout.write(chunk)
}

```

## Error handling

The package throws API errors as exceptions. Wrap your calls in try/catch blocks. For the full list of error types and status codes, see the [ErrorResponse](https://reference.sanity.io/content-agent/ErrorResponse/) [reference](https://reference.sanity.io/content-agent/ErrorResponse/).

**try-catch.ts**

```
try {
  const { text } = await generateText({ model, prompt: 'List all posts' })
  console.log(text)
} catch (error) {
  console.error('Content Agent error:', error.message)
}

```

## Limitations

- The agent can only write to draft and versioned documents.
- The prompt endpoint has a 10,000 character limit for the message field.
- The API version is currently `vX` (preview). Endpoints and behavior may change.
- The API manages thread history server-side. You cannot retrieve or modify past messages through the API.

#### Related

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[Build with AI](https://www.sanity.io/docs/ai)
AI-powered tools to enhance your content and development workflows.



# Content Agent

#### Get started

[Get started with Sanity Content Agent](https://www.sanity.io/docs/content-agent/introduction)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects.

[Content Agent for Slack](https://www.sanity.io/docs/content-agent/content-agent-for-slack)
Build a Slackbot powered by Content Agent

[How AI Credits work](https://www.sanity.io/docs/platform-management/how-ai-credits-work)
Understand how AI credits are consumed and billed for Sanity AI tooling.

#### Additional resources

[Content Agent API](https://www.sanity.io/docs/apis-and-sdks/content-agent-api)
Learn how to add the Content Agent to your own apps

[Community discord](https://snty.link/community)
Connect with us in the #agent channel on Discord.



# Get started

The Content Agent is a conversational AI assistant that helps you work with content across all the projects in your organization.

Instead of navigating Studio structure, writing queries, or asking developers for help, you describe what you need in plain language. The agent understands the shape of your content, follows references, and works within your permissions.

This guide will help you understand what the Content Agent is, how to access it, and how to use its key capabilities.

![AI assistant interface with a search prompt to find marketing campaigns targeting Millennials.](https://cdn.sanity.io/images/3do82whm/next/8e0ed7edb5bd311d8dd04af1e414c9dfafe8f721-810x305.png)

With the Content Agent, you can:

- **Find content** across your project: "Show me all product pages missing meta descriptions."
- **Analyze patterns** like tone inconsistencies, metadata gaps, or outdated terminology.
- **Create documents** that match your schema, from blog posts to product pages.
- **Update content**: rewrite, translate, or improve fields across one or many documents.
- **Generate and transform images** directly within your documents.
- **Combine capabilities** in a single request: "For all articles, summarize cleared feedback items and create a blog post reporting on how community interactions have improved our content."

> [!NOTE]
> Is it safe?
> Absolutely. The Content Agent never makes changes without your approval. All proposed edits require your review, and you control whether to create drafts, add to a content release, or discard changes entirely.

## Requirements

- Access to the [Sanity Dashboard](https://www.sanity.io/docs/dashboard/dashboard-introduction).
- [Sanity Studio](https://www.sanity.io/studio) v5.1.0 or later. v6 is recommended.- Sign in and open your deployed Studio once. It registers its schema automatically, which is what lets the agent understand the shape of your content.



The agent uses your existing Sanity permissions. It can only see documents you can see and edit content where you have write access.

## Core concepts

### Searches and changes

The agent's work falls into two categories:

**Searches** are read operations. When you ask the agent to find, search, analyze, or answer questions about your content, that's a search.

Examples:

- "Find all blog posts from last month"
- "Which pages are missing meta descriptions?"

![A Content Agent AI interface shows a list of five documentation articles missing SEO titles, with three articles selected in a side panel.](https://cdn.sanity.io/images/3do82whm/next/1d2586f7ae7febb465fc215b7d7d675dca02779d-1678x1135.png)

**Changes** are write operations. When the agent creates, updates, or generates content, that's a change.

Examples:

- "Translate this article into Spanish"
- "Generate a hero image for this page"

![A Content Agent user interface displays a conversation about adding SEO titles to articles, next to a list of 5 proposed document updates with 'Confirm all' and 'Discard all' options.](https://cdn.sanity.io/images/3do82whm/next/388ca513fc4bcc069f0db992148c60087077e8db-1678x1135.png)

This distinction shapes your review workflow, and the two categories map to the two tabs in the results panel. It is not, however, how usage is billed. Usage is measured in [AI Credits](https://www.sanity.io/docs/platform-management/how-ai-credits-work), and the split is between messages and tool executions rather than between reading and writing.

### Context

The agent responds based on context. The current context is always shown in the chat input, and updates as you navigate your Studio. The context is provided as a hint for the agent, but you can use natural language to instruct it how to use that information.

![A UI search bar with "Content Release API Cheat Sheet" selected.](https://cdn.sanity.io/images/3do82whm/next/01ff86e0b3dc3979d9515d2dae62dce180813b2b-578x151.png)

The agent understands context naturally throughout your conversation:

- When chatting about a document, the agent focuses on it automatically
- As you search or filter content, those results become your new context
- The agent can consider your entire project when no specific context is set
- You can always see what context the agent is using at the bottom of the chat input, and change it if needed

### Custom instructions

Custom instructions are standing guidelines the agent applies to every conversation, covering things like tone of voice, preferred language, or how detailed you want responses to be. You set them in two places:

- Organization: **Manage > Organization > Settings > Content Agent**. These apply to everyone in the organization, and only an organization administrator can change them.
- User: **Dashboard > Account settings > Content Agent**. These apply to your own conversations, and you set them separately for each organization you belong to.

The two levels combine rather than compete: your instructions are added to your organization's, not substituted for them. Each level holds up to five instructions, and the agent treats all of them as guidance rather than hard rules. Edits take effect on your next message, including in a conversation that's already open.

For the steps and guidance on writing instructions the agent follows, see [Set custom instructions for the Content Agent](https://www.sanity.io/docs/content-agent/custom-instructions).

### How usage affects cost

Each message you send carries a query cost, and every tool execution the agent runs adds a cost on top of that. A single message can trigger several tool executions, so a broad request costs more than a narrow one. The number of executions depends on the amount and type of work required; it is not a fixed number per document. Having the agent do complex work across many documents can consume a lot of credits in a short time.

> [!TIP]
> Do a test run
> Concerned about cost? Try your prompt on a few documents first by selecting them from the results before running it on a large set of documents.

For rates, spending limits, and cost examples, see [How AI Credits work](https://www.sanity.io/docs/platform-management/how-ai-credits-work).

### Request size and batching

Multi-document work is what the agent is for. The limit you're most likely to meet is the size of a single request, not the total amount of content you can change: work that fails as one monolithic command often succeeds when you split it into batches.

A request grows as it multiplies dimensions. Translating five documents into five locales is 25 fields in a single request. Structural edits inside Portable Text, like inserting or reordering blocks rather than replacing a plain-text value, add to that because the agent has to track each document's structure to place every change correctly.

Signs a single request is too large:

- The run takes far longer than the same work on a single document, without finishing.
- The run ends with no proposed changes in the **Changes** tab.

To work through a large set, split it into smaller batches and run one batch at a time, reviewing and confirming each batch's changes before you start the next. Start each batch in a new chat: context accumulates through a conversation as you search and filter, so a fresh chat keeps the previous batch out of the new request.

No single batch size fits every request. A batch that sets one plain-text field can be much larger than a batch that rewrites Portable Text across several locales, so size your batches to the kind of change you're making rather than to a fixed document count.

When you confirm changes into a content release rather than into drafts, the release's own limits apply: a release holds at most 1,000 document versions and 100 MB of JSON. See [Technical limits](https://www.sanity.io/docs/content-lake/technical-limits).

For jobs in the thousands of documents, or for changes you want to run repeatedly, [Agent Actions](https://www.sanity.io/docs/agent-actions/introduction) or async requests with the [Content Agent AI](https://www.sanity.io/docs/apis-and-sdks/content-agent-api) fit better than a conversational request.

## The interface

The Content Agent is available from your organization's dashboard across all your projects.

![An AI content agent interface showing articles with updated SEO titles, listed in a release management panel.](https://cdn.sanity.io/images/3do82whm/next/7c2bfdd14e4e3550fcdbacf58d7c9a36ce7f47a5-1833x1053.png)

### Find the agent and start a chat

- **Agent panel**: the Content Agent lives in a dedicated side panel in the dashboard, which can be collapsed and expanded as needed.
- **Dashboard side menu**: toggle the agent sidebar from the dashboard side menu.
- **Dashboard chat input**: start a chat from the chat input at the top of the dashboard home page.

![A Content Agent software dashboard showing options for finding, creating, and editing content, with AI assistant prompts and recent activity logs.](https://cdn.sanity.io/images/3do82whm/next/f700a812915183d2d688d016792c07dae72bdcda-1668x979.png)

### What's in the agent panel

Your chat with the agent. Responses, document lists, and status updates appear here.

![A software interface showing a Content Agent processing a request to list AI Assist articles lacking SEO titles, with a related list of articles visible on the right.](https://cdn.sanity.io/images/3do82whm/next/f780ec54e481e5907e9149ffadda9902473a3469-1285x923.png)

The **input field** at the bottom shows your current context ([read more about context](https://www.sanity.io/docs/content-agent/introduction)). Remove or change context anytime. This is also where you can review a log of the agent's actions and reasoning. You can attach files directly in the chat using the attachment button. Supported file types: PDF, TXT, Markdown, HTML, CSV, TSV, XML, JSON, DOCX, XLS, XLSX, JPEG, PNG, GIF, and WebP (up to 32 MB per file). Attachments belong to the thread rather than to a single message, so the agent can refer back to them later in the conversation.

### Results panel

The right-hand panel displays what the Content Agent has found or proposed changes for you to review. Toggle between two tabs:

- **Searches**: view documents matching your query criteria. Select specific items using checkboxes to include them in your next action. This helps you narrow down exactly which content you want to work with, and is also useful for testing your intended changes before applying them to large sets of documents. Once you're happy with your plan, you can direct the agent to work on the whole set of documents by deselecting your subset.
- **Changes**: pending edits awaiting approval.

![Content Agent AI content management interface, showing a list of five articles missing SEO titles and a selection panel with three articles chosen for an AI chat.](https://cdn.sanity.io/images/3do82whm/next/acc0515f539995874e38c575f8f873f293beba67-1348x923.png)

The changes displayed here are prepared but not executed. At this stage, no drafts have been created, and no content has been modified in your project.

> [!NOTE]
> No history?
> Until you confirm them, the agent's proposed changes don't appear in document history, content releases, or search results. They are not private, however; they remain readable through the API by anyone with access to the project.

Once you're satisfied with the proposed changes, you can choose to:

- Confirm all changes to create drafts
- Add the changes to a content release
- Discard the changes if they don't meet your requirements

This preview step gives you full control to review exactly what the Content Agent will modify before any actual changes are made to your content.

![A user interface for a Content Agent application showing proposed SEO title changes and document updates.](https://cdn.sanity.io/images/3do82whm/next/a4f4987dad9d36e100fd9343c0f502bfaffac8bc-1348x923.png)

## A practical example

This example walks through a complete content operation assisted by the agent, in a project with common content types such as articles and blog posts.

### Search

Start by asking the agent to find articles that lack keywords.

![Content Agent AI interface showing 445 articles lacking keywords, with a chat suggesting to add them and a list of article titles.](https://cdn.sanity.io/images/3do82whm/next/4e9cb0fe12f3f6ab75542a80a629cb614d9dcf62-1462x995.png)

The agent found a large number of articles without keywords, and noted that some of them are old. Since every change the agent makes consumes credits, we don't want to indiscriminately update all 400+ matches. Next, filter the results by telling the agent to focus only on articles from the last couple of months.

![Content Agent application interface showing a chat with filtered articles needing keywords on the left, and a list of SQL 2.0 articles with four selected on the right.](https://cdn.sanity.io/images/3do82whm/next/bd09c1dff5909e0dbba6eb77d44cd8e34d580361-1544x1031.png)

This narrows the search down to 18 documents, a more manageable set. You can reduce the set further by checking individual matches on or off in the results view.

You can also narrow the set by asking about it rather than filtering again. Follow-up questions like "Which of these are still published?" refine the selection conversationally, and each answer becomes your new context.

![A user interface displaying a completed multi-step data retrieval process for articles, followed by a chat session on "All Articles Without Search Keywords."](https://cdn.sanity.io/images/3do82whm/next/f7a7e4e6991f5e668b451db56f2c953ad2044a5e-444x383.png)

To see how the agent reasoned when fetching your results, open the expandable **Thinking process** log above the chat input.

### Change

Once you're happy with the result, ask the agent to apply one or more changes to the selected set, in this case the articles that need keywords.

![A software interface called "Content Agent" displays proposed keyword additions and updates for 18 SQL 2.0 related articles.](https://cdn.sanity.io/images/3do82whm/next/12eb02f1b7c47d9bea6726e00b5959c679d51083-1544x1031.png)

The agent will plan out the changes and report back in the **Changes** tab. Even at this point, the changes are still proposals. All changes require your approval, so you review each proposed edit before anything is applied.

![UI with "Discard all" and "Confirm all" buttons, with a dropdown showing "Create release" and "Create / overwrite drafts" options.](https://cdn.sanity.io/images/3do82whm/next/c49f85f1668aebc090044fe9f65076d741586c15-329x148.png)

When you are happy, click the **Confirm** button. Depending on your preferred workflow, select either to create drafts for the relevant documents or to put all the changes into a [content release](https://www.sanity.io/docs/user-guides/content-releases). You can now review the drafted changes and publish when ready.

![A Content Agent interface displays a task titled "Adds keywords to articles," showing 18 proposed changes to various articles listed on the right.](https://cdn.sanity.io/images/3do82whm/next/f7ba4d081db01060017f4547aade441741d25c73-1720x1064.png)

## What you can do

For practical tips and instructions on what you can do with Content Agent, see the [Content Agent quick start guide](https://www.sanity.io/docs/user-guides/content-agent-user-guide).

## Limitations

- **Limited local file support.** The agent works with content in your dataset or available on the web. You can attach files in the chat (see the supported file types above, up to 32 MB each), but the agent can't browse your local filesystem.
- **Won't publish.** The agent creates drafts or adds the changes to a content release. Publishing is always a separate step that you take yourself, so a human stays in the loop for final review.
- **No rollback within the agent.** Use Studio's document history to revert changes.- Proposed changes stay separate from your document history until you review and accept them, so you won't end up with half-applied edits.


- **No deletion.** For safety, the agent cannot delete documents.
- **No Canvas support.** The agent can't read or modify content in Canvas. Media Library is supported when your organization has the Media Library application enabled, though the agent can't delete assets.

## FAQs

**Why didn't the agent find something?** Try rephrasing your request or adding more context. Check the **Thinking process** log for misunderstandings. Confirm you have permission to view the document.

**Why didn't my document update?** The agent proposes changes but doesn't apply them automatically. Look for the review prompt and approve the update.

**Does the agent remember past conversations?** You can revisit past chats from the agent panel, but context doesn't carry over between sessions automatically.

**How is my content used?** Conversations may be stored for up to 30 days to improve the system. See our [Terms of Service](https://www.sanity.io/legal/tos) and [AI Terms of Service](https://www.sanity.io/legal/tos-ai) for details.

**Which Sanity AI tool should I use?**

- **Content Agent** is a conversational assistant in the Dashboard. Use it for project-wide tasks: searching, auditing, analyzing patterns, and bulk updates.
- **AI Assist** is a Studio plugin with inline AI help. Use it for quick, field-level tasks, such as rewriting a paragraph.
- **Agent Actions** are developer APIs for running AI tasks automatically. Use them for migrations, localization pipelines, or background automation.



# Understanding AI Credits

Whether working with [Content Agent](https://www.sanity.io/docs/content-agent), [Agent Actions](https://www.sanity.io/docs/agent-actions), or [certain MCP server tools](https://www.sanity.io/docs/ai/mcp-server), AI usage in Sanity is measured and billed using AI Credits. This article examines what AI Credits are and how they work.

> [!TIP]
> Free credits every month!
> Every organization receives a free number of AI credits each month to explore and experiment. See the [pricing page](https://www.sanity.io/pricing) more details.

## Credit pricing

Each AI credit costs **$0.05**. Credit consumption depends on the type and scope of interaction.

- **Query** (your message to Content Agent): **4 credits** ($0.20)
- **Action** (tool use by Content Agent): **2 credits** ($0.10)
- **Agent Action** (a request with any [Agent Action](https://www.sanity.io/docs/agent-actions)): **1 credit** ($0.05)

**Queries** are messages you send to the Content Agent. Each request includes a 4-credit query cost.

**Actions** are operations the agent performs on your behalf: GROQ queries, web searches, document analysis, content creation, and image generation. Each tool execution costs 2 credits.

A single request may involve multiple tool executions depending on the complexity of the task. The number of executions depends on the amount and type of work required; it is not a fixed number per document.

### MCP server

Certain [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server) tools invoke Agent Actions under the hood and consume credits at the same rate. Most MCP tools, however, are standard API calls and don't consume credits.

### Example cost estimates

Costs vary based on document size, structure, and workflow complexity. The examples below are directional. When Content Agent asks you to confirm a bulk operation, the estimate it shows is rounded up: to the nearest 10 credits below 100, and to the nearest 100 credits at or above 100. That figure can read higher than the ones in this table.

##### AI Credits cost examples

| Prompt | Estimated calculation | Estimated credits |
| --- | --- | --- |
| "Show Q3 blog posts" | 1 query (4) + small read (2) | ~6 credits |
| "Analyze 10 articles (~1 MB total)" | 1 query (4) + analysis (~2 credits per 100 KB) | ~24 credits |
| "Update 5 documents" | 1 query (4) + mutation (~6 credits per document) | ~34 credits |
| "Translate 3 documents into 2 languages" | 1 query (4) + translation (~12 credits per document per language) | ~76 credits |

For large bulk operations, Content Agent asks you to confirm before it proceeds. Confirmation is triggered when the estimated cost is 100 credits or more, or when the estimate meets or exceeds your organization's remaining credits. If your organization reaches its spending limit, AI operations pause until the start of the next calendar month or until the limit is increased.

## Controlling costs

Content Agent can easily operate on a large number of documents which can incur unexpected billing if used indiscriminately. To shield your organization from unintended costs you can set spending limits that will halt all AI operations once reached.

### Control usage

You can find detailed overviews of your AI usage by visiting sanity.io/manage and clicking the **Usage** tab in the top level navigation.

![An AI usage dashboard showing 6,826 total credits used, broken down by Agent Actions, Content Agent Queries, and Content Agent Actions, with a bar chart visualizing daily usage trends over 8 days.](https://cdn.sanity.io/images/3do82whm/next/498b3df2397fb1ad92f032114a0f3eb76aec0bf2-953x992.png)

The usage overview also shows which individuals in your organization are the most prolific users of AI features.

![Dashboard detailing AI usage by user, featuring a table of total usage and a stacked bar chart of daily usage over time.](https://cdn.sanity.io/images/3do82whm/next/364af7a3dc39983cf00722aa84dc4ba053f8b151-966x764.png)

#### Set spending limits for AI 

You can set a monthly spending limit to prevent unexpected charges. When your organization reaches the cap, AI features pause until the start of the next calendar month or until you raise the limit. If an operation is started while credits are still available, it will run to completion even if it exceeds the remaining budget, and usage is calculated with a short delay, so actual spend can slightly exceed the cap before AI operations pause. Visit [sanity.io/manage](https://sanity.io/manage) and navigate to your organization's **Settings** to set or change your spending limits. You can pick the default cap, a custom monthly amount, or no limit at all. Setting a custom cap requires accepting the AI Credits Additional Terms.

![AI usage dashboard showing $100 remaining and a $100 spending cap.](https://cdn.sanity.io/images/3do82whm/next/d14dba9878e5867eae73fc530116bb4823eb1eb3-1008x249.png)

### Roll out AI features to your team

Before you open Content Agent to a whole team, set an organization spending limit and plan for how that limit behaves under load:

- Set the limit before rollout. Only members with billing permissions on the organization can set or change it.
- Leave headroom above what you expect to spend. An operation that has already started runs to completion, and usage is metered with a short delay, so a month can end slightly above the limit.
- Expect more confirmation prompts as usage approaches the limit. Content Agent also asks for confirmation when an estimate meets or exceeds your organization's remaining credits, not only at the 100-credit threshold.
- Choose the limit deliberately. You can't lower it below what your organization has already spent in the current month.

### Tips for efficient usage

When working with large document sets, select a few documents first to refine your prompt before applying it to the entire set. This helps you optimize your queries and reduce unnecessary credit consumption.

## Credits when you change plans

AI credits are a monthly allowance. The credits included with your plan reset at the start of each calendar month, at 00:00 UTC, and don't carry over to the next one.

Your spending limit is set on your organization, separately from your plan. Changing plans, including converting a trial to a paid plan, doesn't raise or reset the limit, so upgrading isn't a reliable way to resume a paused Content Agent. If the agent is still paused after a plan change, raise or remove the limit.

A paused agent reports `Limit Hit - Content Agent Paused.` along with the credits you have used and your organization's monthly limit in both dollars and credits. To resume, raise or remove the limit under **Settings** at [sanity.io/manage](https://sanity.io/manage).





# Custom instructions

Custom instructions are standing guidelines the Content Agent applies to every conversation. Use them for the things you'd otherwise repeat in each chat: your tone of voice, the language you write in, a house style rule, or how much detail you want in a response.

This guide explains how to set custom instructions for yourself and for your organization, how the two sets combine, and how to write instructions the agent follows reliably.

## Prerequisites

- Access to the [Sanity Dashboard](https://www.sanity.io/docs/dashboard/dashboard-introduction), with an organization selected.
- The [Administrator role](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing) on the organization, to set or view organization-level instructions. If you're not an administrator, ask one which instructions are set.

## Set your own instructions

Your own instructions apply to your conversations only.

1. In the Sanity Dashboard, go to **Account settings**.
2. Under **Custom Instructions**, click **Add Instruction**.
3. Enter one instruction, then click **Done**.
4. To add another, click **New**. You can save up to five.

![Content Agent user interface for adding custom instructions to define agent behavior, tone, and response detail.](https://cdn.sanity.io/images/3do82whm/next/0475b703ee051f8efdcf81d29814832e38640004-2062x1548.png)

Your instructions are stored per organization. If you work across two organizations, set them separately in each.

## Set instructions for your organization

Organization instructions apply to everyone who uses the Content Agent in that organization.

1. In Manage, go to **Organization > Settings > Content Agent**.
2. Under **Custom Instructions for your organization**, click **Add Instruction**, enter one instruction, and click **Done**.

Only an organization administrator can see or change these. Keep them to things that hold for the whole team: product names that shouldn't be translated, a house style rule, the language your team writes in.

## How the two sets combine

Both sets apply at once. Your organization's instructions come first, and yours are added to them. Yours do not replace or override the organization's.

> [!NOTE]
> Contradictions aren't resolved for you
> The agent receives both sets as a single list, with nothing marking which instruction came from where. If one of your instructions contradicts an organization instruction, no rule decides the winner, and the outcome can differ from one conversation to the next. When you need to depart from an organization instruction, ask for that directly in the conversation instead of writing a competing instruction.

Custom instructions are guidance, not enforcement. The agent weighs them against everything else in the conversation, so treat them as a strong steer rather than a guarantee, and keep reviewing what it proposes.

## Where custom instructions apply

Custom instructions follow the agent, not the interface. The same instructions apply whether you're working in the dashboard or in [Slack](https://www.sanity.io/docs/content-agent/content-agent-for-slack), because both talk to the same agent.

They also apply when the agent is reached through the [Content Agent API](https://www.sanity.io/docs/apis-and-sdks/content-agent-api), on top of any instructions an application passes with a request. An application authenticating with an API token has no signed-in user behind it, so only your organization's instructions apply in that case.

## Write instructions the agent follows well

- **Be specific and actionable. **"Write in American English" works better than "be mindful of language."
- **Use imperative verbs. **Tell the agent what to do rather than what you'd prefer.
- **Keep each instruction to one idea. **Five focused instructions work better than one that covers everything.
- **Keep them short. **Past about 1,000 characters the dashboard warns you that long instructions reduce accuracy.
- **Stay on content. **Instructions about how to write, structure, and edit content are what the agent can act on.

To have the agent tighten an instruction for you, click **Check**. It proposes a rewritten version as a diff, which you can **Accept proposal** or **Revert back**. Accepting a proposal puts the rewritten text in the editor; click **Done** to save it.

![The Content Agent custom instructions editor showing a proposed rewrite of an instruction, with Accept proposal and Revert back buttons.](https://cdn.sanity.io/images/3do82whm/next/17ba110b37c1bde4e0cdd5a9c40d85eed6bbc620-2056x1542.png)

## Edit or remove an instruction

Click an instruction to edit it, then click **Done** to save or **Cancel** to discard. To delete a saved instruction, click the trash icon (**Remove instruction**) beside it. Removing an instruction saves immediately and can't be undone.

Changes save as soon as you click **Done**, and they take effect on your next message, including in a conversation that's already open. You don't need to start a new chat.

## Limits on custom instructions

- Five instructions per level, so up to ten can apply if your organization has its own set.
- Up to 50,000 characters per instruction, though much shorter is better.

#### Related articles

[Get started with Sanity Content Agent](https://www.sanity.io/docs/content-agent/introduction)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects.

[Content Agent for Slack](https://www.sanity.io/docs/content-agent/content-agent-for-slack)
Use the Sanity Content Agent directly in Slack. Search, create, and update content in your Sanity projects through conversation, without leaving your workspace.

[Content Agent API](https://www.sanity.io/docs/apis-and-sdks/content-agent-api)
Build custom integrations with the Sanity Content Agent API.



# Content Agent for Slack

## Install the Slack app

Add the Content Agent to your Slack workspace:

[Install Sanity for Slack](https://api.sanity.io/v1/agent/integrations/slack/install)

This installs the **Sanity** app into your workspace. A workspace admin may need to approve the installation.

### Requirements

- A Sanity account with at least one project
- A Slack workspace (Free, Pro, Business+, or Enterprise 
- [Sanity Studio](https://www.sanity.io/docs/sanity-studio) v5.1.0 or later (v5.2.0 or later is recommended, as your schema is broadcast when you visit your deployed Studio)

Your schema needs to be discoverable by Sanity. You can do this in any of the following ways:

- Visit your deployed Studio at least once after upgrading to v5.2.0 or later. Your schema broadcasts automatically.
- For external studios, run `npx sanity deploy --external` to register your Studio URL with Sanity without hosting the Studio on Sanity. This approach is available in `sanity` v5.8.0 or later.

## Connect your Sanity account

After installation, each team member connects their own Sanity account. The agent uses your individual permissions, so it can only see and edit content you have access to.

1. Open a conversation with the **Sanity** app in Slack (find it in your Apps sidebar), or @mention it in any channel
2. The app prompts you to **Connect Sanity Account**
3. Click the link to authorize with your Sanity credentials

To check your connection status or disconnect, use the **Account Settings** shortcut (search "Account Settings" in Slack's shortcuts menu).

## Start a conversation

There are three ways to talk to the Content Agent in Slack:

### Assistant threads

Open the **Sanity** assistant from Slack's AI sidebar. This gives you a dedicated thread with suggested prompts to get started:

- "Show me all campaigns targeting a 'Millennials' audience that are still in draft."
- "Draft three distinct blog post outlines about the impact of remote work on company culture."
- "Translate our ten most popular blog posts into German, but keep our company name and product names untranslated."

### Direct messages

Send a message directly to the Sanity app in your Apps sidebar. This works the same as an assistant thread.

### @mentions in channels

Mention **@Sanity** in any channel or thread where the app has been added. The agent responds in-thread to keep the conversation organized.

**Multi-person threads:** When a second person joins a thread the agent is active in, the agent steps back and only responds to direct @mentions. This keeps group conversations clean.

## Choose a studio

When you start your first conversation, the agent asks which Sanity studio to use. Your choice determines which project and dataset the agent works with.

If your organization has multiple studios, you can tell the agent which one to use at any time. Say something like *"Switch to my marketing studio"* or *"Use the production dataset."*

## What you can do

The Content Agent for Slack has the same capabilities as the [Dashboard Content Agent](https://www.sanity.io/docs/content-agent). Describe what you need in plain language.

### Find content

- *"Show me all product pages missing meta descriptions."*
- *"Do we have any articles about eco-friendly packaging?"*

The agent searches by meaning, not just keywords. Ask for *"content about sustainability"* and it can find relevant documents even if they don't use that exact word.

### Create content

- *"Create a new blog post draft about our Content Agent feature."*
- *"Write a product description for our reusable water bottle."*

The agent produces structured content that matches your schema, not just free text.

### Update content

- *"Rewrite this paragraph to be more concise."*
- *"Translate this article into Spanish."*
- *"Add missing alt text to all images in this document."*

All proposed edits require your review. The agent never makes changes without approval.

### Analyze and audit

- *"Analyze our blog posts for tone inconsistencies."*
- *"How many articles did each author publish last month?"*

## Feedback

After each response, the agent shows 👍 and 👎 buttons. Use these to help improve the agent's responses over time.

## Limitations

- **Cannot publish documents.** The agent creates drafts. A human reviews and publishes.
- **Cannot delete documents.** For safety, deletion isn't supported.
- **No local files.** The agent only works with content in your Sanity dataset or available on the web.
- **No Canvas or Media Library.** The agent works with Studio content only.
- **No rollback within the agent.** Use the Studio's document history to revert changes.
- **One organization per session.** Your connection is scoped to a single Sanity organization. To work with projects in a different organization, disconnect and reconnect with the other org.
- **Actions are attributed to you.** Any content the agent creates or edits appears under your name in the document history.

## Troubleshooting

**The app isn't responding**
Check that your Sanity account is connected. Open the **Account Settings** shortcut to verify. If you recently connected, try sending a new message.

**"Connect your account" keeps appearing**
Your session may have expired. Click the authorization link again to reconnect.

**The agent can't find my content**
Make sure you've selected the correct studio. The agent only searches within the project and dataset tied to your chosen studio. Also verify you have permission to view the documents you're looking for.

**The app doesn't respond to messages in a channel**
The Sanity app must be added to the channel first. Mention **@Sanity** to invite it, or add it through Slack's channel settings.

**No Studios available**
The agent can only discover Studios with a broadcast schema. Visit your deployed Studio after upgrading to v5.2.0 or later.

## Support

For help with the Sanity app for Slack, join the Sanity community on [Discord](https://snty.link/community). Paying self-serve customers can also use the [Account Support form](https://www.sanity.io/contact/billing) for questions on billing, plans, and quotas.

Note that Content Agent can make mistakes in its responses.

For information on how we handle your data, see our [Privacy Policy](https://www.sanity.io/legal/privacy).

#### Related articles

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[How AI Credits work](https://www.sanity.io/docs/platform-management/how-ai-credits-work)
Understand how AI credits are consumed and billed for Sanity AI tooling.

[Schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)
Deploy your schema into your dataset to enable deep integration between your content model and Sanity apps.

[Join the community](https://snty.link/community)
Join the Sanity community on Discord

[Content Agent API](https://www.sanity.io/docs/apis-and-sdks/content-agent-api)
Build custom integrations with the Sanity Content Agent API.



# Content Agent API

Build chat interfaces, automate content workflows, and create custom tools that read and write Sanity content through natural language. The [content-agent npm package](https://npmx.dev/package/content-agent) is a [Vercel AI SDK](https://sdk.vercel.ai/) provider that handles streaming, authentication, and thread management.

The package supports two interaction modes: **threads** for stateful, multi-turn conversations (`.agent()`) and **one-shot prompts** for stateless single-turn tasks (`.prompt()`). Both work with the standard Vercel AI SDK functions like `generateText` and `streamText`.

For the full API reference, see the [content-agent](https://reference.sanity.io/content-agent/) [reference docs](https://reference.sanity.io/content-agent/).

#### Related

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[Build with AI](https://www.sanity.io/docs/ai)
AI-powered tools to enhance your content and development workflows.

## Prerequisites

Before you start, you need:

- A Sanity project with a [deployed schema](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)
- A project-level API token with **Editor** role or above. Create one in [sanity.io/manage](https://sanity.io/manage) under Your Project → API → Tokens.
- Your organization ID (visible in your project settings)
- Node.js 18+
- A Sanity Studio (v5.1.0+) opened at least once after deployment. This registers the Studio with the Content Agent service.

> [!NOTE]
> Content Agent calls consume AI credits
> Every Content Agent API call uses [AI credits](https://www.sanity.io/docs/platform-management/how-ai-credits-work). Costs vary by operation: read-only queries cost less than write operations. Monitor your usage in your project settings.

## Quick start

First, install the packages:

**npm**

```shell
npm install content-agent ai
```

**pnpm**

```shell
pnpm add content-agent ai
```

**yarn**

```shell
yarn add content-agent ai
```

**bun**

```shell
bun add content-agent ai
```

### Generating text

This example sends a single prompt to the Content Agent and prints the response. It uses `generateText` from the Vercel AI SDK.

**quick-start.ts**

```
import { createContentAgent } from 'content-agent'
import { generateText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('my-thread')

const result = await generateText({
  model,
  prompt: 'What blog posts do I have?',
})

console.log(result.text)
```

### Streaming

Use `streamText` to display results as they arrive.

**quick-start-stream.ts**

```
import { createContentAgent } from 'content-agent'
import { streamText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const { textStream } = streamText({
  model: contentAgent.agent('my-thread'),
  prompt: 'Summarize my latest content',
})

for await (const text of textStream) {
  process.stdout.write(text)
}
```

## Installation and setup

### Install the packages

The [content-agent](https://npmx.dev/package/content-agent) package is available on npm.

**npm**

```shell
npm install content-agent ai
```

**pnpm**

```shell
pnpm add content-agent ai
```

**yarn**

```shell
yarn add content-agent ai
```

**bun**

```shell
bun add content-agent ai
```

The `content-agent` package is a [Vercel AI SDK](https://sdk.vercel.ai/) provider. The `ai` package is a peer dependency required for `generateText`, `streamText`, and other Vercel AI SDK functions.

### Create the provider

**provider.ts**

```
import { createContentAgent } from 'content-agent'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})
```

For all provider options, see the [createContentAgent](https://reference.sanity.io/content-agent/createContentAgent/) [reference](https://reference.sanity.io/content-agent/createContentAgent/).

### Authentication

All API requests require a **project-level** API token with the **Editor** role or above. Create one from sanity.io/manage → Your Project → API → Tokens. Organization-level tokens and viewer tokens will not work.

> [!WARNING]
> Keep tokens secure
> Don't expose authentication tokens in client-side code. For browser-based apps, proxy requests through your own backend.

> [!WARNING]
> Common authentication errors
> - `SIO-401-ANF` ("Session not found"): You are likely using an organization token or a robot token instead of a project-level API token.
> - `projectUserNotFoundError`: The token does not belong to the target project. Verify you created the token under the correct project in sanity.io/manage.
> - `NO_COMPATIBLE_APPLICATIONS`: No registered Studio found. Open your Sanity Studio in a browser at least once to connect it to the Content Agent service.

## Applications

Each application key uniquely identifies a deployed Sanity Studio workspace. Since multiple studios can share the same project ID and dataset, the application key targets the right one.

Use `.applications()` to list available studios for the authenticated user, then pass the key to `.agent()` or `.prompt()`:

**list-apps.ts**

```
const apps = await contentAgent.applications()

const app = apps.find((a) => a.title === 'My Studio')

const model = contentAgent.agent('my-thread', {
  application: { key: app.key },
})
```

## Configuration

The `config` object controls agent behavior. Pass it as part of the options to `.agent()` or `.prompt()`. For the full type definition, see the [Config](https://reference.sanity.io/content-agent/Config/) [reference](https://reference.sanity.io/content-agent/Config/).

**config.ts**

```
const model = contentAgent.agent('my-thread', {
  config: {
    capabilities: { read: true, write: false },
  },
})
```

Here are three common patterns:

**config-patterns.ts**

```
// Read-only: the agent can query but not modify content
config: { capabilities: { read: true, write: false } }

// Scoped: limit to specific document types
config: {
  capabilities: { read: true, write: false },
  filter: { read: '_type in ["post", "author"]' },
}

// Release-scoped: read and write within a specific release
config: {
  capabilities: { read: true, write: true },
  perspectives: { read: ['myRelease'], write: 'myRelease' },
}

```

For full details on each option, see the subsections below.

### Capabilities

Capabilities control what the agent can do. Configure `read` and `write` independently. Each accepts `true` (standard preset), `false` (no access), or an object with a preset name. For the full type definition, see the [Capabilities](https://reference.sanity.io/content-agent/Capabilities/) [reference](https://reference.sanity.io/content-agent/Capabilities/).

| Preset | Read features | Write features |
| --- | --- | --- |
| false | No access | No access |
| { preset: 'minimal' } | Document queries, web search | Simple mutations |
| true or { preset: 'standard' } | Document queries, sets (bulk analysis), web search | Simple and bulk mutations |

> [!NOTE]
> Drafts only
> The agent can't write to published documents directly. It can only create or update draft and versioned documents.

**capabilities.ts**

```
// Read-only with all read tools
const readOnly = {
  capabilities: { read: true, write: false },
}

// Minimal read (basic queries, no bulk analysis)
const minimalRead = {
  capabilities: { read: { preset: 'minimal' }, write: false },
}

// Full read, minimal write
const readWriteMinimal = {
  capabilities: { read: true, write: { preset: 'minimal' } },
}
```

Use `capabilities.features` to toggle individual features on or off, overriding the preset defaults:

**no-web-search.ts**

```
// Standard read but disable web search
const noWebSearch = {
  capabilities: {
    read: true,
    write: false,
    features: { webSearch: false },
  },
}

```

### Filters

Use GROQ boolean expressions to control which documents the agent can see and modify. For the full type definition, see the [Filter](https://reference.sanity.io/content-agent/Filter/) [reference](https://reference.sanity.io/content-agent/Filter/).

**filters.ts**

```
const model = contentAgent.agent('my-thread', {
  config: {
    filter: {
      // Only these document types are visible
      read: '_type in ["post", "author", "category"]',
      // Only posts can be modified
      write: '_type == "post"',
    },
  },
})

```

### Perspectives

Perspectives control which document versions the agent reads from and writes to. Values are Sanity perspective IDs: `"drafts"`, `"published"`, `"raw"`, or a release ID.

**perspectives.ts**

```
// Only read published documents
const publishedOnly = {
  perspectives: { read: ['published'] },
}

// Lock to a specific release for both reading and writing
const releaseScoped = {
  perspectives: { read: ['myRelease'], write: 'myRelease' },
}
```

When you set `read`, the agent's query tools are restricted to the listed perspectives. When you set `write`, new documents are created in the specified perspective (for example, `"drafts"` creates `drafts.*` IDs).

### User message context

The `userMessageContext` field passes contextual information that the agent appends to each user message. Each key becomes an XML tag with the value as content.

**msg-context.ts**

```
const config = {
  userMessageContext: {
    'slack-channel': '#marketing',
    'slack-user': '@john.doe',
  },
}
// Renders as: <slack-channel>#marketing</slack-channel>

```

### Custom instructions

The `instruction` field adds custom instructions to the agent's system prompt.

**instruction.ts**

```
const config = {
  instruction:
    'You are a Slack bot helping users manage blog content. Always respond in a friendly, concise tone.',
}
```

## Custom tools

You can extend the agent with your own tools using the [Vercel AI SDK tool pattern](https://sdk.vercel.ai/). Pass custom tools when calling `generateText` or `streamText`. The package forwards tool schemas to the agent and runs execution locally on your server.

**tool.ts**

```
import { generateText, tool } from 'ai'
import { z } from 'zod'

const model = contentAgent.agent('my-thread', {
  application: { key: '<your-application-key>' },
  config: { capabilities: { read: true, write: false } },
})

const { text } = await generateText({
  model,
  prompt: 'What is the weather in San Francisco?',
  tools: {
    getWeather: tool({
      description: 'Get the current weather for a location',
      parameters: z.object({
        location: z.string().describe('City name'),
      }),
      execute: async ({ location }) => {
        return { temperature: 72, condition: 'sunny' }
      },
    }),
  },
})

```

Custom tools run alongside the agent's built-in tools. The agent decides when to call them based on the message and the tool descriptions you provide.

## Examples

### Read-only document explorer

Restrict the agent to querying documents without making changes.

**read-only-explorer.ts**

```
import { createContentAgent } from 'content-agent'
import { generateText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('explorer-thread', {
  application: { key: '<your-application-key>' },
  config: {
    capabilities: {
      read: { preset: 'standard' },
      write: false,
    },
    filter: {
      read: '_type in ["post", "author", "page"]',
    },
  },
})

const { text } = await generateText({
  model,
  prompt: 'Show me all posts published this month',
})

console.log(text)

```

### Chat with user context

Pass contextual information about the current environment or workflow to the agent.

**chat.ts**

```
import { createContentAgent } from 'content-agent'
import { streamText } from 'ai'

const contentAgent = createContentAgent({
  organizationId: '<your-org-id>',
  token: '<your-sanity-token>',
})

const model = contentAgent.agent('slack-bot-thread', {
  application: { key: '<your-application-key>' },
  config: {
    instruction: 'You are a Slack bot helping users manage content.',
    userMessageContext: {
      'slack-channel': '#content-team',
      'slack-user': '@john.doe',
    },
    capabilities: {
      read: true,
      write: false,
    },
  },
})

const { textStream } = streamText({
  model,
  prompt: 'What content needs review this week?',
})

for await (const chunk of textStream) {
  process.stdout.write(chunk)
}

```

## Error handling

The package throws API errors as exceptions. Wrap your calls in try/catch blocks. For the full list of error types and status codes, see the [ErrorResponse](https://reference.sanity.io/content-agent/ErrorResponse/) [reference](https://reference.sanity.io/content-agent/ErrorResponse/).

**try-catch.ts**

```
try {
  const { text } = await generateText({ model, prompt: 'List all posts' })
  console.log(text)
} catch (error) {
  console.error('Content Agent error:', error.message)
}

```

## Limitations

- The agent can only write to draft and versioned documents.
- The prompt endpoint has a 10,000 character limit for the message field.
- The API version is currently `vX` (preview). Endpoints and behavior may change.
- The API manages thread history server-side. You cannot retrieve or modify past messages through the API.

#### Related

[Content Agent](https://www.sanity.io/docs/content-agent)
Sanity Content Agent is an AI assistant that helps you work with content across your Sanity projects—without writing code or GROQ queries. 

[Build with AI](https://www.sanity.io/docs/ai)
AI-powered tools to enhance your content and development workflows.



# Build custom applications on Sanity

#### Get started

[App SDK Quickstart Guide](https://www.sanity.io/docs/app-sdk/sdk-quickstart)
Get up and running quickly with the Sanity App SDK .

[Conceptual Walkthrough](https://www.sanity.io/docs/app-sdk/sdk-introduction)
Explore the App SDK in a follow-along format.

#### Concepts

[Document handles](https://www.sanity.io/docs/app-sdk/document-handles)
Document handles are a central concept in the Sanity App SDK, and are important to understand when working with many of the SDK's React hooks.

[React Hooks](https://www.sanity.io/docs/app-sdk/sdk-react-hooks)
Meet some of the most important hooks from the React SDK package.

[React Suspense](https://www.sanity.io/docs/app-sdk/react-suspense-sdk)
Learn how the Sanity App SDK uses established React patterns to facilitate working with live content.

#### Headless UI

[Sanity UI](https://www.sanity.io/docs/app-sdk/sanity-ui-sdk)
How to integrate @sanity/ui, or any other UI library, in your app.

[Tailwind CSS](https://www.sanity.io/docs/app-sdk/tailwind-sdk)
Learn how to use Tailwind in your custom apps built on Sanity, powered by the App SDK.

#### Reference and examples

[App SDK – Reference](https://reference.sanity.io/_sanity/sdk-react/)
Dive straight into the nitty gritty. Types! Functions! Hooks!

[App SDK Explorer](https://sdk-explorer.sanity.io)
Check out some example interfaces created with the App SDK.



# Quickstart

## Create a new App SDK app

Initialize a new project by running `npx sanity@latest`: 

**npm**

```shell
npx sanity@latest init --template app-quickstart
```

**pnpm**

```shell
pnpm dlx sanity@latest init --template app-quickstart
```

**yarn**

```shell
yarn dlx sanity@latest init --template app-quickstart
```

**bun**

```shell
bunx sanity@latest init --template app-quickstart
```

When prompted:

- Select **yes** when asked to install the sanity package
- Choose your organization, or create a new one
- Specify a location to save your project locally
- Choose whether you want to work with TypeScript or JavaScript

Once you've worked through these options, the CLI should proceed to install all the necessary dependencies, and report back with a confirmation.

**Terminal**

```text
✅ Success! Your custom app has been scaffolded.
(cd my-cool-project to navigate to your new project directory)

Next, configure the project(s) and dataset(s) your app should work with.

Get started in `src/App.tsx`, or refer to our documentation for a walkthrough:
https://sanity.io/docs/app-sdk/sdk-configuration
```

## Navigate to the project directory

If you chose to install your project in a folder different to the current directory, such as a sub-folder, navigate into the project root.

**Terminal**

```sh
cd my-cool-project
```

## Inspect the project folder

In your favorite editor, open the project root and have a look around. Note the `sanity.cli.ts`, `App.tsx`, and `ExampleComponent.tsx` files in particular.

> [!TIP]
> JS|TS|JSX|TSX
> For readability we won't note every time a file could be either a `js/jsx`-file or a `ts/tsx`-file. We'll default to showing the examples inTypeScript going forth. If you are working in JavaScript, replace those T's with J's!

### sanity.cli.ts

This is the main configuration for your project . By default, it contains the unique ID for your organization, and the entrypoint for your app.

**sanity.cli.ts**

```
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your-org-id',
    entry: './src/App.tsx',
  },
})
```

### src/App.tsx

This is the main entrypoint for your application. It contains the `<SanityApp />` context provider, and demonstrates how to connect your application to an existing Sanity project. The `<SanityApp />` component provides child components with the necessary context to use the SDK React hooks to interact with the content in your project.

**Before moving on,** modify the `config` variable to include the `projectId` and `dataset` for the Sanity project you’d like to work with in your custom app.

**src/App.tsx**

```tsx
import {type SanityConfig} from '@sanity/sdk'
import {SanityApp} from '@sanity/sdk-react'
import {ExampleComponent} from './ExampleComponent'
import './App.css'  
  
export default function App() {
  // apps can access one or many different projects or other sources of data
  const config: SanityConfig[] = [
    {
      projectId: 'project-id',
      dataset: 'dataset-name',
    }
  ]
  
  return (
    <div className="app-container">
      <SanityApp config={config} fallback={<div>Loading...</div>}>
        {/* add your own components here! */}
        <ExampleComponent />
      </SanityApp>
    </div>
  )
}
```

### src/ExampleComponent.tsx

This component just displays some static content to welcome you to your project. Feel free to get rid of it, or use it as a springboard to write something cooler.

**src/ExampleComponent.tsx**

```tsx
import './ExampleComponent.css'

export function ExampleComponent() {
  return (
    <div className="example-container">
      <h1 className="example-heading">Welcome to your Sanity App!</h1>
      <p className="example-text">
        This is an example component. You can replace this with your own content
        by creating a new component and importing it in App.tsx.
      </p>
      <div className="code-hint">
        <p>Quick tip: Create new components in separate files and import them like this in App.tsx / App.jsx:</p>
        <pre>{`import {YourComponent} from './YourComponent'

// Then use it in your JSX 
<SanityApp config={config}>
  <YourComponent />
</SanityApp>`}</pre>
      </div>
    </div>
  )
}

```

## Start the development server

It's time to actually run the app! Enter the following command in your terminal:

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun run dev
```

You should see the CLI reporting on its progress.

**Terminal**

```sh
✓ Checking configuration files...
✓ Starting dev server
Dev server started on port 3333
View your app in the Sanity dashboard here:
https://sanity.io/@your-org-id?dev=http://localhost:3333
```

Once having successfully launched your app, the CLI will provide you with a URL where you can see it running locally in the Sanity Dashboard. Open this link in your browser to see the Dashboard front page, then locate your application in the sidebar.

> [!CAUTION]
> During development, SDK apps may experience connection issues in the Safari browser. This is caused by the way Safari handles mixed content, and how Sanity loads your local app in the Dashboard. To get around this limitation, we suggest using another browser during development.
> This does not affect deployed SDK applications.

![a welcome to your sanity app page](https://cdn.sanity.io/images/3do82whm/next/dcd155e20ae9696632da4c0114811d3aaeb282d5-1229x935.png)

## Deploy your app

Finally, when you are happy with your custom app, it's time to deploy it. Run the following command:

**npm**

```shell
npx sanity@latest deploy
```

**pnpm**

```shell
pnpm dlx sanity@latest deploy
```

**yarn**

```shell
yarn dlx sanity@latest deploy
```

**bun**

```shell
bunx sanity@latest deploy
```

Your custom app will be deployed and made available in your organization dashboard.

## Troubleshooting

If you see an error about the port being in use:

- Kill any existing process using port 3333, or
- Start the dev server on a different port:

**npm**

```shell
npm run dev -- --port 3334
```

**pnpm**

```shell
pnpm run dev -- --port 3334
```

**yarn**

```shell
yarn run dev -- --port 3334
```

**bun**

```shell
bun run dev -- --port 3334
```

If you see an error about missing authorization:

- Make sure your user account has the appropriate privileges
- Log out and back in to Sanity

**npm**

```shell
npx sanity@latest logout

npx sanity@latest login
```

**pnpm**

```shell
pnpm dlx sanity@latest logout

pnpm dlx sanity@latest login
```

**yarn**

```shell
yarn dlx sanity@latest logout

yarn dlx sanity@latest login
```

**bun**

```shell
bunx sanity@latest logout

bunx sanity@latest login
```

## Next steps

- Explore the [React App SDK reference docs](https://reference.sanity.io/_sanity/sdk-react/)
- See examples of the App SDK in action in the [SDK Explorer](https://sdk-explorer.sanity.io)
- Read the [introduction to the Sanity App SDK](https://www.sanity.io/docs/app-sdk/sdk-introduction)



# Introduction

The Sanity Application Software Development Kit, or **App SDK** for short, is a robust set of tooling that lets you create fully custom apps that interface and interact with your Sanity content. It brings the powerful real-time capabilities and content management features you know from Sanity Studio to your own custom React applications. With a comprehensive set of React hooks and data stores, you can build applications that work seamlessly with your Sanity content across multiple projects and datasets.

This introduction covers the core concepts and patterns behind the App SDK, how to retrieve and change documents, and how to build interfaces that stay in sync with your content.

By the end of this guide, you'll understand:

- How document handles enable efficient document operations.
- When to use different hooks for retrieving and updating content.
- Best practices for building performant real-time applications.
- How to work with content across multiple projects and datasets.

## What is the App SDK?

The App SDK is a toolkit for building custom React applications that interact with your Sanity content. It provides React hooks and data stores for real-time content operations across multiple projects and datasets, and it leaves your interface entirely up to you.

### Purpose and key features

With the App SDK, you can:

- Build fully custom applications that work with Sanity content.
- Enable real-time content operations and live updates.
- Work across multiple projects and datasets.
- Create tailored user experiences beyond what Sanity Studio offers.

### SDK apps and Sanity Studio

![An SDK app and a studio showing different ways to interact with the same content](https://cdn.sanity.io/images/3do82whm/next/34ec1da769de3803cffabb9eb01b0fe5c6dd70a0-600x306.png)
*Sometimes you need a different perspective on your content*

Sanity Studio is a full content management application, and for many Sanity users, Sanity Studio *is* Sanity. Or, in other words, their studio is the main interface through which they interact with the Sanity platform. The ambition of the App SDK is to enable you to build apps that work beyond the scope of a single studio, project, and dataset, unlocking opportunities for new content workflows and operations — all while letting you control your application's UI and UX completely.

### Similarities between SDK apps and Sanity Studio

- Real-time content operations
- Live updates and collaboration features
- Access to Sanity's content platform
- Authentication and permissions handling

### Differences between SDK apps and Sanity Studio

- **Multiple projects and datasets:** While studios can work with a single project and dataset at a time, SDK apps can be configured to work with as many of your organization's projects and datasets as you like.
- **Complete UI freedom**: Unlike the studio's structured interface, you control every aspect of the UI.
- **Custom workflows**: Build exactly the workflow your users need.
- **Focused feature set**: No built-in validation or form building — bring your favorite UI components with you, and shape the functionality just as you want it.

## Technical implementation

### Technology stack

- [TypeScript](https://www.typescriptlang.org/) for type safety and developer experience
- [React](https://react.dev/) for application framework and hooks
- React [Suspense](https://react.dev/reference/react/Suspense) and Transitions for data loading states

## Requirements

The App SDK requires:

- React v19 or later
- Node.js v22.12 or later
- The `@sanity/sdk-react` package
- A Sanity Dashboard to host your app

## What's included

The App SDK includes:

- React hook based interface, taking advantage of modern React patterns like [Suspense](https://react.dev/reference/react/Suspense) and [Transitions](https://react.dev/reference/react/useTransition)
- Document retrieval and content rendering, all live by default
- Optimistic, local-first document editing, ready for collaborative interfaces
- Batchable document actions
- Permissions checking with detailed outputs
- Support for [Sanity Typegen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen)

### Not included

The App SDK does not include:

- UI components or design system

The App SDK pairs nicely with [Sanity UI](https://www.sanity.io/ui) for building applications that are visually consistent with other Sanity apps.

- Router
- Form validation
- Schema validation

These aspects are left to your implementation, giving you complete control over the user experience while the SDK handles the complex data operations underneath.

## Limitations

During development, SDK apps may experience connection issues in the Safari browser. This is caused by the way Safari handles mixed content, and how Sanity loads your local app in the Sanity Dashboard. To get around this limitation, use another browser during development. **This does not affect deployed SDK applications.**



# Installation

The Sanity App SDK is distributed as two separate npm packages: the core TypeScript SDK, and a ready-to-go React implementation.

- [@sanity/sdk](https://reference.sanity.io/_sanity/sdk/)
- [@sanity/sdk-react](https://reference.sanity.io/_sanity/sdk-react/)

You can use the core SDK on its own, but its main purpose is to power the React SDK. It also leaves room for other framework-specific implementations later. For now, the React SDK is the primary focus, and it’s what the bootstrapping process in this guide installs. This guide explains how to bootstrap a new App SDK application with the Sanity CLI.

## Prerequisites

- Some familiarity with JavaScript or TypeScript development.
- A terminal.
- [Node.js v22.12 or later](https://nodejs.org).
- An available project dataset. Many examples in these docs use the **Movies** template and data, which you can select when initializing a new studio. See the [Studio installation guide](https://www.sanity.io/docs/studio/installation).
- A Sanity account and an organization.

## Bootstrap a new app with the Sanity CLI

Use the [Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli) to initialize a new application. The command creates a new React app with all the necessary dependencies and boilerplate:

**npm**

```shell
npx sanity@latest init --template app-quickstart
```

**pnpm**

```shell
pnpm dlx sanity@latest init --template app-quickstart
```

**yarn**

```shell
yarn dlx sanity@latest init --template app-quickstart
```

**bun**

```shell
bunx sanity@latest init --template app-quickstart
```

The CLI bootstraps your app and preconfigures it with your organization ID. If you have worked on a Sanity Studio project locally, the layout is familiar.

Before running your app locally, you need to add a small amount of configuration.

## Next steps

- Read the [App SDK quick start](https://www.sanity.io/docs/app-sdk/sdk-quickstart) to get up and running quickly.
- Read about the [Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli).



# Configuration

App SDK apps have two separate configuration files. The **CLI configuration** (`sanity.cli.ts`) controls your project setup, build tooling, and deployment. The **runtime configuration** (`SanityConfig`) connects your app to one or more Sanity projects at runtime.

## CLI configuration

The `sanity.cli.ts` file at the root of your project defines how the Sanity CLI builds, serves, and deploys your app. Pass your configuration to `defineCliConfig`:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your-org-id',
    entry: './src/App.tsx',
  },
  deployment: {
    appId: 'your-app-id',
  },
})
```

### App icon and title

You can customize how your app appears in the Sanity dashboard by setting an icon and a display title in the app object of your sanity.cli.ts file.

Use `app.icon` to provide a path to an SVG file, and `app.title` to set a default display name for your app.

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your-org-id',
    entry: './src/App.tsx',
    icon: './path/to/icon.svg',
    title: 'Default Title',
  },
  deployment: {
    appId: 'your-app-id',
  },
})
```

> [!TIP]
> SVG files only
> The icon field accepts a relative path to an SVG file from the project root. Other image formats are not supported.

### Control Dashboard visibility

Set `app.visibility` to control whether your app appears in the Dashboard sidebar. It defaults to `default` when omitted:

- `default`: listed in the Dashboard sidebar and opens in the Dashboard when selected.
- `unlisted`: hidden from the sidebar, but still opens in the Dashboard when someone follows a direct link.

> [!WARNING]
> Unlisted apps are not private
> `unlisted` only hides the app from the sidebar. It does not restrict access. Anyone with the link can still open it. Use it to share work in progress, not to secure an app.

Set the value in `sanity.cli.ts`. The CLI applies it when you deploy:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your-org-id',
    entry: './src/App.tsx',
    visibility: 'unlisted',
  },
  deployment: {
    appId: 'your-app-id',
  },
})
```

Setting visibility from the CLI requires the `sanity` package v6.6.0 or later. `sanity.cli.ts` is the source of truth. A redeploy re-applies `app.visibility`, so change it in config and redeploy when or if you need to make it visible (`default`) again. For apps not managed through the CLI, the [Applications API reference](https://www.sanity.io/docs/http-reference/applications-api) exposes the same field.

## Runtime configuration

The runtime configuration connects your app to Sanity projects. Define one or more `SanityConfig` objects, each with a `projectId` and `dataset`, and pass them to the `SanityApp` provider component:

**src/App.tsx**

```tsx
import {SanityApp, type SanityConfig} from '@sanity/sdk-react'

export function App() {
  const config: SanityConfig[] = [
    {
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'YOUR_DATASET',
    }
  ]
  return (
    <div className="app-container">
      <SanityApp config={config} fallback={<div>Loading...</div>}>
        {/* add your own components here! */}
      </SanityApp>
    </div>
  )
}

export default App
```

### Properties

- `projectId`: the Sanity project ID to connect to. Your app needs this value to fetch content.
- `dataset`: the dataset name to query. Your app needs this value to fetch content.
- `studio`: configuration for connecting to a specific Sanity Studio instance. Additional properties include `auth` for custom authentication.

The CLI configuration file supports the following properties:

- `app.organizationId` (required): the Sanity organization ID that owns this app.
- `app.entry` (optional): the file path to your app's entry point. Defaults to `./src/App`.
- `deployment.appId` (optional): a unique identifier for your deployed app. Set automatically on first deploy.
- `server.port` and `server.hostname` (optional): local development server settings. Defaults to `localhost:3333`.

### `SanityApp` component

The `SanityApp` component wraps your application and provides all child components with the context needed to use SDK hooks and methods. It uses React Suspense internally. Props:

- `config`: an array of `SanityConfig` objects. Each needs a `projectId` and `dataset`. Required for standalone apps. Optional when running inside Sanity Studio (zero-config mode).
- `fallback` (optional): a React node to display while the SDK initializes.

Place `SanityApp` at the root of your component tree. All SDK hooks must be called from components rendered inside `SanityApp`.

## Environment variables

Environment variables prefixed with `SANITY_APP_` are automatically picked up by the Sanity CLI tool, development server, and bundler.

Any found environment variables are available as `process.env.SANITY_APP_VARIABLE_NAME`—even in browser code.

By requiring this `SANITY_APP_` prefix, we prevent unrelated (and potentially sensitive) environment variables from getting exposed to the browser bundle. You can learn more about environment variables in the [Studio documentation](https://www.sanity.io/docs/studio/environment-variables).

#### Next steps

[Document handles](https://www.sanity.io/docs/app-sdk/document-handles)
Document handles are a central concept in the Sanity App SDK, and are important to understand when working with many of the SDK's React hooks.

[React Hooks](https://www.sanity.io/docs/app-sdk/sdk-react-hooks)
Meet some of the most important hooks from the React SDK package.

[Fetching and handling content](https://www.sanity.io/docs/app-sdk/fetching-and-handling-content)
Learn about the central concepts and hooks for pulling content from your Sanity project into your custom app.

## App metadata

When you deploy your app for the first time with `npx sanity deploy`, the CLI prompts you for an app identifier. You can manage your deployed app's settings in **Manage**.

## Run your SDK app locally

In your app directory, run the dev command:

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun run dev
```

You should get a confirmation like the one displayed below.

**Terminal**

```sh
Dev server started on port 3333
View your app in the Sanity dashboard here:
https://www.sanity.io/@[ORGANIZATION-ID]?dev=http%3A%2F%2Flocalhost%3A3333
```



# App SDK deployment

## Deploy your app

To deploy your custom application, you use the same command as when deploying a studio: [sanity deploy](https://www.sanity.io/docs/cli-reference/deploy)

**npm**

```shell
npx sanity deploy
```

**pnpm**

```shell
pnpm dlx sanity deploy
```

**yarn**

```shell
yarn dlx sanity deploy
```

**bun**

```shell
bunx sanity deploy
```

Note that to deploy SDK apps you need a role of organization admin, Developer, or equivalent. Organization-level robot tokens with the "Manage SDK Apps" permission (which grants deploy, read, and delete access to SDK applications) can also be used to deploy SDK apps. Read more about roles and permissions [here](https://www.sanity.io/docs/content-lake/roles-concepts).

> [!NOTE]
> Deployment size limit
> A single deployment is limited to 2 GB. The limit applies to the total size of the built files in the deployment, and deploys that exceed it are rejected with an error. The same limit applies to Studio deployments and App SDK app deployments.
> Most deployments are a few megabytes, so typical projects stay well below this limit.



## Undeploy your app

To undeploy your custom application, you can use [sanity undeploy](https://www.sanity.io/docs/cli-reference/undeploy) from within your custom app’s directory.

**npm**

```shell
npx sanity undeploy
```

**pnpm**

```shell
pnpm dlx sanity undeploy
```

**yarn**

```shell
yarn dlx sanity undeploy
```

**bun**

```shell
bunx sanity undeploy
```

Note that you’ll need to have your `app.id` saved in your `sanity.cli.ts` file (as prompted during the deploy process) in order for your app’s deployment to be removed.

## Deployment setup for CI/CD

App SDK deployment requires an **organization-level** robot token with the **Manage SDK Apps** permission. This is different from Studio deployment, which uses project-level tokens. To create a sufficient token, you’ll need org-level developer or administrator permissions.

To create a robot token:

1. Go to **Manage** and select your organization.
2. Navigate to **Settings > API > Robot tokens**.
3. Create a new token and select the **Manage SDK Apps** permission.
4. Copy the token and store it as a secret in your CI/CD environment (for example, as a GitHub Actions secret).

> [!NOTE]
> At this time, you cannot create organization-level robot tokens with the CLI.

Set the `SANITY_AUTH_TOKEN` environment variable to your robot token. The Sanity CLI reads this variable automatically when deploying.

For App SDK apps, the `--title` flag is required for fully unattended deployments. Without it, the CLI will interactively prompt for an app title on the first deploy. In CI/CD pipelines, pass `--title` to skip this prompt. For example: `npx sanity deploy --title "My App"`.

### GitHub Actions example

```yaml
name: Deploy App SDK
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx sanity deploy --title 'My App'
        env:
          SANITY_AUTH_TOKEN: ${{ secrets.SANITY_DEPLOY_TOKEN }}
```

## Environment variables

The following environment variables are relevant for App SDK deployment:

- `SANITY_AUTH_TOKEN`: the organization-level robot token for authentication. Required for non-interactive deployment.
- `SANITY_APP_*`: any environment variables prefixed with `SANITY_APP_` are available in your app's browser code at build time.

## Troubleshooting

The following errors are commonly reported by developers deploying App SDK apps:

- **"Unauthorized" or "Insufficient permissions":** verify that your token is an organization-level robot token with the **Manage SDK Apps** permission enabled.
- **"Session does not match project host":** this can occur in CI/CD environments. Ensure `SANITY_AUTH_TOKEN` is set correctly and that no cached credentials are interfering.



# Document handles

In this article, you'll learn what document handles are, why they're useful, and how to work with them. For the full type definition, see the [DocumentHandle API reference](https://reference.sanity.io/_sanity/sdk/index/DocumentHandle/).

## Prerequisites

- `@sanity/sdk-react` 1.0.0 or later. The examples in this article import hooks and types from this package.
- A React application set up with the App SDK. To create one, follow the [App SDK quick start](https://www.sanity.io/docs/app-sdk/sdk-quickstart).

## What is a document handle?

In short, a `DocumentHandle` is a stub of a document — a small piece of metadata, encoded in a JavaScript object, that acts as a reference to a complete document in your dataset.

It looks like this:

**documentHandle.ts**

```typescript
const myDocumentHandle = {
  documentId: 'my-document-id',
  documentType: 'article'
}
```

This lightweight representation serves several important purposes:

- **Performance**: Loading only the handles instead of full documents reduces initial data transfer and improves application responsiveness.
- **Flexibility**: Handles can be passed to other hooks that load only the specific document data needed for a particular view or operation.
- **Real-time updates**: The SDK can efficiently track changes to documents by monitoring their handles.

A document handle may also contain optional information about the project and dataset it originates from; in that case, it would look like this:

**documentHandle.ts**

```typescript
const myDocumentHandle = {
  documentId: 'my-document-id',
  documentType: 'author',
  dataset: 'dataset-name',
  projectId: 'my-project-id'
}
```

Therefore, for a document in a given dataset that looks (in part) like this:

**result.json**

```json
{
  "_id": "123456-abcdef",
  "_type": "book",
  "title": "Into the Cool",
  "publisher": "The University of Chicago Press",
  "pages": 378,
  "…": "…"
}
```

For that document, the corresponding document handle looks like this:

**documentHandle.ts**

```typescript
{
  documentId: "123456-abcdef",
  documentType: "book"
}
```

## Why are document handles used?

Hooks like [useDocuments](https://reference.sanity.io/_sanity/sdk-react/exports/useDocuments/) and [usePaginatedDocuments](https://reference.sanity.io/_sanity/sdk-react/exports/usePaginatedDocuments/) can return potentially large numbers of documents matching your specified parameters. Returning every matching document in full is an expensive operation. It slows your application down and degrades the user experience. You may also not need each returned document in its entirety. Perhaps you want to render a document preview, one or two fields of a document, or a count of the documents matching your parameters.

This is where the concept of document handles comes in. By returning a small amount of metadata for each document instead of unfurling every returned document, hooks like `useDocuments` can respond as fast as possible, so your application stays responsive.

Unless you only need a count of the documents matching the parameters you pass to these hooks, document handles aren't useful on their own. This is by design — they’re only meant to serve as references to documents which can then be consumed by more specialized hooks, such as [useDocumentProjection](https://reference.sanity.io/_sanity/sdk-react/exports/useDocumentProjection/), [useDocument](https://reference.sanity.io/_sanity/sdk-react/exports/useDocument/), and many more hooks provided by the Sanity App SDK. These specialized hooks are designed to consume document handles and emit only the document content you request, which also delivers huge performance benefits. Other hooks, such as [useDocumentEvent](https://reference.sanity.io/_sanity/sdk-react/exports/useDocumentEvent/) and [useDocumentPermissions](https://reference.sanity.io/_sanity/sdk-react/exports/useDocumentPermissions/) have no need to know the contents of a document — instead, they use the provided document handle to reference a document and retrieve information pertaining to that document.

In short, document handles promote deferring the retrieval of document contents until such time as those contents are actually needed by your application.

## Use your own document handles

You’re not limited to using document handles returned by hooks like `useDocuments` — if it suits your use case (for example: if you know the document ID and type of the document you want to reference), you can write and use your own document handles.

A handle is any object that matches the `DocumentHandle` interface. Three forms work, and they differ only in how much type information TypeScript keeps:

**Plain object**

```tsx
import {useDocumentSyncStatus, type DocumentHandle} from '@sanity/sdk-react'

const myDocumentHandle: DocumentHandle = {
  documentId: 'my-document-id',
  documentType: 'book',
}

export function SyncIndicator() {
  const documentSynced = useDocumentSyncStatus(myDocumentHandle)

  return <span>{documentSynced ? 'Synced' : 'Saving…'}</span>
}
```

**createDocumentHandle**

```tsx
import {createDocumentHandle, useDocumentSyncStatus} from '@sanity/sdk-react'

const myDocumentHandle = createDocumentHandle({
  documentId: 'my-document-id',
  documentType: 'book',
})

export function SyncIndicator() {
  const documentSynced = useDocumentSyncStatus(myDocumentHandle)

  return <span>{documentSynced ? 'Synced' : 'Saving…'}</span>
}
```

**as const**

```tsx
import {useDocumentSyncStatus} from '@sanity/sdk-react'

// `as const` captures the literal type 'book' instead of widening it to string
const myDocumentHandle = {
  documentId: 'my-document-id',
  documentType: 'book',
} as const

export function SyncIndicator() {
  const documentSynced = useDocumentSyncStatus(myDocumentHandle)

  return <span>{documentSynced ? 'Synced' : 'Saving…'}</span>
}
```

While creating handles as plain objects works fine, using the `createDocumentHandle` helper (or similar helpers like `createDatasetHandle`) is recommended, **especially if you are using** [sanity typegen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen).

Why? When [using the SDK hooks with TypeGen](https://reference.sanity.dev/_sanity/sdk-react/Typescript_with_TypeGen_(experimental)/), the hooks can provide much richer type information if they know the *specific* literal type of the `documentType` (for example, knowing it's exactly `'book'`, rather than any `string`). The `createDocumentHandle` function helps TypeScript capture this literal type automatically.

Using either `createDocumentHandle` or `as const` ensures that subsequent hooks like `useDocument` or `useDocumentProjection` can correctly infer types based on the specific `documentType` provided in the handle when TypeGen is enabled.

## How handles flow between hooks

Handles connect two kinds of hook. A hook such as `useDocuments` returns handles for every document matching your parameters. In this example, every document of type `author`:

**AuthorList.tsx**

```tsx
import {useDocuments} from '@sanity/sdk-react'

export function AuthorList() {
  // `authors` holds document handles, not full author documents
  const {data: authors} = useDocuments({documentType: 'author'})

  return <p>{authors.length} authors</p>
}
```

Each entry in `authors` is a document handle. Because the query filters on the `author` document type, each one looks like this:

**documentHandle.ts**

```typescript
{ documentId: 'the-document-id', documentType: 'author' }
```

To read content from one of those documents, pass its handle to a hook that consumes handles, such as `useDocumentProjection`. The handle is [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) into the hook's arguments:

**AuthorDetails.tsx**

```tsx
import {useDocumentProjection, type DocumentHandle} from '@sanity/sdk-react'

interface NameProjection {
  name: string
}

// The AuthorDetails component will accept a document handle for its `document` prop
export function AuthorDetails({document}: {document: DocumentHandle}) {
  const {data} = useDocumentProjection<NameProjection>({
    ...document,
    projection: '{ name }',
  })

  return <p>The author's name is {data?.name ?? 'Unknown'}</p>
}
```

Splitting the work across two hooks separates two concerns: identifying documents, and reading content from them. Your application stays fast no matter how many authors your dataset holds, or how many fields the `author` type defines. For a worked example that builds this pattern into a running app, see [Fetching and handling content](https://www.sanity.io/docs/app-sdk/fetching-and-handling-content).

## Next steps

Put document handles to work in your own app with these guides.

[Fetching and handling content](https://www.sanity.io/docs/app-sdk/fetching-and-handling-content)
Build a preview grid that turns document handles into rendered content.

[React hooks](https://www.sanity.io/docs/app-sdk/sdk-react-hooks)
Meet the App SDK hooks that produce and consume document handles.

[App SDK and TypeGen](https://www.sanity.io/docs/app-sdk/sdk-typegen)
Get typed results from your handles by generating types from your schema.



# React Hooks

The Sanity App SDK comes with a range of hooks available for interacting with your content. A full reference is available for your perusal here:

- [Sanity React App SDK Reference Docs](https://reference.sanity.io/_sanity/sdk-react/)

While visiting every hook, type and component is beyond the scope of this article, a few of the most important hooks are briefly introduced below to give you a sense of how you'll be interacting with your Sanity content using the App SDK. 

For the sake of legibility, assume that examples handling single documents are invoked with a proper [DocumentHandle](https://reference.sanity.io/_sanity/sdk-react/Introducing_Document_Handles/), which is a valid combination of a document ID and document type, and an optional project ID and dataset name indicating the source of the document. Examples that fetch multiple documents usually return an array of `DocumentHandle`s.

**DocumentHandle.tsx**

```
import {type DocumentHandle} from '@sanity/sdk-react'

const documentHandle: DocumentHandle = {
  documentId: 'document-id',
  documentType: 'book',
  projectId: 'project-id',
  dataset: 'production',
}

<OrderLink documentHandle={documentHandle} />
```

## Data Retrieval Hooks

### [useDocuments](https://reference.sanity.io/_sanity/sdk-react/exports/useDocuments/) - Getting collections of documents

The `useDocuments` hook is your primary tool for retrieving collections of documents from your Sanity dataset. It returns Document Handles for documents matching your specified document type (and optional filters and parameters), making it ideal for building document lists and overviews. 

**index.tsx**

```tsx
const {data, hasMore, isPending, loadMore} = useDocuments({
 documentType: 'movie',
 batchSize: 10,
 orderings: [{ field: '_createdAt', direction: 'desc' }]
})
```

`useDocuments` accepts: `documentType` (string, required), `batchSize` (number, optional: how many handles to load per batch, defaulting to a built-in value), and `orderings` (optional, array of `{field, direction: 'asc' | 'desc'}`). It returns:

- `data`: an array of `DocumentHandle`s for the matching documents
- `hasMore`: `true` while more documents remain beyond the current batch
- `isPending`: `true` while the next batch is loading
- `loadMore`: call this (e.g. from a "Load more" button) to append the next batch to `data`

Use these for infinite-scroll or "load more" lists; for numbered pages use `usePaginatedDocuments`.

### [usePaginatedDocuments](https://reference.sanity.io/_sanity/sdk-react/exports/usePaginatedDocuments/) - Paginated document lists

The `usePaginatedDocuments` hook provides a more traditional pagination interface compared to the infinite scroll pattern of `useDocuments`. This makes it ideal for building interfaces with discrete pages of content and explicit navigation controls:

**index.tsx**

```tsx
const { 
  data, 
  isPending,
  currentPage, 
  totalPages,
  nextPage, 
  previousPage,
  hasNextPage,
  hasPreviousPage
} = usePaginatedDocuments({ 
  documentType: 'movie',
  pageSize: 10,
  orderings: [{ field: '_createdAt', direction: 'desc' }]
})
```

`usePaginatedDocuments` accepts `documentType` (string, required), `pageSize` (number of documents per page), and `orderings` (array of `{field, direction}`). It returns:

- `data`: the `DocumentHandle`s for the current page
- `isPending`: `true` while a page is loading
- `currentPage` / `totalPages`: the current page index and total page count
- `nextPage` / `previousPage`: functions to move between pages
- `hasNextPage` / `hasPreviousPage`: booleans for enabling/disabling navigation controls

### [useDocument](https://reference.sanity.io/_sanity/sdk-react/exports/useDocument/) - Reading individual documents

The `useDocument` hook provides real-time access to individual document content. It's designed for reading and subscribing to a document's state, incorporating both local and remote changes:

**index.tsx**

```tsx
// Get the full document
const {data: movie} = useDocument({...movieHandle})

// Get a specific field
const {data: title} = useDocument({
  ...movieHandle,
  path: 'title',
})
```

The hook automatically handles displaying local-first, optimistic updates made via the `useEditDocument` hook, making it ideal for building collaborative editing interfaces that need to stay synchronized with remote changes. However, for static displays where local-first, optimistic updates aren't needed, consider using `useDocumentProjection` (which still return content that's live by default).

### [useDocumentProjection](https://reference.sanity.io/_sanity/sdk-react/exports/useDocumentProjection/) - Accessing specific document fields

The `useDocumentProjection` hook allows you to efficiently retrieve specific fields from a document using GROQ projections:

**index.tsx**

```
const {data: { title, authorName }} = useDocumentProjection({
  ...documentHandle,
  projection: `{
    title,
    'authorName': author->name
  }`
})
```

Alongside a `DocumentHandle`, `useDocumentProjection` accepts `projection` (a GROQ projection string, e.g. `{title, 'authorName': author->name}`) and an optional `ref` (a React ref to an element; the hook won't resolve while that element is offscreen). It returns `{data, isPending}`, where `data` holds the projected fields. Because it fetches via Suspense, call it inside a component wrapped in a `<Suspense>` boundary, typically one rendered per `DocumentHandle` from `useDocuments`.

Putting these together, `useDocuments` for the list, a per-item `<Suspense>` boundary, and `useDocumentProjection` for the titles:

**MovieList.tsx**

```tsx
import {Suspense} from 'react'
import {
  useDocuments,
  useDocumentProjection,
  type DocumentHandle,
} from '@sanity/sdk-react'

function DocumentTitle({documentHandle}: {documentHandle: DocumentHandle}) {
  const {data} = useDocumentProjection({
    ...documentHandle,
    projection: `{title}`,
  })
  return <li>{data.title}</li>
}

export function MovieList() {
  const {data, hasMore, isPending, loadMore} = useDocuments({
    documentType: 'movie',
    batchSize: 10,
    orderings: [{field: '_createdAt', direction: 'desc'}],
  })

  return (
    <>
      <ul>
        {data.map((documentHandle) => (
          <Suspense key={documentHandle.documentId} fallback={<li>Loading…</li>}>
            <DocumentTitle documentHandle={documentHandle} />
          </Suspense>
        ))}
      </ul>
      {hasMore && (
        <button onClick={() => loadMore()} disabled={isPending}>
          {isPending ? 'Loading…' : 'Load more'}
        </button>
      )}
    </>
  )
}
```

## Document Manipulation Hooks

### [useEditDocument](https://reference.sanity.io/_sanity/sdk-react/exports/useEditDocument/) - Modifying documents

This hook is particularly useful for building forms and collaborative editing interfaces. It provides a simple way to update document fields in real-time:

**index.tsx**

```tsx
const editTitle = useEditDocument({
  ...movieHandle, 
  path: 'title',
})

function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
  editTitle(e.currentTarget.value)
}
return (
 <input 
   type="text"
   value={title || ''}
   onChange={handleTitleChange}
 />
)
```

### [useApplyDocumentActions](https://reference.sanity.io/_sanity/sdk-react/exports/useApplyDocumentActions/) - Document operations

The `useApplyDocumentActions` hook provides a way to perform document operations like publishing, unpublishing, creating, and deleting documents:

**index.tsx**

```tsx
import {
  useApplyDocumentActions,
  publishDocument,
  unpublishDocument,
} from '@sanity/sdk-react'

const apply = useApplyDocumentActions()

function MovieActions({ movieHandle }) {
  return (
    <div>
      <button onClick={() => apply(publishDocument(movieHandle))}>
        Publish
      </button>
      <button onClick={() => apply(unpublishDocument(movieHandle))}>
        Unpublish
      </button>
    </div>
  )
}
```

### [useDocumentEvent](https://reference.sanity.io/_sanity/sdk-react/exports/useDocumentEvent/) - Handling document events

The `useDocumentEvent` hook allows you to subscribe to document events like creation, deletion, and updates. This is useful for building features that need to react to changes in your content:

**index.tsx**

```tsx
import {useDocumentEvent, type DocumentEvent} from '@sanity/sdk-react'

const eventCallback = (event) => {
  if (event.type === DocumentEvent.DocumentDeletedEvent) {
    console.log(`Document ${event.documentId} was deleted`)
  } else if (event.type === DocumentEvent.DocumentEditedEvent) {
    console.log(`Document ${event.documentId} was edited`)
  }
})

useDocumentEvent({
  ...documentHandle,
  onEvent: eventCallback,
})
```

This hook is particularly valuable when building interfaces that need to maintain consistency with document state changes, such as notification systems or live collaboration features.

Here's an example of using `useDocumentEvent` to build a simple notification system that alerts users when documents are modified:

**index.tsx**

```tsx
function DocumentChangeNotifier({ documentHandle }) {
  const [notifications, setNotifications] = useState<string[]>([])

  const eventCallback = (event) => {
    switch (event.type) {
      case DocumentEvent.DocumentEditedEvent:
        setNotifications(prev => [
          `Document ${event.documentId} was just edited`,
          ...prev
        ])
        break
      case DocumentEvent.DocumentPublishedEvent:
        setNotifications(prev => [
          `Document ${event.documentId} was published`,
          ...prev
        ])
        break
    }
  }

  useDocumentEvent({
    ...documentHandle,
    onEvent: eventCallback,
  })

  return (
    <div className="notifications">
      {notifications.map((msg, i) => (
        <div key={i} className="notification">{msg}</div>
      ))}
    </div>
  )
}
```





# Suspense

The Sanity App SDK [hooks](https://www.sanity.io/docs/app-sdk/sdk-react-hooks) are optimized for use with [React Suspense](https://react.dev/reference/react/Suspense). This lets you write code in a synchronous fashion, as if the data you’re requesting from App SDK hooks is available immediately.

The following example uses the value returned by the `useProjects` hook without checking whether the request is in flight or resolved:

**ProjectsList.tsx**

```tsx
import {useProjects} from '@sanity/sdk-react'

import ProjectListItem from './ProjectListItem'

export function ProjectsList() {
  const {data: projects} = useProjects()

  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>
          <ProjectListItem projectId={project.id} />
        </li>
      ))}
    </ul>
  )
}
```

## Fallback content with Suspense boundaries

Because App SDK hooks suspend during data fetching, you can render fallback content until data fetching is resolved using Suspense boundaries.

Given the `ProjectsList` component shown earlier, which calls the `useProjects` hook, you can wrap instances of that component in a Suspense boundary:

**ProjectsPanel.tsx**

```tsx
import {Suspense} from 'react'

import {ProjectsList} from './ProjectsList'
import LoadingSkeleton from './LoadingSkeleton'

export function ProjectsPanel() {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <ProjectsList />
    </Suspense>
  )
}
```

If the `ProjectListItem` component uses the `useProject` hook, you can also wrap each item in its own Suspense boundary inside `ProjectsList`:

**ProjectsList.tsx**

```tsx
import {Suspense} from 'react'
import {useProjects} from '@sanity/sdk-react'

import ProjectListItem from './ProjectListItem'

export function ProjectsList() {
  const {data: projects} = useProjects()

  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>
          <Suspense fallback={'Loading project…'}>
            <ProjectListItem projectId={project.id} />
          </Suspense>
        </li>
      ))}
    </ul>
  )
}
```

## Built-in Suspense behavior in App SDK apps

- The [SanityApp](https://reference.sanity.io/_sanity/sdk-react/exports/SanityApp/) component rendered by all Sanity custom apps includes a root-level Suspense boundary. `SanityApp` requires a `fallback` prop; the component you pass renders as the fallback content at the root level of your app. You can also wrap any other component that uses App SDK hooks in its own Suspense boundary. To learn more about how Suspense works, [refer to the React Suspense docs](https://react.dev/reference/react/Suspense).
- App SDK hooks also use [useTransition](https://react.dev/reference/react/useTransition) internally to keep UI that has already been rendered responsive and visible during data fetching.



# Authentication

The App SDK has two authentication mechanisms and automatically uses the appropriate one based on the context in which it's running. This article provides technical information about each of these mechanisms.

> [!WARNING]
> Advanced/experimental usage ahead
> This guide is intended for developers who want to deeply understand the management of authentication within custom apps built with the App SDK. It covers both typical use cases for the App SDK (custom apps in the Sanity Dashboard), as well as more advanced or experimental implementations (such as using the App SDK within Studio).
> **In most cases, developers should not need to know the following information to successfully build with the App SDK.** However, the curious among you are welcome to follow along!

## Overview

Authentication in the SDK is primarily managed by an `authStore`, which tracks the user's [authentication state](https://reference.sanity.io/_sanity/sdk/index/AuthState/) (`LoggedIn`, `LoggedOut`, `LoggingIn`, `Error`). It determines the initial state based on the environment the application is running in — that is, one of: 

- A [Sanity Dashboard](https://www.sanity.io/docs/dashboard) iframe
- [Sanity Studio](https://www.sanity.io/docs/studio)

API client instances, managed by a `clientStore`, will automatically use the current authentication token from the `authStore` for requests. The `clientStore` also handles differentiating between clients configured for 'global' endpoints (such as `api.sanity.io`) and 'default' (project-specific) endpoints (such as `<projectId>.api.sanity.io`).

## Tokens

Several different types of [authentication tokens](https://www.okta.com/identity-101/access-token/) are referred to in the course of this article:

### Global tokens

Global tokens are not tied to a specific project, but instead to a [Sanity user](https://www.sanity.io/docs/content-lake/roles-concepts). They include access to all of the user’s [organizations and projects](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing). Global tokens are required for accessing global Sanity APIs (e.g., project management), and are used when `clientStore` configures a client with `scope: 'global'` or without a `projectId`.

### Project tokens

Project tokens are scoped to a single project (and any of a single project’s datasets). They are used in the Studio mode (described below), and can also be provided manually. These tokens only allow access to project-specific endpoints (e.g. `<projectId>.api.sanity.io`.

### Stamped tokens

Tokens obtained via the `sanity.io/login` authentication flow (and thus also from the Sanity Dashboard) are 'stamped' tokens (`type=stampedToken`). These tokens are refreshed by the App SDK’s `refreshStampedToken` function. Non-stamped tokens, however, will not be refreshed by the App SDK.



## Dashboard mode (default)

### At a glance

The [Sanity Dashboard](https://www.sanity.io/docs/dashboard) enables the default and preferred mode of authentication within custom applications, with the Dashboard providing an authentication token to custom applications built with the App SDK. This results in a seamless experience for the end-user.

This mechanism applies to both third-party custom apps and Sanity’s own applications built with the App SDK.

> [!NOTE]
> In most cases, this is the best authentication method to rely on. It is intended for use when building a custom application running within the Sanity Dashboard.

### In detail

In Dashboard mode, the Sanity Dashboard loads the custom app’s iframe with with an authentication token hash (`#token=…`) in the iframe’s `src` URL. When the custom app is initialized, the `getAuthCode` function (invoked by the App SDK via the `SanityApp` component) will retrieve and validate this token. If for some reason the token is invalid, the `getAuthCode` function will request a new token from the Dashboard, and this new token will be used instead. Once a token is validated, it will be stored in the the `authStore`. No user interaction is required during this exchange — everything is handled automatically, and the process should be completely invisible to an end user.

> [!NOTE]
> This flow presumes a Sanity user is already authenticated within the host Dashboard. If this is not the case, the Dashboard will redirect to `sanity.io/login` in order to first authenticate the user.

With the token thus stored in the application’s `authStore`, it will be used as part of all API client calls made via the App SDK’s hooks, effectively using the current user’s active Dashboard session. This token will be a global, stamped token that is refreshed every 12 hours.

## Studio mode

> [!WARNING]
> The studioMode option is removed
> The studioMode option was deprecated in 2.7.0 and removed in 3.0.0. You can still use the SDK within a Studio; no configuration is needed, since it's picked up automatically from the Studio context. You can still override this for programmatic control by [setting the config](https://reference.sanity.io/_sanity/sdk/index/SanityConfig/#studio).

### At a glance

This authentication mode leverages the studio’s own auth context (via a token or cookie). It’s used when the App SDK is used with the [Sanity Studio](https://www.sanity.io/docs/studio) codebase (not the Dashboard iframe) — for example, within custom input components, tools, or plugins integrated directly into the Studio application.

### In detail

Studio mode is enabled automatically. The Studio wraps its component tree in `SDKStudioContext.Provider`, and `SanityApp` reads the workspace handle from that context to derive the `projectId`, `dataset`, and a reactive auth token source. An explicit `config` prop takes precedence over the Studio context.

```tsx
// Inside a Studio, SanityApp auto-configures from the workspace context
<SanityApp fallback={<Loading />}>
  <MyComponent />
</SanityApp>
```

In this mode, the `authStore` subscribes to the workspace's token source — the Studio stays the single authority for auth and handles token refresh. If the Studio doesn't expose a token source, the `authStore` falls back to one of two methods.

First, the `getStudioTokenFromLocalStorage` function will look for an authentication token specific to the Studio session, which will be stored in local storage under the key `__sanity_auth_token_${projectId}`. This token is project-specific.

If this token is not found, the function `checkForCookieAuth` is called. This function attempts a request to a Studio backend endpoint to check if a valid HTTP-only session cookie exists. If it does, subsequent API requests managed by the App SDK client will rely on this cookie for authentication.

> [!NOTE]
> When this authentication method is used, only project-level endpoints will be work. Any calls made to global endpoints will fail.





# App SDK best practices

If you’ve worked with Sanity before, your experience querying the Content Lake is likely grounded in building Server-Side Rendered (SSR) or statically generated front-end applications designed for page load time performance.

Now, with the Sanity App SDK, you can build feature-rich content applications for authoring. However, this requires a different approach: swapping SSR thinking for Single-Page Application (SPA) best practices.

On top of this, if you’re used to writing React applications, some common patterns for building form-based user interfaces are best avoided when working with App SDK.

## What makes a great content application?

Content applications are defined as distinct, new experiences that give authors a focused environment to perform content operations. Instead of digging through a general-purpose CMS interface, authors work in a fit-for-purpose user interface to get the job done.

Content applications developed with the Sanity App SDK should be:

### Real-time

Any number of documents fetched and rendered into the user interface should continue to update as mutations happen to the source documents. Content applications should avoid concepts that handle stale data like "submit," “save” or "lock" buttons.

### Multiplayer

Two authors looking at the same document should be able to continually make and see edits without fear of overwriting one another’s work.

### Fast

Content rendered in the application should be locally cached, updated optimistically, and kept eventually consistent with the Content Lake.

### Accurate

There should never be stale data in an author's browser as they write content, nor after page load when fetched content is rendered. Updates should be written to and received directly from the Content Lake.

### This is all built-in to Sanity App SDK

These are the baseline expectations that Sanity’s engineers have had while developing Sanity Studio since 2017, and they’re now democratized for everyone to take advantage of via React Hooks in the Sanity App SDK.

## Get comfortable with more fetches

If you’ve built an SSR front end with Sanity before (such as in Next.js), you’ve likely created a Sanity Client and fetched all on-page content in a single query like this.

```tsx
// The SSR way: query and render "event" type documents

import { client } from "../sanity/client";

export async function Page() {
  const events = await client.fetch(
    `*[_type == "event"]`
  );

  return (
    <ul>
      {events.map((event) => (
        <li key={event._id}>{event.title}</li>
      ))}
    </ul>
  );
}
```

This can work great for SSR apps—where only the initial page load is important—since the grunt work of optimization is done behind the scenes, cached and delivered fast in a static format to your end users. But it falls short of a great SPA experience which may involve querying and editing an evolving number and type of documents, while keeping the user interface up to date in real-time.

### Prefer useDocuments over useQuery to fetch documents

Your natural inclination may be to use the App SDK hooks to recreate the "fetch everything in one query" pattern.

```tsx
// ❌ Do not simply swap client.fetch for useQuery
// It's too easy to over-fetch!

import { useQuery } from "@sanity/sdk-react";

export function Page() {
  const { data: events } = useQuery(
    `*[_type == "event"]`
  );

  if (!events) return null;

  return (
    <ul>
      {events.map((event) => (
        <li key={event._id}>{event.title}</li>
      ))}
    </ul>
  );
}
```

This list of documents will receive real-time updates—an upgrade from `client.fetch`—but may unknowingly fetch 1000’s of documents, each with 100’s of attributes.

> [!WARNING]
> Keeping raw GROQ queries performant
> The query in this particular example is problematic for performance. There’s no “array slicing” such as `[0..10]` to reduce the total number of documents returned, and no projection such as `{ title }` to reduce the number of attributes returned. 
> High performance is built-in when you use hooks like `useDocuments` and `usePaginatedDocuments` to return a filtered list of [document handles](https://www.sanity.io/docs/app-sdk/document-handles), but your implementation will need to be more carefully considered when fetching by GROQ queries with `useQuery`.

`useQuery` exists to fetch content with a GROQ query should you need to—but makes it your responsibility to maintain your application’s performance. One particular example of where this may be useful is when a parent component needs all the details of child documents. 

In most cases, you should prefer `useDocuments` to fetch a list of [document handles](https://www.sanity.io/docs/app-sdk/document-handles), and render components that do their own data fetching for more content.

> [!TIP]
> Document handles provide stable `key` values
> Among the benefits of fetching for and using [document handles](https://www.sanity.io/docs/app-sdk/document-handles) is that they provide a stable `documentId` attribute which can be used as the `key` value when mapping over the response to render a list. 
> Stable unique identifiers are preferable to using the index when [rendering lists in a real-time React application](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key).

Here's an example of fetching and rendering the same documents using the App SDK’s more purpose-built hooks.

```tsx
// ✅ Fetch and render event type documents the App SDK way

import { Suspense } from "react";
import {
  useDocuments,
  useDocumentProjection,
  type DocumentHandle,
} from "@sanity/sdk-react";

// Parent component that queries and renders event documents
export function EventsList() {
  const { data: events } = useDocuments({
    documentType: 'event',
  });

  if (!events) return null;

  return (
    <ul>
      {events.map((event) => (
        <Suspense key={event.documentId} fallback={<li>Loading...</li>}>
          <Event {...event} />
        </Suspense>
      ))}
    </ul>
  );
}

// Event component now renders the <li> itself
function Event(props: DocumentHandle) {
  const { data } = useDocumentProjection({ ...props, projection: `{ title }` });

  if (!data) return null;

  return <li>{data.title}</li>;
}
```

This may feel like an anti-pattern if you've been regularly building SSR front-ends—so many fetches! 

Rest assured that in a custom app, this is acceptable and in fact the intended usage pattern. The App SDK will handle concerns around caching and query batch sizing to avoid over-fetching.

**Summary:** Don’t fetch everything at once. First fetch for document handles, then fetch individual documents’ content within dedicated components.

## Apply Suspense boundaries liberally

[Suspense](https://react.dev/reference/react/Suspense) may be an unfamiliar part of the React library to many developers, but it won’t be once you’re familiar with the App SDK. The hooks in the App SDK use Suspense for data fetching—this means that when fetches are in flight, React will navigate up the component “tree” to the nearest Suspense boundary and trigger its `fallback` prop.

```tsx
// A very simple example of using Suspense

import { useDocuments } from "@sanity/sdk-react"
import { Stack, Text } from "@sanity/ui"
import { Suspense } from "react"

// 👇 The `useDocuments` hook in this component returns a promise
function FeedbackListDocuments() {
  const { data } = useDocuments({
    documentType: "feedback",
  })

  return (
    <Stack>
      {data?.map((feedback) => (
        <Text key={feedback.documentId}>{feedback.documentId}</Text>
      ))}
    </Stack>
  )
}

// 👇 So the component must be wrapped in Suspense
// which will render the `fallback` prop until data is loaded
function FeedbackList() {
  return (
    <Suspense fallback={<Text>Loading...</Text>}>
      <FeedbackListDocuments />
    </Suspense>
  )
}
```

The `SanityApp` component which wraps Sanity custom applications includes a Suspense boundary itself. So, if you have not put any Suspense boundaries throughout your app, you may constantly see the entire app re-render.

Keep in mind that for a small, simple enough app, you may rarely see a Suspense boundary invoked. For larger applications, or those that use more complex rendering libraries like TanStack Table or a Google Map, you may occasionally see runaway re-renders and wonder why. Suspense is likely why.

See the [App SDK Suspense documentation page](https://www.sanity.io/docs/app-sdk/react-suspense-sdk) for more information.

### Expect re-renders from real-time updates

Because fetches for Sanity content with the App SDK are real-time and kept up to date, you may see re-renders happening when nothing seems to change. 

It may be that a document has been edited—just not in a way that would be rendered in your application. For example: A document rendered by your application may receive edits in Sanity Studio by another editor to fields that your application is not rendering, thus updating the latest edited date on the document and potentially causing a re-render.

Thus, you need to account for changes to content not performed by your application which will impact your application re-rendering.

### Wrap Suspense around the parent, not the child

A component which uses a data fetching hook such as `useDocuments` may trigger the outer Suspense boundary. In the example below, this means React will look **above** this component in the tree.

```tsx
import { Suspense } from "react"
import { type DocumentHandle, useDocuments } from "@sanity/sdk-react"
import { Stack, Button } from "@sanity/ui"

// 👇 This component needs to be wrapped in Suspense
// because it contains a fetching hook
export function FeedbackList() {
  const { data, hasMore, loadMore } = useDocuments({
    documentType: "feedback",
  })

  return (
    <Stack gap={2} padding={5}>
      {data?.map((feedback) => (
        // 👇 Just like FeedbackItem needs to be wrapped
        // because it does its own data fetching too
        <Suspense key={feedback.documentId}>
          <FeedbackItem {...feedback} />
        </Suspense>
      ))}
    </Stack>
  )
}
```

The child elements are wrapped in Suspense (because they also fetch for data), but if the result of `useDocuments` is being updated, it is **this** parent component which needs to be wrapped in Suspense.

**Summary:** Wrap **every** data-fetching component in Suspense.

### Anticipate and prevent layout shift

"Layout shift" is when an element in an application changes dimensions or location, potentially moving other elements as a result.

A common example is when an image loads, changes size and pushes elements below it further down the web page. This can be a little problematic in web applications, and the effect is exacerbated in real-time applications.

In a real-time application an element which renders content could change dimension without user interaction. Another user may publish a change which adds a value that previously didn't exist, or a string may go from a few words to an entire paragraph. Your application should account for changes—unexpected or otherwise—to any content being fetched and rendered.

### How to prevent layout shift with Suspense  

A Suspense boundary’s `fallback` prop can take a component, not just text. Consider creating a “skeleton” version of the component which has the exact same dimensions as the final component that renders when content has been fetched.

In the example below, the `useNavigateToStudioDocument` hook requires a Suspense boundary. 

```tsx
import { Suspense } from "react"
import {
  type DocumentHandle,
  useNavigateToStudioDocument,
} from "@sanity/sdk-react"
import { Button } from "@sanity/ui"

const BUTTON_TEXT = "Open in Studio"

type OpenInStudioProps = {
  handle: DocumentHandle
}

// The exported component, pre-wrapped in Suspense
export function OpenInStudio({ handle }: OpenInStudioProps) {
  return (
    <Suspense fallback={<OpenInStudioFallback />}>
      <OpenInStudioButton handle={handle} />
    </Suspense>
  )
}

// The fallback component, rendered while the final component is loading
function OpenInStudioFallback() {
  return <Button text={BUTTON_TEXT} disabled />
}

// The final component, rendered after the fallback
function OpenInStudioButton({ handle }: OpenInStudioProps) {
  const { navigateToStudioDocument } = useNavigateToStudioDocument(handle)

  return <Button onClick={navigateToStudioDocument} text={BUTTON_TEXT} />
}
```

So within the one component, we have: 

- The exported component containing the Suspense boundary, which will render either the fallback or the child component depending on the loading state of the `useNavigateToStudioDocument` hook.
- A fallback button, disabled, with the same text to fill the same space.
- The final, active button to be clicked.

**Summary: **Components should not change size or location based on the availability of content or their loading state.

### Components should only have one Suspenseful hook

A good rule to keep in mind is that each component should only have one instance of a hook that fetches content (such as `useDocuments` or `useDocumentProjection`)

```tsx
// ❌ Don't put multiple fetchers in a single component!
// Updates to either list of documents will rerender both lists.

import { useDocuments } from "@sanity/sdk-react";
import { List } from "./List";

export function EventsAndVenues() {
  const { data: events } = useDocuments({
    documentType: 'event'
  });

  const { data: venues } = useDocuments({
    documentType: 'venue'
  });

  if (!events || !venues) return null;

  return (
    <>
      <List title="Events" items={events} />
      <List title="Venues" items={venues} />
    </>
  );
}
```

It’s possible to use multiple fetching hooks in a single component, but any one of these that receive an update will cause React to look up the component tree for a Suspense boundary, putting the entire component (and maybe others) into a loading state.

```tsx
// ✅ Separate fetchers into their own list components

import { Suspense } from 'react'
import { useDocuments } from '@sanity/sdk-react'
import { List } from './List'

// Component that renders two independent document lists
export function EventsAndVenues() {
  return (
    <>
      <Suspense fallback="Loading events...">
        <DocumentListSection documentType="event" />
      </Suspense>

      <Suspense fallback="Loading venues...">
        <DocumentListSection documentType="venue" />
      </Suspense>
    </>
  )
}

// Reusable component to fetch and render a list of documents by type
function DocumentListSection({ documentType }: { documentType: string }) {
  const { data: items } = useDocuments({ documentType })

  if (!items) return null

  return <List items={items} />
}
```

**Summary:** Separate individual fetchers into individual components.

## Read and write state from Content Lake 

Real-time applications require values to be up to date in **all** browsers. Therefore you should always read from and write to Content Lake instead of your local state at all times.

### Antipattern: Local state with controlled inputs with `useEditDocument`

For as long as you’ve been building React applications with hooks, you’ve likely implemented forms with controlled inputs where the `useState` hook stores, writes and renders the value of a field and content is submitted upon completion.

```tsx
// ❌ Do not copy this code example! 
// It only writes values to the browser, not the Content Lake

import { useState, FormEvent } from "react";
import { useEditDocument, type DocumentHandle } from "@sanity/sdk-react";

export function TitleForm(props: DocumentHandle) {
  const [value, setValue] = useState("");
  const editTitle = useEditDocument({ ...props, path: "title" });

  // 😱 This edit will only happen on submission
  function handleSubmit(event: FormEvent) {
    event.preventDefault();
    editTitle(value);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        // 😱 This value only exists in your browser!
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Enter title"
      />
      <button type="submit">Save</button>
    </form>
  );
}
```

In a real-time application this leads to stale data in an author's browser, and creates scenarios where one author can overwrite another's work unknowingly.

### Correct controlled inputs the App SDK way

Hooks in the App SDK have been written to handle the local-first optimistic edits seen in `useState`, while sending and receiving mutations to the Content Lake behind the scenes. This is why you’ll see `useDocument` and `useEditDocument` combined in a pattern like the one below, to achieve the same effect as demonstrated in the incorrect example above.

```tsx
// ✅ Read from and write values directly to the document

import { useDocument, useEditDocument, type DocumentHandle } from '@sanity/sdk-react'

export function TitleInput(props: DocumentHandle) {
  const { data: title } = useDocument({ ...props, path: 'title' })
  const editTitle = useEditDocument({ ...props, path: 'title' })

  return (
    <input
      type="text"
      value={title ?? ''}
      onChange={e => editTitle(e.currentTarget.value)}
      placeholder="Enter title"
    />
  )
}
```

The behavior of this component works the same as the first one, but in a way that will continue to render from and write changes to the Content Lake.

Most magical of all, if your document is in a published state, the first edit made to any value in the document will invoke a new draft version of the document—something Sanity Studio has always done and is now made easy by App SDK.

Instead of requiring the author to “save” changes when they are done, edits are written directly to a “draft” version document. You can “publish” the draft version of the document with the `useApplyActions` hook—[see the documentation](https://reference.sanity.io/_sanity/sdk-react/exports/useApplyDocumentActions/) for more details.

**Summary:** Avoid creating forms that rely on a user’s local session, and always read from and write to the Content Lake.

## What more would you like to know?

Custom applications and the Sanity App SDK are relatively new parts of the Sanity Content Operating System. As such, these best practices are in their early days too. If you feel there is something architecturally difficult to understand, let us know in the [#app-sdk channel of our community](https://snty.link/community).



# Fetching and handling content

The App SDK provides a number of [React hooks](https://www.sanity.io/docs/app-sdk/sdk-react-hooks) for interacting with your Sanity content. In this article we'll look at four specific hooks – `useDocuments`, `useDocument`, `useDocumentProjection`, and `useEditDocument` – and explore how they fill different needs within a single custom app. 

## Loading complex previews with `useDocuments` and `useDocumentProjection`

> [!NOTE]
> The code examples in the following section assume you have an App SDK app successfully connected to a Sanity dataset  populated with the "movies" example schema and content. If you want to follow along and need help getting that set up, visit [this article](https://www.sanity.io/docs/app-sdk/sdk-configuration).

In this section we'll fetch a list of `movie` documents and display them in a nice grid of card elements, each containing some info and a visual.

![A grid layout of card elements displaying movie poster images along with title and top billed cast](https://cdn.sanity.io/images/3do82whm/next/25eb3af195f9eb578440536a104e178c80850305-1523x1104.png)

### Preparing our list view

The first thing we want to do is fetch a list of document handles for all the movies we want to display. [Document handles](https://www.sanity.io/docs/app-sdk/document-handles) are minimalist objects that contain just the necessary amount of information to identify a document in your Content Lake. 

**documentHandle.ts**

```json
{
    "dataset": "YOUR_DATASET",
    "documentId": "movie_679",
    "documentType": "movie",
    "projectId": "YOUR_PROJECT_ID"
}
```

For this task we'll use the [useDocuments](https://reference.sanity.io/_sanity/sdk-react/exports/useDocuments/) hook. 

👉 Create a new component in your `src` folder named `PreviewGrid.tsx` and add the following code to it:

**src/PreviewGrid.tsx**

```tsx
import {useDocuments} from '@sanity/sdk-react'
import {type JSX, Suspense} from 'react'
import {MoviePreview} from './MoviePreview'

export function PreviewGrid(): JSX.Element {
  // Use the `useDocuments` hook to return 
  // an index of document handles for 
  // all of our 'movie' type documents
  // Sort the documents by the release date, descending
  const {data: movies} = useDocuments({
    documentType: 'movie',
    orderings: [{field: '_updatedAt', direction: 'desc'}],
  })

  return (
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
          gap: '1rem',
        }}
      >
        {movies.map((movie) => (
          <Suspense key={movie.documentId} fallback={<div>Loading...</div>}>
            <MoviePreview documentHandle={movie} />
          </Suspense>
        ))}
      </div>
  )
}
```

This will set up a pretty grid layout for our movie previews. Note that we wrap each `<MoviePreview />` component in individual `React.Suspense />`-wrappers. You can read more about how the App SDK employs Suspense to ensure smooth data fetching in [this article](https://www.sanity.io/docs/app-sdk/react-suspense-sdk).

Let's move on to the `<MoviePreview />`-component. This component will receive a `documentHandle` prop, and use that information to fetch the relevant data from the Content Lake.

### Movie preview component with `useDocumentProjection`

The `useDocuments` hook is very handy for fetching document handles for a bunch of documents, but it only contains enough data to *identify* the relevant document. For more complex data fetching, the [useDocumentProjection](https://reference.sanity.io/_sanity/sdk-react/exports/useDocumentProjection/) hook comes in handy. Note that the `useDocumentProjection` hook is not recommended for real-time editing. A better alternative for those situations is discussed in the next section. 

Examining the schema for the `movie` document type, we see that it contains a number of fields. For our purposes, we'll focus on the following: 

- A `title` field of type `string`
- A `poster` field of type `image` 
- A `castMembers` field which is an array of references to a `person` type which has a `name`.

![Shows a studio editor with the document inspection popover open](https://cdn.sanity.io/images/3do82whm/next/42fe7e15f0926c27cc0c928ff3c383d20eea0ee0-1247x971.png)
*You can inspect the schema by clicking the ellipsis menu in the top of the studio editor pane*

We want to display the title along with a poster image and the names of the first two listed cast members, which means we'll need content from three different documents and an asset. Sounds like a job for GROQ! 

Using a GROQ [projection](https://www.sanity.io/docs/content-lake/how-queries-work), we can easily drill into the referenced documents and fetch exactly the structure we need.

**GROQ**

```groq
{
  // The title is a simple string value
  title,
  // Expand the reference to get the URL of the referenced asset
  'posterImage': poster.asset->url,
  // Expand each referenced person to get the name
  'cast': array::join(castMembers[0..1].person->name, ', '),
}
```

**Result**

```json
[
    {
        "cast": "Matt Damon, Jessica Chastain",
        "posterImage": "https://cdn.sanity.io/[...]-780x1170.jpg",
        "title": "The Martian",
    },
    // ... similar objects
]
```

That should be all we need to display our movie cards.

👉 Create a new component named `MoviePreview.tsx` in your `src` folder, and paste the following code into it.

**src/MoviePreview.tsx**

```tsx

import {type DocumentHandle, useDocumentProjection} from "@sanity/sdk-react";
import {type JSX, useRef} from "react";

interface ProjectionResults {
  data: {
    title: string;
    cast: string;
    posterImage: string;
  };
}

// Project the title, first 2 cast members, 
// and poster image values for the document
const movieProjection = `{
  title,
  'cast': array::join(castMembers[0..1].person->name, ', '),
  'posterImage': poster.asset->url, 
}`;

export function MoviePreview({documentHandle}: {documentHandle: DocumentHandle}): JSX.Element {
  // Generate a ref for the outer element
  // This keeps the useDocumentProjection hook from resolving if
  // the preview is not currently displayed in the viewport
  const ref = useRef(null);

  // No async await here
  const {data: { title, cast, posterImage }}: ProjectionResults = useDocumentProjection({
    ...documentHandle,
    ref,
    projection: movieProjection,
  });

  return (
    <div
      // Assign the ref to the outer element
      ref={ref}
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: '0.5rem',
        border: '1px solid #e5e7eb',
        borderRadius: '0.5rem',
        padding: '1rem'
      }}
    >
      <img
        alt={`Poster for ${title}`}
        src={posterImage}
        style={{
          width: "100%",
          aspectRatio: "1",
          objectFit: "cover",
          borderRadius: "4px",
        }}
        width="400"
        height="400"
      />
      <p style={{
        fontSize: '1.25rem',
        fontWeight: 700,
        margin: '.8rem 0 0 0',
      }}>
        {title}
      </p>
      <p style={{ 
        fontSize: '0.875rem', 
        color: '#4b5563',
        margin: '0'
        }} 
        >
          {cast}
      </p>
    </div>
  );
}

```

The final step we need to do is update our `src/App.tsx` to display our shiny new movie grid.

👉 Edit your `src/App.tsx` to import and render our grid component:

**src/App.tsx**

```tsx
import './App.css'
// import './movies.css'
import {type SanityConfig} from '@sanity/sdk'
import {SanityApp} from '@sanity/sdk-react'
- import {ExampleComponent} from './ExampleComponent'
+ import {PreviewGrid} from './PreviewGrid'

function App() {
  // apps can access many different projects or other sources of data
  const sanityConfigs: SanityConfig[] = [
    {
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'YOUR_DATASET',
    }
  ]

  return (
    <div className="app-container">
      <SanityApp config={sanityConfigs} fallback={<div>Loading...</div>}>
        {/* add your own components here! */}
-       <ExampleComponent />
+       <PreviewGrid />
      </SanityApp>
    </div>
  )
}

export default App

```

You should now see each preview card updated with the actual information we wanted to display. Go ahead and change the title of any movie document in the corresponding studio to see the preview card live update as you make changes.

![Shows a list of movie preview cards complete with posters, titles, and top billed cast](https://cdn.sanity.io/images/3do82whm/next/774f21ab712af807b3c95880b2c4d8ce78bc42a6-1307x1039.png)



## Make real-time edits with `useEditDocument`

`useDocumentProjection` is great, but it's not suitable for situations where you need down to the millisecond responsive content updates for, e.g., live collaborative editing. Let's expore this by making our movie titles editable with [useEditDocument](https://reference.sanity.io/_sanity/sdk-react/exports/useEditDocument/). 

👉 Create a new file in `src/` named `TitleEditor.tsx` and paste the following code:

**src/TitleEditor.tsx**

```tsx
import {
  DocumentHandle,
  useDocument,
  useEditDocument,
} from "@sanity/sdk-react";
import { type JSX, useCallback, useRef } from "react";

interface TitleEditorProps {
  documentHandle: DocumentHandle;
}

export function TitleEditor({ documentHandle }: TitleEditorProps): JSX.Element {
  const ref = useRef(null);
  // First, we fetch the current title from the document
  const { data: title } = useDocument({ ...documentHandle, path: "title" });
  // Then, we use the useEditDocument hook to create an edit function using the document handle
  const editMovieTitle = useEditDocument(documentHandle);

  // We use useCallback to create a stable event handler
  const handleTitleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const newTitle = event.target.value;
      // Use the functional updater for safe partial updates
      editMovieTitle((prev) => ({
        ...prev,
        title: newTitle,
      }));
    },
    [editMovieTitle]
  );

  return (
    <input
      type="text"
      ref={ref}
      value={typeof title === "string" ? title : ""}
      onChange={handleTitleChange}
      style={{
        fontSize: '1.25rem',
        fontWeight: 700,
        border: 'none',
        background: 'transparent',
        outline: 'none',
      }}
    />
  );
}

```

In this component we first use the [useDocument](https://reference.sanity.io/_sanity/sdk-react/exports/useDocument/) hook to fetch the current value of `title`, and then we use `useEditDocument` to create a real-time edit function that is called on every change event from the input element.

> [!TIP]
> Plural or singular? It makes a difference.
> It's easy to get `useDocument` and `useDocuments` mixed up when scanning a guide like this. That rascally little `s` at the end means the difference between fetching lots of document handles and subscribing to the state of a single document.

👉 Remember to also update `MoviePreview.tsx`:

**src/MoviePreview.tsx**

```
import {type DocumentHandle, useDocumentProjection} from "@sanity/sdk-react";
import {type JSX, useRef} from "react";
import { TitleEditor } from "./TitleEditor";

interface ProjectionResults {
  data: {
    title: string;
    cast: string;
    posterImage: string;
  };
}

// Project the title, first 2 cast members, 
// and poster image values for the document
const movieProjection = `{
  title,
  'cast': array::join(castMembers[0..1].person->name, ', '),
  'posterImage': poster.asset->url, 
}`;

export function MoviePreview({documentHandle}: {documentHandle: DocumentHandle}): JSX.Element {
  // Generate a ref for the outer element
  // This keeps the useDocumentProjection hook from resolving if
  // the preview is not currently displayed in the viewport
  const ref = useRef(null);

  // No async await here
  const {data: { title, cast, posterImage }}: ProjectionResults = useDocumentProjection({
    ...documentHandle,
    ref,
    projection: movieProjection,
  });

  return (
    <div
      // Assign the ref to the outer element
      ref={ref}
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: '0.5rem',
        border: '1px solid #e5e7eb',
        borderRadius: '0.5rem',
        padding: '1rem'
      }}
    >
      <img
        alt={`Poster for ${title}`}
        src={posterImage}
        style={{
          width: "100%",
          aspectRatio: "1",
          objectFit: "cover",
          borderRadius: "4px",
        }}
        width="400"
        height="400"
      />
      <TitleEditor documentHandle={documentHandle} />
      <p style={{ 
        fontSize: '0.875rem', 
        color: '#4b5563',
        margin: '0'
        }} 
        >
          {cast}
      </p>
    </div>
  );
}

```

You should be able to click any movie title and edit it. Open up your studio to observe the changes happening to the document in real time.

![Shows our grid app side by side with Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/00c5c17284537a6344d0adf5f24cd70d4e23d8a3-1860x1104.png)



# Editing documents

The Sanity App SDK ships with everything you need to build powerful document editing interfaces. The variety of options available, however, might make you wonder which is best suited for your use case.

This guide is designed to inform your decision making by showcasing in detail the different React hooks, components, and patterns that can be used in the course of building document editing interfaces with the App SDK. After reading this guide, you’ll be equipped to build a variety of document editing workflows. All that will be left for you to do is evaluate which of these options best matches the needs of your application.

## Prerequisites:

- Basic familiarity with content operations
- A project with at least one document
- A custom app built with the Sanity App SDK
- Basic familiarity with Document Handles
- For the Portable Text section: `@portabletext/editor` v8 or later and `@portabletext/plugin-sdk-value` v6 or later

[Content operations](https://www.sanity.io/docs/user-guides/content-operations-cheatsheet)
Practical tips and instructions for managing your content within the Sanity ecosystem

[Documents overview](https://www.sanity.io/docs/content-lake/documents)
Sanity stores your data, and some system data, in JSON documents. 

[App SDK introduction](https://www.sanity.io/docs/app-sdk/sdk-introduction)
Get a high-level introduction to the Sanity App SDK.

[Document handles](https://www.sanity.io/docs/app-sdk/document-handles)
Document handles are a central concept in the Sanity App SDK, and are important to understand when working with many of the SDK's React hooks.

## Basic document editing with `useEditDocument`

The `useEditDocument` hook is the first hook you should look to for editing document content. This hook can be used to edit an entire document, or a single field within a document.

> [!NOTE]
> Further reading
> You can find the complete reference documentation for the `useEditDocument` hook on [the Sanity Library Reference Docs](https://reference.sanity.io/_sanity/sdk-react/exports/useEditDocument/)

Additionally, this hook can be used for functional updates based on the document or document field’s current state.

> [!TIP]
> Functional updates
> Functional state updates are performed via callbacks, with the current state provided as a parameter of the callback, and the new state returned at the end of the callback.
> For example:
> - `setState((count) => count + 1)`
> - `setState((state) => ({…state, butAlso: 'I’m new!'})`

### Edit a document field

In the example below, we export a component that implements editing of a single document field. The component accepts a [Document Handle](https://www.sanity.io/docs/app-sdk/document-handles) as a prop, and renders a text input for displaying and editing the ‘SKU’ field in the document referenced by the Document Handle.

**SkuEditor.tsx**

```tsx
import {useDocument, useEditDocument, type DocumentHandle} from '@sanity/sdk-react'
 
interface SkuEditorProps {
  productHandle: DocumentHandle
}
 
export function SkuEditor({productHandle}: SkuEditorProps) {
  // Get the value for the product’s SKU field;
  // this can be used in place of useState to populate the input value
  const {data: currentSku} = useDocument<string>({
    ...productHandle,
    path: 'sku'
  })
  
  // Create a function to edit the product’s SKU field
  const editSku = useEditDocument<string>({
    ...productHandle,
    path: 'sku'
  })
  
  return (
    <form>
      <label>
        SKU
        <input
          type="text"
          value={currentSku}
          onChange={(e) => editSku(e.currentTarget.value)}
        />
      </label>
    </form>
  )
}
```

### Edit multiple document fields (multiple getters & setters)

In the example below, we enable editing of multiple document fields by defining multiple ‘getters’ (with the `useDocument` hook) and multiple ‘setters’ (with the `useEditDocument` hook). This also demonstrates the use of dot notation to access nested paths, i.e. `price.standard` and `price.sale`.

**ProductPricesEditor.tsx**

```tsx
import {useDocument, useEditDocument, type DocumentHandle} from '@sanity/sdk-react'
 
interface ProductPricesEditorProps {
  productHandle: DocumentHandle
}
  
export function ProductPricesEditor({productHandle}: ProductPricesEditorProps) {
  // Get the current standard price
  // (presuming price is an object with 'standard' and 'sale' fields)
  const {data: standardPrice} = useDocument<string>({
    ...productHandle,
    path: 'price.standard'
  })
  
  // Get the current sale price
  const {data: salePrice} = useDocument<string>({
    ...productHandle,
    path: 'price.sale'
  })
  
  // Create a function to edit the standard price
  const editStandardPrice = useEditDocument<string>({
    ...productHandle,
    path: 'price.standard'
  })
  
  // Create a function to edit the sale price
  const editSalePrice = useEditDocument<string>({
    ...productHandle,
    path: 'price.sale'
  })

  
  return (
    <form>
      <label>
        Standard price
        <input
          type="number"
          value={standardPrice}
          onChange={(e) => editStandardPrice(e.currentTarget.value)}
        />
      </label>

      <label>
        Sale price
        <input
          type="number"
          value={salePrice}
          onChange={(e) => editSalePrice(e.currentTarget.value)}
        />
      </label>
    </form>
  )
}
```

### Edit one or more document fields (functional updates)

The `useEditDocument` hook can be used *without* a `path` parameter to return the data for an entire document. When combined with a functional update, this enables editing one or more fields on a document in a single operation.

In the example below, we demonstrate this pattern for a single, dynamic field edit:

**EditDocumentTextFields.tsx**

```tsx
import {useDocument, useEditDocument, type DocumentHandle} from '@sanity/sdk-react'
 
interface EditDocumentTextFieldsProps {
  documentHandle: DocumentHandle
  paths: Array<string>
}
 
export function EditDocumentTextFields({documentHandle, paths}: EditDocumentTextFieldsProps) {
  // Get the current document content
  const {data: document} = useDocument(documentHandle)
  
  // Define a function to update the entire document
  const editDocument = useEditDocument(documentHandle)
  
  // Define a function to handle an update on any of the text fields rendered via `paths`
  function handleFieldChange(event) {
    // Get the path that was edited via the event target's ID (see render method below)
    const {id: editedPath} = event.currentTarget
  
    // The value of the edited field
    const {value} = event.currentTarget
  
    // Edit the document with a functional update, applying only the changes to the edited path
    editDocument(current => ({
      ...current,
      [editedPath]: value
    }))
  }
  
  // Render a label and text input for all of the provided `paths`;
  // set the inputs’ ID and value using the `path`
  return (
    <form>
      {paths.map(path => (
        <label key={path}>
          {path}
          <input
            id={path}
            type="text"
            value={document[path]}
            onChange={handleFieldChange}
          />
        </label>
      ))}
    </form>
  )
}
```

The functional update pattern can also be used to edit multiple fields at once, as in the example below:

**EditBasicFields.tsx**

```tsx
import {useDocument, useEditDocument, type DocumentHandle} from '@sanity/sdk-react'
 
interface EditBasicFieldsProps {
  documentHandle: DocumentHandle
}
 
export function EditBasicFields({documentHandle}: EditBasicFieldsProps) {
  // Get document content
  const {data: document} = useDocument(documentHandle)
  
  // Define a function to edit the entire document
  const editDocument = useEditDocument(documentHandle)
  
  // Update the document when the form is submitted
  function handleSubmit(event) {
    // Prevent page reload
    event.preventDefault()
  
    // Get the form data
    const formData = new FormData(event.target)
  
    // Convert the form data into an object;
    // keys will be input names, and values will be input values
    const updates = Object.fromEntries(formData)
  
    // Edit the document with a functional update;
    // spread the current values, followed by the updated field paths and their values
    editDocument(current => ({
      ...current,
      ...updates,
    }))
  }

  // Render a form with a text input for the document title
  // and a textarea for the document description.
  // Use the input & textarea name attributes to track document path names.
  // Update both fields at once when the form is submitted.
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Title
        <input
          name="title"
          type="text"
          defaultValue={document?.title || ''}
        />
      </label>
      <label>
        Description
        <textarea
          name="description"
          defaultValue={document?.description || ''}
        ></textarea>
      </label>
      <button type="submit">Submit edits</button>
    </form>
  )
}
```

### Edit published documents with `liveEdit`

The `useEditDocument` hook is designed to apply edits to draft documents by default, in order to avoid pushing changes to published documents unexpectedly.

Depending on the document referenced by the document handle passed to `useEditDocument`, the invocation of the returned edit function will either:

- apply edits to the current draft if one already exists, or
- create a new draft (copying from the published version) and apply edits to this new draft.

If you instead want to *apply edits directly to a published document*, this draft creation can be bypassed by setting the `liveEdit` field on the document handle to `true`, as in the example below.

> [!NOTE]
> Publish first
> Your document must already be published to use `liveEdit` — using this hook with `liveEdit: true` will not convert a draft document to a published document.

**SaleToggle.tsx**

```tsx
import {useDocument, useEditDocument, type DocumentHandle} from '@sanity/sdk-react'
  
export function SaleToggle() {
  // Mark `liveEdit: true` to enable edits directly
  // to the published document
  const salesConfig: DocumentHandle = {
    documentId: 'sale-config-document',
    documentType: 'settings',
    liveEdit: true,
  }
  
  // Get the current value of the sale’s `active` field
  const {data: active} = useDocument({
    ...salesConfig,
    path: 'active',
  })
  
  // Define a function to edit the `active` field
  const editSaleActive = useEditDocument({
    ...salesConfig,
    path: 'active',
  })
  
  // Render a checkbox that will edit the `active` field
  return (
    <form>
      <label>
        <input
          type="checkbox"
          checked={active}
          onChange={() => editSaleActive(current => !current)}
        />
        Enable sale
      </label>
    </form>
  )
}
```

## Compose editing workflows with `useApplyDocumentActions`

Under the hood, the `useEditDocument` hook uses the lower level `useApplyDocumentActions` hook to apply edits to documents. If your use case goes beyond what’s available with the `useEditDocument` hook as demonstrated above, you can opt to leverage the `useApplyDocumentActions` hook and the associated document action functions to get things done.

> [!NOTE]
> Further reading
> You can find the complete reference documentation for the `useApplyDocumentActions` hook on the [Sanity Library Reference Docs](https://reference.sanity.io/_sanity/sdk-react/exports/useApplyDocumentActions/)

Below, you’ll find two examples of workflows that can be created this way.

### Create a document with initial field values

By default, [the createDocument document action function ](https://reference.sanity.io/_sanity/sdk/index/createDocument/)simply creates a new document with nothing more than the basic fields required by its Document Handle (document ID and type). However, an object of field values can be passed as an optional second parameter, enabling the document to be created with some initial field values.

This is demonstrated in the example below:

**CreateArticleButton.tsx**

```tsx
import {createDocument, createDocumentHandle, useApplyDocumentActions} from '@sanity/sdk-react' 

function CreateArticleButton() {
  // Get a function to apply document actions
  const apply = useApplyDocumentActions()

  function handleCreateArticle() {
    // Create a new document handle for an article
    const newArticleHandle = createDocumentHandle({
      documentId: crypto.randomUUID(),
      documentType: 'article'
    })
    
    // Use the `apply` function to apply document action functions' results
    apply(
      // Use the `createDocument` function’s optional second
      // parameter to populate the new document’s fields
      createDocument(newArticleHandle, {
        title: 'Life Is Like the Arisu River',
        author: 'Katagiri San',
      })
    )
  }

  return (
    <button onClick={handleCreateArticle}>New Article</button>
  )
}
```

### Create and publish a new document

Multiple document actions can be combined in a single call to the `apply` function returned by `useApplyDocumentActions`. This enables the creation of multistep workflows within a single transaction.

For example, you might want to create a new document, populate it with some initial values, and then publish the new document immediately. This is demonstrated below:

**PublishNewArticle.ts**

```tsx
import {
  createDocument,
  createDocumentHandle,
  publishDocument,
  useApplyDocumentActions
} from '@sanity/sdk-react'

function PublishNewArticle() {
  // Get a function to apply document actions
  const apply = useApplyDocumentActions()

  function createAndPublish() {
    // Create a new document handle
    const newHandle = createDocumentHandle({
      documentId: crypto.randomUUID(),
      documentType: 'article',
    })

    // Prepare some initial content
    const titleOptions = ['Ume', 'Sake', 'Tarako']
    const randomTitle = titleOptions[Math.floor(Math.random() * titleOptions.length)]

    // Pass multiple document action functions to the `apply` function;
    // actions will be dispatched as a single transaction.
    apply([
      createDocument(newHandle, {
        author: 'The Ochazuke Sisters',
        title: randomTitle,
      }),
      publishDocument(newHandle)
    ])
  }

  return (
    <button onClick={createAndPublish}>
      Create and Publish New Article
    </button>
  )
}
```

## Edit rich text fields with Portable Text

Another way to edit fields on a document with the App SDK is with the SDK Value Plugin for the [Portable Text Editor](https://www.portabletext.org/editor/). `SDKPortableTextEditable` wires the editor to a document field: two-way sync, real-time updates from edits made by other users, optimistic updates, and other people’s carets.

Install the editor and the plugin. Their major versions must match, because the plugin declares the editor as a peer dependency:

**npm**

```shell
npm install @portabletext/editor @portabletext/plugin-sdk-value
```

**pnpm**

```shell
pnpm add @portabletext/editor @portabletext/plugin-sdk-value
```

**yarn**

```shell
yarn add @portabletext/editor @portabletext/plugin-sdk-value
```

**bun**

```shell
bun add @portabletext/editor @portabletext/plugin-sdk-value
```

Building an editor takes three pieces:

- A schema declares what content the field accepts, through `defineSchema`.
- Node registrations own how each piece of that content renders. Create them with `defineTextBlock`, `defineDecorator`, `defineAnnotation`, and their siblings, then mount them with `NodePlugin`.
- `SDKPortableTextEditable` renders the editable surface and syncs it with the document field.

**RichTextEditor.tsx**

```tsx
import {
  EditorProvider,
  defineAnnotation,
  defineDecorator,
  defineSchema,
  defineTextBlock,
} from '@portabletext/editor'
import {NodePlugin} from '@portabletext/editor/plugins'
import {SDKPortableTextEditable} from '@portabletext/plugin-sdk-value'
import {type DocumentHandle} from '@sanity/sdk-react'

// Replace this with the schema of the field you're editing
const schemaDefinition = defineSchema({
  decorators: [{name: 'strong'}, {name: 'em'}],
  annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}],
  styles: [{name: 'normal'}, {name: 'h2'}, {name: 'blockquote'}],
  lists: [{name: 'bullet'}, {name: 'number'}],
})

// Keep this at module scope: a new array identity re-registers
// every node on every render
const nodes = [
  defineTextBlock({
    type: 'block',
    render: ({attributes, children, node}) => {
      if (node.style === 'h2') {
        return <h2 {...attributes}>{children}</h2>
      }
      if (node.style === 'blockquote') {
        return <blockquote {...attributes}>{children}</blockquote>
      }
      return <p {...attributes}>{children}</p>
    },
  }),
  defineDecorator({
    type: 'strong',
    render: ({children}) => <strong>{children}</strong>,
  }),
  defineDecorator({
    type: 'em',
    render: ({children}) => <em>{children}</em>,
  }),
  defineAnnotation({
    type: 'link',
    // Annotation fields type as `unknown`, so narrow before use
    render: ({annotation, children}) =>
      typeof annotation.href === 'string' ? (
        <a href={annotation.href}>{children}</a>
      ) : (
        children
      ),
  }),
]

interface RichTextEditorProps {
  articleHandle: DocumentHandle
  // The path to the Portable Text field, for example `content`
  path: string
}

export function RichTextEditor({articleHandle, path}: RichTextEditorProps) {
  return (
    <EditorProvider initialConfig={{schemaDefinition}}>
      <NodePlugin nodes={nodes} />
      <SDKPortableTextEditable {...articleHandle} path={path} />
    </EditorProvider>
  )
}
```

Use this component to edit any document field configured with [the block type](https://www.sanity.io/docs/studio/block-type).

The schema and the registrations do different jobs, and both are required. The schema declares what the editor allows; a registration renders what the schema already permits. A decorator declared in the schema but never registered still applies to the text and still saves to the document, but it renders through the engine’s default, which passes the text through unchanged, so the formatting is invisible to the person typing. Custom block objects and inline objects work the same way: declare them in `blockObjects` or `inlineObjects`, then register them with `defineBlockObject` or `defineInlineObject`. See [Rendering](https://www.portabletext.org/editor/concepts/rendering/) for the full model, and [Custom blocks and inline objects](https://www.portabletext.org/editor/guides/custom-blocks/) for those two.

> [!NOTE]
> Sync is handled for you
> `SDKPortableTextEditable` provides two-way sync between the editor and the document field. Don’t wire the editor’s `onChange` to `useEditDocument`, and don’t sync the value yourself. Mount it inside `EditorProvider` with a document handle and a `path`, and it reads and writes the field, including real-time and optimistic updates.

### Show other people’s carets

`SDKPortableTextEditable` reports the local user’s caret and draws everyone else’s, using a built-in caret that needs no styling of your own. Pass `renderCursor` to draw your own instead:

```tsx
<SDKPortableTextEditable
  {...articleHandle}
  path={path}
  renderCursor={({user}) => (props) => (
    <span
      style={{borderLeft: '2px solid currentColor'}}
      title={user.profile.displayName}
    >
      {props.children}
    </span>
  )}
/>
```

Pass `renderCursor={null}` to report presence without drawing any carets.

To render the editable surface yourself instead, use `PortableTextEditable` with the lower-level `SDKValuePlugin` and `SDKPresencePlugin` mounted alongside it.

## Edit documents with Agent Actions

> [!WARNING]
> Experimental feature
> This section describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

Documents can be edited (or ‘patched’) using a hook that leverages [the Agent Actions API](https://www.sanity.io/agent-actions) — `useAgentPatch`. This hook applies patches to your document in the same manner as [the Patch Agent Action](https://www.sanity.io/docs/agent-actions/patch-quickstart), meaning it validates paths and ensures that the provided values are compatible with the target schema.

A basic example of this is shown below:

**ResetTitle.tsx**

```tsx
import {useAgentPatch} from '@sanity/sdk-react'

export function ResetTitle({documentId}: {documentId: string}) {
  const patch = useAgentPatch()

  async function handleReset() {
    const result = await patch({
      documentId,
      schemaId: '_schemas.default',
      target: [
        {
          path: 'title',
          operation: 'set',
          value: 'Untitled document',
        },
        {
          path: 'lastModified',
          operation: 'set',
          value: new Date().toISOString(),
        }
      ]
    })
    console.log('Patch result: ', result)
  } 

  return (
    <button onClick={handleReset}>
      Reset Title
    </button>
  )
}
```

> [!TIP]
> Further reading
> More examples uses of the `useAgentPatch` hook can be found on [the Sanity Library Reference Docs](https://reference.sanity.io/_sanity/sdk-react/exports/useAgentPatch/)

## Summary

In this guide, we’ve demonstrated editing single document fields, multiple document fields, using functional updates, editing published documents live, composing editing and publishing workflows, using Portable Text to edit block fields, and the experimental `useAgentPatch` to apply edits via the Agent Actions API.

With such a variety of ways to handle document editing, the Sanity App SDK is well equipped to power both traditional and unique editing use cases.

If, however, you have a custom application interface or use case that the App SDK doesn’t seem equipped to handle, we’d love to hear from you! Feel free to [drop into our Discord community](https://snty.link/community), and find us in the #app-sdk channel to let us know.



# Sanity UI

The Sanity App SDK gives you complete freedom to craft your application’s design. Whether your preferred styling solution is [Sanity UI](https://www.sanity.io/ui), [Tailwind](https://www.sanity.io/docs/app-sdk/tailwind-sdk), vanilla CSS, or something else entirely, the SDK‘s headless approach allows you to style your app with the tools your team knows best, while benefiting from powerful React hooks that unlock Sanity platform capabilities.

## Use Sanity UI in a new app

If you know you’d like to use Sanity UI as your component library of choice when creating your custom app, you can choose to initialize your app with our Sanity UI template, which implements the work shown above for you.

Just initialize your app using the `app-sanity-ui` template instead of the usual `app-quickstart` template:

**npm**

```shell
npx sanity@latest init --template app-sanity-ui
```

**pnpm**

```shell
pnpm dlx sanity@latest init --template app-sanity-ui
```

**yarn**

```shell
yarn dlx sanity@latest init --template app-sanity-ui
```

**bun**

```shell
bunx sanity@latest init --template app-sanity-ui
```

## Add Sanity UI to an existing app

First, begin by installing Sanity UI:

**npm**

```shell
npm install @sanity/ui styled-components
```

**pnpm**

```shell
pnpm add @sanity/ui styled-components
```

**yarn**

```shell
yarn add @sanity/ui styled-components
```

**bun**

```shell
bun add @sanity/ui styled-components
```

Then, in your custom application’s `src/App.tsx`, instantiate Sanity UI’s ThemeProvider as usual:

**App.tsx**

```tsx
// App.tsx
import {SanityApp, type SanityConfig} from '@sanity/sdk-react'

// Sanity UI
import '@sanity/ui/styles.css'
import {ThemeProvider} from '@sanity/ui'
import {buildTheme} from '@sanity/ui/theme'

import {ExampleComponent} from './ExampleComponent'

// Build the Sanity UI theme
const theme = buildTheme()

export function App() {
  // apps can access many different projects or other sources of data
  const config: SanityConfig[] = [
    {
      projectId: 'project-id',
      dataset: 'dataset-name',
    },
  ]

  return (
    <ThemeProvider theme={theme}>
      <SanityApp config={config} fallback={<div>Loading...</div>}>
        {/* add your own components here! */}
        <ExampleComponent />
      </SanityApp>
    </ThemeProvider>
  )
}

export default App
```

You can now use Sanity UI as expected within your custom application.

This approach can be used for other component libraries and styling solutions, as well — just be sure to set them up with `src/App.tsx`.

## Optional: faster styled components

We’re working to migrate Sanity UI off of styled components, but until then we recommend using our fork of the library to improve performance. 

Add the corresponding package for your project’s React version to your project.

**npm**

```shell
# React 18
npm install --save-exact styled-components@npm:@sanity/styled-components
# React 19
npm install --save-exact styled-components@npm:@sanity/css-in-js
```

**pnpm**

```shell
# React 18
pnpm add --save-exact styled-components@npm:@sanity/styled-components
# React 19
pnpm add --save-exact styled-components@npm:@sanity/css-in-js
```

**yarn**

```shell
# React 18
yarn add --exact styled-components@npm:@sanity/styled-components
# React 19
yarn add --exact styled-components@npm:@sanity/css-in-js
```

**bun**

```shell
# React 18
bun add --exact styled-components@npm:@sanity/styled-components
# React 19
bun add --exact styled-components@npm:@sanity/css-in-js
```

You can read the full explanation of why we forked `styled-components`, and what benefits it offers in [the blog post](https://www.sanity.io/engineering/cut-styled-components-into-pieces-this-is-our-last-resort).



# Tailwind CSS

[Tailwind](https://tailwindcss.com/) is a popular styling library beloved by many developers — including some who build custom apps with the Sanity App SDK. This guide demonstrates how to get up and running with Tailwind and the App SDK.

> [!NOTE]
> This guide was written and tested with [Tailwind 4.1](https://github.com/tailwindlabs/tailwindcss/releases/tag/v4.1.0). We endeavor to keep up, but if you spot any outdated information, please let us know!

## Step 1: Prerequisites

In order to get started, we’ll presume you’ve already got a custom app initialized with the App SDK. If you don’t, [follow along with our quickstart guide](https://www.sanity.io/docs/app-sdk/sdk-quickstart)!

Once your app is initialized, you’ll need to install two Tailwind dependencies — the main [Tailwind library](https://www.npmjs.com/package/tailwindcss), and [Tailwind’s Vite plugin](https://www.npmjs.com/package/@tailwindcss/vite):

**npm**

```shell
npm install tailwindcss @tailwindcss/vite
```

**pnpm**

```shell
pnpm add tailwindcss @tailwindcss/vite
```

**yarn**

```shell
yarn add tailwindcss @tailwindcss/vite
```

**bun**

```shell
bun add tailwindcss @tailwindcss/vite
```

## Step 2: Configure Tailwind’s Vite plugin

As you may know, custom apps built with the App SDK are React applications that run on [Vite](https://vite.dev/).

Given that fact, you might be tempted to follow [Tailwind’s installation guide for Vite](https://tailwindcss.com/docs/installation/using-vite), but we require a slightly different setup process to accomodate for the [auto updating](https://www.sanity.io/docs/studio/latest-version-of-sanity) nature of apps built with the SDK.

Thus, rather than setting up your own Vite config that might interfere with the core functionality of custom apps, you’ll need to extend the built in Vite config in order to register Tailwind’s Vite plugin. You can do this within the `sanity.cli.ts` file at the root of your custom app.

Use the example below to update the contents of your `sanity.cli.ts` file and register the Tailwind plugin:

**sanity.cli.ts**

```
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your organization ID goes here',
    entry: './src/App.tsx',
  },
  vite: async (viteConfig) => {
    const {default: tailwindcss} = await import('@tailwindcss/vite')
    return {
      ...viteConfig,
      plugins: [
        ...viteConfig.plugins,
        tailwindcss()
      ],
    }
  }
})
```

The Tailwind plugin is now configured for your app! All that’s left to do is to import Tailwind and put it to work.

## Step 3: Import Tailwind’s CSS

Within your app’s `src/App.css` (or any other CSS file you choose to make use of and import within the scope of your application), add an `@import` statement to import Tailwind’s CSS:

**App.css**

```css
@import 'tailwindcss';
/* Other styles here if you like… */
```

Be sure that the CSS file you add this `@import` statement to is itself imported within your application — likely within your `src/App.tsx` file:

**App.tsx**

```
import './App.css'
// Rest of your App.tsx here…
```

## Step 4: Start the development server

Start your custom app’s development server as usual:

**npm**

```shell
npm run dev
```

**pnpm**

```shell
pnpm run dev
```

**yarn**

```shell
yarn run dev
```

**bun**

```shell
bun run dev
```

## Step 5: Start using Tailwind in your components

You can now use Tailwind’s CSS classes in any of your app’s React components. For further guidance on using Tailwind, refer to Tailwind’s docs. Have fun!



# TypeGen

> [!WARNING]
> The experimental TypeGen integration described on this page is deprecated. It may conflict with modern Sanity CLI versions. For new projects, use the current, non-experimental TypeGen instead — see [Migrating from experimental TypeGen with the App SDK.](https://www.sanity.io/docs/app-sdk/migrating-from-experimental-typegen-in-app-sdk)

> [!NOTE]
> This page is for using TypeGen with the App SDK. See [the TypeGen article](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) to generate types for your Studio and front end applications.

[Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) is a tool that generates TypeScript types directly from your Sanity schemas and GROQ queries. When used with the Sanity App SDK, it provides strong type safety and autocompletion suggestions for your documents, query results, and projections.

In this guide, we’ll walk through setting up and using TypeGen within your SDK app.

> [!WARNING]
> Experimental feature
> TypeGen support in the App SDK is currently in its early stages. We’re actively working on improving this integration and the developer experience around it. For now, some parts of this process may be suboptimal, but we invite the adventurous among you to follow along!

## Setup

Using Typegen involves two main steps: extracting your schema(s) and then generating the types. Both commands are available via the CLI.

### Extract schemas

First, you need to extract your Sanity schema(s) into a JSON format that Typegen can understand. **Currently, this step relies on the full** **sanity** **package**, typically used within your Sanity Studio project, as Typegen needs access to the complete schema definition to generate accurate types.

Schema extraction is performed within your Studio setup to generate the `schema.json` file. Once created, this file can be used independently by other tools or parts of your workflow.

> [!NOTE]
> We recognize that requiring the Studio environment solely for this generation step isn't ideal, and we're actively working on improving this workflow in future App SDK updates to make the process more self-contained.

Use the `sanity schema extract` command within your Studio project or a project that has the `sanity` package installed:

**npm**

```shell
npx sanity schema extract --workspace <workspace-name> --output-path <path/to/schema.json>
```

**pnpm**

```shell
pnpm dlx sanity schema extract --workspace <workspace-name> --output-path <path/to/schema.json>
```

**yarn**

```shell
yarn dlx sanity schema extract --workspace <workspace-name> --output-path <path/to/schema.json>
```

**bun**

```shell
bunx sanity schema extract --workspace <workspace-name> --output-path <path/to/schema.json>
```

This `schema.json` file can be copied to (or the `--output-path` can be set directly to) your Sanity app's repository. Your application itself does *not* need the full `sanity` package as a dependency to use the generated types; it only needs the `schema.json` file for the `typegen generate` step.

If your Studio project defines multiple workspaces or you need types for different schemas (e.g., for different datasets), run the `extract` command for each one, outputting to separate JSON files. For example, you could configure you Studio’s `package.json` as follows:

**package.json**

```json
{
  "scripts": {
    "schema:extract:test": "sanity schema extract --workspace test --output-path ../my-frontend-app/schema-test.json",
    "schema:extract:prod": "sanity schema extract --workspace production --output-path ../my-frontend-app/schema-prod.json",
    "schema:extract": "npm run schema:extract:test && npm run schema:extract:prod"
  }
}
```

We plan to improve this schema extraction process as the SDK matures to potentially reduce the dependencies and improve overall developer experience.

### Install (experimental) packages

To use the Typegen features described in this guide, your SDK app needs specific experimental versions of `@sanity/cli` and `groq` installed. Install these packages from within your SDK app directory:

**npm**

```shell
npm install groq@typegen-experimental-2025-04-23
npm install --save-dev @sanity/cli@typegen-experimental-2025-04-23
```

**pnpm**

```shell
pnpm add groq@typegen-experimental-2025-04-23
pnpm add --save-dev @sanity/cli@typegen-experimental-2025-04-23
```

**yarn**

```shell
yarn add groq@typegen-experimental-2025-04-23
yarn add --dev @sanity/cli@typegen-experimental-2025-04-23
```

**bun**

```shell
bun add groq@typegen-experimental-2025-04-23
bun add --dev @sanity/cli@typegen-experimental-2025-04-23
```

> [!WARNING]
> Package names and installation
> These are experimental pre-release versions. The package names and installation process may change as these features stabilize.

### Configure TypeGen (optional)

For the most common use case – a single Sanity schema for your project – **no configuration file is needed**. However, you'll need to create a TypeGen configuration file for more complex use cases, such as:

- Using multiple schemas (e.g., from different workspaces or for different datasets).
- Needing to explicitly map a single schema to a specific `schemaId` for accurate schema scoping (instead of using the default  `'default'`).
- Using a different name or location for your schema file(s).
- Specifying a custom output path for the generated types file.

If you need this level of configuration, create a TypeGen configuration file (`sanity-typegen.json` ) at the root of your SDK app and use the `unstable_schemas` array:

**sanity-typegen.json**

```json
// sanity-typegen.json
{
  "unstable_schemas": [
    {
      // Path to the schema
      "schemaPath": "./schemas/products-schema.json",
      // The schema ID, formatted as `projectId.datasetName`
      "schemaId": "YOUR_PROJECT_ID.products"
    },
    {
      "schemaPath": "./schemas/authors-schema.json",
      "schemaId": "YOUR_PROJECT_ID.authors"
    }
    // Add more schema objects if needed
  ],
  "overloadClientMethods": false // client methods are not needed for the App SDK
  // Optional: Specify output path for generated types
  // "outputPath": "./src/generated/sanity-types.ts"
}
```

Objects in the `unstable_schemas` array each consist of the following properties:

- **schemaPath:** The path (relative to the project root) to the corresponding extracted schema JSON file.
- **schemaId:** A string combining your `projectId` and `dataset` (e.g., `"YOUR_PROJECT_ID.YOUR_DATASET"`). This is used to map the schema to the correct project and dataset context for type generation, as the extracted `schema.json` doesn't contain this information itself.

The optional **outputPath** property specifies where to write the generated `sanity.types.ts` file. It defaults to the project root.

By default, TypeGen works seamlessly for the common single-schema setup without extra configuration. Use `sanity-typegen.json` only when your needs require more explicit control.

### Generate types

With the necessary packages installed and your schema(s) extracted (and optionally configured in `sanity-typegen.json`), you can run the `sanity typegen generate` command from within your SDK app directory:

**Terminal**

```sh
# use `@sanity/cli` package directly for now
./node_modules/@sanity/cli/bin/sanity typegen generate
```

This command reads your configuration (either `sanity-typegen.json` or the default `schema.json`), processes the specified schemas, and generates a `sanity.types.ts` file, which contains your types. It's recommended to add this command to your SDK app’s `package.json` scripts. For example:

**package.json**

```json
{
  "scripts": {
    "typegen": "./node_modules/@sanity/cli/bin/sanity typegen generate"
  }
}
```

Congratulations! You've now generated types for your schema documents and query results. With your `sanity.types.ts` file in place, the App SDK hooks will automatically pick up these types.

Next, we’ll cover how to make use of these generated types in your SDK app.

## Use the generated types

TypeGen generates interfaces for each document type defined in your schemas. For projects using multiple schemas/datasets defined in `sanity-typegen.json`, it utilizes a helper type `SchemaOrigin` (imported from `groq`) to brand the types.

This allows TypeScript to narrow down the possible document types based on the dataset context provided via a `DocumentHandle`. See the code below for an example of this:

**Document type narrowing**

```
import {useDocument, createDocumentHandle} from '@sanity/sdk-react'

// Assuming 'book' is only in 'test' dataset, 'dog' only in 'production'
const testHandle = createDocumentHandle({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'test',
  documentId: 'some-id',
  documentType: 'book', // Type narrowed to 'book'
})

const prodHandle = createDocumentHandle({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  documentId: 'another-id',
  documentType: 'dog', // Type narrowed to 'dog'
})

function MyComponent() {
  const {data: bookData} = useDocument(testHandle)
  // bookData is correctly typed as Book

  const {data: dogData} = useDocument(prodHandle)
  // dogData is correctly typed as Dog

  // ...
}
```

### Handles and literal types

For TypeGen to correctly infer types in hooks like `useDocument`, it needs to know the *specific* literal type of the `documentType` (e.g., `'book'` instead of just `string`).

The App SDK provides helper functions (like `createDocumentHandle `and `createDatasetHandle`) that help capture these literal types:

**Document handle creation**

```
import {createDocumentHandle} from '@sanity/sdk'

// Using the helper ensures handle.documentType is typed as 'book'
const handle = createDocumentHandle({
  documentId: '123',
  documentType: 'book',
  dataset: 'production',
  projectId: 'abc',
})
```

Alternatively, if you prefer defining handles as plain objects, use `as const` to ensure the `documentType` has the literal type of `'book'`:

**Document handle creation with object literals**

```
const handle = {
  documentId: '123',
  documentType: 'book',
  dataset: 'production',
  projectId: 'abc',
} as const // 'as const' ensures documentType is 'book', not string
```

> [!TIP]
> We recommend that you use `createDocumentHandle` (or other `create*Handle` helpers) when using Typegen for cleaner code.

### GROQ queries

When using the `useQuery` hook, you **must** define your GROQ queries using `defineQuery` from the `groq` package to get type inference:

**Defining queries**

```
import {defineQuery} from 'groq'
import {useQuery} from '@sanity/sdk-react'

// Typegen derives the type name (AllBooksQuery) from the variable name
export const allBooksQuery = defineQuery('*[_type == "book"]{ _id, title }')

function BookList() {
  // Type of `data` is inferred from `allBooksQuery`
  const {data} = useQuery({query: allBooksQuery})

  // data is typed as Array<{_id: string, title: string}> (or similar)
  return (
    <ul>
      {data.map((book) => (
        <li key={book._id}>{book.title}</li>
      ))}
    </ul>
  )
}
```

Note that `useQuery` accepts options as a single object, allowing you to spread handles easily. For example:

**Spreading options to `useQuery`**

```
const handle = createDatasetHandle({dataset: 'test', projectId: 'abc'})
const {data} = useQuery({...handle, query: allBooksQuery})
```

### Document lists

The App SDK’s document list hooks, `useDocuments` and `usePaginatedDocuments`, benefit from TypeGen through dataset scoping (as shown earlier). You can use the `documentType` option to specify the document type(s) you are querying:

**Paginated list example**

```
import {usePaginatedDocuments, createDatasetHandle} from '@sanity/sdk-react'
import {DocumentPreview} from './your-document-preview'

const testDataset = createDatasetHandle({dataset: 'test', projectId: 'abc'})

function MixedList() {
  // Specify the types being queried
  const {data} = usePaginatedDocuments({
    ...testDataset,
    documentType: ['author', 'book'], // Pass string or array of strings
  })

  // `data` is an array of DocumentHandles, correctly scoped.
  // If used with `useDocument` (and other hooks) later, types will be scoped
  // appropriately (e.g. Author | Book).
  return (
    <ul>
      {data.map((doc) => (
        <Suspense key={doc.documentId} fallback={<li>Loading...</li>}>
          <DocumentPreview doc={doc} />
        </Suspense>
      ))}
    </ul>
  )
}
```

### Specific document types

When you know the specific document type you're dealing with, you can make your TypeScript code even more precise using the methods described below.

#### Parameterizing `DocumentHandles`

`DocumentHandle` is a generic type that accept type parameters. You can provide a specific document type literal (like `'book'`) as a type argument. This is useful for typing props or variables that should only reference a handle for a specific document type:

**Parameterizing a Document Handle**

```
import {type DocumentHandle} from '@sanity/sdk-react'

// This function expects a handle that *must* reference a 'book' document
function BookComponent({doc}: {doc: DocumentHandle<'book'>}) {
  // Thanks to DocumentHandle<'book'>, TypeScript knows the context
  const {data} = useDocument(doc)
  // `data` will be typed as the generated `Book` interface
  // ...
}
```

This works because the full definition of `DocumentHandle` includes generic type parameters (`TDocumentType`, `TDataset`, `TProjectId`) that default to `string` but can be made more specific.

#### Using `SanityDocument` for document data

If you need the type for the actual document *data* itself (not just the handle), the `groq` package exports the `SanityDocument<TDocumentType>` helper type. Pass the document type literal to get the corresponding generated interface for the document content:

**Parameterizing SanityDocument**

```
import {type SanityDocument} from 'groq'

type BookData = SanityDocument<'book'>
// BookData is now equivalent to the generated Book interface (e.g., { _id: string; title: string; ... })

// This function expects the fully typed book data
function processBook(book: BookData) {
  console.log(book.title) // Autocomplete works!
}
```

In summary:

- Use `DocumentHandle<'yourType'>` to constrain a document handle to documents of a specific type.
- Use `SanityDocument<'yourType'>` to type the actual data structure of a document of a specific type.

## Workflow considerations

### Regeneration

You'll need to re-run `npm run typegen` whenever you:

- Change your Sanity schemas.
- Add or modify queries defined with `defineQuery`.
- Consider integrating this into your `dev` script or a file watcher.

### TypeGen is additive

TypeGen is designed to enhance the App SDK experience. If you don't use it, the App SDK hooks will still work, but data types will often default to `any` or `unknown`, losing the benefits of TypeScript. Adopting TypeGen later should be a non-breaking change that simply adds type safety.

### JavaScript projects

Even if your project doesn't use TypeScript, you can still leverage TypeGen to enhance your JavaScript development experience.

By following the steps in this guide – extracting your schema, installing the necessary packages, using helpers like `createDocumentHandle` and `defineQuery`, and running `npm run typegen` – you create a `sanity.types.ts` file.

While your JavaScript code won't undergo compile-time type checking, modern code editors (like VS Code) that use the TypeScript language service can read this generated file.

This often results in significantly better autocompletion within your JavaScript files when interacting with App SDK hooks and data. Remember, however, that using `defineQuery` is still required for TypeGen to generate types for those specific artifacts.



# Workflows

#### Start here

[Workflows](https://www.sanity.io/docs/workflows/introduction)
What Workflows is, the core concepts behind it, and which surface to build on.

[Quick start: run your first workflow](https://www.sanity.io/docs/workflows/getting-started)
Define your first workflow in TypeScript, deploy it, and move a Sanity document through its stages.

[Configure and deploy workflow definitions](https://www.sanity.io/docs/workflows/deploy-definitions)
Install and authenticate the workflow CLI, write the sanity.workflow.ts config that binds your definitions to a Sanity resource, and deploy them to one environment or several.

[Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions)
Use GROQ-triggered and scheduled Sanity Functions to start workflows, reevaluate conditions, and process queued effects.

[Add Workflows to Sanity Studio](https://www.sanity.io/docs/workflows/studio-plugin)
Install the Workflows plugin in a Sanity Studio, bind it to your deployed definitions, and put workflows in front of editors.

[How early access works](https://www.sanity.io/docs/workflows/prerelease)
What building on Workflows during early access commits you to: one fixed 0.x stack, a stricter contract for stored documents, what the Content Lake does not yet enforce, and the runtime you supply.

#### Model your process

[Definitions, instances, and stages](https://www.sanity.io/docs/workflows/definitions-and-instances)
A definition describes a process. An instance is one run of it, pinned to the definition version it started under and sitting in exactly one stage.

[Fields](https://www.sanity.io/docs/workflows/fields)
Fields carry the typed data belonging to a workflow instance.

[Activities and actions](https://www.sanity.io/docs/workflows/activities-and-actions)
An activity is work scoped to one stage visit. Actions resolve it, write instance state, and queue effects, fired by a caller or automatically by the engine.

[Conditions](https://www.sanity.io/docs/workflows/conditions)
Write GROQ conditions over the engine’s bounded instance snapshot: what the snapshot holds, which sites bind the caller, named predicates, and the start filter and requirements.

[Operations](https://www.sanity.io/docs/workflows/operations)
The write vocabulary: a small set of ops that mutate an instance’s fields and statuses, carried by actions and by effect completions.

[Subworkflows](https://www.sanity.io/docs/workflows/subworkflows)
How a large process composes out of smaller ones: an action spawns a child workflow per row of a query, and a trigger resolves the parent’s activity once they all settle.

[Global document references](https://www.sanity.io/docs/workflows/global-document-references)
Why every document pointer in a workflow carries its location, and how resource aliases keep deployed definitions portable across environments.

#### Run and enforce

[Engine](https://www.sanity.io/docs/workflows/engine)
The library that evaluates and commits Workflow instances.

[Effects and runtimes](https://www.sanity.io/docs/workflows/effects-and-runtimes)
Why the engine queues effects instead of running them, and where the runtime lives: the verbs your code calls, and the drainer that delivers queued work.

[Guards and enforcement](https://www.sanity.io/docs/workflows/guards)
Declare a guard that restricts which mutations a document accepts while an instance occupies a stage, and know what honors it today.

[Actors, tokens, and what's actually enforced](https://www.sanity.io/docs/workflows/actors-and-enforcement)
Who the engine acts as, where a condition can read the caller, and which of the engine’s checks would stop a client that bypasses it.

[History and audit trail](https://www.sanity.io/docs/workflows/history-and-audit-trail)
Understand the durable event history stored on every Workflows instance, what it records, and where its provenance boundary ends.

[Evaluation insights](https://www.sanity.io/docs/workflows/evaluation-insights)
Explain condition outcomes and field proposals from a Workflows evaluation.

#### Build on it

[Workflows in Sanity Studio](https://www.sanity.io/docs/workflows/studio-user-guide)
See where your work stands, complete the tasks a workflow is waiting on, find work assigned to you, and understand a held publish.

[Build a workflow interface with the App SDK](https://www.sanity.io/docs/workflows/app-sdk)
Render live workflow state and commit actions from your own App SDK application: mount a session, handle its states, render activities and fields from the evaluation, and list instances.

[The reactive session](https://www.sanity.io/docs/workflows/reactive-session)
How a reactive session projects one workflow instance for a UI: what keeps it current, what each session state means, and when a preview becomes a commit.

[Reusable UI components](https://www.sanity.io/docs/workflows/ui-components)
Add assignment, date, member, and workflow-diagram controls to a custom Workflows interface.

[Create a workflow-powered Document Action](https://www.sanity.io/docs/workflows/custom-studio-integrations)
Build a custom Submit for review Document Action in Sanity Studio with the @sanity/workflow-studio adapter: read the document's workflow, respect the evaluated verdict, and commit the action.

[Custom reactive adapters](https://www.sanity.io/docs/workflows/custom-reactive-adapters)
Connect Workflows to an unsupported host or data layer by implementing the store-agnostic reactive observer contract.

[Connect an agent over MCP](https://www.sanity.io/docs/workflows/mcp)
Install and authenticate the Workflows MCP server, register it with your agent, address a workflow environment, and see which tools change state.

#### Operate

[Test your workflows](https://www.sanity.io/docs/workflows/testing)
Run the real workflow engine in memory: drive every path of a workflow, control the clock, simulate guard enforcement, and assert on exactly what happens.

[Coordinate content across projects and datasets](https://www.sanity.io/docs/workflows/cross-resource-workflows)
Run one workflow over content that lives in other projects, datasets, Media Libraries, or Canvas, and prove the routing before you rely on it.

[Upgrade Workflows packages](https://www.sanity.io/docs/workflows/upgrade)
Take a new Workflows release without breaking in-flight instances: the lockstep set, what the reader-model literal claims, and the readers-first order.

#### Reference

[Reference](https://www.sanity.io/docs/workflows/reference)
Find the authoritative API and type reference for each Workflows domain. Exact contracts live at the bottom of the corresponding concept page.

[Workflow CLI command reference](https://www.sanity.io/docs/workflows/cli-reference)
Every Workflows CLI command with its flags, selectors, JSON output, and exit behavior, for deploying definitions and driving instances.

[Limits](https://www.sanity.io/docs/workflows/limits)
The engine’s operational caps and defaults: what each protects, and where to tune the ones you can.

[Workflows release notes](https://www.sanity.io/docs/workflows/release-notes)
Curated overviews of recent Workflows prerelease package waves.

#### Worked examples

[Cookbook](https://www.sanity.io/docs/workflows/cookbook)
Worked, runnable workflow examples for Sanity: editorial review, AI content pipelines, coordinated releases, and more, each a complete definition.

[Cookbook: Editorial review](https://www.sanity.io/docs/workflows/cookbook-editorial-review)
A four-stage editorial review workflow for Sanity: assignment, drafting, review, and published, driven by human actions in the Studio.

[Cookbook: AI content pipeline](https://www.sanity.io/docs/workflows/cookbook-ai-content-pipeline)
An AI content pipeline built on Workflows: effect handlers call generation APIs while editors approve results through workflow actions.

[Cookbook: Coordinated release](https://www.sanity.io/docs/workflows/cookbook-coordinated-release)
A release workflow that coordinates approvals across many documents and hands the atomic go-live to a Content Release.

[Cookbook: Client–server asset intake](https://www.sanity.io/docs/workflows/cookbook-client-server-asset-intake)
Start an image-review workflow from an app, then let a server import or discard the staged file.

[Cookbook: Handle workflows when referenced content is deleted](https://www.sanity.io/docs/workflows/cookbook-handle-deleted-subject)
Apply an application-owned lifecycle policy when content watched by an Workflow is deleted.



# Studio

#### The basics

[Configuration](https://www.sanity.io/docs/studio/configuration)
Sanity Studio lets you quickly get up and running by configuring it with JavaScript or TypeScript.

[Schema types](https://www.sanity.io/docs/studio/schemas-and-forms)
Add out-of-the-box and custom document and field types

[Block Content](https://www.sanity.io/docs/studio/block-content)
Rich text and custom block content that can render in any front end

[Sanity Studio quick start](https://www.sanity.io/docs/sanity-studio-quickstart)

#### Get ready for production

[Visual Editing](https://www.sanity.io/docs/visual-editing)
Add shareable live previews, click-to-edit, and drag and drop

[Hosting and deployment](https://www.sanity.io/docs/studio/deployment)
How to deploy Sanity Studio, either on your own or using our hosted service.

[Studio versions and auto-updating](https://www.sanity.io/docs/studio/latest-version-of-sanity)
Explore the features and improvements in the latest version of Sanity.

#### Customize the Studio

[Custom components](https://www.sanity.io/docs/studio/intro-to-custom-studio-components)
Introduction to custom components for Sanity Studio

[Structure builder](https://www.sanity.io/docs/studio/structure-builder-introduction)
Customize document lists, views, menus, and more

[Localize the Studio](https://www.sanity.io/solution/localization)
How to get the Studio in any language

#### Bells and whistles

[Comments for Sanity Studio](https://www.sanity.io/docs/studio/comments)
Learn to use Comments in Sanity Studio for effective collaboration, including leaving comments, @mentions, and resolving comments.

[Tasks for Sanity Studio](https://www.sanity.io/docs/studio/tasks)
Learn to use Sanity Studio's tasks for collaboration, assign tasks, comment on tasks, and resolve tasks for efficient content creation.

[Dashboard](https://www.sanity.io/docs/dashboard)
Get started with Sanity Dashboard, the hub of all your content operations.

[Content releases](https://www.sanity.io/docs/studio/content-releases-configuration)
Organize and schedule updates across multiple documents.

[Localizing Sanity Studio](https://www.sanity.io/docs/studio/localizing-studio-ui)
Sanity Studio supports UI localization via plugins. Users can install languages, override translations, and contribute to localization.

[Install and configure Sanity AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)
How to install and configure the AI Assist plugin for Sanity Studio.



# Installation

If you’re new to Sanity, we recommend exploring [the different ways of getting started](https://www.sanity.io/docs/getting-started), as this article is only focused on the installation instructions for Sanity Studio.

## Initiating a new Studio from the CLI

Launching a Studio from the CLI is typically useful when you:

- Are starting a new project.
- Prefer to figure tools out on your own.
- Need to set up a content backend quickly.

To install and run the Sanity Studio development server locally, [you will need to have Node.js v22.12 or later and npm installed](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (or [an npm-compatible package manager](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Understanding_client-side_tools/Package_management#what_exactly_is_a_package_manager)). 

To initiate a new Studio, you can run the following command using a package manager:

**npm**

```shell
npm create sanity@latest
```

**pnpm**

```shell
pnpm create sanity@latest
```

**yarn**

```shell
yarn create sanity@latest
```

**bun**

```shell
bun create sanity@latest
```

> [!TIP]
> Dataset visibility
> Sanity datasets are public by default. See [dataset visibility](https://www.sanity.io/docs/content-lake/keeping-your-data-safe) for info about private datasets.



The CLI will take you through creating or signing into an account and choosing options such as a Studio template, TypeScript, and your preferred package manager. It will make a new folder on the desired path and bootstrap a Studio with the necessary configuration.

Once the CLI has installed the studio, you can `cd` into the Studio folder and run `npm run dev` to start the local development server.

## Adding Sanity to an existing project

If you have an existing application and want to add Sanity to it, you can use `sanity init` or `npm create sanity@latest`. Both commands run the same initializer. When run inside an existing project using a supported framework (like Next.js), the CLI detects it and sets up Sanity within your current project directory instead of creating a new folder. Use the `--output-path` flag to control placement explicitly.

**npm**

```shell
npx sanity@latest init
```

**pnpm**

```shell
pnpm dlx sanity@latest init
```

**yarn**

```shell
yarn dlx sanity@latest init
```

**bun**

```shell
bunx sanity@latest init
```

The CLI walks you through connecting to a project, choosing a dataset, and generating a `sanity.config.ts` file. If you want the Studio files in a subfolder, use the `--output-path` flag:

**npm**

```shell
npx sanity@latest init --output-path studio
```

**pnpm**

```shell
pnpm dlx sanity@latest init --output-path studio
```

**yarn**

```shell
yarn dlx sanity@latest init --output-path studio
```

**bun**

```shell
bunx sanity@latest init --output-path studio
```

### Embedding Studio in a framework

You can embed Sanity Studio directly in a framework application as a route. See the framework-specific quickstart guides for step-by-step instructions:

- [Next.js](https://www.sanity.io/docs/next-js-quickstart)
- [Nuxt](https://www.sanity.io/docs/nuxt-js-quickstart)
- [Astro](https://www.sanity.io/docs/astro-quickstart)
- [React Router (Remix)](https://www.sanity.io/docs/react-router-quickstart)

### CORS configuration

When embedding Studio or making API requests from a browser, add your application's origin to the project's CORS settings:

1. Go to [sanity.io/manage](https://www.sanity.io/manage) and select your project.
2. Go to **Settings** > **API settings**.
3. Under **CORS Origins**, add your development URL (for example, `http://localhost:3000`).
4. If the Studio is hosted on that origin, enable **Allow credentials**.

[Learn more about CORS](https://www.sanity.io/docs/content-lake/cors).



# Project structure

Sanity Studio’s file structure is a slim single-page React application where logic and code are contained in [npm modules](https://docs.npmjs.com/about-packages-and-modules). The Studio comes with a framework that lets you customize and add your own components to different parts of it.

This makes it possible to confidently upgrade to new versions of Sanity Studio, and also to install and ship plugins in self-contained packages.

## Studio file layout

The file structure of Studio projects can look different depending on how you installed it. The example below is from the blog template example that you can initiate from the CLI:

```text
.
├── README.md
├── dist
│   ├── index.html
│   └── static
│       ...
│       └── ...
├── package-lock.json
├── package.json
├── sanity.cli.ts
├── sanity.config.ts
├── schemas
│   ├── author.ts
│   ├── blockContent.ts
│   ├── category.ts
│   ├── index.ts
│   └── post.ts
├── static
└── tsconfig.json
```

Sanity Studio contains the following files and folders out of the box:

- `package.json`: Contains the necessary dependencies for the studio project. There will also be a dependency lock file that might look different depending on which package manager you use.
- `sanity.cli.ts`: The configuration file for the Sanity CLI. Contains information on what project ID and dataset the CLI should connect to for project-specific commands.
- `sanity.config.ts`: The [configuration file](https://www.sanity.io/docs/studio/config-api-reference) for the Studio contains information about what project(s) that the Studio should connect to, as well as [schemas](https://www.sanity.io/docs/apis-and-sdks/introduction-to-schemas), plugins, and other customizations.
- `schemas`: It's a convention to organize schema files in a dedicated folder. It's not required to have a schemas folder, as schemas are imported as JavaScript into the Studio config object.
- `static`: If you want to bundle static files in the studio, you can place these files in the static folder. The built-in developer tooling using Vite will pick these up automatically. If you use a different bundler, then you might need to configure this to bundle files from this folder as well.
- `tsconfig.json` (only if TypeScript is used) Contains the settings for [the transpilation of TypeScript into JavaScript](https://vitejs.dev/guide/features.html#typescript).
- `dist`: This folder is auto-generated and contains the production build of Sanity Studio as a result of running `npm run build.`

> [!TIP]
> Sanity ecosystem structure
> When you’re working with the larger Sanity ecosystem, it can be helpful to think of how Studio fits in your codebase. [Check out our guide](https://www.sanity.io/docs/blueprints/project-layout-and-monorepos) on the different ways to organize Functions, Blueprints, and other parts of Sanity.

## Configuration patterns

Configuration is split between `sanity.config.ts` (the Studio itself) and `sanity.cli.ts` (the CLI). The Studio config manages how your studio works and how it interacts with Content Lake, and the CLI config manages terminal actions like deployments, migrations, and queries from the CLI. See [CLI Configuration](https://www.sanity.io/docs/cli-reference/cli-config) for the details.

### Environment variables

Switch a Studio between environments (such as a development dataset and a production dataset) by reading values from `process.env`. Variables that the bundled Studio reads must be prefixed with `SANITY_STUDIO_`. The Studio loads `.env`, `.env.local`, and mode-specific files such as `.env.development` automatically. See [Environment Variables](https://www.sanity.io/docs/studio/environment-variables) for the full prefix rules and loading priorities.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
  dataset: process.env.SANITY_STUDIO_DATASET!,
  // ...
})
```

### Multiple workspaces

Run more than one Studio in the same project by passing an array of configs to `defineConfig`. Each workspace needs its own `name` and `basePath`. See [Workspaces](https://www.sanity.io/docs/studio/workspaces) for typical layouts and visibility controls.

### Property callbacks

Many configuration properties accept a `(prev, context) => ...` callback so that you can compose values conditionally, for example per workspace, per current user, or per schema type. See [Configuration](https://www.sanity.io/docs/studio/configuration) for the full list of callback-aware properties.

## Where to put custom code

Sanity Studio does not enforce a folder convention. The recommended pattern for Studios with more than a handful of customizations is to put custom code under a `src/` directory, grouped by customization type. For smaller Studios with one or two customizations, keeping files at the project root is also fine.

**Example src/ layout**

```text
my-studio/
├── sanity.config.ts
├── sanity.cli.ts
├── src/
│   ├── schemaTypes/
│   │   ├── index.ts
│   │   └── seoType/
│   │       ├── index.ts
│   │       └── seoInput.tsx
│   ├── actions/
│   ├── badges/
│   ├── structure/
│   └── plugins/
└── static/
```

### Co-locate components with the schemas they belong to

When a schema type has its own custom input, preview, or field component, put those files in the same folder as the schema, as shown in `src/schemaTypes/seoType/` above. Co-located customizations are easier to find later and easier to lift into a plugin if you want to share them across Studios.

### Related customization guides

- [Document actions](https://www.sanity.io/docs/studio/document-actions): custom buttons in the document pane action menu.
- [Form Components](https://www.sanity.io/docs/studio/form-components): custom inputs, previews, fields, and items.
- [Get started with Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-introduction): custom navigation, lists, and panes.
- [Installing and configuring plugins](https://www.sanity.io/docs/studio/installing-and-configuring-plugins): adding third-party customizations.
- [An opinionated guide to Sanity Studio](https://www.sanity.io/docs/developer-guides/an-opinionated-guide-to-sanity-studio): a worked example of the `src/` pattern in practice.



# Development

Sanity Studio is distributed as [a single package on npm](https://www.npmjs.com/package/sanity). It also comes with built-in tooling for local development based on [Vite](https://vite.dev). The package also exports the Studio as a React component, including a render function for mounting it on a DOM node in an HTML document.

> [!TIP]
> Protip
> Sanity Studio always connects to a dataset in the hosted Content Lake, also when run locally. Your content is never stored locally. This means that you can have differences in the schema between a local and a hosted Studio. If the Studio finds content in a document that doesn't match its schema, it will display a warning. 
> You can safely change schemas with the confidence that no existing content will be changed. To change content to comply with schema changes, you will need to run [a migration script](https://www.sanity.io/docs/content-lake/schema-and-content-migrations).

## Prerequisites

- An existing Studio project on your machine, created with the Sanity CLI. See [Installation](https://www.sanity.io/docs/studio/installation).
- Node.js v22.12 or later and a package manager such as npm. See [System requirements](https://www.sanity.io/docs/studio/system-requirements).

## Local development

You can start a local development server with the following command within the Studio project folder:

**npm**

```shell
# For Studios initiated with the CLI
npm run dev

# Alternative method
npx sanity dev
```

**pnpm**

```shell
# For Studios initiated with the CLI
pnpm run dev

# Alternative method
pnpm dlx sanity dev
```

**yarn**

```shell
# For Studios initiated with the CLI
yarn run dev

# Alternative method
yarn dlx sanity dev
```

**bun**

```shell
# For Studios initiated with the CLI
bun run dev

# Alternative method
bunx sanity dev
```

> [!TIP]
> Official VS Code extension
> Working in VS Code or Cursor? [Check out the official extension](https://open-vsx.org/extension/sanity-io/vscode-sanity/).

### To start the development server on a different port

The local development server will make the Studio available on `http://localhost:3333` by default. You can specify the port with the following command:

**npm**

```shell
# For Studios initiated with the CLI
npm run dev -- --port 3000

# Alternative method
npx sanity dev --port 3000
```

**pnpm**

```shell
# For Studios initiated with the CLI
pnpm run dev -- --port 3000

# Alternative method
pnpm dlx sanity dev --port 3000
```

**yarn**

```shell
# For Studios initiated with the CLI
yarn run dev -- --port 3000

# Alternative method
yarn dlx sanity dev --port 3000
```

**bun**

```shell
# For Studios initiated with the CLI
bun run dev -- --port 3000

# Alternative method
bunx sanity dev --port 3000
```

> [!WARNING]
> Gotcha
> To run the Studio on a different port locally, you will have to [enable CORS origins for that domain with authenticated requests enabled](https://www.sanity.io/docs/content-lake/cors). 
> You can add this with the Sanity CLI by running the following command in your Studio project folder:
> `npx sanity cors add http://localhost:3000 --credentials`

## Local production build

To build your Studio for production locally, run the following command in the Studio project folder:

**npm**

```shell
# For Studios initiated with the Sanity CLI
npm run build

# Alternative method
npx sanity build
```

**pnpm**

```shell
# For Studios initiated with the Sanity CLI
pnpm run build

# Alternative method
pnpm dlx sanity build
```

**yarn**

```shell
# For Studios initiated with the Sanity CLI
yarn run build

# Alternative method
yarn dlx sanity build
```

**bun**

```shell
# For Studios initiated with the Sanity CLI
bun run build

# Alternative method
bunx sanity build
```

The build command will bundle the Studio files into a `dist` folder by default. You can specify the production folder name (for example `public`) by passing it as a parameter:

**npm**

```shell
# For Studios initiated with the Sanity CLI
npm run build -- public

# To build the Studio to a folder named "public"
npx sanity build public
```

**pnpm**

```shell
# For Studios initiated with the Sanity CLI
pnpm run build -- public

# To build the Studio to a folder named "public"
pnpm dlx sanity build public
```

**yarn**

```shell
# For Studios initiated with the Sanity CLI
yarn run build -- public

# To build the Studio to a folder named "public"
yarn dlx sanity build public
```

**bun**

```shell
# For Studios initiated with the Sanity CLI
bun run build -- public

# To build the Studio to a folder named "public"
bunx sanity build public
```

This can be useful if your hosting provider requires a specific filename when you are [self-hosting the Studio](https://www.sanity.io/docs/studio/deployment).

### Preview a production build locally

To preview the local *production *build, you can run the following command:

**npm**

```shell
npx sanity preview

# To specify the folder ("./public") for the production build
npx sanity preview public
```

**pnpm**

```shell
pnpm dlx sanity preview

# To specify the folder ("./public") for the production build
pnpm dlx sanity preview public
```

**yarn**

```shell
yarn dlx sanity preview

# To specify the folder ("./public") for the production build
yarn dlx sanity preview public
```

**bun**

```shell
bunx sanity preview

# To specify the folder ("./public") for the production build
bunx sanity preview public
```

This will run a local server for the production build of the Studio on `http://localhost:3333`.

> [!WARNING]
> Gotcha
> It's easy to forget that any changes you make to the Studio files won't get reflected when running the preview of the production build. To enable hot-module reloading, you have to run `npm run dev` or `npx sanity dev`. 

## Customizing the built-in Vite configuration

To extend or change the built-in Vite configuration, you need [a configuration file for the Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli). Let's say you want to alias your root folder to enable relative imports like `import CustomComponent from '@/components/CustomComponent'`. The following code examples show you how you can overwrite certain properties in the Studio's Vite configuration to do so:

```javascript
// sanity.cli.js
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    // the rest of the config...
  },
  vite: {
    resolve: {
      alias: {
        '@': __dirname,
      },
    }
  },
})
```

You can learn more about configuring Vite [in their documentation](https://vite.dev/config/).



# Hosting and deployment

[Sanity Studio](https://www.sanity.io/studio) is an open-source React-based Single Page Application (SPA) that runs entirely in the browser and connects with Sanity's hosted APIs and Content Lake.

There are two primary ways of hosting Sanity Studio:

- Sanity hosting: Sanity serves the studio for you at a `my-company.sanity.studio` URL. One Sanity CLI command deploys it, and you can deploy and manage multiple studios for different environments or use cases under the same project.
- Self-hosting: you deploy the studio to any hosting platform that supports single-page application (SPA) routing.

Sanity hosting is the quickest way to make your studio accessible on the web. Self-hosting is the better fit when you want platform-specific features that Sanity hosting doesn't offer, or when you want the studio on your own domain.

> [!NOTE]
> You can also [embed Sanity Studio](https://www.sanity.io/docs/studio/embedding-sanity-studio) in an application as a dependency. Depending on your setup and configuration, you might lose features that are tied to the build tooling in the Sanity CLI.

[Upgrading Sanity Studio](https://www.sanity.io/docs/studio/upgrade)

## Prerequisites

This page assumes:

- A Sanity project and a studio you can build locally.
- The Sanity CLI, run through `npx sanity@latest` so that you're on the current version.
- For self-hosting: a host you control that supports single-page application routing, and permission to add its domain to the project's CORS origins.

The `--external`, `--url`, `--no-build`, and `--schema-required` flags on `sanity deploy` require a recent Sanity CLI, which `npx sanity@latest` always resolves to.

## Host with Sanity

**npm**

```shell
npx sanity@latest deploy
```

**pnpm**

```shell
pnpm dlx sanity@latest deploy
```

**yarn**

```shell
yarn dlx sanity@latest deploy
```

**bun**

```shell
bunx sanity@latest deploy
```

Running this command from your studio project folder builds and deploys your studio, making it available on a `*.sanity.studio` URL. When you deploy, you're asked to choose a unique hostname for your studio. Deployed studios also appear in [Dashboard](https://www.sanity.io/docs/dashboard).

> [!WARNING]
> Allowed characters in a studio hostname
> Studio hostnames can only contain letters, numbers, and hyphens. A hostname has to start and end with a letter or a number, so hyphens are allowed only in the middle.

You're also prompted to add your `appId` to your CLI configuration. This is optional. Adding it gives you fine-grained control over how and when your studio auto-updates, in the [project management settings](https://www.sanity.io/manage).

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production'
  },
  deployment: {
    appId: 'YOUR_APP_ID',
    autoUpdates: true
  }
})
```

> [!WARNING]
> Studio access
> The `sanity deploy` command works by building the source files in your Studio project into static files, which are then uploaded and served from your chosen `sanity.studio` domain.
> Logged-in access to your Studio, and private data in your Content Lake, is always secured by authentication. However, no authentication is involved when serving the built Studio's files. Make sure not to include any sensitive data, such as authentication tokens, in your Studio's configuration files.

> [!NOTE]
> Deployment size limit
> A single deployment is limited to 2 GB. The limit applies to the total size of the built files in the deployment, and deploys that exceed it are rejected with an error. The same limit applies to Studio deployments and App SDK app deployments.
> Most deployments are a few megabytes, so typical projects stay well below this limit.



## Undeploy the studio

**npm**

```shell
npx sanity@latest undeploy
```

**pnpm**

```shell
pnpm dlx sanity@latest undeploy
```

**yarn**

```shell
yarn dlx sanity@latest undeploy
```

**bun**

```shell
bunx sanity@latest undeploy
```

Run `npx sanity@latest undeploy` from your studio folder to change the hostname later, or to remove the studio from the web. The hostname is released asynchronously, so it can take a few minutes to become unavailable. After that, you can choose a new one the next time you deploy.

The `undeploy` command resolves the target from `deployment.appId` first, then `studioHost`, in your `sanity.cli.ts` configuration. Use the environment variable strategy under Host with Sanity in a CI/CD flow if you want to deploy and undeploy different studio instances.

## Host with Sanity in a CI/CD flow

You can host with Sanity automatically with continuous integration tools. This is convenient for updating the hosted studio when you push local changes to a source repository, or when you do manual releases. Add `sanity` as a development dependency and configure your CI/CD workflow to run `sanity deploy`. Keep the `sanity.cli.ts` config file in your studio folder.

If you need to accommodate test, staging, and production deployments, define `deployment.appId` and any other configuration in [environment variables](https://www.sanity.io/docs/studio/environment-variables), then read them in the config file:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: process.env.SANITY_STUDIO_PROJECT_ID,
    dataset: process.env.SANITY_STUDIO_DATASET,
  },
  deployment: {
    appId: process.env.SANITY_STUDIO_APP_ID,
    autoUpdates: true
  }
})
```

### Authorize studio deployments

You also need to provide an authorization token in the `SANITY_AUTH_TOKEN` environment variable. `sanity deploy` authenticates with your local user session, which isn't available in a CI/CD workflow. Create a deploy token in [the project management dashboard](https://www.sanity.io/manage).

### Deploy pre-built studios

If your CI/CD pipeline builds the studio in a separate step, use `--no-build` to skip the build and deploy the existing `dist/` directory:

**npm**

```shell
npx sanity@latest deploy --no-build
```

**pnpm**

```shell
pnpm dlx sanity@latest deploy --no-build
```

**yarn**

```shell
yarn dlx sanity@latest deploy --no-build
```

**bun**

```shell
bunx sanity@latest deploy --no-build
```

Schema extraction and manifest upload still run during deploy, and the manifest is written into `dist/static`, so the command modifies the directory rather than leaving it untouched. `dist/` must exist before you run this command. `--no-build` applies to Sanity-hosted deploys only: `--external` never builds, and the two flags can't be combined. See [Deploy](https://www.sanity.io/docs/cli-reference/deploy) for the full pipeline.

## Self-host the studio

Since the studio consists of static HTML, CSS, and JavaScript files and communicates with Sanity through our HTTP API, it can be hosted anywhere. Popular hosting services like [Vercel](https://vercel.com) and [Netlify](https://www.netlify.com) make it possible to automatically deploy new versions of your studio when you push it to a code repository like GitHub.

Two things have to be true when you host the studio yourself or with a service:

1. The server that delivers the studio files has to be configured for single-page application routing. If the requested URL path doesn't exist on the filesystem, it should serve `index.html` so the frontend router can handle the request. Most hosting services have a configuration option for this.
2. The domain where the studio is hosted has to be [added as a valid domain in the project's CORS settings](https://www.sanity.io/docs/content-lake/cors). For security, the Sanity API ensures that only approved studios can communicate with your project. This is in addition to other security measures such as user authentication, private datasets, and custom access rules.

If you host with Sanity, both are handled for you. If your host doesn't support single-page application routing, add a redirect rule so non-existent paths resolve to `index.html`. Check the documentation for your provider or server software.

> [!WARNING]
> Self-hosted studios must be registered
> [The Sanity Dashboard](https://www.sanity.io/docs/dashboard/dashboard-introduction) (including the [Content Agent](https://www.sanity.io/docs/content-agent)) is a separate experience accessed at [sanity.io/welcome](https://www.sanity.io/welcome). It is not part of your self-hosted studio URL.
> Serving the studio files yourself is not enough for Sanity to know your studio exists. Registering the studio is what makes it resolvable by Dashboard, Media Library, Canvas, and Agent Actions:
> - Run `npx sanity@latest deploy --external --url https://example.com/studio` from your studio folder. This records where your studio is served and deploys the workspace schema in the same run.
> - Add the [bridge script](https://www.sanity.io/docs/dashboard/dashboard-configure) (`https://core.sanity-cdn.com/bridge.js`) if your studio is embedded or not built with `sanity build`, which injects it for you.
> Adding a studio URL in Sanity Manage does not register the studio, and neither does serving manifest files from your own domain. Until `sanity deploy --external` has run, surfaces that need to resolve a workspace will not find one. A common symptom is the Media Library "in use" dialog, which lists the documents referencing an asset but can't open the studio when you click a row.
> Your studio itself keeps working either way. See [Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure) for the full walkthrough.

### Specify the base path

Normally, the studio expects to be hosted at the root level of its hostname, for instance `https://studio.example.com/`. To serve the studio on a subpath, such as `https://example.com/studio`, you need to edit the CLI configuration file. You'll find it as `sanity.cli.js` or `sanity.cli.ts` in the root of your studio project.

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  project: {
    basePath: '/studio'
  },
  // ...config continued
})

```

The studio can now be served from `https://example.com/studio`. This also changes the base path of static files.

Most cases where you embed the studio in another application require you to set `basePath`.

> [!WARNING]
> The CLI and workspace base paths are joined
> The `sanity.config.ts` file *also* has a `basePath` property: it defines the base path of the [workspace](https://www.sanity.io/docs/studio/workspaces), not the studio itself.
> In other words, the two base paths get joined together: if the CLI base path is set to `/studio` and the workspace base path is `/production`, the resulting base path for the `production` workspace is `/studio/production`.

Setting the `SANITY_STUDIO_BASEPATH` environment variable is an alternative way to define the base path for the studio, and it overrides any value set in the configuration file. The CLI warns you when both are set.

### Build the studio for hosting

**npm**

```shell
npx sanity@latest build

# Specify the build folder name to be "public"
npx sanity@latest build public
```

**pnpm**

```shell
pnpm dlx sanity@latest build

# Specify the build folder name to be "public"
pnpm dlx sanity@latest build public
```

**yarn**

```shell
yarn dlx sanity@latest build

# Specify the build folder name to be "public"
yarn dlx sanity@latest build public
```

**bun**

```shell
bunx sanity@latest build

# Specify the build folder name to be "public"
bunx sanity@latest build public
```

Run `npx sanity@latest build` from your studio folder to generate the files for hosting. This outputs the files to the `dist/` directory by default. Sometimes your environment requires another directory name, for instance `public`. You can specify this by entering the desired name after the `build` command.

Once the build is complete, the directory can be uploaded and hosted from any web host where you can control redirects for a single-page application, like [Vercel](https://vercel.com), [Netlify](https://netlify.com), or [Cloudflare](https://pages.cloudflare.com/).

### Register the studio and deploy the schema

One command does both jobs. `sanity deploy --external` records where your studio is served and deploys the workspace schema in the same run. It uploads no files, and it doesn't build: `--external` can't be combined with the build flags, so `sanity build` stays a separate step.

Without this step, the features that resolve a workspace, including Dashboard, Canvas, Media Library, and Agent Actions, can't discover your studio or its schema.

After building your studio and uploading the files to your own host, register it:

**npm**

```shell
# Register a studio you host yourself and deploy its schema
npx sanity@latest deploy --external --url https://example.com/studio
```

**pnpm**

```shell
# Register a studio you host yourself and deploy its schema
pnpm dlx sanity@latest deploy --external --url https://example.com/studio
```

**yarn**

```shell
# Register a studio you host yourself and deploy its schema
yarn dlx sanity@latest deploy --external --url https://example.com/studio
```

**bun**

```shell
# Register a studio you host yourself and deploy its schema
bunx sanity@latest deploy --external --url https://example.com/studio
```

Run `npx sanity@latest schemas deploy` only when you want to update the schema without deploying a studio, such as a schema-only pipeline. See [Schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment) for its options.

`--external` tells Sanity that the studio is hosted somewhere other than Sanity's own hosting, so nothing is uploaded. It records the studio's location and links it to the schema deployed in the same run, which is what makes the studio resolvable from Dashboard, Media Library, Canvas, and the App SDK.

By default, a schema failure inside `deploy` is reported but doesn't stop the deployment, and the command still exits 0. Add `--schema-required` in a pipeline so that a schema failure fails the command.

`--url` takes the full URL where the studio is served, including any base path. If you set a `basePath` of `/studio`, as covered under Specify the base path, the registered URL has to include it.

You can set `studioHost` in `sanity.cli.ts` instead of passing `--url` on every run. For an external studio, the CLI validates it as a full URL and uses it as the registered location.

`studioHost` is deprecated in favor of `deployment.appId`, which takes precedence when both are set. `deployment.appId` identifies the application; the URL still comes from `--url` or `studioHost`.

Run this command on every deployment. Registration itself is idempotent and persists, so `--url` is only needed on the first run or when the URL changes. What each run updates is the schema and the manifest. See the [Deploy CLI command reference](https://www.sanity.io/docs/cli-reference/deploy) for the full option list.

### Environment variables

Sometimes you want to configure the `projectId`, `dataset`, or `studioHost` specified in `sanity.cli.ts` and `sanity.config.ts` at build time. This is useful for building multiple studios from the same schema and code, for different environments. See the documentation on [environment variables](https://www.sanity.io/docs/studio/environment-variables) for your options.

## Errors you might see when deploying

Two of the most common failures come from the values you pass to the deploy command:

- `Hostnames can only contain letters, numbers, and hyphens.` The hostname you chose uses characters outside that set, or starts or ends with a hyphen.
- `URL must use http or https protocol`. The value passed to `--url` isn't an http or https URL. The same check runs on a `studioHost` set in the config file when you deploy with `--external`.

Two more to expect: `--no-build` fails when the output directory holds no built studio, and any deploy fails when the CLI can't find a project ID, which it reads from `api.projectId` in `sanity.cli.ts`.

## Manage registered studios

Every studio registered to a project is listed on the project's **Studios** tab in [Sanity Manage](https://www.sanity.io/manage). The list covers studios deployed with `sanity deploy` and self-hosted studios registered with `sanity deploy --external`. Each row links to that studio, and the row's context menu lets you rename it, control whether it appears in Dashboard, and remove it from the project.

### How the Open Sanity Studio button picks a destination

The project page in Manage shows a single button for opening a studio. Its destination is a rule rather than a setting, so you can't pin it to a particular studio. Manage resolves it from the studios registered to the project:

- No registered studios: no button appears.
- Exactly one self-hosted studio: **Open Sanity Studio** opens that studio, no matter how many Sanity-hosted studios are also registered.
- No self-hosted studios and exactly one Sanity-hosted studio: **Open Sanity Studio** opens that studio.
- Any other combination, such as two self-hosted studios or two Sanity-hosted studios and no self-hosted one: **View Studios** appears instead and links to the **Studios** tab.

Because a single self-hosted studio takes precedence, registering one changes where the button goes. This is expected behavior, not a regression. To reach a specific studio regardless of the rule, open it from its row on the **Studios** tab.

> [!NOTE]
> Dashboard visibility doesn't change the button
> The rule counts every studio registered to the project. Hiding a studio with **Hide in Dashboard** leaves it registered, so the button resolves to the same destination as before.

### Remove a studio from a project

On the **Studios** tab, open a studio's context menu, click **Remove studio**, and confirm. Removing a studio de-registers it from the project and can't be undone. To register a self-hosted studio again, run `npx sanity@latest deploy --external` from the studio folder.

For a self-hosted studio, removing the entry affects the registration only:

- Your hosting keeps serving the studio at its own URL. Your datasets, content, and any in-progress edits are untouched. They live in Content Lake, independent of this list.
- The studio no longer appears in Dashboard, and features that rely on its registered schema, including Content Agent, Canvas, and Agent Actions, can no longer reach it.
- The studio stops counting toward the button rule, which can change what the project page shows.

> [!WARNING]
> Taking a Sanity-hosted studio offline
> To remove a Sanity-hosted studio from the web, run `npx sanity@latest undeploy` from the studio folder. That command frees the hostname, so you can choose a new one the next time you deploy.

## GraphQL

Deploying a GraphQL API is covered in the [GraphQL documentation](https://www.sanity.io/docs/content-lake/graphql).

## Next steps

[Deploy CLI command reference](https://www.sanity.io/docs/cli-reference/deploy)

[Access your data (CORS)](https://www.sanity.io/docs/content-lake/cors)

[Set up and configure Dashboard](https://www.sanity.io/docs/dashboard/dashboard-configure)



# Embedding Sanity Studio

Sanity Studio is a React application distributed as [a single dependency on npm](https://www.npmjs.com/package/sanity). In principle, this means you can embed the Studio in any web application, as long as you can control the routing to redirect all Studio URLs to the page where it's hosted.

**We recommend deploying your studio to Sanity directly** with the `npx sanity deploy` command. This gives you the latest integrations with the greater Sanity ecosystem.

If you must embed a studio within your application, follow the instructions below.

## Prerequisites

- A Sanity project with a project ID and dataset name. See [Installation](https://www.sanity.io/docs/studio/installation) to create one.
- An application where you control routing, so that all of the Studio's sub-routes resolve to the page hosting the Studio.
- Node.js v22.12 or later and a package manager such as npm. See [System requirements](https://www.sanity.io/docs/studio/system-requirements).

## Adding the Studio as a dependency with `npm`

If you work on a project where [node package manager](https://www.npmjs.com/) is supported, you can add the Studio as a dependency using the following command:

**npm**

```shell
npm install sanity@latest
```

**pnpm**

```shell
pnpm add sanity@latest
```

**yarn**

```shell
yarn add sanity@latest
```

**bun**

```shell
bun add sanity@latest
```

> [!TIP]
> Protip
> If you plan to do Studio customization, then it can be useful to install `@sanity/ui` and `@sanity/icons` as well.

## Accommodating the Studio

> [!WARNING]
> Gotcha
> It's easy to forget, but you will always have to add the domain where you host your Studio to [your project's CORS origins settings](https://www.sanity.io/docs/content-lake/cors) with authenticated requests **enabled**.

### Styling

The Studio is built as a responsive web app. For the best editor experience, it should take the full width and height of the browser window. This means that you have to make sure that the DOM node that the Studio is mounted on is styled accordingly:

```css
/* This assumes no margin or padding on #app's parent(s) */
#app {
  height: 100vh;
  max-height: 100dvh;
  overscroll-behavior: none;
  -webkit-font-smoothing: antialiased;
  overflow: auto;
}

```

### Routing

If you embed the Studio inside of another app, it's likely you want to access it on a sub-route, e.g., `/studio` or `/admin`. This means that you have to:

1. Add this route to `basePath` in the Studio's configuration object.
2. Make sure that the app's routing redirects all the Studio's sub-routes to the page/view where the Studio is mounted.

Frontend frameworks with file-based routing like [Next.js](https://nextjs.org/docs/routing/dynamic-routes#optional-catch-all-routes), [Nuxt.js](https://nuxtjs.org/docs/features/file-system-routing/#unknown-dynamic-nested-routes), [Svelte](https://kit.svelte.dev/docs/advanced-routing), [Remix](https://remix.run/docs/en/v1/guides/routing#splats), [Astro](https://docs.astro.build/en/core-concepts/routing/#rest-parameters), and others, have conventions for “catch-all,” “rest,” or “splat” routes. These can be used to make sure that all routes under the Studio's `basePath` will be redirected and resolved by the Studio application.

If you are embedding the Studio outside of a framework like this, then you need to control the redirects on the server or hosting level. For example, if you host with [Netlify](https://docs.netlify.com/routing/redirects/), then you need to add a setting like this:

```toml
# netlify.toml
[[redirects]]
  from = "/admin/*"
  to = "/admin"
  status = 200
  force = true
```

### Rendering the Studio in React applications

The `sanity` package exports a `<Studio />` component that renders a Studio given a `config` object like so:

```jsx
// StudioRoute.tsx
import { defineConfig, Studio } from "sanity";

const config = defineConfig({
  projectId: "your_project_id",
  dataset: "your_dataset",
  basePath: "/some-route-in-your-app"
});

export default function StudioRoute() {
  return <Studio config={config} />
}
```

As mentioned in "Routing" above, you need to ensure all sub-routes of the `basePath` are redirected to this route.

## Embedding Sanity Studio in a Next.js app

If you want to embed Sanity Studio in a Next.js project, then you can use the official `next-sanity` library. In addition to making embedding the Studio easier, it also comes with tools for live preview and other useful things.

[Learn more about next-sanity](https://www.sanity.io/docs/nextjs/embedding-sanity-studio-in-nextjs)

## Rendering the Studio in non-React applications

> [!CAUTION]
> This approach is deprecated
> The `esm.sh/build` service has been deprecated. We suggest [deploying your Studio](https://www.sanity.io/docs/studio/deployment) to Sanity when possible. If you must embed and aren’t in an environment where you can do so in a React application, you may wish to pre-bundle Studio with a tool like [Vite](https://vite.dev/) or similar.

If you aren't in a React project, then you can use the `renderStudio` function to mount it on a DOM node:

```javascript
const {default: build} = await import("https://esm.sh/build")

const mod = await build({
  dependencies: {
    "sanity": "^3.27.0",
    "@sanity/vision": "^3.27.0",
  },
  source: `
      import { defineConfig, renderStudio } from "sanity";
      import { structureTool } from "sanity/structure";
      import {presentationTool} from 'sanity/presentation';
      import { visionTool } from "@sanity/vision";

      const config = defineConfig({
        basePath: '/',
        projectId: "pv8y60vp",
        dataset: "production",
        schema: {
          types: [
            {
              type: "document",
              name: "post",
              title: "Post",
              fields: [
                {
                  type: "string",
                  name: "title",
                  title: "Title"
                }
              ]
            }
          ]
        },
        plugins: [structureTool(), presentationTool({}), visionTool()]
      });
const div = document.createElement('div')
document.body.innerHTML = ''
document.body.appendChild(div)
export const render = () => renderStudio(div, config);
  `,
  // for types checking and LSP completion
  types: `
    export function render(): string;
  `,
});

// import module
const { render } = await import(mod.bundleUrl);

render()
```

### Next.js

For Next.js applications, the `next-sanity` toolkit provides a `<NextStudio />` component that wraps the Studio in a Next.js-friendly layout with mobile viewport handling, loading states, and metadata configuration. The recommended approach is to mount the Studio on an App Router catch-all route (e.g., `/studio`). This works even if the rest of your application uses Pages Router. For the complete setup guide, see [Embedding Sanity Studio in Next.js](https://www.sanity.io/docs/nextjs/embedding-sanity-studio-in-nextjs).



# Upgrading Sanity Studio

Sanity Studio is distributed as [an npm package](https://www.npmjs.com/package/sanity), which means that upgrades are done as with any other dependency in `package.json`.

Upgrading with `sanity@latest` installs the current major version, which is v6. If your project is on an older major version, read the migration guide for each major you pass through first: [Studio v3 to v4](https://www.sanity.io/docs/help/v3-to-v4), [Studio v4 to v5](https://www.sanity.io/docs/help/v4-to-v5), and [Studio v5 to v6](https://www.sanity.io/docs/help/v5-to-v6).

To upgrade the core package for Sanity Studio, run:

**npm**

```shell
npm install sanity@latest
```

**pnpm**

```shell
pnpm add sanity@latest
```

**yarn**

```shell
yarn add sanity@latest
```

**bun**

```shell
bun add sanity@latest
```

Make sure that your lock file (for example, `package-lock.json`) has been updated as well.

To confirm the upgrade, print the installed versions:

**npm**

```shell
npx sanity versions
```

**pnpm**

```shell
pnpm dlx sanity versions
```

**yarn**

```shell
yarn dlx sanity versions
```

**bun**

```shell
bunx sanity versions
```

Then restart your development server with `npx sanity dev` and check that the studio loads with the new version.

## Upgrading plugins and other dependencies for Sanity Studio

As with the core `sanity` dependency, plugins and other dependencies are also upgraded by installing newer versions. You can also look into tooling like [npm-upgrade](https://www.npmjs.com/package/npm-upgrade) and [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) that give you interactive CLI workflows for upgrading your dependencies. Code editors like VS Code also have [extensions that list your dependencies and update them from the editor](https://marketplace.visualstudio.com/items?itemName=idered.npm).

## Deploying upgrades

After you have upgraded your Studio project's dependencies, you can deploy the new version depending on your deployment strategy:

- Run the `sanity deploy` command in your command line.
- Or, check the changes into Git and push/merge to the branch that deploys the Studio to production.

We advise you to always check and track your Studio code into Git and push it to a remote Git repository. That way, you won't accidentally lose your work.

[Learn more about hosting and deployment](https://www.sanity.io/docs/studio/deployment)

## Automatic Studio upgrades

### Using the built-in `autoUpdates` configuration property

If you deploy your Studio using the Sanity build tools, you can configure it to stay up to date automatically. Auto-updates apply new patch and minor releases. They also apply major releases that have no runtime effects, such as a change to the required Node.js version. Major releases that change how your Studio code behaves are not applied automatically.

[Read more about auto-updating studios](https://www.sanity.io/docs/studio/latest-version-of-sanity)

### Using Renovatebot

If you deploy Sanity Studio from GitHub (or other compatible platforms), then you can automate upgrades by setting up [Renovatebot](https://github.com/renovatebot/renovate). We maintain the preset configuration for Sanity projects that you can reuse.

Go to [the Sanity config presets on GitHub and follow the instructions](https://github.com/sanity-io/renovate-config?tab=readme-ov-file#usage) to set it up.

## Stay on top of what's new in the changelog

We publish updates to Sanity Studio up to every week. For each release of the Studio, we'll also post [release notes on GitHub](https://github.com/sanity-io/sanity/releases) and in [our changelog](https://www.sanity.io/changelog) (that also covers other packages and APIs).

Changelog entries on sanity.io also mark any documentation article that was affected by the upgrade. Each affected article links its related entries in a "Related changelog entries" section.

When a release has breaking changes, the changelog entry includes migration instructions.



# Environment variables

Environment variables let you configure Sanity Studio differently depending on the context it runs in, such as development or production. They keep values like API URLs and titles out of your code, so you can change them per environment without editing your configuration. This article covers how the Studio picks up, exposes, and replaces environment variables, and how to work with them safely.

**Note:** Make sure you are using Sanity Studio v3.5.0 or later to make full use of environment variables.

## Exposed variables

Environment variables prefixed with `SANITY_STUDIO_` are automatically picked up by the Sanity CLI tool, development server, and bundler.

Any found environment variables are available as `process.env.SANITY_STUDIO_VARIABLE_NAME`, even in browser code.

By requiring this `SANITY_STUDIO_` prefix, we prevent unrelated (and potentially sensitive) environment variables from getting exposed to the browser bundle.

## Static replacement

It is important to note that these variables are **statically replaced** during production. It is therefore necessary to always reference them using the full static string. For example, dynamic key access like `process.env[key]` will not work (they *might* be accessible this way in development, but will fail in production).

Similarly, logging or iterating over `process.env` will not give you consistent results in development and production. In production, referencing `process.env` directly will fail with an error saying `process` is not defined, as all the values are **statically replaced** during the build. In other words:

```javascript
// In development:
const studioTitle = process.env.SANITY_STUDIO_TITLE

// In production:
const studioTitle = "the value of the env var"
```

Note that during a build, it will also replace these references appearing in JavaScript strings. This should be a rare case, but it can have unintended side effects. You may see errors like `Missing semicolon` or `Unexpected token`. One way to work around this behavior is to break the string up with a Unicode zero-width space, e.g., `'process\u200b.env.SANITY_STUDIO_FOO'`.

## Keeping secret things secret

We recommend using the [@sanity/studio-secrets](https://github.com/sanity-io/plugins/tree/main/plugins/%40sanity/studio-secrets) plugin, which gives you hooks and UI components for handling secrets.

Make sure that your `.env.local` files are ignored by your version control system (usually git), and do not put sensitive information or keys into `.env` files that are committed.

## Loading variables from `.env` files

Sanity will read `.env` files by default and make their variables available for the development server, production builds, command-line actions, and similar. Note that you still have to follow the variable naming conventions mentioned above. We also support env loading priorities for situations where you want to differentiate between sharing certain variables in all environments and overwriting them for production builds.

```sh
.env               # loaded in all cases
.env.local         # loaded in all cases, ignored by git
.env.[mode]        # only loaded in specified mode
.env.[mode].local  # only loaded in specified mode, ignored by git

```

Also, Sanity uses [dotenv-expand](https://github.com/motdotla/dotenv-expand) to expand variables out of the box. To learn more about the syntax, check out [their docs](https://github.com/motdotla/dotenv-expand#what-rules-does-the-expansion-engine-follow).

Manually declared environment variables (outside of `.env` files) take precedence, overriding any values set in .env files.

## Modes

By default, the `build` and `deploy` commands run in `production` mode, while all other commands run in `development` mode. Other commands can be run in production mode by setting `NODE_ENV` to `production` (note that only this value is supported; for other modes, use `SANITY_ACTIVE_ENV`).

This means when running `sanity build`, it will load the environment variables from `.env.production` if that file exists. For example:

```sh
# .env.production
SANITY_STUDIO_TITLE=My Studio
```

Given this environment variable, you could then render the title in your app using `process.env.SANITY_STUDIO_TITLE`.

In some cases, you may want to run `sanity build` with a different mode to render a different title. You can overwrite the default mode used for a command by setting an environment variable named `SANITY_ACTIVE_ENV`. For example, if you want to build your Studio in staging mode:

```sh
SANITY_ACTIVE_ENV=staging sanity build
```

And create an `.env.staging` file:

```sh
# .env.staging
SANITY_STUDIO_TITLE=My Studio (staging)
```

## Best practices

We encourage you to keep environment variables to a minimum, and not spread them throughout the code base. Common (and valid) use cases are things like configuration files.

To make it easier to tell which environment variables are used, and to keep things tidy, we recommend having a single file that re-exports environment variables to the rest of your code. For instance:

```typescript
// src/environment.ts
export const myStudioTitle = process.env.SANITY_STUDIO_TITLE
export const myCompanyApiUrl = process.env.SANITY_STUDIO_COMPANY_API_URL

```

Similarly, plugins should generally never use environment variables directly. Instead, they should take a configuration object which the user can then choose to pass environment variables to:

```typescript
import {somePluginApiUrl} from './src/environment'

defineConfig({
  plugins: [
    // ...
    somePlugin({
      // process.env.SANITY_STUDIO_SOME_PLUGIN_API_URL
      apiUrl: somePluginApiUrl
    })
  ]
})
```

## Differences from Vite

Sanity's environment variable behavior is heavily inspired by (and partially powered by) Vite. There is, however, one key difference:

Vite exposes environment variables under `import.meta.env`. While Sanity *also* lets you access them that way, we strongly recommend that you access them using `process.env`.

Using `process.env` allows us to expose the same variables to both the browser and Node.js/Node.js-powered tools without too much work. It also eases cross-environment migrations, such as moving from the default Vite-based bundler to (for instance) an embedded setup inside of Next.js.

## Programmatic usage

Should you want to reuse the environment variable handling in other contexts (such as your own scripts or a different bundler), you can import and utilize the `getStudioEnvironmentVariables()` method from `sanity/cli`:

```typescript
import {getStudioEnvironmentVariables} from 'sanity/cli'

console.log(getStudioEnvironmentVariables())
// {SANITY_STUDIO_SOME_VAR: 'yourVariableValue'}

```

Note that `.env` files are not loaded by default when using this method. To do so, pass an `envFile` option:

```typescript
import {getStudioEnvironmentVariables} from 'sanity/cli'

console.log(
  getStudioEnvironmentVariables({
    envFile: {
      mode: 'production',
      envDir: '/path/to/some-dotenv-root'
    }
  })
)

```

For usage in bundlers (such as Vite's `define` option or Webpack's `DefinePlugin`), you'll usually want the keys to be fully qualified with the `process.env` prefix, and the values to be JSON-encoded. The method can do all of this for you:

```typescript
import {getStudioEnvironmentVariables} from 'sanity/cli'

console.log(
  getStudioEnvironmentVariables({
    jsonEncode: true,
    prefix: 'process.env.'
  })
)

```

## Built-in Studio environment variables

The following environment variables are integrated in the Studio code base and will be picked up when specified in a `.env` file (see [Loading variables from .env files](https://www.sanity.io/docs/studio/environment-variables) above). Note that they only apply when using the `sanity` CLI; if you render using your own bundler, these will not work.

```text
SANITY_STUDIO_BASEPATH            Sets the base path for the studio
SANITY_STUDIO_SERVER_HOSTNAME     Hostname for the development/preview server
                                  (localhost by default)
SANITY_STUDIO_SERVER_PORT         Port number for the development/preview server
                                  (3333 by default)
SANITY_STUDIO_REACT_STRICT_MODE   Enable React strict mode. Its use is discouraged
                                  unless you know what you're doing, as it leads
                                  to worse performance in development
```



# Using TypeScript in Sanity Studio

TypeScript is a superset of JavaScript that adds optional static typing to the language. You can learn more about TypeScript in [their getting started guide](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html).

If you initiate a Sanity Studio with the CLI, then TypeScript will be the default. Sanity Studio uses [Vite](https://vitejs.dev/guide/features.html#typescript) to perform the transpilation of TypeScript files.

> [!WARNING]
> Gotcha
> If you customize the Studio with your own React components, that is, using [JSX syntax](https://reactjs.org/docs/introducing-jsx.html), you will have to (re)name the file as `.tsx`. 

## Inline documentation with TSDoc

The Sanity Studio codebase uses TSDoc for inline documentation. This is still a work in progress. However, you can already inspect if an API is considered internal, beta, or public:

- `@internal`: Not considered as stable for public consumption. If you rely on internal APIs, they might break between minor [semver](https://semver.org/) releases
- `@beta`: APIs that we intend to ship as public-facing, but that we are testing externally and might be subject to change between minor [semver](https://semver.org/) releases. We will make our best effort to document breaking changes for `@beta` APIs in the release notes.
- `@public`: These are public APIs that you can use confidently. Breaking changes will only happen in major [semver](https://semver.org/) releases and will be documented.

## Default `tsconfig.json`

If you initiate a studio project using the Sanity CLI and don't opt out of TypeScript, then it will generate the following `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "Preserve",
    "moduleDetection": "force",
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
```



# Understanding the latest version of Sanity

Keeping up with frequent releases and making sure your editorial teams get access to all the latest improvements and bug fixes is more convenient than ever before.

With auto updates enabled, the core Sanity Studio app will automatically be kept up to date whenever a patch and minor release drops, while your custom code remains untouched and continues to work seamlessly. We also update to new major versions when there are no runtime effects, like Node.js version changes. Instead of building and re-deploying your Studio for each new version, the core Sanity Studio application is served on-demand to your browser, which means you'll always get access to the latest updates as they are released.

No breaking changes. Just the good stuff.

## Prerequisites

- Using auto-updating Studios requires a browser that supports [import maps](https://caniuse.com/import-maps). This feature has been considered a [baseline](https://github.com/web-platform-dx/web-features/blob/main/docs/baseline.md) feature in modern browsers since March 2023, and is supported in all the latest versions of common browsers.
- Auto-updates is only supported for Studios compiled with Sanity build tooling. I.e., running [sanity deploy](https://www.sanity.io/docs/cli-reference/deploy) or [sanity build](https://www.sanity.io/docs/cli-reference/build) in your command line. Third-party build tools and embedded Studios are not currently in scope for this feature.

> [!NOTE]
> Build or deploy?
> For the auto-update feature to work, you must compile your Studio using the build tooling provided in the core [Sanity Studio](https://github.com/sanity-io/sanity) package. Somewhat confusingly, we refer to both the [sanity deploy](https://www.sanity.io/docs/cli-reference/deploy) and [sanity build](https://www.sanity.io/docs/cli-reference/build) commands in this article, but what's the difference?
> In short, the `autoUpdates` config parameter is used by both commands. If your Studio is hosted on a free `*.sanity.studio`-domain you probably use `sanity deploy` to build and deploy your Studio in one fell swoop, while if you are hosting the Studio elsewhere chances are that you compile the Studio with `sanity build`, and then deal with deployment with a custom workflow.

## Enable automatic updates

As of Sanity Studio version 3.57.3, all new projects are initialized with automatic updates enabled. You can check by viewing the `sanity.cli.ts` file in your Studio project directory.

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: '<your-project-dataset>'
  },
  deployment: {
    /**
     * Get the appId for a previously deployed Studio under the "Studio" tab for your project in sanity.io/manage
     * Note: this is required for fine-grained version selection
     */
    appId: '<your-studio-app-id>',
    /**
     * Enable auto-updates.
     * Learn more at https://www.sanity.io/docs/studio/latest-version-of-sanity
     */
    autoUpdates: true,
  }
})

```

**sanity.cli.ts (prior to v4.9.0)**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: '<your-project-dataset>'
  },
  /**
   * Enable auto-updates for studios.
   * Learn more at https://www.sanity.io/docs/cli#auto-updates
   */
  autoUpdates: true,
})

```

If you don't see the `deployment.autoUpdates` option set to `true`, add it to enable automatic updates. Make sure you've confirmed that your deployment and development environments meet the prerequisites above, and take note of the caveats at the end of this article.

## Fine-grained version selection

When auto-updating is enabled, your Studio will by default be kept up to date with what is currently published as the `latest` version on [npm](https://www.npmjs.com/). You can also select the update schedule that best suits your needs.

> [!NOTE]
> Version selection requires Sanity CLI v4.9.0 and appId
> Fine-grained version selection requires `appId` to be added to the `deployment` section in `sanity.cli.ts`. Support for `appId` was introduced in version 4.9.0 of `@sanity/cli`. Check the current CLI version by running `npx sanity versions`.

The available options are:

- Latest (Default): The latest official Studio version, usually released once a week.
- Next: Early bird updates with bug fixes and changes as they're merged into the Studio project and scheduled for the upcoming release. Select this if you want to stay on the absolute latest changes.
- Stable: Select if you want to stay current with more hardened, battle-tested versions. Stable versions will typically be released when a version has been in the latest channel for about a week without any reported issues.
- Pin a specific version: Allows you to set a fixed version. Note that this will effectively opt you out of receiving future updates, so we recommend using this option with caution, and make sure to update the pinned version on a regular schedule.

> [!NOTE]
> We'll only do safe downgrades
> A deployed Studio will never run with a version that predates the version it had at the time of deploy. This means that you will not be able to downgrade further than to the Sanity version you had when deploying your Studio.
> For example, if you develop against `sanity@4.5.0` locally, and then deploy your auto-updating Studio, your deployed Studio will not be able to run with a version that predates `v4.5.0`.
> So even if you downgrade to `v4.4.0` in [sanity.io/manage](https://www.sanity.io/manage), your Studio will still run with `v4.5.0`.
> This also means that if you deploy with a more recent version than the version you have pinned, the pinned version will be ignored entirely, and your Studio will be served the version of Sanity it had installed during local development.

To select a tag or version, navigate to the **Studios** tab for your project in [sanity.io/manage](https://www.sanity.io/manage).

![A screenshot of the auto-update channel and version selection UI in sanity.io/manage](https://cdn.sanity.io/images/3do82whm/next/805d1fb7e755427d1d56017d3b3f9bc0411b772d-1876x926.png)

Each available update channel is kept in sync with their corresponding [dist tag](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag#purpose) on npm, so running `npm install sanity@latest` will install the exact same version your auto-updated Studio are currently running when `latest` is selected. Similarly, the `next` and `stable` channels are also available on npm with `npm install sanity@next` and `npm install sanity@stable`. This is kept in sync with the `stable` [tag](https://docs.npmjs.com/cli/v8/commands/npm-dist-tag#purpose) on npm.

> [!NOTE]
> Auto-updating self-hosted Studios
> Self-hosted Studios with auto-updates enabled will make every attempt to update to the version displayed in the channel selection interface, but in some cases where additional dependencies are required, they will remain on their past version until updated manually. One example of this is from [v4 to v5](https://www.sanity.io/docs/changelog/fd3ab62e-9264-4e7b-825a-fd4f99abd481), where you'll need to update and redeploy to get back on the auto-update channel.

## Developing auto-updating Studios

Your local development process remains unchanged. You will still install the latest version of the Sanity Studio package locally and run your dev server on localhost, quite possibly at port 3333. When you are ready to commit, make sure `deployment.autoUpdates` is enabled and build and deploy your Studio.

If you have auto-updating enabled and are developing locally against a version that is not up to date, you will receive a warning in your build step to ensure you are aware of any potential discrepancies.

> [!TIP]
> Working with Turbo
> For auto-updating Studios, the `sanity dev` script will check which dependencies you have installed and may occasionally prompt you to keep your Sanity Studio dependencies up to date. If you use a tool like **Turbo** to run the `sanity dev` script, please [make sure it's able to show interactions.](https://turborepo.com/docs/reference/configuration#interactive)

## Opting out

If you need to support older browsers without support for import maps, if you have customized your Studio using internal APIs, or if you need full control over the Studio dependencies, you can opt out of auto-updates. If you decide to opt out of auto-updates, set `deployment.autoUpdates: false` in your `sanity.cli.ts` configuration file, and then build and deploy again.

**sanity.cli.ts**

```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: '<YOUR_DATASET>',
  },
  deployment: {
    autoUpdates: false,
  }
})
```

**sanity.cli.ts (prior to v4.9.0)**

```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: '<YOUR_DATASET>',
  },
  autoUpdates: false,
})
```

## Caveats and key takeaways

- Auto-updating is currently only supported for Studios built with the `sanity build` command, or deployed using `sanity deploy`. Other build processes that do not use the build or deploy commands are not supported.
- Auto-updates requires a browser that supports [import maps](https://caniuse.com/import-maps), which has been considered a [baseline](https://github.com/web-platform-dx/web-features/blob/main/docs/baseline.md) feature in modern browsers since March 2023, and is supported in all the latest versions of common browsers.
- When developing with auto-updates enabled, you may receive warnings in your build step if your local version is out of sync with the auto-updated deployed version. Keep an eye out for these to ensure you're aware of any discrepancies between your development and production environments.
- Auto-updating keeps your Studio current with patch releases and minor updates. We also update to new major versions when there are no runtime effects, like Node.js version changes. Rest assured that breaking changes will be announced clearly and in a timely fashion, as always.
- To opt out of auto-updating, rebuild and redeploy your Studio using the  feature flag or the `deployment.autoUpdates` configuration property set to `false`. Changes will take effect for your editorial teams once the new build has been successfully deployed.
- Auto-updating keeps your Studio up to date, but you're still responsible for updating and maintaining any custom code, plugins, or configurations layered on top of the base Sanity Studio.

The latest version of Sanity offers a range of new features and improvements to enhance your content management experience.



# System requirements

## System requirements

Sanity Studio is a web application that runs in the browser. This page covers the network, browser, and infrastructure requirements for running Studio in managed environments.

Share this page with your IT or network security team if they manage firewalls, proxies, or endpoint policies.

### Network requirements

Studio communicates with Sanity services over HTTPS (port 443) with TLS 1.2 or later. All connections require **HTTP/2 or later**. HTTP/1.1 causes degraded performance or complete failure.

#### Domains to allowlist

For most deployments, allowlisting `*.sanity.io` and `*.sanity-cdn.com` covers all required domains. If your network policy requires specific entries:

| Domain | Purpose |
| --- | --- |
| *.api.sanity.io | Content Lake API, authentication, AI features, telemetry |
| *.apicdn.sanity.io | API CDN (cached reads) |
| cdn.sanity.io | Image and file asset CDN |
| sanity-cdn.com | Module CDN for auto-updating Studios |
| core.sanity-cdn.com | Core UI bridge script |
| m.sanity-cdn.com | Video streaming CDN |
| media.sanity.io | Media Library UI |
| manage.sanity.io | Project management |

**Optional:**

| Domain | Purpose |
| --- | --- |
| sentry.sanity.io | Error reporting. No functional impact if blocked. |
| maps.googleapis.com | Google Maps Static API. Only required if your schema uses the geopoint input type. Requires a separate Google API key. |

Real-time features use Server-Sent Events (SSE) over the same API domains on port 443. No additional ports or protocols are required.

Studio does not load resources from third-party domains. The geopoint input is an exception: it requires Google Maps. AI features route through api.sanity.io.

#### Webhook egress IPs

If your infrastructure receives [webhooks](https://www.sanity.io/docs/webhooks) from Sanity, allowlist the IP addresses published at [sanity.io/files/webhooks-egress-ips.txt](https://www.sanity.io/files/webhooks-egress-ips.txt).

### Browser requirements

Studio works with all recent versions of Chrome, Edge, Firefox, and Safari, on both desktop and mobile.

### Common blockers

These network configurations are known to cause issues with Studio:

| Configuration | Problem |
| --- | --- |
| VPN with HTTP downgrade | Some VPNs downgrade connections from HTTP/2 to HTTP/1.1. |
| TLS-inspecting proxy | Proxies that terminate and re-establish HTTPS connections (such as Zscaler) can break HTTP/2. |
| DNS filtering | Blocking *.sanity.io prevents Studio from reaching Sanity services. |

#### Diagnosing HTTP/1.1 issues

Open your browser's DevTools, go to the **Network** tab, and check the **Protocol** column. If connections show `h1` or `http/1.1`, Studio is running over HTTP/1.1 and may not function correctly.

For more detail, see [HTTP/1.1 performance issues](https://www.sanity.io/docs/help/http1-performance-issues).

### Development requirements

Setting up and building Studio locally requires:

| Requirement | Details |
| --- | --- |
| Node.js | See the engines field in Sanity Studio's package.json file for the current supported versions. |
| Package manager | npm, yarn, pnpm, and should work in any Node.js compatible package manager. |

Check the `engines` field in [Sanity Studio's package.json](https://github.com/sanity-io/sanity/blob/main/packages/sanity/package.json) for the currently supported Node.js versions.



# Introduction

You configure Sanity Studio in code, using JavaScript or TypeScript. This article covers where the configuration file lives, how to define one or more workspaces, and the most commonly used configuration properties.

Typically, you find the Studio configuration inside a `sanity.config.ts` (or `sanity.config.js`) file located at the root of your project. The development server for Sanity Studio automatically picks up what's returned from the exported [defineConfig](https://reference.sanity.io/sanity/index/defineConfig/) function. This function takes either a single workspace configuration object or [an array of configuration objects](https://www.sanity.io/docs/studio/workspaces) as its only argument. By implementing the predefined properties of this object, you are able to customize a range of options and behaviors in the studio, as well as control how plugins and other studio extensions are configured.

> [!TIP]
> Protip
> All these are valid file suffixes for the Studio configuration file: `.js`, `.jsx`, `.ts`, and `.tsx`.

## Minimal Studio configuration example

### Single Studio configuration

For a single Studio configuration, the `defineConfig` function takes a single configuration object. The only *required* properties are `projectId` and `dataset`, but since this won't make for a very useful studio, we've included the [structureTool()](https://reference.sanity.io/sanity/structure/structureTool/) plugin and some schemas in our example to reflect a more typical setup.

[More about schemas and forms ->](https://www.sanity.io/docs/studio/schemas-and-forms)

```javascript
// Single workspace configuration

import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  plugins: [structureTool()],
  schema: {
    types: schemaTypes,
  },
})
```

### Multiple workspace configurations

When configuring multiple workspaces, you supply an array of configuration objects. Each of these must, in addition to `projectId` and `dataset`, also include a unique `basePath` and `name` for each workspace.

[More about workspaces ->](https://www.sanity.io/docs/studio/workspaces)

```javascript
// Multiple workspace configuration
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'

export default defineConfig([
  {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'YOUR_DATASET',
    name: 'production-workspace',
    basePath: '/production',
    title: 'Default Workspace',
    subtitle: 'production',
    plugins: [structureTool()],
    schema: {
      types: schemaTypes,
    },
  },
  {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'staging',
    name: 'staging-workspace',
    basePath: '/staging',
    title: 'Another workspace',
    subtitle: 'staging',
    plugins: [structureTool()],
    schema: {
      types: schemaTypes,
    },
  },
])
```

## Property callback functions

Many of the properties of the config object have the option of accepting a callback function instead of a static value. These callbacks are usually invoked with the previous value and a context object.

```javascript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  plugins: [structureTool()],
  schema: {
    types: (prev, context) => {
      console.log(context) // logs { projectId, dataset }
      return [...schemaTypes, ...prev]
    },
  },
})
```

> [!WARNING]
> Gotcha
> If you choose to use the callback function, you need to make sure you return the previous value along with whatever new value you want to add. When using static values this is handled automatically by the Studio.

The information included in the context object varies depending on the property in question.

```jsx
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {RocketIcon} from '@sanity/icons/Rocket'
import {Card} from '@sanity/ui'
import {schemaTypes} from './schemas'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  plugins: [structureTool()],
  schema: {
    types: schemaTypes,
  },
  tools: (prev, context) => {
    console.log(context) // logs { getClient, currentUser, schema, projectId, dataset, i18n }
    return [
      {
        name: 'my-tool',
        title: 'My super-cool tool',
        icon: RocketIcon,
        component: (props) => <Card>I am a tool, albeit not a useful one</Card>,
      },
      ...prev, // remember to include previous values
    ]
  },
})
```

> [!WARNING]
> Gotcha
> The example above includes some JSX in the inline component declaration. Vite, the default studio bundler, requires files that contain JSX to have a file extension of either `.jsx` or `.tsx`.

## Commonly used configuration properties

### Workspace properties

Every workspace configuration needs to at least include appropriate string values for `dataset` and `projectId`. If you are working with multiple workspaces in your studio, each workspace should also include a `name` and `basePath`.

```javascript
//⬇ Required
dataset: 'YOUR_DATASET',
projectId: 'YOUR_PROJECT_ID',
//⬇ Optional if only using a single workspace
name: 'cool-studio',
basePath: '/my-default-workspace',
//⬇ Optional
title: 'My Cool Studio',
subtitle: 'production',
icon: RocketIcon, 
```

[More about workspaces ->](https://www.sanity.io/docs/studio/workspaces)

### Schema

The `schema` property is where you declare your schema types. You can specify a static array of schema objects or a callback function that returns such an array.

```javascript
schema: {
	types: mySchemas,
}
```

```javascript
schema: {
  types: (prev, context) => {
    console.log(context) // logs { projectId, dataset }
    return [...mySchemas, ...prev]
  },
},
```

You may also set [initial value templates](https://www.sanity.io/docs/studio/initial-value-templates) using the aptly named `templates` property. You can specify a static array of template objects or a callback function that returns such an array.

```javascript
schema: {
  templates: (prev) => [
    {
      id: 'category-child',
      title: 'Category: Child',
      schemaType: 'category',
      parameters: [{name: `parentId`, title: `Parent ID`, type: `string`}],
      value: ({parentId}) => ({
        parent: {_type: 'reference', _ref: parentId},
      }),
    },
    {
      id: 'article-with-author',
      title: 'Article: Author',
      schemaType: 'article',
      parameters: [{name: `authorId`, title: `Author ID`, type: `string`}],
      value: ({authorId}) => ({
        author: {_type: 'reference', _ref: authorId},
      }),
    },
    ...prev,
  ]
},
```

[More about schemas and forms ->](https://www.sanity.io/docs/studio/schemas-and-forms)

### Plugins

This is where you declare plugins for your Studio. It accepts a static array of plugin config objects or a callback function that returns such an array. The default studio templates come with the `structureTool` plugin included already.

```javascript
plugins: [structureTool()],
```

You’ll notice that the plugin function usually needs to be invoked, not just referred to. This is because plugins, by convention, are functions that can accept configuration options as arguments.

```javascript
plugins: [
    structureTool(),
    visionTool({
      defaultApiVersion: 'v2025-08-19',
      defaultDataset: 'production',
    }),
  ],
```

[More about plugins ->](https://www.sanity.io/docs/studio/installing-and-configuring-plugins)

### Tools

[Tools are full-page components](https://www.sanity.io/docs/studio/studio-tools), in that they “take over” most of the studio interface when activated, like the structure tool or vision plugin. Because of this behavior they also show up in your Studio’s nav bar, and they can be navigated to by appending their `name` to your studio’s URL. For example, `https://my-cool-site.com/studio/my-tool`.

Tools are declared much in the same way as plugins. The property accepts either a static array of tool configuration objects or a callback function that returns such an array.

```javascript
tools: [
  {name: 'my-tool', title: 'My Tool', component: MyTool},
  {name: 'tool-2', title: '2nd Tool', component: MyOtherTool},
],

// Example using the callback function with some conditional logic
tools: (prev, {currentUser}) => {
  if (currentUser.roles.find((r) => r.name === 'admin')) {
    return [
      ...prev,
      {name: 'admin', title: 'Admin', component: MyAdminTool},
    ]
  }
  return prev
},
```

### Form

The `form` config property lets you configure asset sources for files and images, as well as override the default rendering of form components.

```jsx
form: {
  file: {
    assetSources: myFileAssetSourceResolver,
    directUploads: true,
  },
  image: {
    assetSources: myImageAssetSourceResolver,
    directUploads: true,
  },
  components: {
    input: (props) => isStringInputProps(props) ? <MyCustomStringInput {...props} /> : props.renderDefault(props),
    field: MyCustomField,
  }
},
```

> [!WARNING]
> Gotcha
> Overriding the rendering of inputs and fields in the top-level Studio configuration will affect all fields in your studio. If you wish to customize the rendering of only certain fields, you probably want to do so by setting the components property of the appropriate fields. More info: [Introduction to Component API](https://www.sanity.io/docs/studio/intro-to-custom-studio-components).

#### Learn more

[Custom asset sources](https://www.sanity.io/docs/studio/custom-asset-sources)
How to add custom asset sources for Sanity Studio.

[Form components](https://www.sanity.io/docs/studio/form-components)
The Form Components API lets you customize the look and feel of the fields in your studio individually, or at a root level that will affect every field in the Studio. 

### Document

This property lets you configure [document actions](https://www.sanity.io/docs/studio/document-actions) and [badges](https://www.sanity.io/docs/studio/custom-document-badges), as well as set a `productionUrl` for previews and specify [options for new documents](https://www.sanity.io/docs/studio/new-document-options). You can also disable the **Ask to edit** button that will show by default for users with insufficient permissions to edit a document.

```javascript
document: {
  actions: (prev) =>
    prev.map((previousAction) =>
      previousAction.action === 'publish' ? MyPublishAction : previousAction
    ),
  productionUrl: (prev, context) => {
    return `http://example.com/${context.document?.slug?.current || '404.html'}`
  },
  askToEdit: {enabled: false},
},
```

[More about actions & badges ->](https://www.sanity.io/docs/studio/document-actions-api)

### Auth

This property lets you implement custom authentication by providing a configuration object that conforms to the [AuthConfig](https://reference.sanity.io/sanity/index/AuthConfig/) signature.

```javascript
import {defineConfig} from 'sanity'
/* ... */

auth: {
  redirectOnSingle: false,
  providers: (prev) => [
    ...prev,
    {
      name: 'vandelay',
      title: 'Vandelay Industries',
      url: 'https://api.vandelay.industries/login',
      logo: '/static/img/vandelay.svg',
    },
  ],
  loginMethod: 'dual',
}
```

[More about authentication ->](https://www.sanity.io/docs/studio/custom-auth)



# Workspaces

Running more than one workspace is useful when different teams, datasets, or regions need their own tailored editing environment without maintaining separate studios. Sanity Studio can accommodate multiple workspaces, each with its own configuration. To set up a studio with more than one workspace, supply an array of configurations to [defineConfig](https://reference.sanity.io/sanity/index/defineConfig/) instead of a single config object.

## Prerequisites

- A Sanity Studio project. If you're setting up a studio for the first time, see [Configuration](https://www.sanity.io/docs/studio/configuration).
- Familiarity with studio configuration properties. For a complete list, see the [Configuration API](https://www.sanity.io/docs/studio/config-api-reference) reference.

**sanity.config.ts**

```typescript
// Multiple workspace configuration
import {defineConfig} from 'sanity'
import {EarthAmericasIcon} from '@sanity/icons/EarthAmericas'
import {EarthGlobeIcon} from '@sanity/icons/EarthGlobe'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'

export default defineConfig([
  {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'YOUR_DATASET',
    name: 'us-workspace',
    basePath: '/us',
    title: 'USA',
    subtitle: 'All US content',
    icon: EarthAmericasIcon,
    plugins: [structureTool()],
    schema: {
      types: schemaTypes,
    },
  },
  {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'YOUR_DATASET',
    name: 'eu-workspace',
    basePath: '/eu',
    title: 'Europe',
    subtitle: 'All EU content',
    icon: EarthGlobeIcon,
    plugins: [structureTool()],
    schema: {
      types: schemaTypes,
    },
  },
])
```

The Studio will pick up your new workspace and display a dropdown next to the Studio title in the navbar to let you quickly switch between workspaces.

![Shows an active popover menu next to the Studio title in the navbar. The menu lists two workspaces, with the first indicated as currently active.](https://cdn.sanity.io/images/3do82whm/next/1a5330749ab0b1793ab7bdb1fbd4d55d6dfbe33f-2144x1388.png)

## Workspace configuration properties

[Studio configurations](https://www.sanity.io/docs/studio/configuration) and workspace configurations are the same thing. We refer to them as *studio configs* when there's only one configuration, and as *workspace configs* when there are multiple configurations. 

In practice, all configuration properties are workspace configuration properties. There are a few properties that, while legal and valid also for single workspaces, don't have actual value outside the context of a multi-workspace setup. For more information, see [the Configuration API reference](https://www.sanity.io/docs/studio/config-api-reference).

## Hiding workspaces

The `hidden` property on a workspace configuration controls whether that workspace is visible in the workspace menu and chooser. When `hidden` evaluates to `true`, the workspace is removed from the workspace menu and chooser, and direct URL navigation to it shows a not-found screen. The property accepts either a boolean or a callback that receives `{currentUser}`.

> [!WARNING]
> Gotcha
> The `hidden` property controls client-side UI visibility only. It does not restrict data access. Always enforce access control server-side using Sanity's role-based access control.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig([
  {
    name: 'admin',
    title: 'Admin',
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production',
    basePath: '/admin',
    hidden: ({currentUser}) => {
      if (currentUser === null) return false
      return !currentUser.roles.some((role) => role.name === 'administrator')
    },
  },
  {
    name: 'editor',
    title: 'Editor',
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production',
    basePath: '/editor',
  },
])
```

When the user is not yet authenticated in a workspace, `currentUser` is `null`. Callbacks should handle this case explicitly, as shown above: returning `true` when `currentUser` is `null` hides the workspace from unauthenticated users and prevents them from signing in to it. Before auth state resolves, callback-hidden workspaces are treated as visible and the Studio shows a loading screen.



# Schema and forms

You write schemas in plain JavaScript (or TypeScript) objects that let you describe your content model in Sanity Studio. The Studio creates the forms and inputs needed to create and edit your content and stores it as structured content in [your dataset in the schema-less Content Lake database](https://www.sanity.io/docs/content-lake/datasets).

[Schema types reference](https://www.sanity.io/docs/studio/schema-types)
See all available schema types for Sanity Studio

[Schemas and the Content Operating System](https://www.sanity.io/docs/apis-and-sdks/introduction-to-schemas)
Learn how schemas work with the Sanity Content Operating System

> [!NOTE]
> A faster start with @sanity/presets
> [@sanity/presets](https://www.npmjs.com/package/@sanity/presets) is an experimental package of ready-made schema types for common content patterns (pages, links, images, SEO metadata, and rich text) that lets you skip the boilerplate and get started quickly. As it is experimental, its APIs may change.

## Anatomy of schemas

**Content model or schema**: A conceptual overview, often expressed as a diagram, of the document types in a Studio and the schema types or attributes they contain.

**Document types:** A collection of schema types used to build a standalone piece of content. Documents typically consist of multiple fields, have a revision history, can be drafted and published, and can have queryable references between them.

> [!WARNING]
> Gotcha
> *Document types can be whatever you like, and do not have to map to “a page” or “a post.”*

**Form:** The order and structure of the schema types used for a document.

**Schema types:** Catch-all for attributes used on a form to make a document and maps to `schema.types` in the config API. Sometimes we use “field types” to talk about the same concept.

### Schemas are content models

![Diagram showing that a schema is made up of document types, and document types are made up of field (schema) types.](https://cdn.sanity.io/images/3do82whm/next/d64b5188da8e1f9d53f2aefab79ac0a1160e1f62-882x372.png)

When we talk about a schema or the content model, we are referring to all the document types created for a studio. All document types are made of schema/field types. In short form:

Schema → document types → schema/field types

### Where schemas are declared

Schemas are declared in the root configuration for your Sanity Studio project, typically found in a file named `sanity.config.ts` (or `sanity.config.js` for JavaScript projects). 

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemaTypes'

export default defineConfig({
  name: 'default',
  title: 'my-studio',

  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',

  plugins: [structureTool()],

  schema: {
    types: schemaTypes,
  },
})

```

You *can* declare your schemas inline in the configuration as an array of JavaScript objects, but the more common practice is to put your schemas in external files and import them into the `schema.types` array.

The default Studio setup when you create a new project in the CLI will include a folder at the root level called `schemaTypes` with a single file called `index.ts`. This file exports an array, which, depending on whether you started from a template or with a clean project, might be empty or might already contain some schemas.

## Example schema

Let's look at an example schema for a `person` document type. We'll add a `string` field for the name of the person and an `image` field for a portrait.

**schemaTypes/person.ts**

```typescript
export default {
  name: 'person',
  title: 'Person',
  type: 'document',
  fields: [
    {
      name: 'fullName',
      title: 'Full name',
      type: 'string',
    },
    {
      name: 'portrait',
      title: 'Portrait',
      type: 'image',
      options: {
        hotspot: true,
      }
    }
  ]
}
```

We then import our schema into our main schema file (`./schemaTypes/index.ts`) and add it to the schema array.

**schemaTypes/index.ts**

```typescript
import person from './person'

export const schemaTypes = [person]
```

The Studio will now let you create a new "Person" and provide the form inputs needed to equip your person with a name and a portrait.

![Studio screenshot showing a form with a string input for "Full name" and an image input for "Portrait"](https://cdn.sanity.io/images/3do82whm/next/dfb34aef1e43072e1a8cacd82ab35870c2df7693-800x791.png)

Your Studio comes with a range of default schema types, such as the ones we just used to create this document, and these types can be combined to create an endless number of data structures.

## Next steps

Now that you've defined a document type, learn how to shape and refine your content model with these guides.

[Validation](https://www.sanity.io/docs/studio/validation)
Add validation rules to your fields to keep content consistent.

[Conditional fields](https://www.sanity.io/docs/studio/conditional-fields)
Show, hide, or make fields read-only based on document values or user roles.



# Conditional fields

Sometimes you want to reduce the cognitive load and the complexity of a content form by controlling the visibility of its fields, or make certain fields read-only under certain conditions. You can make fields in Sanity Studio's form appear and disappear using the `hidden` property, and make them read-only with the `readOnly` property on all field types. This also works on fieldsets. This feature is commonly referred to as “conditional fields.” The `hidden` and `readOnly` properties can take a static `true` or `false` value or a callback function that contains the specific logic you want and returns `true` or `false` accordingly.

## Prerequisites

- A Studio project with a schema you can edit. See [Schemas and forms](https://www.sanity.io/docs/studio/schemas-and-forms) for an introduction to defining schema types.
- Familiarity with JavaScript functions, as conditions are written as callback functions.

## Examples

### Hide based on a value in the current document

Only show the `subtitle` field if the `title` field is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy):

```javascript
{
  name: 'subtitle',
  type: 'string',
  title: 'Subtitle',
  hidden: ({document}) => !document?.title
}
```

### Set read-only based on the current user's role

Only show the `productSKU` field as editable if the current user is an administrator; otherwise, show it as read-only:

```javascript
{
  name: 'productSKU',
  type: 'string',
  title: 'SKU',
  readOnly: ({currentUser}) => {
    return !currentUser?.roles.find(({name}) => name === 'administrator')
  }
}
```

### Hide based on a value in a sibling field

Hide a field if it's empty and its sibling field has a value:

```javascript
{
  name: 'link',
  type: 'object',
  title: 'Link',
  fields: [
    {
      name: 'external',
      type: 'url',
      title: 'URL',
      hidden: ({parent, value}) => !value && parent?.internal
    },
    {
      name: 'internal',
      type: 'reference',
      to: [{type: 'route'}, {type: 'post'}],
      hidden: ({parent, value}) => !value && parent?.external
    }
  ]
}
```

### Set read-only on an entire fieldset

Make every field in the links fieldset read-only when the document title is 'Hello world':

```javascript
{
  name: 'product',
  type: 'document',
  title: 'Product',
  fieldsets: [
    {
      name: 'links',
      title: 'Links',
      options: {columns: 2},
      readOnly: ({document}) => document?.title === 'Hello world',
    }
  ],
  fields: [
    {
      name: 'title',
      type: 'string',
      title: 'Title',
    },
    {
      name: 'external',
      type: 'url',
      title: 'URL',
      fieldset: 'links'
    },
    {
      name: 'internal',
      type: 'reference',
      to: [{type: 'route'}, {type: 'post'}],
      fieldset: 'links'
    }
  ]
}
```

> [!WARNING]
> Gotcha
> You can't return [a promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) from the `hidden` or `readOnly` callback functions. This is because of performance optimizations.

> [!TIP]
> Editor experience
> Be mindful that Sanity Studio is a real-time collaborative application. That means that someone else can make a condition true that hides the field you're currently working in. You can consider mentioning if a field has a condition in its `description`, or letting the content team know.

## Reference

### Callback properties

The `hidden` and `readOnly` callback functions take an object as an argument with the following properties:

#### Properties

**document** (object | undefined)

The current state of the document with all its values. Remember that it can return undefined. You can use optional chaining to avoid errors in the console, for example, document?.title.

**parent** (object | undefined)

The values of the field's parent. This is useful when the field is part of an object type. Remember that it can return undefined. You can use optional chaining to avoid errors in the console, for example, parent?.title. 

If it's a root field, it will contain the document's values.

**value** (any)

The field's current value.

**currentUser** (object | null)

The current user with the following fields:

email (string)

id (string)

name (string)

profileImage (string)

provider (string)

roles (array of objects with name, title, and description)

**path** (array (Path))

The path to the field or fieldset the callback is evaluated for, as an array of path segments, for example ['link', 'external'].



# Field groups

When editing documents in the Studio, it can sometimes be helpful to show certain fields together to provide context and alleviate visual input overload. Document and object types accept a `groups` property that you use to define the groups you want, and you can assign fields to appear in the groups you have defined using the `group` property on a field. Fields can also appear in more than one group.

For example, say you have a long document and want to focus on the fields related to SEO. To achieve this, first define an SEO group in your document's properties and then add the property `group: 'seo'` to a field to make it appear in the SEO group:

![Side-by-side view of a Sanity Studio document form: the default view with all fields visible on the left, and the SEO field group view showing only SEO fields on the right.](https://cdn.sanity.io/images/3do82whm/next/5313d064b74f6aac7b75bbcef69b2330e416fd6e-2452x1720.png)
*Left: default view with all fields visible. Right: groups view with only relevant fields visible.*

> [!TIP]
> Protip
> Adding `default: true` to the object setup in `groups: []` will make it the default field group.

The schema to produce the document structure in the example above might look like this (note the `groups` property on the document itself, as well as the `group` property on the fields related to SEO):

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'article',
  title: 'Article',
  type: 'document',
  groups: [
    {
      name: 'seo',
      title: 'SEO',
    },
  ],
  fields: [
    defineField({name: 'title', title: 'Title', type: 'string'}),
    defineField({name: 'icon', title: 'Icon', type: 'image'}),
    defineField({
      name: 'related',
      title: 'Related',
      type: 'array',
      of: [{type: 'reference', to: [{type: 'article'}]}],
    }),
    defineField({name: 'seoTitle', title: 'SEO title', type: 'string', group: 'seo'}),
    defineField({name: 'seoKeywords', title: 'Keywords', type: 'string', group: 'seo'}),
    defineField({name: 'seoSlug', title: 'Slug', type: 'slug', group: 'seo'}),
    defineField({name: 'seoImage', title: 'Image', type: 'image', group: 'seo'}),
  ],
})
```

Fields can belong to more than one group. Expanding on the previous example, say you want another view showing only fields that include images. You can create a new group called Media and add all the fields with a graphic element to it:

![Sanity Studio document form with the Media group tab selected, showing only the image fields Icon and Image.](https://cdn.sanity.io/images/3do82whm/next/3c4125368043f3275c7f92ae2729e548d4357fc0-1158x368.png)
*A group showing only image fields*

To do this, add another group called Media in `groups`, add `group: 'media'` to the `icon` field, and change the `group` property on the `seoImage` field to an array of strings so it appears in both groups:

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'article',
  title: 'Article',
  type: 'document',
  groups: [
    {
      name: 'seo',
      title: 'SEO',
    },
    {
      name: 'media',
      title: 'Media',
    },
  ],
  fields: [
    defineField({name: 'title', title: 'Title', type: 'string'}),
    defineField({name: 'icon', title: 'Icon', type: 'image', group: 'media'}),
    defineField({
      name: 'related',
      title: 'Related',
      type: 'array',
      of: [{type: 'reference', to: [{type: 'article'}]}],
    }),
    defineField({name: 'seoTitle', title: 'SEO title', type: 'string', group: 'seo'}),
    defineField({name: 'seoKeywords', title: 'Keywords', type: 'string', group: 'seo'}),
    defineField({name: 'seoSlug', title: 'Slug', type: 'slug', group: 'seo'}),
    defineField({name: 'seoImage', title: 'Image', type: 'image', group: ['seo', 'media']}),
  ],
})
```

> [!TIP]
> Protip
> Using field groups in a document or object does not change the structure of the document. It only affects how and where fields appear in the Studio.

In addition to document types, field groups can also be defined on object types.

> [!WARNING]
> Gotcha
> A field inside an object cannot appear in a group by itself.

## Conditional field groups

It can be useful to show or hide certain groups based on conditions. A group can be conditionally hidden using the boolean values `true` or `false`, but you can also pass a function. This function receives a single context object with the properties `document`, `currentUser`, `value`, and `parent`, where `value` is the current value of the document or object the groups are defined on, and `parent` is the value of its enclosing object (for groups defined directly on a document, this is null).

For example, you can hide the SEO group from users who don't have the administrator role:

```typescript
import {defineType} from 'sanity'

export default defineType({
  name: 'article',
  title: 'Article',
  type: 'document',
  groups: [
    {
      name: 'seo',
      title: 'SEO',
      // Hide the SEO group from users without the administrator role
      hidden: ({currentUser}) => !currentUser?.roles.some((role) => role.name === 'administrator'),
    },
  ],
  fields: [
    // ...fields
  ],
})
```

## Customizing the All fields group

When creating your first group, you may notice a new group is added by default with the title **All fields**.

That group is a necessary addition because you might not have all your fields organized in groups. However, you have the option to hide this group.

You can do this by defining the following group in your schema:

**customSchema.ts**

```typescript
import {ALL_FIELDS_GROUP, defineType} from 'sanity'

export default defineType({
  name: 'mySchemaType',
  type: 'document',
  groups: [
    {
      name: 'details',
      title: 'Details',
    },
    {
      ...ALL_FIELDS_GROUP,
      hidden: true,
    },
  ],
  fields: [
    // ...fields
  ],
})
```

Note that even if you hide the group, it will still be visible under certain conditions:

- The review changes inspector is open.
- A field that is not part of any group has been linked by a comment or any deep-linking action.

![Sanity Studio document form showing field group tabs with the All fields tab hidden.](https://cdn.sanity.io/images/3do82whm/next/367492c72943bb170063675101e0075063d94f30-1468x1052.png)

## Reference

### Groups declaration

Property: `groups`

Type: `array`

Defined on `document` or `object`

#### Properties

**name** (string, required)

A unique name for the group. Fields will use this name to indicate which group they belong to.

**title** (string)

A more descriptive, human-readable name.

**icon** (React Component)

A React component that is displayed as the group's icon in Studio. See the icon documentation for details.

**hidden** (boolean | function)

Set to true to hide the group. Also accepts a function, which takes an object argument with the properties currentUser, parent, value. Must return a boolean. See the example below. Defaults to false.

**default** (boolean)

Defines the group as the default group. Defaults to false.

```javascript
groups: [
  {
    name: 'groupName',
    title: 'Group title',
    icon: CogIcon, // optional
    default: true, // optional, defaults to false
    hidden: ({currentUser, value, parent}) => true // optional
  }
]
```

### Field declaration

Property: `group`

Type: `string` or `array`

Defined on a field. Set to one or more group names to assign the field to one or more groups.

```typescript
defineField({
  name: 'fieldName',
  title: 'Field title', 
  type: 'string',
  group: 'groupName' // or ['groupName']
})
```



# List previews

Sanity Studio will often need to render a compact representation of a document or object for list views and similar situations, and we call this a *list preview*. You can decide which fields should be used and how by configuring the `preview` property ([PreviewConfig](https://reference.sanity.io/sanity/index/PreviewConfig/)) on schema types. By default, Sanity Studio tries to guess which fields should be used for preview by introspecting the type's defined fields. For example, if your type has a field of type `string` named `title`, it will infer that this should be used as the title when previewing values of this type.

Sanity Studio offers two ways of customizing how documents and objects are previewed:

1. Specify preview options for the type in the schema for lists and arrays to use automatically
2. Implement a custom preview component to display when used as block content in the Portable Text Editor

> [!TIP]
> Protip
> Looking to create previews inside of the document pane? Read more on [creating custom content previews](https://www.sanity.io/blog/evolve-authoring-experiences-with-views-and-split-panes) inside split panes with the Structure Builder API.
> For previews of content presentation in front ends, go to [the documentation for Visual Editing and Presentation](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing).

## Configuring preview options

Normally, a list preview has three "slots": title, subtitle, and media. If you want to specify which fields should be used for what, you can control this by adding a `preview` key to the type defined in the schema. For example:

```javascript
export default {
  name: 'movie',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string'
    },
    {
      title: 'Release Date',
      name: 'releaseDate',
      type: 'date'
    }
  ],
  preview: {
    select: {
      title: 'title',
      subtitle: 'releaseDate'
    }
  }
}
```

Above, the `preview.select` object will inform the Sanity Studio preview logic that for this document, `movie.title` should be used as `title` and `movie.releaseDate` should be used as `subtitle`.

This might be sufficient in many cases, but sometimes, you want to reformat the selected values. With the `prepare` function, you can access the values that you have selected and customize them.

Say we only want the year for `releaseDate` (e.g., 2016-04-25):

```javascript
export default {
  name: 'movie',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string'
    },
    {
      title: 'Release Date',
      name: 'releaseDate',
      type: 'datetime'
    }
  ],
  preview: {
    select: {
      title: 'title',
      date: 'releaseDate'
    },
    prepare(selection) {
      const {title, date} = selection
      return {
        title: title,
        subtitle: new Date(date).getFullYear() // YYYY-MM-DD --> YYYY
      }
    }
  }
}
```

Above, `title` and `releaseDate` are selected. The result of this selection is passed to the `prepare` function, where you can transform the selection however you like (only keeping the year, in this case).

> [!TIP]
> Protip
> In these examples we have put the preview object after the `fields` array, however you can also place it before it. This might give you a better idea at first glance of how the document is previewed.

## Show custom previews for different sort orders

The `prepare` function receives, in addition to the chosen selection of fields, a `viewOptions` object which contains the [sort order setting](https://www.sanity.io/docs/studio/sort-orders) for the current document list pane. This can be used to display different previews for different sort orders.

```
export default {
  name: 'movie',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string'
    },
    {
      name: 'genre',
      title: 'Genre',
      type: 'string',
      options: {
        list: [
          { title: 'Action', value: 'action' },
          { title: 'Adventure', value: 'adventure' },
          { title: 'Comedy', value: 'comedy' },
          { title: 'Drama', value: 'drama' },
          { title: 'Fantasy', value: 'fantasy' },
        ]
      }
    },
    {
      title: 'Release Date',
      name: 'releaseDate',
      type: 'datetime'
    }
  ],
  preview: {
    select: {
      title: 'title',
      genre: 'genre',
      releaseDate: 'releaseDate'
    },
    prepare({title, genre, releaseDate}, viewOptions) {
      const sortedByDate = viewOptions?.ordering?.some(o => o.field === 'releaseDate')
      return {
        title: title,
        subtitle: sortedByDate ? releaseDate?.toLocaleDateString() : genre
      }
    }
  }
}
```

## Preview using fields from referenced documents

You can follow [references](https://www.sanity.io/docs/content-lake/how-queries-work) by using dot notation to the related document field you want to display in `preview.select`. Note that using GROQ joins *is not supported* here (it’s what the Studio will do under the hood).

Here's an example of a preview for a movie document where the `director` field is a reference, and the referenced document has a `name` field:

```javascript
export const movie = {
  name: 'movie',
  type: 'document',
  fields: [
    //...other fields
    {
      name: 'director',
      type: 'reference',
      to: [{ type: 'person' }]
    }
  ],
  preview: {
    select: {
      title: 'title',
      director: 'director.name' // if the movie has a director, follow the reference and get the name
    },
    prepare(selection) {
      const {title, director} = selection
      return {
        title: title,
        subtitle: `Directed by: ${director ? director : 'unknown'}`
      }
    }
  }
}
```

## Previewing from predefined string lists

When using a [predefined list of strings](https://www.sanity.io/docs/studio/string-type), you can use objects with `title` and `value` keys. This might be useful if you're using a list of U.S. states, for example: The `title` can be the spelled-out state, while the `value` can be a two-letter state code:

```javascript
{
  title: 'U.S. State',
  name: 'state',
  type: 'string',
  options: {
    list: [
      { "title": "Alabama", "value": "AL"},
      { "title": "Alaska", "value": "AK"},
      { "title": "Arizona", "value": "AZ"},
      // ...
    ],
    layout: 'dropdown'
  }
}
```

If you wish to use that value in your preview, Sanity will default to providing the `title`—*unless you use a *`prepare()`* function*. In that case, the `value` (and *only* the `value`) will be passed along to `prepare()`.

If you want to render the `title` in your document preview but need to manipulate it in some way (which is done using `prepare()`, as seen in the [second example above](https://www.sanity.io#770fd57a8f95)), you can specify your list outside of the schema, use it as your list in `options.list`, and then consult that list in your `prepare()` function. This is best explained via an example:

```javascript
const STATES = [
  { "title": "Alabama", "value": "AL"},
  { "title": "Alaska", "value": "AK"},
  { "title": "Arizona", "value": "AZ"},
  // ...
]

export default {
  // ...
  fields: [
    // ...
    {
      name: "state",
      title: "U.S. State",
      type: "string",
      options: {
        list: STATES,
        layout: "dropdown",
      },
    }
  ],
  preview: {
    select: {
      state: 'state',
    },
    prepare: ({ state }) => {
      const stateName = state && STATES.flatMap(option => option.value === state ? [option.title] : [])
      return {
        title: state ? `${state} is ${stateName}` : 'No state selected',
      }
    }
  }
}
```

## Previewing from array values

Fetching entire arrays of values can potentially result in large and complex responses, especially in the case of large arrays. We encourage you only to select a subset of the array values:

```javascript
export default {
  name: 'book',
  type: 'document',
  fields: [...],
  preview: {
    select: {
      title: 'title',
      author0: 'authors.0.name', // <- authors.0 is a reference to author, and the preview component will automatically resolve the reference and return the name
      author1: 'authors.1.name',
      author2: 'authors.2.name',
      author3: 'authors.3.name'
    },
    prepare: ({title, author0, author1, author2, author3}) => {
      const authors = [author0, author1, author2].filter(Boolean)
      const subtitle = authors.length > 0 ? `by ${authors.join(', ')}` : ''
      const hasMoreAuthors = Boolean(author3)
      return {
        title,
        subtitle: hasMoreAuthors ? `${subtitle}…` : subtitle
      }
    }
  }
}
```

> [!WARNING]
> Gotcha
> Resolving references in arrays works the same as covered above, with dot notation.

## Selecting an image field to use for the thumbnail

The easiest way to show an image in the preview is to assign a field containing an image to the `media` property. The different views take care of a proper rendering of the image, including any `hotspot` and `crop` specifics. 

```javascript
export default {
  name: 'person',
  type: 'document',
  fields: [...],
  preview: {
    select: {
      title: 'name',
      media: 'userPortrait' // Use the userPortait image field as thumbnail
    }
  }
}
```

## Rendering React components

You can also use JSX to render a thumbnail. Here's an example of how to show specific emojis based on the status of our document. This example is partly taken from our [Community Studio](https://www.sanity.io/blog/how-we-manage-community-support-with-sanity).

```jsx
// src/schemaTypes/ticket.jsx

export const ticket = {
  name: 'ticket',
  type: 'document',
  fields: [...],
  preview: {
    select: {
      title: 'title',
      summary: 'summary',
      status: 'status'
    },
    prepare({ title, summary, status }) {
      const EMOJIS = {
        open: '🎫',
        resolved: '✅',
        cancelled: '🚫'
      }
      return {
        title: title,
        subtitle: summary,
        media: <span style={{fontSize: '1.5rem'}}>{status ? EMOJIS[status] : '🎫'}</span>
      }
    }
  }
}
```

## Custom preview component

If you want complete control of how the document or object list preview is rendered, you can also provide a React component invoked when the document or object is previewed in that context. 

> [!WARNING]
> Gotcha
> Custom preview components will only display in lists that appear inside the document pane—not in the top-level Structure tool document list.

To learn more about this option, visit the [article on form components](https://www.sanity.io/docs/studio/form-components).

## Preview in the Studio

Depending on how your schema is set up, here is an example of how `Preview` could look in your Studio. This uses `title`, `subtitle`, and `media`.

![View of Preview in the Studio](https://cdn.sanity.io/images/3do82whm/next/9fc7748d46db973027e0cce787cadc8f5661f23c-1152x700.png)

## Form preview title

![The form title preview displaying "They Are Gutting a Body of Water — Lucky Styles (2022, Smoking Room)](https://cdn.sanity.io/images/3do82whm/next/3013ebad552fa130fb391387d396f47e22e7ea82-1362x560.png)

Since v3.24.1, Sanity Studio has also rendered a large title in the document form to make it easier to discern which document you are currently in. It shares the logic with list previews, looking for a `preview` configuration and returning to the inferred preview title. In cases you don't wish to have this title, you can turn it off: 

```typescript
// src/schemaTypes/location.ts

export const location = {
  name: 'location',
  title: 'Location',
  type: 'document',
  __experimental_formPreviewTitle: false,
  fields: [
    //..fields
  ],
}
```



# Connected content

## What is a “reference”?

When we talk about references in the context of Sanity, we usually mean one of three related things:

- The reference field type in the Studio's schema files
- The field UI that you'll interact with in the Studio
- The specific data shape that you'll find in the JSON documents that hold your content

You can put reference fields inside a document type, object, and array fields, as well as inside annotations in the [Portable Text](https://www.sanity.io/docs/studio/portable-text-editor-configuration) Editor.

### Typical use cases for references

You can use references pretty much anywhere it makes sense to connect two pieces of content, but let's look at some common patterns and use cases:

- An `author` field that references a `person` document
- An `internalLink` field that references document types like `route`, `product`, `post`, `service`
- Taxonomy fields that reference `tag` and `category` documents
- A `parent` field that establishes a hierarchical structure
- A `related` field that references documents of the same type

### References are always bidirectional

A reference will always point to another *document*. The [Content Lake](https://www.sanity.io/docs/content-lake) will index references bidirectionally, meaning that you can query them from “both sides.” If you are used to database terminology, this means that the Content Lake acts more like a graph database than a relational database. If you're not, it means that any document in your Content Lake can be connected to any other document by a reference!

In other words, as long as you have a reference inside of a document pointing to another, you can ask the Content Lake to return all the data of a document and include the content of the document it refers to, but you can also ask it to return all the documents that contain a reference to a particular document, sometimes referred to as “incoming references.” There are also ways of querying for documents that have a reference to another document in a *given* field.

Hence, where to place a reference field is mostly a consideration for the editorial experience. From where does it make sense to manage the references? Typically, some document types will be relevant in many different contexts, and you want references to point to these. If you wish to display incoming references in a document, you can implement an [incoming reference decoration](https://www.sanity.io/docs/studio/incoming-reference-decoration).

#### Example

Let's say you have document types for `post` and `person`. While you could put an array of references to `post` on the `person` document type, that would be cumbersome when authoring the post. To connect it to a `person`, that is, its author, you would have to go to the given document and add the new post to the array. Hence, it's “natural” to make a field called `author` that's a reference to the type `person` on the `post` document type.

```javascript
// post.js
export default {
  name: 'post',
  type: 'document',
  fields: [
    // other fields
    // ...
    {
      name: 'author',
      type: 'reference',
      title: 'Author',
      to: [{type: 'person'}]
    }
  ]
}
```

> [!TIP]
> Protip
> While we're demonstrating a single author field in the code above, it's often wise to make it an `array` field called `authors` that can hold multiple references to persons. It's likely that you'll need support for multiple authors at some point.

These fields will produce a data structure that looks like this:

```json
{
  "_type": "post",
  "author": {
    "_type": "reference",
    "_ref": "82b75d44-13af-4351-90ee-13045f84cf3b"
  }
}
```

The value of the `_ref` property is the `_id` of the document it's referencing.

### Referential integrity

Not only are your references indexed and queryable; the Content Lake will also make sure that they keep their integrity. That means that it will prevent you from deleting a document that is referenced elsewhere. This simplifies implementing connected content, and enables you to have confidence in the structure of your data.

Sometimes you don't need this guarantee but still want the convenience of references. Referential integrity can be turned off by adding the `weak: true` property to a reference field configuration. This will add `_weak: true` to the reference object in the data, which is also the way to convert a strong reference to a weak one programmatically (notice the underscore `_` that signifies a special Content Lake property).

```json
// Example of a weak reference. The referenced document can be deleted because this reference is set as weak.
{
  "_type": "feedback",
  "message": "This was a great article!",
  "article": {
    "_type": "reference",
    "_ref": "4049517c-3258-4747-8e00-2956ca5b894b",
    "_weak": true
  }
}
```

If you use weak references and a reference field points to a non-existent document, this will show up for editors in the Studio as a warning:

![Screenshot of the warning displayed in the Studio when referencing a non-existent document via a weak reference: “Non-existent document: This is currently referencing a document that doesn’t exist (ID: <id>). You can either remove the reference or replace it with another document.”](https://cdn.sanity.io/images/3do82whm/next/c7a91f5de08b670967c866f15ba0540dc5a8f216-678x297.png)

## Working with references in the Studio

A reference field in the Studio lets you do mainly four things:

- Search and select a document you want to reference
- Create a new document that can be referenced
- Open an already referenced document in a new pane next to the current one
- Delete a reference to a document

### Adding references

To add a reference to an existing document, click into the reference field and type into it to search for a document from most of its text-based fields. The Studio will create the reference when you select the document. If the document you selected is a draft that has never been published, the Studio will still make the connection, but it will block the referring document from publishing until the referenced document has been published (unless the reference field has the `weak` option set to true).

### Create and edit documents in place

Wanting to reference some piece of content you haven't created yet is fairly common. A simple example is when a new author is creating their first post and they don't have their own `author` document yet. In many systems, they'll have to leave the current document that they're editing, create and publish a new author document, and then get back to where they left. The reference fields for Sanity Studio remove this friction by letting you create and edit documents in a new pane next to the current.

![Shows the Studio workflow for editing a reference in place](https://cdn.sanity.io/images/3do82whm/next/cc85c96f60addcc0d7b2ea95131d6f299d107089-1304x1220.png)
*The referring document of the type book on the left is blocked from publishing until the referenced document of the type author on the right has been published. Setting the reference field's weak option to true disables this safety measure.*

## Querying connected content

The true potential of connected content is revealed when you start taking advantage of references in your queries. Using GROQ, you can follow any reference and include any value from that document in your result. *Including references*, which means you can follow the trail from a `book` via its reference to an `author` which might include references to `award`s they've won, which might have been referenced by other `author`s who have won that same `award`.

```groq
// For books by any award-winning author
// return title of book
// follow the author reference to get their name
// follow the award reference via the author to get the award title
// finally list names of other authors who have received the same award

*[_type == "book" && defined(author->award)] {
  title,
  "By: ": author->name,
  "Winner of: ": author->award->title,
  "Also won by: ":
    *[_type == "author" && references(^.author->award._ref) ].name
}
```

The query above might yield a result like this:

```json
[
  {
    "title": "One Flew Over the Cuckoo's Nest",
    "By: ": "Ken Kesey",
    "Winner of: ": "Pulitzer",
    "Also won by: ": [
      "Astrid Lindgren",
      "Niccolo Machiavelli",
      "Terry Pratchett"
    ]
  },
  ...
]
```

While this example might seem a bit convoluted (and probably wouldn't stand up to the scrutiny of the Pulitzer board), it also demonstrates how using references can reveal patterns and possibilities in connected content using only a few lines of GROQ.

Delving further into the syntax and features of GROQ is beyond the scope of this article, but rest assured that we have [ample docs and examples](https://www.sanity.io/docs/groq-reference) on the possibilities afforded by references in your queries.

## Further reading

- [How joins in GROQ work](https://www.sanity.io/docs/content-lake/how-queries-work)
- [Content Modeling Guide](https://www.sanity.io/content-modeling)
- [Designing Connected Content: Plan and Model Digital Products for Today and Tomorrow](https://www.pearson.com/us/higher-education/program/Hane-Designing-Connected-Content-Plan-and-Model-Digital-Products-for-Today-and-Tomorrow/PGM1816611.html)



# Validation

Sanity Studio allows you to specify validation rules on your document types and fields. Field-level validation is the most specific and gives the Studio a better chance to help the user understand where the validation failed and why, whereas the document-level validation provides slightly more control since it can validate based on the values of the entire document.

Each schema type has a set of built-in validation methods. [See the schema type documentation for a detailed list →](https://www.sanity.io/docs/schema-types)

> [!TIP]
> Validation is client-side only
> Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See [Schema validation and the Content Lake](https://www.sanity.io/docs/content-lake/schema-validation-and-the-content-lake) for details.
> You can also [validate multiple documents in bulk using the CLI](https://www.sanity.io/docs/cli-reference/documents).

## Basics

Validation is defined by setting the `validation` property on a document type or field. It takes a function which receives a [rule](https://reference.sanity.io/sanity/index/Rule/) as the first argument. By calling methods on this rule, you add new validation modifiers. Here's an example which validates that a string field has a value and that the string is between 10 and 80 characters long:

```typescript
defineField({
  title: 'Title',
  name: 'title',
  type: 'string',
  validation: rule => rule.required().min(10).max(80)
})
```

Without the `required()` call, the title is also considered valid if it does not have a value.

## Error levels and error messages

By default, values that do not pass the validation rules are considered errors - these will block the draft from being published until they have been resolved. You can also set a rule to be a warning, simply by calling `warning()` on the rule. Similarly, you can customize the error message displayed by passing a string to the `warning()` or `error()` method:

```typescript
defineField({
  title: 'Title',
  name: 'title',
  type: 'string',
  validation: rule => rule.max(50).warning('Shorter titles are usually better')
})
```

If you want to combine both warnings and errors in the same validation set, you can use an array:

```typescript
defineField({
  title: 'Title',
  name: 'title',
  type: 'string',
  validation: rule => [
    rule.required().min(10).error('A title of min. 10 characters is required'),
    rule.max(50).warning('Shorter titles are usually better')
  ]
})
```

## Referencing other fields

Sometimes you may want to build a rule that is based on the value of a different field. By calling the `rule.valueOfField` method, you can achieve this.

```javascript
defineField({
  title: 'Start date',
  name: 'startDate',
  type: 'datetime',
  validation: rule => rule.required().min('2022-03-01T15:00:00.000Z')
}),
defineField({
  title: 'End date',
  name: 'endDate',
  type: 'datetime',
  validation: rule => rule.required().min(rule.valueOfField('startDate'))
})
```

Note however that it only allows referencing sibling fields. If you need to refer to things outside of this scope, you will have to use document-level validation.

> [!WARNING]
> Gotcha
> `rule.valueOfField()` returns the literal value of a field, allowing you to validate that the end date is always equal to or greater than the start date (as in the previous example). However, it cannot be used for inserting a field value into conditional logic and creating a validation based on the result.

## Skipping validation for hidden fields

The validation function receives a `context` parameter as the second argument. This [context provides information](https://reference.sanity.io/sanity/index/ValidationContext/) about the field's current state. The `context.hidden` property indicates whether the field is hidden by a condition on itself or an ancestor.

Use `rule.skip()` to skip validation entirely for a field. This is useful for conditionally hidden fields with required validation:

```typescript
defineField({
  name: 'title',
  title: 'Title',
  type: 'string',
  validation: (rule, context) => (context?.hidden ? rule.skip() : rule.required().min(5)),
})
```

In this example, the validation function receives both the `rule` and `context` parameters. When `context.hidden` is `true`, `rule.skip()` tells the validation system to skip all validation for this field. When the field is visible, the normal validation rules apply.

The `rule.skip()` method ensures the validation system properly understands that no validation should be performed when the field is hidden.

## Custom validation

Sometimes you will need to validate values beyond what Sanity provides. The `custom()` method allows you to do this. It takes a function as the first argument, which should return either `true` (in the case of a valid value) or an error message as a string (in the case of an invalid value). You may also return a promise that resolves with one of those values, should you need to do asynchronous operations:

```javascript
defineField({
  name: 'location',
  type: 'geopoint',
  title: 'Location of bar',
  description: 'Required, must be in Norway',
  validation: rule =>
    rule.required().custom(geoPoint =>
      someGeoService
        .isWithinBounds(
          {
            latitude: geoPoint.lat,
            longitude: geoPoint.lng
          },
          someGeoService.BOUNDS_NORWAY
        )
        .then(isWithinBounds => (isWithinBounds ? true : 'Location must be in Norway, somewhere'))
    )
})

```

Please note that custom validators are also run on undefined values, unless the rule is explicitly set as optional by calling `rule.optional()`. This allows for conditionally allowing undefined values based on some external factor, with the slight drawback that you need to make sure your functions check for undefined values. Here's an example:

```javascript
defineField({
  name: 'breweryName',
  type: 'string',
  title: 'Brewery name',
  validation: rule => rule.custom(name => {
    if (typeof name === 'undefined') {
      return true // Allow undefined values
    }
    
    // This would crash if we didn't check
    // for undefined values first
    return name.startsWith('Brew')
      ? 'Please be more creative'
      : true
  }).warning()
})

```

Should you need to reference other fields from within the custom validator function, you can use the second argument (`context`) to the function:

```javascript
defineField({
  name: 'durationInMinutes',
  type: 'number',
  title: 'Duration of talk, in minutes',
  validation: rule => rule.custom((duration, context) => {
    const isShortTalk = duration && duration <= 10
    if (isShortTalk && context.document.talkType !== 'lightning') {
      return 'Only lightning talks should be 10 minutes or less'
    }
    
    return true
  })
})
```

You can also access the closest `parent` from the context, along with the `path` of the current element being validated.

### Asynchronous validation using the client

If you want to base your rule on another part of your content, you can access the client via the validation context.

```typescript
validation: (Rule) =>
  Rule.custom((value, context) => {
    const client = context.getClient({apiVersion: '2026-03-25'}).withConfig({perspective: 'drafts'})
    // ...rest of your rule
    return true
  }),
```

### Validating children

In certain cases, you may want to validate children of an object or array. In this case you can return an array of error objects, each with a `message` and a `path` property. The path is an array of *path segments* leading to the child you want to flag as the culprit. Let's say you want to disallow empty blocks/paragraphs in a portable text field:

```typescript
defineField({
  name: 'introduction',
  title: 'Introduction',
  type: 'array',
  of: [{type: 'block'}],
  validation: rule => rule.custom(blocks => {
    const emptyBlocks = (blocks || []).filter(
      block =>
        block._type === 'block' &&
        block.children.every(span =>
          span._type === 'span' &&
          span.text.trim() === ''
        )
    )

    const emptyErrors = emptyBlocks.map((block, index) =>
      block._key
        ? {message: 'Paragraph cannot be empty', path: [{_key: block._key}]}
        : {message: 'Paragraph cannot be empty', path: [index]}
    )

    return emptyErrors.length === 0 ? true : emptyErrors
  })
})
```

For each of the empty blocks we find, we collect the path to it, which can either be the `_key` property (preferably), or the array index if a key cannot be found.

## Document level validation

Sometimes you want to validate a whole document rather than just specific fields in a document. To do this, you can give a document the `validation` property and access fields inside the document by passing a prop. In this example, the validation ensures that editors can't add a "Guest Author" and an "Author."

```javascript
export default defineType({
  name: 'post',
  type: 'document',
  title: 'Blog Post',
  validation: rule => rule.custom(fields => {
    if (fields.authors.length > 0 && Object.keys(fields.guest).length > 0) return "You can't have an author AND guest author"
    return true
  }),
  fields: [
    // ... 
    defineField({
      name: 'authors',
      title: 'Authors',
      type: 'array',
      of: [
        {
          type: 'authorReference',
        }
      ]
    }),
    defineField({
      name: 'guest',
      title: 'Guest Author',
      type: 'object',
      fields: [
        {name: 'name', type: 'string', title: 'Guest Author Name'},
        {name: 'site', type: 'string', title: 'Guest Author Site'},
      ],
    }),
  ]
})
```

### Marking nested fields as invalid

Similar to the example in "Validating children" above, you can return an object to specify what field the message should apply to when using document level validation.

```javascript
export default defineType({
    name: 'post',
    type: 'document',
    title: 'Blog Post',
    validation: (rule) =>
        rule.custom((fields) => {
            if (
                fields.authors.length > 0 &&
                Object.keys(fields.guest).length > 0
            )
                return {
                    message: "You can't have an author AND guest author",
                    path: ['guest'], // add keys to array for nested fields, ex ['guest', 'title'] for guest.title
                }
            return true
        }),
    fields: [
        // ...
        defineField({
            name: 'authors',
            title: 'Authors',
            type: 'array',
            of: [
                {
                    type: 'authorReference',
                },
            ],
        }),
        defineField({
            name: 'guest',
            title: 'Guest Author',
            type: 'object',
            fields: [
                defineField({ name: 'name', type: 'string', title: 'Guest Author Name' }),
                defineField({ name: 'site', type: 'string', title: 'Guest Author Site' }),
            ],
        }),
    ],
})
```

## Validation cascade

When referencing schema types from other types, such as when setting a document's fields to custom types, any "higher level" validation rules will override existing validation rules.

For example, if you have an `articleType` and `authorType` as shown in the example below, any rules applied to the field in `articleType` will override the `authorType` rules.

```typescript
export const authorType = defineType({
  type: 'object',
  name: 'authorType',
  validation: (rule) => rule.custom(...)
})

export const articleType = defineType({
  type: 'document',
  name: 'articleType',
  fields: [
    defineField({
      type: 'authorType',
      validation: (rule) => rule.custom(...) // Overrides the authorType validation
    })
  ]
})
```

## Validation debouncing and delays

Validations don’t include any debouncing. They run in parallel on document updates. Custom validations do have a non-configurable concurrency limit, so we recommend keeping your validations performant.

You can incorporate throttling into custom validations, but be careful not to negatively impact the user experience. If you find that you need intentionally slow / long-running custom validations, you can prevent the console warning by setting the [bypassConcurrencyLimit](https://reference.sanity.io/sanity/index/CustomValidator/#bypassconcurrencylimit) to `true`.

## Validation messages in arrays

Sanity Studio surfaces validation messages differently for arrays of primitives and arrays of objects. The shared item-render API passes a `validation` prop to the item renderer in both cases; the visible difference is what the default item component does with it.

- **Arrays of primitives:** each item renders inline with its input. Validation messages appear next to the input in the standard form layout.
- **Arrays of objects:** each item renders as a collapsible preview row. The row shows a tone indicator (error or warning) for compactness, and the full message text appears at the array-field level. Authors see the message text inline by opening the item.

This is a UX choice that keeps the preview row scannable. If you want each row of an object array to display the full message text, override the item component with the `components.item` slot.

### Show per-item validation messages

Provide a custom item component that reads `props.validation` and renders the messages. Use `props.renderDefault(props)` to keep the default preview row and decorate around it, rather than replacing the rendering entirely.

**tagsField.tsx**

```typescript
import {defineField} from 'sanity'
import type {ItemProps} from 'sanity'

function ItemWithValidation(props: ItemProps) {
  const errors = props.validation.filter((marker) => marker.level === 'error')

  return (
    <div>
      {props.renderDefault(props)}
      {errors.length > 0 && (
        <ul style={{margin: 0, padding: '4px 12px', color: 'red'}}>
          {errors.map((marker, i) => (
            <li key={i}>{marker.message}</li>
          ))}
        </ul>
      )}
    </div>
  )
}

export const tagsField = defineField({
  name: 'tags',
  type: 'array',
  of: [{type: 'tag'}],
  components: {item: ItemWithValidation},
})
```

### Schema-level or workspace-level scope

The example above applies the override to a single field. You can also apply it across the workspace by setting `form.components.item` on your Studio configuration:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {ItemWithValidation} from './components/ItemWithValidation'

export default defineConfig({
  // ...
  form: {
    components: {
      item: ItemWithValidation,
    },
  },
})
```

Use the schema-level slot when one array needs a different treatment from the rest of the Studio. Use the workspace-level slot to apply the same rendering to every array in the workspace. The component receives the same `ItemProps` in both cases, so the same custom component works at either scope.

### Related resources

- [Form Components](https://www.sanity.io/docs/studio/form-components) covers the full set of override slots, including `components.input`, `components.field`, and `components.preview`.
- [Configuration](https://www.sanity.io/docs/studio/configuration) covers workspace-level `form.components` setup in context.



# Initial Value Templates

By default, whenever you create a new document in the studio, the document will be initialized with empty values for every field (actually: `undefined`). Sometimes this is not what you want. In this article, we'll look at some of the ways you can set your documents with some prefilled initial values.



There are two ways of defining initial values, depending on what you want to achieve:

1. Define a single set of initial values to apply to all new documents of the same type
2. Define a set of different templates to choose from when creating a new document

## Define a single set of initial values

If you always want a particular document type to have a single set of initial values, the simplest way to do this is by specifying the `initialValue` property on a document type. In the following example, the `project` schema type is given the value `false` for the `isHighlighted` field.

```javascript
export default {
  name: 'project',
  type: 'document',
  title: 'Project',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string'
    },
    {
      name: 'isHighlighted',
      title: 'Highlighted',
      type: 'boolean'
    },
    {
      name: 'releaseDate',
      title: 'Release date',
      type: 'datetime'
    }
  ],
  initialValue: {
    isHighlighted: false
  }
}
```

Sometimes you may want to compute property values. For instance, in the example above, you may want to set the `releaseDate` property to be the current date. You can do this by specifying a function for the `initialValue` property:

```javascript
export default {
  // ...
  initialValue: () => ({
    isHighlighted: false,
    releaseDate: (new Date()).toISOString()
  })
}
```

> [!WARNING]
> Gotcha
> The `datetime` example above will not work for a field of type `date`, since those should not include the time along with the date string. A similar strategy for `date` fields might look like this: 
> `initialValue: new Date().toISOString().slice(0, 10)`

## Set an initial value for a specific field

In the creation of a field in a document, you can specify an `initialValue` for that specific instance of the field.

```javascript
export default {
  name: 'project',
  type: 'document',
  title: 'Project',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string'
    },
    {
      name: 'isHighlighted',
      title: 'Highlighted',
      type: 'boolean',
      initialValue: false
    },
    {
      name: 'releaseDate',
      title: 'Release date',
      type: 'datetime'
    }
  ]
}
```

### Single-field examples

Initial values can be used on any schema type.

```javascript
export default {
  name: 'project',
  type: 'document',
  title: 'Project',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      initialValue: 'This string'
    },
    {
      title: 'title',
      name: 'myArray',
      type: 'array',
      initialValue: ['red', 'green'],
      of: [
          {type: 'string'},
      ],
    },
    {
      title: 'Complex Array',
      name: 'myArray',
      type: 'array',
      initialValue: [
        {
          // Required _type to tell the schema what fields to map
          _type: 'mySecondObject', 
          stringField: 'Starting string'
        }
      ],
      of: [
        {type: 'myFirstObject'},
        {type: 'mySecondObject'}
      ],
    },
    {
      name: 'myObject',
      title: 'My custom input',
      type: 'object',
      initialValue: {
        name: "some name",
        someField: "Some other thing"
      },
      fields: [
        {
            name: 'name',
            title: 'Title',
            type: 'string'
        },
        {
            name: 'someField',
            title: 'Something',
            type: 'string'
        },
      ],
    },
    {
      title: 'Custom type',
      type: 'myCustomObjType',
      name: 'myCustomObjType',
      initialValue: {
        customName: "This is a custom name",
        customString: "This is a custom string"
      }
    },
    {
      name: 'isHighlighted',
      title: 'Highlighted',
      type: 'boolean',
      initialValue: false
    },
    {
      name: 'releaseDate',
      title: 'Release date',
      type: 'datetime',
      initialValue: (new Date()).toISOString()
    }
  ]
}
```

## Initial value precedence

Initial values for nested types will be deeply merged. The initial value for the above `project` type will be resolved to something similar to:

```javascript
{
  "releaseDate": "2021-04-28T10:39:17.622Z",
  "isHighlighted": false
}
```

In a case where an object type declares initial values for a field that also defines an initial value, the object type's initial value will "win" and override the field's initial value.

In the following example, the resolved initial value for the project type will be `{"isHighlighted": true}`:

```javascript
export default {
  name: 'project',
  type: 'document',
  title: 'Project',
  fields: [
    {
      name: 'isHighlighted',
      title: 'Highlighted',
      type: 'boolean',
      initialValue: false
    },
    //...
  ],
  initialValue: {
    // this overrides the initial value defined on the field
    isHighlighted: true
  }
}
```

> [!TIP]
> Protip
> If you want to clear the initial value defined for a nested type, you can do this by setting the initial value to `undefined`.

## Define multiple templates

So far, we’ve only looked at customizing the initial value for all documents of a document type. Quite often, you'll want to provide a set of different templates that an editor can choose from.

> [!WARNING]
> Gotcha
> Initial value *templates* only applies to `document` types.

In this example, we'll be working directly in the `defineConfig` configuration of our imaginary studio, but you can of course choose to export your templates/template functions elsewhere as they grow unwieldy.

```javascript
// sanity.config.js|ts

import {defineConfig} from 'sanity'

export default defineConfig({
  //...rest of config
  schema: {
    templates: (prev, context) => {
        console.log(prev)
        // logs array of templates for existing types
        console.log(context);
        // logs: schema, currentUser, getClient, dataset, projectId
        return prev;
      }
    } 
  }
})
```

We’ve now recreated the exact same functionality that you get out of the box, but we also get a look at the templates that exist for our current types. For a document of type `movie` (such as you might find in the default "Movie project" example) the template looks like this:

```javascript
{
    id: "movie",
    schemaType: "movie",
    title: "Movie",
    value: {
        "_type": "movie"
    }
}
```

> [!NOTE]
> Behind the scenes
> This gives you an idea of what happens behind the scenes: each document type will generate a template for its type, which will produce either an empty object as its initial value or whichever value is set in the schema type definitions *initialValue* property.

Let's take what we've learned and make it useful by defining a custom initial values template.

In the following example, we include all the default templates, and then we add a template for documents of type `person` which will set up each new document with a prefilled value for the `role` field.

```javascript
// sanity.config.js|ts
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...rest of config
  schema: {
    templates: (prev) => [
      ...prev,
      {
      id: 'person-developer',
      title: 'Developer',
      schemaType: 'person',
      value: {
        role: 'developer'
      },
    },
	],
})
```

When creating new documents, an editor will now get the option to create either a Person (the default template for the person type) or a Developer, which is the specific template we defined above with a pre-populated `role` property. The `value` property can also be defined as a function.

![Shows the "new document" option as a dropdown with two options: "Person" and "Developer"](https://cdn.sanity.io/images/3do82whm/next/a745d7910129fa7566bd6668fdd90a320d520981-384x158.png)

To polish this editor experience, it's a best practice to assign custom icons for templates to make them easier to differentiate. You can do this by setting the `icon` property:

```javascript
// sanity.config.js|ts
import {defineConfig} from 'sanity'
import {CogIcon} from '@sanity/icons/Cog'

export default defineConfig({
  // ...rest of config
  schema: {
    templates: (prev) => [
      ...prev,
      {
      icon: CogIcon,
      id: 'person-developer',
      title: 'Developer',
      schemaType: 'person',
      value: {
        role: 'developer'
      },
    },
	],
})
```

## Inserting objects or references as default values

Fields that are object types (or is a reference to another document) have a few rules that you need to keep in mind:

- In instances of nested objects, it's necessary to include a `_type` property to define what schema type to reference. For instance, if inserting a `geopoint`, you will set the field value to:
`{_type: 'geopoint', lat: 59.92409, lng: 10.7584}`
- To create a Reference initial value, the `_ref` property must be defined. This means that if you want to reference a specific document, you need to provide that document's ID as the value for `_ref`. `{_ref: 'document-id-to-reference'}`
- [Images](https://www.sanity.io/docs/image-type) and [files](https://www.sanity.io/docs/file-type) are represented as objects with a nested `asset` field (which is a reference to the actual asset document). Combining the two points above, the default value for an image field would look something like this:
`{_type: 'image', asset: {_type: 'reference', _ref: 'image-someId-200x300-jpg'}}`
- While objects in arrays should generally have a `_key` property, the initial value system will generate the `_key` property if it is not included in the provided value (using a randomly generated string).

## Resolving initial values asynchronously

The initial value can also be specified as an asynchronous function (a function returning a promise). This allows exciting things like running a request to an API to get data needed for the initial value. For instance:

```javascript
import axios from 'axios'

export default {
  // ...
  initialValue: async () => {
    const response = await axios.get('https://api.sanity.io/pets')
    return {favoritePetName: response.data[0].name}
  }
}
```

## Parameterized templates

A common use case is to populate fields based on a set of parameters. You can do this by defining a `parameters` array for your template. By defining the `value` property as a function, you'll get the parameters passed to the template as the argument to the function. Each item in the `parameters` array follows the same declaration as fields within a schema object type:

```javascript
// sanity.config.js|ts
import {defineConfig} from 'sanity'

export default defineConfig({
  //...rest of config
  schema: {
    templates: [
      {
        id: 'person-role',
        title: 'Person with role',
        schemaType: 'person',
        parameters: [
          {
            name: 'roleName',
            title: 'Role name',
            type: 'string',
          },
        ],
        value: (parameters) => ({
          role: parameters.roleName,
        }),
      },
    ],
  },
})

```

> [!WARNING]
> Parameterized templates are only supported in Structure
> You can only use parameterized templates in Structure. They will not work as expected in other parts of the Studio UI.

## Using templates in a structure

When defining your structure for the Structure tool (using the [Structure Builder](https://www.sanity.io/docs/overview-structure-builder)), a common use case is creating filtered document lists. In these cases, you probably want the **new document** action to not start with a totally empty document but have pre-populated values that match the user is current structure.

In a dataset containing many books and their related authors, you may want to segment the books by author. To do this, you might create a structure that looks like this:

```javascript
// structure.js

export const structure = (S) =>
  S.list()
  .id('root')
  .title('Content')
  .items([
    S.listItem({
      id: 'books-by-author',
      title: 'Books by author',
      schemaType: 'book',
      child: () =>
        S.documentTypeList('author').child(authorId =>
          S.documentTypeList('book')
            .title('Books by author')
            .filter('_type == $type && author._ref == $authorId')
            .params({type: 'book', authorId})
        )
    }),
    ...S.documentTypeListItems()
  ])
```

This satisfies the navigation part, but it doesn’t select the author we want when creating a new document. For this to work, we need to first create a parameterized initial value template, then tell the structure to use it. Let’s first define the initial value template. In your `sanity.config.js`:

```javascript
// sanity.config.js|ts
import {defineConfig} from 'sanity'
import {structure} from './structure'

export default defineConfig({
 //...rest of config
  plugins: [
    structureTool({ structure }),
   ],
  schema: {
    templates: [
  		  {
      id: 'book-by-author',
      title: 'Book by author',
      description: 'Book by a specific author',
      schemaType: 'book',
      parameters: [{name: 'authorId', type: 'string'}],
      value: params => ({
        author: {_type: 'reference', _ref: params.authorId}
      })
    }
	],
 },
})
```

Now let’s use it in our structure:

```javascript
// structure.js

export const structure = (S) =>
  S.list()
  .id('root')
  .title('Content')
  .items([
    S.listItem({
      id: 'books-by-author',
      title: 'Books by author',
      schemaType: 'book',
      child: () =>
        S.documentTypeList('author').child(authorId =>
          S.documentTypeList('book')
            .title('Books by author')
            .filter('_type == $type && author._ref == $authorId')
            .params({type: 'book', authorId})
            .initialValueTemplates([
              S.initialValueTemplateItem('book-by-author', {authorId})
            ])
        )
    }),
    ...S.documentTypeListItems()
  ])
```

Note how we’re defining which initial value templates should be valid in this context: by specifying just a single template, that is the only template valid in this context and will be used. If you specify multiple templates, you will get a choice of which template to use. The second argument to `S.initialValueTemplateItem()` is a set of parameters – in this case, we're passing the `authorId` from the parent pane as a parameter.

> [!TIP]
> Removing + document buttons
> When using the `.initialValueTemplates` method, you can set it's value to an empty array to remove the "+" button from appearing in structure builder. This is useful for sections where you only want to display documents, but not create them. For example: `.initialValueTemplates([])`.



# Cross-dataset references

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

A fundamental requirement for enabling a content-driven workflow is having access to the proper tools to help you compartmentalize and then [connect your content](https://www.sanity.io/docs/studio/connected-content). A way of composing sets of fields to create documents, and of connecting documents to create relationships. Boxes and arrows, if you will.

The premier tool for connecting content in Sanity is the [reference schema type](https://www.sanity.io/docs/studio/reference-type), for creating binding relationships between content types. The reference type only allows references within a single dataset. This covers most use cases, but sometimes more complex architectures and content needs present a legitimate case for a way of communicating across datasets.

Enterprise organizations often have different teams working with different content across channels, geographies, and markets. You might have one team managing product data. A handful of other teams each in charge of the digital experience for their specific brand, in their specific market. And a centralized legal team, who supports various brands and markets by providing copy for Terms of Service, warranties, and other official information. Making sure these teams all refer to the same single source of truth for any specific bit of content is a challenge in most CMSes and often leads to duplication of effort and content debt.

For these scenarios, there is the [crossDatasetReference](https://reference.sanity.io/sanity/index/CrossDatasetReferenceDefinition/) schema type! With it comes the ability to make references between documents in different datasets. See the [CrossDatasetReferenceDefinition](https://reference.sanity.io/sanity/index/CrossDatasetReferenceDefinition/) reference for the full type definition.

While closely related to the `reference` type, the `crossDatasetReference` type has some unique capabilities and some different limitations that you should be aware of.

## The anatomy of a cross-dataset reference

A cross-dataset reference is, as its name suggests, a reference in one dataset to a document in another dataset. In order for this to be possible, there are some requirements that must be met.

> [!NOTE]
> For the remainder of this article, we’ll use the term **referencing dataset** when we discuss the dataset where the reference originates (i.e., the document that has a field pointing to a document in a different dataset), and **referenced dataset** when we talk about the dataset that is being referred to.

- Both datasets must belong to the same project, which must be on an enterprise plan and have this feature enabled.
- Cross-dataset references are supported in all current versions of Sanity Studio (legacy Studio v2 requires `v2.34.3` or later).
- The dataset name of the **referenced dataset** must be known at the time of creating the reference field in the **referencing dataset**.
- Similarly, the type of document you wish to refer to in the **referenced dataset**, and one or more of its fields, must be known in order to set up previews in the **referencing dataset**.

## Exploring the `crossDatasetReference` schema

> [!NOTE]
> To read details about the `crossDatasetReference` schema type, visit the [schema type reference](https://www.sanity.io/docs/studio/cross-dataset-reference-type) documentation.

The `crossDatasetReference` type is, as mentioned, closely related to the `reference` type. It supports most of the same properties and options, in addition to some specific ones. Let’s have a look at a minimal example of a `crossDatasetReference` schema, and then go a bit further once we’ve established the basics.

```javascript
//Type definition on the schema of the "referencing" dataset,
//i.e. where the reference originates

{
  name: 'my-reference-field',
  title: 'Reference to a document in another dataset',
  type: 'crossDatasetReference',
  dataset: 'name-of-the-other-dataset',
  to: [
    {
      type: 'article',
      preview: {
        select: {
          title: 'title'
        },
      },
    },
  ],
}
```

- All fields in the above example, except the title, are required.
- The `type` must be set to `crossDatasetReference`.
- The `dataset` must have the appropriate value.
- The `to` field accepts an array of entries to different document types in the referenced dataset. You may define as many types here as you please, but each `crossDatasetReference` field is limited to connecting to a single referenced dataset.
- Because the entire schema of all document types in the **referenced dataset** is not known to the **referencing dataset**, the following is true for each entry in the `to` array:- In addition to `type`, each entry must specify one or more fields to use when searching for and previewing content in the **referenced dataset**. To learn more, refer to the [previews and list views documentation](https://www.sanity.io/docs/studio/previews-list-views).



Let’s add a few more fields and a little more complexity to our schema:

```javascript
{
  title: 'Reference to a document in a another dataset',
  name: 'myCoolReferenceAcrossDatasets',
  type: 'crossDatasetReference',
  dataset: 'name-of-other-dataset',
  studioUrl: ({ type, id }) => `https://target.studio/structure/${type};${id}`,
  to: [
    {
      type: 'article',
      preview: {
        select: {
          title: 'title',
          media: 'heroImage',
        },
      },
    },
    {
      type: 'person',
      preview: {
          select: {
            name: 'name',
            picture: 'portrait',
            honorific: 'jobTitle',
          },
          prepare({ name, picture, honorific }) {
            return {
              title: name,
              media: picture,
              subtitle: honorific,
            };
          },
        },
      },
  ],
}
```

Let’s look at what we’ve added.

- The `studioUrl` field on line 6 accepts a function, which is invoked with the `type` and `id` of your referenced document, and which should return a string in the shape of a URL to the document editing pane address in the referenced dataset studio. This field is used to create a direct link from the reference preview to its editing environment (providing your editors have access to it, of course).
- Finally, we’ve added a second document type to our `to` array with an expanded preview configuration.

## The cross-dataset reference field in your Studio

Having configured your schema, you should see the `crossDatasetReference` field show up in your Studio. While similar to `reference` inputs, they differ in some key aspects:

- The “Create New” button and option to open the referenced document in a new pane to the right are not available across datasets. Instead, you will find an intent link that will open the referenced document in the target studio (if you have access to it, and have set the `studioUrl` property).
- Linking to drafts is not available across datasets. Unless the document has been published at some point, it will not show up in search.
- Depending on network conditions, searching and previewing cross-dataset reference fields might be less performant than doing the same operations on internal references.

![Cross-dataset reference input in Sanity Studio showing search results from the target dataset.](https://cdn.sanity.io/images/3do82whm/next/f4247b1c4574e78c9c9aa7821e8939cd47371343-1800x934.png)
*Searching for documents in the target studio.*

## Editor support for cross-dataset references

> [!TIP]
> Protip
> The visibility of cross-dataset references depends on the permissions of the current user or token. For private datasets this means that:
> - A user or token can see that a reference exists if they have at least read permissions on the source document. If they don’t have read permissions on the target document, they’ll see that the reference exists but not the content of the target document.
> - A user or token can fetch the referenced document if they have at least read permissions on the target document.
> - A user who wants to create a reference to a document can search for and attach any documents they have read permissions on.

![Shows cross-dataset input with popover](https://cdn.sanity.io/images/3do82whm/next/5481da83a30701082d52a5237da2bc8ec98ee447-1800x720.png)
*If studioUrl is set, the referenced document will open in the target studio.*

As with the `reference` schema type, `crossDatasetReference` fields are by default assumed to have bidirectional integrity, which means that if you try to delete a document that is referred to by another document, the Studio will alert you with a warning.

![Shows warning dialog](https://cdn.sanity.io/images/3do82whm/next/bad0cf6915c6b732624bd378802fdb9c74da7e5f-1800x1016.png)

However, unlike references within a single dataset, the studio will allow you to proceed with deleting or unpublishing documents that are referenced by cross-dataset references from other datasets.

If you go ahead and delete the document despite the warnings, it will show up as unavailable in any studio referencing it and will block publishing until the problem is fixed if any changes are made to the referring document.

![Shows warning about missing content](https://cdn.sanity.io/images/3do82whm/next/5cd229a42e5761bd11f2c7b6913c7ecfcea3941a-1800x720.png)

These measures are in place so that you can feel confident about connecting your content across datasets, and that you will be notified if a referenced document disappears.

Sometimes you don't need this guarantee but want to keep the convenience of references. This warning can be turned off by adding the `weak: true` property to a reference field configuration.

You will still be notified that the document you are referring to has gone missing, but you will no longer be blocked from publishing.

## Querying cross-dataset references

> [!WARNING]
> Gotcha
> Cross-dataset references require you to use API version `v2022-03-07` or later. [Read more about the Sanity API versioning scheme here](https://www.sanity.io/docs/content-lake/api-versioning).

> [!WARNING]
> Gotcha
> Cross-dataset references can only be dereferenced using GROQ queries. Dereferencing through GraphQL endpoints is not currently supported.

To GROQ, a `crossDatasetReference` behaves similarly to an internal reference, except that dereferencing must always start from the “referencing” document. For example, for these two schemas, each in a different dataset:

```javascript
// Movie type (movies-dataset)

{ 
  name: 'movieName',
  ...
},
{
  name: 'Actors',
  title: 'Actors',
  type: 'array',
  of: [{
    type: 'crossDatasetReference',
    dataset: 'people-dataset',
    to: [
      {
        type: 'person',
        preview: {
          select: {
            name: 'firstname'
          },
        },
      },
    ]
  }]
}
```

```javascript
// Person type (people-dataset)

{ 
  name: 'firstname',
  ...
},
{ 
  name: 'lastname',
  ...
},
...
```

A GROQ query starting at the movie type can dereference the “actors” field elements to retrieve the person document's fields:

```groq
*[_type == "movie"] {
  ...,
  "actors": actors[]->{
    ...,
    firstname,
    lastname,
  }
}
```

There are no limitations on the number of levels or nesting of references supported by the dereferencing operation, but dereferencing can only be done through the `->` operator, following the “unidirectionality” of cross-dataset references. For example, if the person type had an “awards” cross-dataset reference field, it could be further dereferenced as follows:

```groq
*[_type == "movie"] {
  ...,
  "actors": actors[]->{
    ...,
    firstname,
    lastname,
    awards->name
  }
}
```

However, other ways of dereferencing, for example, using the `references()` function, are **not supported**:

```groq
// This is a NOT SUPPORTED query
*[_type == "movie"] {
  ...,
  "actors": actors[]->{
    ...,
    firstname,
    lastname,
    "awards": *[_type == "awards" && references(^._id)] // <== here the reference function will not work for a cross-dataset reference
  }
}
```

> [!WARNING]
> Perspectives are limited to the initiating dataset (API versions prior to 2025-06-19)
> **Prior to API version 2025-06-19**, Perspectives only applied to the initiating dataset and would not apply to items in the referenced dataset. This can result in only seeing published documents, even when in preview environments.
> As of v2025-06-19, cross-dataset references now respect the perspective of the querying dataset.

## In conclusion

The cross-dataset reference schema type is a powerful tool for enabling shared content across datasets. It allows you to keep your content connected beyond its original context by extending the reference field with methods for authenticating and querying across datasets.

Further reading:

- [crossDatasetReference schema type](https://www.sanity.io/docs/studio/cross-dataset-reference-type)
- [Connected Content article](https://www.sanity.io/docs/studio/connected-content)



# Sort orders

When displaying a collection of documents it's useful to be able to [sort the collection](https://www.sanity.io/docs/content-lake/how-queries-work) by different fields. You do this by specifying an `orderings` property in the schema:

```javascript
{
  name: 'movie',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string'
    },
    {
      title: 'Release Date',
      name: 'releaseDate',
      type: 'date'
    },
    {
      title: 'Popularity',
      name: 'popularity',
      type: 'number'
    }
  ],
  orderings: [
    {
      title: 'Release Date, New',
      name: 'releaseDateDesc',
      by: [
        {field: 'releaseDate', direction: 'desc'}
      ]
    },
    {
      title: 'Release Date, Old',
      name: 'releaseDateAsc',
      by: [
        {field: 'releaseDate', direction: 'asc'}
      ]
    },
    {
      title: 'Popularity',
      name: 'popularityDesc',
      by: [
        {field: 'popularity', direction: 'desc'}
      ]
    }
  ]
}
```

The `orderings` above define a list of possible ways to order a collection of movies. To the user these appear as options in the Studio when the movies are listed, with each object in `orderings` being its own sort option (one can sort by `Release Date, New` OR `Release Date, Old` OR `Popularity`).

## Default sort orders

If no sort orders are defined, Sanity will do its best to guess what fields would make sense to sort by.

When no ordering is specified:

- If the document type has *string* fields named `title`, `name`, `label`, `heading`, `header`, `caption` or `description`, we enable options to order by all of these.
- If your type has no fields named any of the above, we will generate ordering configs for *all* fields of primitive types, that is fields of type `string`, `number`, or `boolean`.

If you specify your own ordering, we skip the default heuristics above.

## Null ordering

You can control when null/undefined values appear in [results](https://spec.groq.dev/GROQ-1.revision5/) with the `nulls` option.

Defaults are unchanged and still:

- `desc` -> nulls `first`
- `asc` -> nulls `last`

```typescript
defineType({
  name: 'book',
  type: 'document',
  orderings: [
    {
      title: 'Publication year',
      name: 'publicationYear',
      by: [
        {
          field: 'publicationYear',
          direction: 'desc',
          nulls: 'last' // or 'first'
        }
      ],
    },
  ],
  // ...
})
```

Note that overriding the default may have performance implications and negatively impact loading times for document types with lots of documents.

## Ordering by reference fields

The Structure Tool automatically dereferences [references](https://www.sanity.io/docs/specifications/groq-data-types) accessed via dot notation in orderings. This differs from how [GROQ dereferencing](https://www.sanity.io/docs/content-lake/how-queries-work) works, which uses the `->` syntax. You can sort by a field on a referenced document using dot notation, for example, `author.lastName` where `author` is a reference field. This behavior is non-standard compared to GROQ and is handled automatically by the Studio.

```typescript
defineType({
  name: 'article',
  type: 'document',
  fields: [
    {
      name: 'title',
      type: 'string',
      title: 'Title',
    },
    {
      name: 'author',
      type: 'reference',
      to: [{type: 'author'}],
      title: 'Author',
    },
  ],
  orderings: [
    {
      title: 'Author last name, A-Z',
      name: 'authorLastNameAsc',
      by: [
        {field: 'author.lastName', direction: 'asc'},
      ],
    },
  ],
})
```

The dot notation used here (e.g., `author.lastName`) is automatically resolved by the Studio and does not require the `->` operator that GROQ uses for dereferencing.

## Ordering by array items

You can order by a specific item in an [array field](https://www.sanity.io/docs/content-lake/how-queries-work) using bracket notation. For example, `tags[0]` sorts by the first element of a `tags` string array. This lets you create orderings based on the leading value in an ordered list.

```typescript
defineType({
  name: 'post',
  type: 'document',
  fields: [
    {
      name: 'title',
      type: 'string',
      title: 'Title',
    },
    {
      name: 'tags',
      type: 'array',
      of: [{type: 'string'}],
      title: 'Tags',
    },
  ],
  orderings: [
    {
      title: 'First tag, A-Z',
      name: 'firstTagAsc',
      by: [
        {field: 'tags[0]', direction: 'asc'},
      ],
    },
  ],
})
```



# Incoming reference decoration

Studio's inline incoming references feature allows you to define a component that will display any incoming references to the current document. 

Unlike a field containing an array of references, these incoming references are not part of the document and will display automatically when new references are made.

Prerequisites:

- Studio v5.8.0 or later is required to use incoming reference fields.

## Add an inline incoming reference decoration

To add incoming references to a field in your schema, import and use the `defineIncomingReferenceDecoration` helper.

**authorSchema.ts**

```
import {defineType} from 'sanity'
import {defineIncomingReferenceField} from 'sanity/structure'

export default defineType({
  name: 'author',
  type: 'document',
  renderMembers: (members) => {
   return [
     ...members,  // Places the decoration after all existing fields.
     defineIncomingReferenceDecoration({
       name: 'incomingReferences',
       title: 'Incoming references',
       types: [{type: 'author'}],
     }),
   ]
  },
 fields: [],
})
```

This adds a component into the document that looks like the following:

![The incoming reference interface showing "Posts by this author"](https://cdn.sanity.io/images/3do82whm/next/1ef34b6c429e5c08d2b8728c383069381685da05-1334x704.png)

### Create and reference new documents

You can allow the "Create" button to pre-populate a new document with a reference to the source document.

This feature can leverage the initialValue of a new document to set the reference. On the schema for the referencing document type, use the `isIncomingReferenceCreation` helper to check if an incoming reference field is creating the new document. Then pass in the reference to the appropriate field. In this example, the `book` document type references the `author` type.

**bookSchema.ts**

```
import {defineType} from 'sanity'
import {isIncomingReferenceCreation} from 'sanity/structure'

export defineType({
  name: "book",
  fields: [
    // ...
  ],
   initialValue: (params) => {
    return {
      author: isIncomingReferenceCreation(params) ? params.reference : undefined,
    }
  },
)
```

### Add reference to existing documents

If you have existing documents that you want to search/assign to the current document, use the `onLinkDocument` option. 

**index.ts**

```
import {defineType} from 'sanity'
import {defineIncomingReferenceDecoration} from 'sanity/structure'

export default defineType({
  name: 'author',
  type: 'document',
  renderMembers: (members) => {
   return [
     ...members, 
     defineIncomingReferenceDecoration({
      name: 'booksCreatedByThisAuthor',
      types: [{type: 'book'}],
      onLinkDocument: (document, reference) => {
        return {
         ...document,
         author: reference, // <-- the reference is passed to the document
        }
      },
    }),
   ]
  },
  fields: [
    defineField({type: "string", name: "name"}),
   // ...
  ]
})
```

### Create custom actions

You can also pass custom actions to the incoming reference field. This format is similar to [document actions](https://www.sanity.io/docs/studio/document-actions), but occurs in the `defineIncomingReferenceDecoration` helper. This example creates an action in `ReferenceActions.tsx` and applies it in the `actions` array.

**authorSchema.ts**

```
import {defineType} from 'sanity'
import {defineIncomingReferenceDecoration} from 'sanity/structure'
import {RemoveReferenceAction} from './ReferenceActions'

export default defineType({
  name: 'author',
  type: 'document',
  renderMembers: (members) => {
   return [
     ...members,  
     defineIncomingReferenceDecoration({
       name: 'incomingReferences',
       title: 'Incoming references',
       types: [{type: 'author'}],
       actions: [RemoveReferenceAction]
     }),
   ]
  },
 fields: [],
})
```

**ReferenceActions.tsx**

```
import {type IncomingReferenceAction} from 'sanity/structure'
import {getDraftId} from 'sanity'
import {useState} from 'react'

export const RemoveReferenceAction: IncomingReferenceAction = ({document, getClient}) => {
  const [dialogOpen, setDialogOpen] = useState(false)
  const client = getClient({apiVersion: '2025-10-01'})

  return {
    label: 'Remove reference',
    icon: TrashIcon,
    tone: 'critical',
    dialog: dialogOpen
      ? {
          type: 'confirm',
          message: 'Are you sure you want to remove the reference?',
          onCancel: () => setDialogOpen(false),
          onConfirm: async () =>
            await client.createOrReplace({
              ...document,
              _id: getDraftId(document._id),
              author: undefined, // Removes the reference from the document
            }),
        }
      : null,
    onHandle: () => setDialogOpen(true),
  }
}

```

### Support cross-dataset references

Cross-dataset references are also supported. You'll need to supply additional details as shown below.

**index.ts**

```
import {defineType} from 'sanity'
import {defineIncomingReferenceDecoration} from 'sanity/structure'

export default defineType({
  name: 'author',
  type: 'document',
  renderMembers: (members) => {
   return [
     ...members,
     defineIncomingReferenceDecoration({
       name: 'incomingReferencesCrossDataset',
       title: 'Incoming references CrossDataset',
        types: [
          {
            type: 'book',
            dataset: 'test-us',
            title: 'Book in test-us dataset',
            studioUrl: ({id, type}) => {
              return type ? `/us/intent/edit/id=${id};type=${type}` : null
            },
            preview: {
              select: {title: 'title', media: 'coverImage', subtitle: 'publicationYear'},
            },
          },
        ]
     }),
   ]
  },
 fields: [],
})

```



# Introduction

Block content in Sanity uses Portable Text, a structured format for rich text that stores content as blocks. It allows you to create content with formatting, custom blocks, and annotations while keeping the content separate from its presentation.

In Sanity Studio, block content lets you build flexible editing experiences where you can include custom content types, add structured data to text, and control how your content appears across different platforms.

Here's what you can do with block content:

- **Create rich text content** with customizable styles, decorators, and annotations.
- **Embed custom content blocks** like images, videos, or code snippets directly within your text.
- **Add structured data to text** through annotations, enabling features like internal linking to references.
- **Customize the editing experience** with your own toolbar icons, block styles, and plugins.
- **Serialize Portable Text** for a common targets like HTML, React, Vue, Markdown, or even write your own custom serializer.

## Core concepts

### Portable Text

When you define your schema, you define rich text as an array of blocks. This is the fundamental shape of Portable Text. 

Portable Text is built on the idea of rich text as an array of blocks, where each block is an array of child spans.

#### Blocks

Blocks are units representing paragraphs, headings, or other block-level elements. Each block can have a style (like normal, h1, h2, etc.) and contains an array of spans or inline objects.

#### Spans

Spans are the text content within blocks. They can have marks applied to them, which are either simple decorators (like bold or italic) or more complex annotations (like links with structured data).

#### Marks

Marks let you label sections of inline text, either for stylistic reasons for to add additional information to the text. There are two types of marks.

Decorators are simple marks applied to spans, like bold, italic, or inline code formatting. They're stored as string values in the marks array of a span.

Annotations are more complex marks that can contain structured data. For example, a link annotation might include a URL or a reference to another document. 

#### Custom blocks

Beyond text blocks, Portable Text allows you to insert custom content blocks like images, videos, or any other content type you define. These appear as separate items in the Portable Text array.

### The Portable Text Editor

When you use an array of blocks in your schema, Studio inserts a pre-configured version of the Portable Text Editor(PTE). The editor itself is open source and allows you to build on top of the same foundation that Studio uses for its rich text experience. Learn more about the [standalone editor](https://portabletext.org).

### Extending the editor in Studio

You can customize the built-in editor experience by customizing blocks individually, and by creating behavior plugins. You can even replace the entire editor with your own implementation of the standalone PTE.

### Rendering Portable Text in your apps

Because block content uses the Portable Text specification, you can use any portable text serializer to render the content in your front end code.

## Limitations

### Attribute limits

Block content is powerful, but can sometimes lead to complex documents made up of many attributes. Refer to the [advice in this guide on attribute limits](https://www.sanity.io/docs/content-lake/attribute-limit) to use block content responsibly.

#### Related articles

[Block](https://www.sanity.io/docs/studio/block-type)
Schema type for block which provides a rich text editor for block content.

[Configure the Portable Text Editor](https://www.sanity.io/docs/studio/portable-text-editor-configuration)
Configure the Portable Text Editor: styles, lists, decorators, annotations, custom blocks, tables, and the built-in Markdown and typography behaviors.

[Add Portable Text Editor plugins to Studio](https://www.sanity.io/docs/studio/add-portable-text-plugins)
Use official and community-built Portable Text Editor plugins in your studio.

[Attribute limit](https://www.sanity.io/docs/content-lake/attribute-limit)
Everything about the attribute limit: what it is, how to avoid it, and what to do if you hit the limit on one of your projects.



# Configure the Portable Text Editor

This page covers how to configure the Portable Text Editor (PTE) in Sanity Studio: which styles, lists, decorators, and annotations editors can apply, which custom blocks they can insert, and how to turn the editor's built-in Markdown, typography, table, and paste-link behaviors on or off.

Portable Text is extensible. Each block can have a style and a set of mark definitions that describe data structures distributed in the child spans. It also enables inserting arbitrary data objects in the array, requiring only a `_type` key. In a Sanity document, array members also need a `_key`, which the editor generates for you if it is missing. Portable Text allows custom content objects in the root array, enabling editing and rendering environments to mix rich text with custom content types.

You can create as many versions of the PTE as you want. A frequent pattern is a base configuration with selected decorators and annotations, and a more comprehensive configuration with custom block types.

This is helpful if editors only use emphasis and annotate text as internal links in some settings (for example, a caption) and have a full toolbox available in another (for example, an article body).

## Minimal configuration

The following example shows a minimal configuration to implement Portable Text and the editor in Sanity Studio:

```typescript
export default {
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    }
  ]
}
```

The code, an array of blocks, renders the PTE with a default configuration for styles, decorators, and annotations.

![The default editor configuration](https://cdn.sanity.io/images/3do82whm/next/eb1f7fdd0885f9fc65cd51779ee2490e41655052-516x307.png)

Portable Text is markup-agnostic; however, the default configuration maps easily to HTML conventions. **Bold** and *italics* set the decorators `strong` and `em` (emphasis), and produce a data structure like this:

```json
[
  {
    "_type": "span",
    "_key": "eab9266102e81",
    "text": "strong",
    "marks": [
      "strong"
    ]
  },
  {
    "_type": "span",
    "_key": "eab9266102e82",
    "text": " and ",
    "marks": []
  },
  {
    "_type": "span",
    "_key": "eab9266102e83",
    "text": "emphasis.",
    "marks": [
      "em"
    ]
  }
]
```

Portable Text isn't designed for direct human authoring or reading, but instead should be parsed by software. The `_type` key also makes it queryable in Sanity's APIs, and by other JSON tools, such as [jq](https://stedolan.github.io/jq/) or [groq-js](https://github.com/sanity-io/groq-js).

## Default behaviors

### Markdown behaviors

The PTE in Studio includes many default Markdown behaviors that may be familiar from other text editors. They map to the standard styles, lists, decorators, and annotations. These include:

- `# Title`: One or more `#` symbols followed by a space sets the matching heading level.
- `> block quote content`: The `>` character followed by a space and text converts to a block quote.
- Backspace at the beginning of a styled block removes its style.
- `-`, `*`, or `1.`: At the beginning of a line, these characters start a list.
- ``code``: Wrapping text in single backticks creates inline code.
- `*italic*` or `_italic_`: Wrapping text in single asterisks or underscores creates italic text.
- `**bold**` or `__bold__`: Wrapping text in double asterisks or underscores creates bold text.
- `~~strikethrough~~`: Wrapping text in double tildes creates strikethrough text.

You can undo any of these transformations by pressing Backspace immediately after it happens. Moving the cursor first commits the change.

#### Disable default Markdown behavior

You can disable these Markdown behaviors by modifying the configuration, either in your `sanity.config.ts` file globally, or on individual schema types.

**sanity.config.ts (globally)**

```typescript
export default defineConfig({
  // ... rest of config,
  form: {
    components: {
      portableText: {
        plugins: (props) => {
          return props.renderDefault({
            ...props,
            plugins: {
              ...props.plugins,
              markdown: {
                enabled: false
              }
            }
          })
        }
      },
    },
  }
})
```

**noMarkdownSchema.ts (individual)**

```typescript
export default defineType({
  type: 'array',
  name: 'noMarkdown',
  title: 'No markdown',
  description: 'Markdown disabled in this PTE',
  of: [
    {
      type: 'block',
    },
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            markdown: {
              enabled: false
            }
          }
        })
      }
    }
  }
})
```

If you're already using [custom PTE behavior plugins](https://www.sanity.io/docs/studio/add-portable-text-plugins), you can add the contents of `props.renderDefault` above into the `renderDefault` call in the behavior plugin.

### Typographic behaviors

The editor also includes a set of common typographic helpers, available from Studio v4.16.0 and enabled by default from v5.0.0. These transform the input inline, saving the transformed version to the document.

Each entry below lists the behavior name, the input text, and the output text. Use the behavior name to enable or disable an individual behavior in the configuration. The default behaviors apply to every PTE field unless you turn them off.

**Default behaviors**:

- `emDash`: `--` → —
- `ellipsis`: `...` → …
- `openingDoubleQuote`: `"` → “
- `closingDoubleQuote`: `"` → ”
- `openingSingleQuote`: `'` → ‘
- `closingSingleQuote`: `'` → ’
- `leftArrow`: `<-` → ←
- `rightArrow`: `->` → →
- `copyright`: `(c)` → ©
- `trademark`: `(tm)` → ™
- `servicemark`: `(sm)` → ℠
- `registeredTrademark`: `(r)` → ®

The four quote behaviors take the same straight-quote input. The editor chooses the opening or closing form based on the surrounding text.

**Optional behaviors**:

- `oneHalf`: `1/2` → ½
- `plusMinus`: `+/-` → ±
- `notEqual`: `!=` → ≠
- `laquo`: `<<` → «
- `raquo`: `>>` → »
- `multiplication`: `*` or `x` between numbers → ×
- `superscriptTwo`: `^2` → ²
- `superscriptThree`: `^3` → ³
- `oneQuarter`: `1/4` → ¼
- `threeQuarters`: `3/4` → ¾

#### Enable or disable typography behaviors

You can enable or disable typography behaviors at the global level, or directly in the schema definition for the block.

Explicitly disable all typography behaviors:

**sanity.config.ts (globally)**

```typescript
export default defineConfig({
  // ... rest of config,
  form: {
    components: {
      portableText: {
        plugins: (props) => {
          return props.renderDefault({
            ...props,
            plugins: {
              ...props.plugins,
              typography: {
                enabled: false
              }
            }
          })
        }
      },
    },
  }
})
```

**noTypographySchema.ts (individual)**

```typescript
export default defineType({
  type: 'array',
  name: 'noTypography',
  title: 'No typography',
  description: 'Typographic behaviors disabled in this PTE',
  of: [
    {
      type: 'block',
    },
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            typography: {
              enabled: false
            }
          }
        })
      }
    }
  }
})
```

Set the `preset` key to one of:

- `default`: Enables the default behaviors, and applies when unset.
- `all`: Enables both default and optional behaviors.
- `none`: Disables both default and optional behaviors. Use this if you plan to enable only specific behaviors.

**sanity.config.ts (globally)**

```typescript
export default defineConfig({
  // ... rest of config,
  form: {
    components: {
      portableText: {
        plugins: (props) => {
          return props.renderDefault({
            ...props,
            plugins: {
              ...props.plugins,
              typography: {
                preset: 'all'
              }
            }
          })
        }
      },
    },
  }
})
```

**allTypographySchema.ts (individual)**

```typescript
export default defineType({
  type: 'array',
  name: 'allTypography',
  title: 'All typography',
  description: 'Default and optional typographic behaviors enabled in this PTE',
  of: [
    {
      type: 'block',
    },
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            typography: {
              preset: 'all'
            }
          }
        })
      }
    }
  }
})
```

Enable or disable individual behaviors with the `enable` or `disable` key. Each accepts an array of behavior names.

**sanity.config.ts (globally)**

```typescript
export default defineConfig({
  // ... rest of config,
  form: {
    components: {
      portableText: {
        plugins: (props) => {
          return props.renderDefault({
            ...props,
            plugins: {
              ...props.plugins,
              typography: {
                enable: ['oneQuarter', 'threeQuarters'],
                disable: ['openingDoubleQuote', 'openingSingleQuote', 'closingDoubleQuote', 'closingSingleQuote']
              }
            }
          })
        }
      },
    },
  }
})
```

**customTypographySchema.ts (individual)**

```typescript
export default defineType({
  type: 'array',
  name: 'customTypography',
  title: 'Custom typography',
  description: 'Selected typographic behaviors enabled in this PTE',
  of: [
    {
      type: 'block',
    },
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            typography: {
              enable: ['oneQuarter', 'threeQuarters'],
              disable: ['openingDoubleQuote', 'openingSingleQuote', 'closingDoubleQuote', 'closingSingleQuote']
            }
          }
        })
      }
    }
  }
})
```

### Map Markdown behaviors to custom styles, lists, and decorators

If you're using non-standard names for your marks and decorators, you can map the default behaviors to the different names.

In this example, the unordered list is remapped to a list named "dot". Note that the schema declares the list with a `value` key while the callback reads `list.name`. The editor compiles `value` into `name` when it builds its own schema.

**customBlock.ts**

```typescript
export default defineType({
  name: 'customBlock',
  // ... rest of config
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [/* ... */],
      },
      lists: [{value: 'dot', title: 'Dot'}],
    },
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            markdown: {
              unorderedList: ({context}) =>
                context.schema.lists.find((list) => list.name === 'dot')?.name,
            }
          }
        })
      }
    }
  }
})
```

### Paste link behavior

If your Studio uses the default link annotation, you can select text and paste a URL to annotate the text as a link. To disable this behavior, set the `pasteLink.enabled` value to `false`.

**sanity.config.ts (globally)**

```typescript
export default defineConfig({
  // ... rest of config,
  form: {
    components: {
      portableText: {
        plugins: (props) => {
          return props.renderDefault({
            ...props,
            plugins: {
              ...props.plugins,
              pasteLink: {
                enabled: false
              }
            }
          })
        }
      },
    },
  }
})
```

**noPasteLinkSchema.ts (individual)**

```typescript
export default defineType({
  type: 'array',
  name: 'noPasteLink',
  title: 'No Pasteable links',
  description: 'pasteLink disabled in this PTE',
  of: [
    {
      type: 'block',
    },
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            pasteLink: {
              enabled: false
            }
          }
        })
      }
    }
  }
})
```

By default, the plugin looks for an annotation of name `'link'` with an `'href'` string field, and if that is present, it uses that to create the link. This behavior can be configured by providing a custom link matcher function (a function that has access to the current editor schema and returns either a typed object or undefined):

**sanity.config.ts**

```typescript
// ...rest of config above
pasteLink: {
  link: ({context, value}) => {
    const customLink = context.schema.annotations.find((a) => a.name === 'customLink')
    if (!customLink) return undefined
    return {_type: 'customLink', url: value.href}
  },
}
// ...
```

## Table editing

The Portable Text Editor includes built-in table editing, powered by `@portabletext/plugin-table`. Table editing is available from Studio v6.6.0 and is disabled by default. When enabled, the editor renders `table` blocks as editable tables with row and column controls, plus a table menu with a header row toggle. Tables inserted from the insert menu start as a 3×3 grid with a header row.

![A document editor displaying a table with the "Interaction" column highlighted.](https://cdn.sanity.io/images/3do82whm/next/1a7bbdf45d0fedcc9ce558fb1bce79232155ec93-665x586.png)

Enabling table editing takes two steps: declare the table schema the plugin binds to, and turn on the plugin.

### Define the table schema

By default, the plugin binds to a canonical schema shape: a `table` object whose `rows` array holds `row` objects, each holding a `cells` array of `cell` objects, each with a `value` array of blocks. Declare a `headerRows` number field on the table as well. (If your dataset already has a table-shaped type under different names, see the "Use your own table type names" section below.)

**table.ts**

```typescript
export const table = defineType({
  type: 'object',
  name: 'table',
  fields: [
    defineField({type: 'number', name: 'headerRows'}),
    defineField({
      type: 'array',
      name: 'rows',
      of: [
        defineArrayMember({
          type: 'object',
          name: 'row',
          fields: [
            defineField({
              type: 'array',
              name: 'cells',
              of: [
                defineArrayMember({
                  type: 'object',
                  name: 'cell',
                  fields: [
                    defineField({
                      type: 'array',
                      name: 'value',
                      of: [defineArrayMember({type: 'block'})],
                    }),
                  ],
                }),
              ],
            }),
          ],
        }),
      ],
    }),
  ],
})
```

Register the `table` type in your schema's `types` array, and add `table` to the `of` array of your Portable Text field so editors can insert it (shown in the next section).

> [!WARNING]
> Gotcha
> **Declare headerRows on the table type.** The editor strips fields the schema doesn't declare, so without it the insert menu's header-row scaffolding and the table menu's header row toggle silently do nothing: no error, no warning, and the first row never renders as a header.

Cells hold regular block content. You can extend the cell's `value` array with inline objects, images, or other custom members like any other Portable Text field.

### Enable the table plugin

Enable the plugin in your `sanity.config.ts` file globally, or on individual schema types.

**sanity.config.ts (globally)**

```typescript
export default defineConfig({
  // ... rest of config,
  form: {
    components: {
      portableText: {
        plugins: (props) => {
          return props.renderDefault({
            ...props,
            plugins: {
              ...props.plugins,
              table: {
                enabled: true
              }
            }
          })
        }
      },
    },
  }
})
```

**tableSchema.ts (individual)**

```typescript
export default defineType({
  type: 'array',
  name: 'bodyWithTables',
  of: [
    {type: 'block'},
    {type: 'table'},
  ],
  components: {
    portableText: {
      plugins: (props) => {
        return props.renderDefault({
          ...props,
          plugins: {
            ...props.plugins,
            table: {
              enabled: true
            }
          }
        })
      }
    }
  }
})
```

### Use your own table type names

If your dataset already contains a table-shaped type, bind the plugin to it instead of migrating data. The `containers` option takes `defineContainer` definitions for the `table`, `row`, and `cell` roles, where the type and array field names are yours. `defineContainer` comes from `@portabletext/editor`, which you add as a dependency.

**tableContainers.ts**

```typescript
import {defineContainer} from '@portabletext/editor'

// Define the containers at module scope: a new object identity
// re-registers the plugin on every render.
export const tableContainers = {
  table: defineContainer({type: 'richTable', arrayField: 'rows'}),
  row: defineContainer({type: 'row', arrayField: 'cells'}),
  cell: defineContainer({type: 'richTableCell', arrayField: 'content'}),
}
```

Pass them alongside `enabled`, in either the global or the individual setup shown above:

```typescript
plugins: (props) => {
  return props.renderDefault({
    ...props,
    plugins: {
      ...props.plugins,
      table: {
        enabled: true,
        containers: tableContainers
      }
    }
  })
}
```

Roles you omit fall back to the canonical names, and omitting `containers` entirely behaves exactly like the canonical setup above. Any fields on your types that table editing doesn't manage persist untouched, as long as your schema declares them. Each container definition also accepts a custom `render`; note that providing one for the `table` role replaces the Studio's table UI, including its table menu and header row toggle. If your render still uses the plugin's own table component without supplying a menu, the plugin's built-in menu renders in its place.

## Add custom blocks

Since Portable Text defines block content as an array, adding custom content blocks for images, videos, or code embeds means inserting these items between paragraph blocks.

> [!WARNING]
> Gotcha
> Content blocks for the Portable Text Editor must be object-like types, and not primitive types like `string`, `number`, or `boolean`. 
> You can also use types that are installed with [plugins](https://www.sanity.io/docs/studio/installing-and-configuring-plugins). Some plugins, such as those for tables, may export [an array type](https://www.sanity.io/docs/studio/array-type). Since it's not possible to store arrays directly within other arrays, you first need to wrap them in an object.

### Example: images

To add images to Portable Text, append a new type object to the array:

```typescript
export default {
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    },
    {
      type: 'image'
    }
  ]
}
```

This configuration adds an insert menu with **Image** as the only option:

![The insert menu showing Image as the only option](https://cdn.sanity.io/images/3do82whm/next/42fe954183c37bb88fe216b729fb308cfc255a4f-520x309.png)
*The insert image option is added to the toolbar*

Selecting an image inserts the block with a preview in the Portable Text Editor. You can drag the image and drop it in its designated position; you can also edit it by double-clicking the preview box or by selecting the edit option from the context menu:

![Edit or delete a custom block](https://cdn.sanity.io/images/3do82whm/next/e26f1b6be0cc28d7272d325f7b67c7f8c2f58d68-634x589.png)
*Edit or delete a custom block*

The Portable Text data structure for this example looks like the following:

```json
[
  {
    "style": "normal",
    "_type": "block",
    "markDefs": [],
    "_key": "09cc5f099d3b",
    "children": [
      {
        "_type": "span",
        "_key": "09cc5f099d3b0",
        "text": "Kokos is a miniature schnauzer.",
        "marks": []
      }
    ]
  },
  {
    "_type": "image",
    "_key": "a5e9155ee3f5",
    "asset": {
      "_type": "reference",
      "_ref": "image-61991cfbe9182124c18ee1829c07910faadd100e-2048x1366-png"
    }
  },
  {
    "style": "normal",
    "_type": "block",
    "markDefs": [],
    "_key": "54145e9cb006",
    "children": [
      {
        "_type": "span",
        "_key": "54145e9cb0060",
        "text": "Kokos is a good dog!",
        "marks": []
      }
    ]
  }
]
```

The image is its own object, where `asset` references the asset document. You can derive the image URL from the asset's `_id` (which matches the `_ref` value above), though most projects use the `@sanity/image-url` builder instead. You can also [join the asset document](https://www.sanity.io/docs/specifications/groq-joins) with a conditional projection:

```groq
*[_type == "post"]{
  ...,
  content[]{
    ...,
    _type == "image" => {
      ...,
      asset->
    }
  }
}
```

### Example: code input

Our documentation features many code blocks. They are custom blocks that we added to our editor. Install the code input plugin with npm, or with the `sanity install` [CLI command](https://www.sanity.io/docs/cli-reference/install):

**npm**

```shell
npm install @sanity/code-input
```

**pnpm**

```shell
pnpm add @sanity/code-input
```

**yarn**

```shell
yarn add @sanity/code-input
```

**bun**

```shell
bun add @sanity/code-input
```

Once installed and [configured](https://www.sanity.io/docs/studio/installing-and-configuring-plugins), you can add the code block to your Portable Text Editor configuration:

```typescript
export default {
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    },
    {
      type: 'image'
    },
    {
      type: 'code'
    }
  ]
}
```

*Code* is now available as a selection in the insert menu. To change the label, add `title: 'My title'` to the same object. Inserting a code block produces a preview and a code editor:

![The code editor with some schema code in JavaScript](https://cdn.sanity.io/images/3do82whm/next/a30a849860f32f198563e0b0c343a31ec4b1fd8d-942x790.png)
*The code editor with some schema code in JavaScript*

You can set [more options](https://www.npmjs.com/package/@sanity/code-input#options) for the code input.

[How to add a custom YouTube block](https://www.sanity.io/guides/portable-text-how-to-add-a-custom-youtube-embed-block)
Add a custom object block that embeds a YouTube video in Portable Text.

## Configure styles for text blocks

Out of the box, the Portable Text Editor includes the following styles: `normal`, `h1` through `h6`, and `blockquote`. By default, they map to HTML, but a style can be an arbitrary value. The `normal` style is always available, because the editor prepends it if your `styles` array omits it.

```typescript
// The default set of styles
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      styles: [
        {title: 'Normal', value: 'normal'},
        {title: 'Heading 1', value: 'h1'},
        {title: 'Heading 2', value: 'h2'},
        {title: 'Heading 3', value: 'h3'},
        {title: 'Heading 4', value: 'h4'},
        {title: 'Heading 5', value: 'h5'},
        {title: 'Heading 6', value: 'h6'},
        {title: 'Quote', value: 'blockquote'}
      ]
    }
  ]
}
```

![The default style configuration in the editor](https://cdn.sanity.io/images/3do82whm/next/d194c6084605d055ec365557a77c358543db1fc1-648x523.png)
*The default style configuration in the editor*

We recommend keeping the configuration reasonably abstract and following established conventions. If you plan to render with Sanity's Portable Text tooling, stay close to HTML naming conventions.

To override the default configuration for styles, add the `styles` key and set an array of `title`/`value` objects:

```typescript
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      styles: [
        { title: 'Normal', value: 'normal' },
        { title: 'Heading 2', value: 'h2' },
        { title: 'Quote', value: 'blockquote' },
        { title: 'Hidden', value: 'blockComment' }
      ]
    }
  ]
}
```

Here we have set four possible styles. The first three are from the default settings and are parsed in HTML to `<p>`, `<h2>`, and `<blockquote>`.

`blockComment` is an arbitrary style that we set because we plan to make it possible for editors to hide selected blocks of text from rendering, while keeping them available in the source code as block comments.

To change how blocks look inside the editor, see [Customize the Portable Text Editor](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor).

![Editor with style configuration](https://cdn.sanity.io/images/3do82whm/next/f91121e5420d896b44d5f6b92b8e575bb4cd5e8e-611x404.png)
*Editor with style configuration*

## Configure lists for text blocks

The editor supports two types of lists: bullet (unordered) and number (ordered). If your `block` type doesn't contain a `lists` definition, your editor features both a bullet list and a numbered list option:

![The portable text editor UI with the list selectors highlighted](https://cdn.sanity.io/images/3do82whm/next/0a0bc15e8cb00a34be4402fe639d113ee82c8739-451x49.png)
*Bullet and numbered lists*

The default is the equivalent of explicitly naming both:

```typescript
// The default set of lists
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      lists: [
        {title: 'Bulleted list', value: 'bullet'},
        {title: 'Numbered list', value: 'number'}
      ] // yes please, both bullet and numbered
    }
  ]
}
```

You can override the default by naming the lists you want. To disable lists altogether, leave the array empty:

```typescript
// No lists
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      lists: [] // no lists, thanks
    }
  ]
}
```

You also decide what goes into the title: `{title: 'Prioritized', value: 'number'}` works equally well.

## Configure marks for inline text

Portable Text enables marks to label inline text with additional data. There are two types of marks: *decorators* and *annotations*. Decorators are simple string values, while annotations are keys pointing to a data structure. Annotations are a powerful feature of Portable Text in combination with the Content Lake, because they allow embedding complex data structures and references in running text.

### Decorators

Decorators work similarly to styles, but they apply to spans, that is, inline text. The defaults are `strong`, `em`, `code`, `underline`, and `strike-through` (which the toolbar labels "Strike"). To replace these with your own set, add an array to the `decorators` key, under `marks`:

```typescript
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [
          {title: 'Strong', value: 'strong'},
          {title: 'Italic', value: 'em'},
          {title: 'Code', value: 'code'}
        ]
      }
    }
  ]
}
```

Decorators are displayed as icons in the toolbar. This configuration looks like this:

![Toolbar with custom decorator configuration](https://cdn.sanity.io/images/3do82whm/next/bbeb64ad8cef7d839770996e3196d17116b2d029-608x224.png)
*Toolbar with custom decorator configuration*

### Annotations

Annotations enable embedding rich content data in inline text. An example can be a reference to another document, typically used for internal linking.

To add an internal link annotation, configure the Portable Text schema like this:

```typescript
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [
          // ...
        ],
        annotations: [
          {
            name: 'internalLink',
            type: 'object',
            title: 'Internal link',
            fields: [
              {
                name: 'reference',
                type: 'reference',
                title: 'Reference',
                to: [
                  { type: 'post' },
                  // other types you may want to link to
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}
```

> [!WARNING]
> Gotcha
> If you plan to use Sanity’s GraphQL API, you should hoist `internalLink` as a schema type, and use `type: 'internalLink'` as the annotation, instead of the anonymous example above.
> [Learn more about using GraphQL with Sanity](https://www.sanity.io/docs/content-lake/graphql).

Annotations without an icon are displayed in the toolbar as question mark icons. For more information on editing toolbar icons, see [Customize the Portable Text Editor](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor).

![Reference modal for internal link annotation](https://cdn.sanity.io/images/3do82whm/next/49ec3c9475f4ddd23c5dd5c32d81132e865e7b10-614x409.png)
*Reference modal for internal link annotation*

The corresponding Portable Text data structure looks like this:

```json
[
  {
    "_key": "da9dc50335a0",
    "_type": "block",
    "children": [
      {
        "_key": "da9dc50335a00",
        "_type": "span",
        "marks": [
          "5b86c1132a66"
        ],
        "text": "This is an internal link"
      },
      {
        "_key": "da9dc50335a01",
        "_type": "span",
        "marks": [],
        "text": "."
      }
    ],
    "markDefs": [
      {
        "_key": "5b86c1132a66",
        "_type": "internalLink",
        "reference": {
          "_ref": "1dfa4e95-9f92-4e13-901b-1a769724e23c",
          "_type": "reference"
        }
      }
    ],
    "style": "normal"
  }
]
```



# Customize the Portable Text Editor

Sanity Studio's [Portable Text Editor](https://www.sanity.io/docs/studio/block-content) is customizable so that it can fit different editorial needs. You can configure and tailor several different editors throughout the studio.
For more information about configuring the editor, see [Configuring the Portable Text Editor](https://www.sanity.io/docs/studio/portable-text-editor-configuration).

In general, customization works by passing components to the schema definitions of the editor's content types.

## Toolbar icons and span rendering

When you configure custom marks, like decorators (simple values) and annotations (rich data structures), they display as icons in the toolbar. The default icon is a question mark. You can customize it to display a different icon. 

If you add custom decorators and annotations, you may want to control their visual preview in the editor. By default, decorators are invisible, whereas annotations have a gray background and a dotted underline.

### Decorators

Some often-used decorators, such as **strong**, *emphasis*, and `code`, feature rendering out of the box.

For example, let’s say you created a decorator to highlight text using the following configuration:

```jsx
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
          { title: 'Code', value: 'code' },
          { title: 'Highlight', value: 'highlight' }
        ]
      }
    }
  ]
}
```

Now, add a custom toolbar icon by passing in an anonymous function that returns `H` as a string to `.icon:`

```javascript
// RichTextEditor.jsx
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
          { title: 'Code', value: 'code' },
          { 
            title: 'Highlight',
            value: 'highlight',
            icon: () => 'H'
          }
        ]
      }
    }
  ]
}
```

The string is rendered in the decorator button in the toolbar:

![Toolbar with custom decorator button](https://cdn.sanity.io/images/3do82whm/next/3260a0230e618c96da5db15f00a2c9d33329e105-2562x1768.png)
*Toolbar with custom decorator button*

You can also pass a [JSX component](https://react.dev/learn/writing-markup-with-jsx) directly in the schema, or via an import.
The following example adds simple inline styling to a span holding the character *H*.

**RichTextEditor.jsx**

```jsx

import React from 'react'

const HighlightIcon = () => (
  <span style={{fontWeight: 'bold'}}>H</span>
  )

export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
          { title: 'Code', value: 'code' },
          {
            title: 'Highlight',
            value: 'highlight',
            icon: HighlightIcon
          }
        ]
      }
    }
  ]
}
```

The next step is to render the actual highlighted text in the editor. We do this by passing the props into a React component and wrapping them in a span with some styling.

**RichTextEditor.jsx**

```jsx
import React from 'react'

const HighlightIcon = () => (
  <span style={{ fontWeight: 'bold' }}>H</span>
)
const HighlightDecorator = props => (
  <span style={{ backgroundColor: 'yellow' }}>{props.children}</span>
)

export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
          { title: 'Code', value: 'code' },
          {
            title: 'Highlight',
            value: 'highlight',
            icon: HighlightIcon,
            component: HighlightDecorator
          }
        ]
      }
    }
  ]
}
```

The rendered presentation in the editor is a yellow background for highlighted text:

![Editor with custom render and icon for the highlight decorator](https://cdn.sanity.io/images/3do82whm/next/3b4821d70974eb08a77c1be9f0aa48fb0d8b82a3-672x340.png)
*Editor with custom render and icon for the highlight decorator*

> [!TIP]
> Protip
> When you create your custom decorators, you can keep all, some, or none of the built-in decorators.
> These are the built-in decorators:
> `{ "title": "Strong", "value": "strong" },
> { "title": "Emphasis", "value": "em" },
> { "title": "Code", "value": "code" },
> { "title": "Underline", "value": "underline" },
> { "title": "Strike", "value": "strike-through" }`
> Make sure you include those you intend to keep.

### Annotations

Customizing annotations works much in the same way as decorations: you pass an icon and a renderer in the schema definition.

A common use case is to have an annotation for an internal reference, in addition to a link with an external URL.
You can customize the editor to display a custom icon for the internal link, and a renderer that helps recognize external links when they are inline in the text.

The following example imports an icon from the `@sanity/icons`-package. In the example, you configure a user icon to represent internal references to a `person` type.

**RichTextEditor.ts**

```typescript
import { UserIcon } from '@sanity/icons/User'

export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        annotations: [
          {
            name: 'link',
            type: 'object',
            title: 'link',
            fields: [
              {
                name: 'url',
                type: 'url'
              }
            ]
          },
          {
            name: 'internalLink',
            type: 'object',
            title: 'Internal link',
            icon: UserIcon,
            fields: [
              {
                name: 'reference',
                type: 'reference',
                to: [
                  { type: 'person' }
                  // other types you may want to link to
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}
```

Now the user icon replaces the default question mark icon in the toolbar: 

![The editor with a custom user icon for the internal link annotation](https://cdn.sanity.io/images/3do82whm/next/8c1ec5147315ac7d6e2a5b97df048935b278bd3c-627x327.png)
*The editor with a custom user icon for the internal link annotation*

#### Custom components

The next step is to create a custom renderer for external links. The following example appends an "arrow out of a box" icon to mark these links. To do this, you pass a small React component.

In the `/schemas/components` directory, create a file and name it `ExternalLinkRenderer.tsx`.

**ExternalLinkRenderer.tsx**

```tsx
import React from 'react'
import { LaunchIcon } from '@sanity/icons/Launch'

const ExternalLinkRenderer = props => (
  <span>
    {props.renderDefault(props)}
    <a contentEditable={false} href={props.value.href}>
      <LaunchIcon />
    </a>
  </span>
)

export default ExternalLinkRenderer

```

Then, import the following component, and pass it to `components.annotation` in the schema type:

**RichTextEditor.ts**

```typescript
import { UserIcon } from '@sanity/icons/User'
import ExternalLinkRenderer from './components/ExternalLinkRenderer'

export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        annotations: [
          {
            name: 'link',
            type: 'object',
            title: 'link',
            fields: [
              {
                name: 'url',
                type: 'url'
              }
            ],
            components: {
              annotation: ExternalLinkRenderer
            }
          },
          {
            name: 'internalLink',
            type: 'object',
            title: 'Internal link',
            icon: UserIcon,
            fields: [
              {
                name: 'reference',
                type: 'reference',
                to: [
                  { type: 'person' }
                  // other types you may want to link to
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}
```

As a result, external links now look like this:

![The editor with custom renderer for external links.](https://cdn.sanity.io/images/3do82whm/next/9a23809a820a2e2a5b34d560b9e90536737bf9e4-634x317.png)
*The editor with custom renderer for external links.*

### Include default annotations and decorators

There may be instances where you want to add to the default Sanity Studio annotations and decorators rather than overwrite them. Import the constants and spread them in their respective sections as shown below. Then include your custom additions.

```typescript
import {defineType, defineArrayMember, DEFAULT_ANNOTATIONS,
  DEFAULT_DECORATORS} from 'sanity'

export default defineType({
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'block',
      marks: {
        decorators: [
          // Spread the default decorators
          ...DEFAULT_DECORATORS,
          // ... your custom decorators
        ],
        annotations: [
          // Spread the default annoations
          ...DEFAULT_ANNOTATIONS,
          // ... your custom annotations
        ]
      }
    })
  ]
})
```

## Block styles

The Portable Text Editor ships with a set of styles that translate well to their corresponding HTML ones. You can also customize how these appear in the editor by supplying your own components.

The following example produces a custom title style using Garamond as the font face with a slightly increased font size. First, define a custom style called `title`:

```javascript
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      styles: [
        {title: 'Normal', value: 'normal'},
        {title: 'Title', value: 'title'},
        {title: 'H1', value: 'h1'},
        {title: 'H2', value: 'h2'},
        {title: 'H3', value: 'h3'},
        {title: 'Quote', value: 'blockquote'},
      ]
    }
  ]
}
```

Without any customization, the block looks exactly like the `normal` one.


To change that, create and pass a React component to `component`. The props of the block contain the element to style and the appropriate styling.
In the following example, the React component is added to the configuration file. 

**RichTextEditor.jsx**

```jsx
import React from 'react'

const TitleStyle = props => (
  <span style={{fontFamily: 'Garamond', fontSize: '2em'}}>{props.children} </span>
)

export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      styles: [
        {title: 'Normal', value: 'normal'},
        {title: 'H1', value: 'h1'},
        {title: 'H2', value: 'h2'},
        {title: 'H3', value: 'h3'},
        {title: 'Quote', value: 'blockquote'},
        {
          title: 'Title',
          value: 'title',
          component: TitleStyle
        },
      ]
    }
  ]
}
```

The `component` prop applies the custom style to the title block, as it's rendered in the editor:

![The editor with a custom title block style](https://cdn.sanity.io/images/3do82whm/next/abcb65d0ba817b6cd74ba5524ee915118f40e785-1008x823.png)
*The editor with a custom title block style*



## Validation of annotations

Like other content types, annotations support [content validation](https://www.sanity.io/docs/studio/validation). Warnings are displayed in the margin and in the document. A pointer activates the annotation modal for the editor. Validations help editors structure the content correctly. It's generally a good idea to involve editors in creating validations and testing the warning messages so that they are helpful for them.

Let's say that you are using the same content for multiple websites. In this case, it's important that internal linking use an annotation with a `reference` input. This helps prevent accidental deletion of linked content and resolve internal links in the front-end project.
You can create a validation that takes care of this:

```javascript
export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        annotations: [
          {
            name: 'link',
            type: 'object',
            title: 'link',
            fields: [
              {
                name: 'url',
                type: 'url',
                validation: Rule =>
                  Rule.regex(
                    /https:\/\/(www\.|)(portabletext\.org|sanity\.io)\/.*/gi,
                    {
                      name: 'internal url',
                      invert: true
                    }
                  ).warning(
                    `This is not an external link. Consider using internal links instead.`
                  )
              }
            ]
          },
          {
            name: 'internalLink',
            type: 'object',
            title: 'Internal link',
            fields: [
              {
                name: 'reference',
                type: 'reference',
                to: [
                  { type: 'post' }
                  // other types you may want to link to
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}
```

The regular expression `/https:\/\/(www\.|)(portabletext\.org|sanity\.io)\/.*/i` triggers on all URLs that match all the variations of either `portabletext.org` or `sanity.io` with some sub-paths (this allows linking to the root domain).

![A validation warning for the link annotation in the editor.](https://cdn.sanity.io/images/3do82whm/next/e32117870e43964c12d7c8df5c51e1f50e87792e-2562x1768.png)
*A validation warning for the link annotation in the editor.*

## Further reading

[Guide: Add inline blocks to your Portable Text Editor and enrich your block content](https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor)

[Guide: Ultimate Guide on customising Portable Text](https://www.sanity.io/guides/ultimate-guide-for-customising-portable-text-from-schema-to-react-component)



# Create a Portable Text behavior plugin

You can add custom behaviors to your studio's Portable Text Editor (PTE) by creating custom React components, hooking into the editor's behavior API, registering a plugin, and adding it to your configuration or schema.

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

In this guide we'll create a behavior plugin that auto-closes bracket pairs. For example, when users type an opening bracket (`{`), the editor will automatically add a closing bracket (`}`) and move the cursor in between them. The focus of this guide is on incorporating PTE behaviors in Studio. Additional details for working with the Behaviors API can be found in the [Portable Text Editor documentation](https://portabletext.org).

Prerequisites:

- `sanity` version 3.92.0 or higher is required for Studio to apply plugins.

## Create the custom behavior component

Navigate to your studio's project directory and add the latest version of `@portabletext/editor` with the package manager of your choice. For example:

**NPM**

```sh
npm i @portabletext/editor 
```

**PNPM**

```sh
pnpm add @portabletext/editor
```

PTE Plugins are React components. This allows them to maintain their own state, handle additional logic, and render any components they need. 

Create a new component file in the location of your choice. We recommend creating a `plugins/pte/` directory or similar. This example names the file `auto-close-brackets-plugin.tsx`.

Start by importing the dependencies you'll need to create the plugin.

**plugins/pte/auto-close-brackets-plugin.tsx**

```tsx
import {useEditor} from '@portabletext/editor'
import {defineBehavior, execute} from '@portabletext/editor/behaviors'
import {useEffect} from 'react'
```

### Define the behavior

Next, in the same file, define the behavior using the `defineBehavior` helper.

**plugins/pte/auto-close-brackets-plugin.tsx**

```tsx
// ... imports
const autoCloseBracketsBehavior = defineBehavior({
  on: 'insert.text',
  guard: ({event}) => {
    const bracketPairs: Record<string, string | undefined> = {
      '(': ')',
      '[': ']',
      '{': '}',
    }
    const lastInsertedChar = event.text.at(-1)
    const closingBracket =
      lastInsertedChar !== undefined ? bracketPairs[lastInsertedChar] : undefined
    if (closingBracket !== undefined) {
      return {closingBracket}
    }
    return false
  },
  actions: [
    ({event}) => [
      execute(event),
    ],
    (_, {closingBracket}) => [
      execute({
        type: 'insert.text',
        text: closingBracket,
      }),
      execute({
        type: 'move.backward',
        distance: closingBracket.length,
      }),
    ],
  ],
})
```

All behaviors follow this process:

1. **Listen for an event**. In the example, `on` listens for a `insert.text` event. 
2. **Use a guard to decide if they should run or not**. In the example, guard checks if the last inserted character matches any bracket characters. If it does, it returns the closing character to pass it on to the next step.
3. **Trigger a set of actions to perform on the editor**. In the example, we first send the original action back to insert the first bracket into the editor. Then we send a pair of actions to insert the closing bracket and move the cursor over one place so it rests between the brackets.

This guide doesn't go much further into the Behaviors API. You can learn more about these concepts in the [Portable Text Editor](https://www.portabletext.org/) documentation.

### Create a React component to register the behavior

Next, in the same file, create a function component to register the new behavior with the PTE.

**plugins/pte/auto-close-brackets-plugin.tsx**

```tsx
// ... imports
const autoCloseBracketsBehavior = defineBehavior({ ... })

export function AutoCloseBracketsBehaviorPlugin() {
  const editor = useEditor()
  
  useEffect(() => {
    const unregisterBehavior = editor.registerBehavior({
      behavior: autoCloseBracketsBehavior,
    })
  
    return () => {
      unregisterBehavior()
    }
  }, [editor])
  
  return null
}
```

Aside from some React conventions, this code does one core task:  it registers the `autoCloseBracketsBehavior` from the previous step with any instance of the PTE it is attached to (we'll do this soon). Other behaviors may use this space to perform additional logic like state management.

Finally, to make adding the plugin to your schema and config files easier, create a file to export a function that wraps the plugin (or group of plugins). This is optional, but makes it easier to use them as [custom form components](https://www.sanity.io/docs/studio/form-components) without changing your file types to support TSX.

**plugins/pte/index.tsx**

```tsx
import type { PortableTextPluginsProps } from 'sanity'
import { AutoCloseBracketsBehaviorPlugin } from './auto-close-brackets-plugin'

export function PortableTextEditorPlugins(props: PortableTextPluginsProps) {
  return (
    <>
      {props.renderDefault(props)}
      <AutoCloseBracketsBehaviorPlugin />
      {/* Add any other plugins here */}
    </>
  )
}
```

Here's our completed `auto-close-brackets-plugin.tsx` and plugin `index.tsx` file:

**plugins/pte/auto-close-brackets-plugin.tsx**

```tsx
// plugins/pte/auto-close-brackets-plugin.tsx
import {useEditor} from '@portabletext/editor'
import {defineBehavior, execute} from '@portabletext/editor/behaviors'
import {useEffect} from 'react'
/**
 * This Studio Plugin shows how to:
 *
 * 1. Define a standalone and portable Behavior using `defineBehavior`
 * 2. Register the Behavior using `editor.registerBehavior` inside a React component
 * 3. Package the component as a plugin to import into a Studio config
 */

/**
 * This Behavior will auto-close brackets when the user inserts an opening
 * bracket. It will also move the cursor in between the brackets so the user
 * can start typing immediately.
 */
const autoCloseBracketsBehavior = defineBehavior({
  on: 'insert.text',
  guard: ({event}) => {
    const bracketPairs: Record<string, string | undefined> = {
      '(': ')',
      '[': ']',
      '{': '}',
    }
    const lastInsertedChar = event.text.at(-1)
    const closingBracket =
      lastInsertedChar !== undefined ? bracketPairs[lastInsertedChar] : undefined

    if (closingBracket !== undefined) {
      // Pass the closing bracket to the actions for reuse
      return {closingBracket}
    }

    return false
  },
  actions: [
    ({event}) => [
      // Execute the original event that includes the opening bracket
      execute(event),
    ],
    (_, {closingBracket}) => [
      execute({
        type: 'insert.text',
        text: closingBracket,
      }),
      execute({
        type: 'move.backward',
        distance: closingBracket.length,
      }),
    ],
  ],
})

export function AutoCloseBracketsBehaviorPlugin() {
  const editor = useEditor()

  useEffect(() => {
    const unregisterBehavior = editor.registerBehavior({
      behavior: autoCloseBracketsBehavior,
    })

    return () => {
      unregisterBehavior()
    }
  }, [editor])

  return null
}
```

**plugins/pte/index.tsx**

```tsx
import type { PortableTextPluginsProps } from 'sanity'
import { AutoCloseBracketsBehaviorPlugin } from './auto-close-brackets-plugin'

export function PortableTextEditorPlugins(props: PortableTextPluginsProps) {
  return (
    <>
      {props.renderDefault(props)}
      <AutoCloseBracketsBehaviorPlugin />
      {/* Add any other plugins here */}
    </>
  )
}
```

### Integrate the plugin with your studio schema

There are two ways to add this PTE plugin to your studio. Globally for all PTE blocks, or locally to specific blocks in your schema.

#### Globally

To apply the plugin to all PTE instances throughout your studio, you can add it globally by setting it as the `pte` form component.

In your studio config file, use the Form Components configuration to add the plugin as shown in this example.

**sanity.config.ts**

```
import { defineConfig } from "sanity"
import { PortableTextEditorPlugins } from './plugins/pte'

export default defineConfig({
  // ...
  form: {
    components: {
      portableText: {
        plugins: PortableTextEditorPlugins,
      },
    },
  },
  // ...
})
```

#### Locally

Sometimes you want certain plugins for certain PTE fields. In those instances, customize the component in field for the schema type.  For example:

**postSchema.ts**

```
import { defineType } from 'sanity'
import { PortableTextEditorPlugins } from './plugins/pte'

export const post = defineType({
  title: 'Blog post',
  name: 'post',
  type: 'document',
  fields: [
    // ...
    {
      type: 'array',
      name: 'content',
      title: 'Post Body',
      of: [
        {
          type: 'block',
        }
      ],
      components: {
        portableText: {
          plugins: PortableTextEditorPlugins,
        }
      }
    },
    // ...
  ],
})
```

## Optional: Composing multiple plugins

The above examples use the `PortableTextEditorPlugins` function to prepare the behavior for inclusion in Sanity schemas and configuration files. You can also do this in-line in the schema or configuration by converting those files to `tsx|jsx` files and using the `AutoCloseBracketsBehaviorPlugin` directly instead. For example:

**sanity.config.tsx**

```tsx
import { defineConfig } from "sanity"
import { AutoCloseBracketsBehaviorPlugin } from './plugins/pte/auto-close-brackets-plugin.tsx'

export default defineConfig({
  // ...
  form: {
    components: {
      portableText: {
        plugins: (props) => {
        return (
          <>
            {props.renderDefault(props)}
            <AutoCloseBracketsBehaviorPlugin />
          </>
        )
        },
      }
    }
  },
  // ...
})
```

Include additional plugins as needed, for example:

**sanity.config.tsx**

```tsx
import { defineConfig } from "sanity"
import { AutoCloseBracketsBehaviorPlugin } from './plugins/pte/auto-close-brackets-plugin.tsx'
import { SomeOtherPlugin } from './plugins/custom/plugins.ts'

export default defineConfig({
  // ...
  form: {
    components: {
      portableText: {
        plugins: (props) => {
        return (
          <>
            {props.renderDefault(props)}
            <AutoCloseBracketsBehaviorPlugin />
            <SomeOtherPlugin />
          </>
        )
        },
      }
    }
  },
  // ...
}) 
```

Additionally, you can use this same approach with the earlier `PortableTextEditorPlugins` technique to package groups of plugins together.



# Add Portable Text Editor plugins to Studio

Studio uses the standalone Portable Text Editor (PTE) to power the block content editing experience. This makes the editor aware of your schema, and also lets you modify the editor from your existing Sanity config and schema.

Because the PTE itself doesn't require Studio, there can be times where you need to include a PTE plugin that isn't set up to work with Studio.

This guide shows you how to use an existing PTE plugin with your studio.

Prerequisites:

- `sanity` version 3.92.0 or higher is required for Studio to apply plugins.

## Add the plugin to your project

The first step is to add the PTE plugin to your project. For the examples in this guide, we'll use the `CharacterPairDecoratorPlugin` from the [official plugins repository](https://github.com/portabletext/editor). It lets you define a markdown-style shortcut and link it to a decorator.

**NPM**

```sh
npm install @portabletext/plugin-character-pair-decorator
```

**PNPM**

```sh
pnpm add @portabletext/plugin-character-pair-decorator
```

We'll configure the plugin to make any text wrapped in `#` bold. For example, `#example#` will receive the "strong" decorator.

## Option 1: Add the plugin directly

The most straightforward approach is to import the plugin and add it directly to your Studio config for global use, or your schema for specific component use.

> [!NOTE]
> Note the change to TSX
> This approach requires that you change your files to TSX to accommodate JSX syntax. It means fewer files, but may not be your preferred technique. See the other approaches below for alternatives.

### Global

This applies the plugin to all block content editors throughout your studio.

**sanity.config.tsx**

```tsx
import { defineConfig } from 'sanity'
import { CharacterPairDecoratorPlugin } from '@portabletext/plugin-character-pair-decorator'

export default defineConfig({
  // ... omitted for brevity
  form: {
    components: {
      portableText: {
        plugins: (props)=>{
          return (
            <>
              {props.renderDefault(props)}
              <CharacterPairDecoratorPlugin 
                decorator={({context}) =>
                  context.schema.decorators.find((d) => d.name === 'strong')?.name
                }
                pair={{char: '#', amount: 1}}
              />
            </>
          )
        },
      }
    },
  }
})
```

### Local

**postSchema.tsx**

```tsx
import { defineType } from 'sanity'
import { CharacterPairDecoratorPlugin } from '@portabletext/plugin-character-pair-decorator'

export const post = defineType({
  title: 'Blog post',
  name: 'post',
  type: 'document',
  fields: [
    // ...
    {
      type: 'array',
      name: 'content',
      title: 'Post Body',
      of: [
        {
          type: 'block',
        }
      ],
      components: {
        portableText: {
          plugins: (props)=>{
            return (
              <>
              {props.renderDefault(props)}
              <CharacterPairDecoratorPlugin 
                decorator={({context}) =>
                  context.schema.decorators.find((d) => d.name === 'strong')?.name
                }
                pair={{char: '#', amount: 1}}
              />
            </>
            )
          }
        }
      }
    },
    // ...
  ],
})
```

## Option 2: Create a container for plugins

If you don't want to change your schema and configuration files to TSX, you can wrap all editor plugins in a single container component, then use that when needed. For example, in the guide on [creating your own PTE plugin](https://www.sanity.io/docs/studio/pte-plugins), we export a `PortableTextEditorPlugins` function.

**plugins/portable-text/index.tsx**

```tsx
import type { PortableTextPluginsProps } from 'sanity'
import { CharacterPairDecoratorPlugin } from '@portabletext/plugin-character-pair-decorator'

export function PortableTextEditorPlugins(props: PortableTextPluginsProps) {
  return (
    <>
      {props.renderDefault(props)}
      <CharacterPairDecoratorPlugin 
        decorator={({context}) =>
          context.schema.decorators.find((d) => d.name === 'strong')?.name
        }
        pair={{char: '#', amount: 1}}
      />
      {/* Add more plugins here  */}
    </>
  )
}
```

Then use that container globally in the `sanity.config.ts`, or locally in your schema.

**sanity.config.ts (global usage)**

```typescript
import { defineConfig } from "sanity"
import { PortableTextEditorPlugins } from './plugins/portable-text'

export default defineConfig({
  // ...
  form: {
    components: {
      portableText: {
        plugins: PortableTextEditorPlugins,
      },
    },
  },
  // ...
})
```

**postSchema.ts (local usage)**

```typescript
import { defineType } from 'sanity'
import { PortableTextEditorPlugins } from './plugins/portable-text'

export const post = defineType({
  title: 'Blog post',
  name: 'post',
  type: 'document',
  fields: [
    // ...
    {
      type: 'array',
      name: 'content',
      title: 'Post Body',
      of: [
        {
          type: 'block',
        }
      ],
      components: {
        portableText: {
          plugins: PortableTextEditorPlugins,
        }
      }
    },
    // ...
  ],
})
```

## Option 3: Wrap in a Studio plugin

One approach is to wrap the PTE plugin in a Sanity Studio plugin using `definePlugin`, then add it to your config file as a plugin.

**plugins/characterPair.tsx**

```tsx
import { definePlugin } from 'sanity'
import { CharacterPairDecoratorPlugin } from '@portabletext/plugin-character-pair-decorator'

export const characterPair = definePlugin({
  name: 'characterPair',
  form: {
    components: {
      portableText: {
        plugins: (props)=>{
          return (
            <>
              {props.renderDefault(props)}
              <CharacterPairDecoratorPlugin 
                decorator={({context}) =>
                  context.schema.decorators.find((d) => d.name === 'strong')?.name
                }
                pair={{char: '#', amount: 1}}
              />
            </>
          )
        },
      }
    },
  }
})
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { characterPair } from './plugins/characterPair'
export default defineConfig({
  // ... omitted for brevity
  plugins: [
    //...
    characterPair()
  ]
})
```

If you're packaging a plugin this way for distribution, you should also expose the configuration so it's available from the `sanity.config.ts`.



# Common patterns

This article collects patterns for customizing the Portable Text Editor in Sanity Studio. Each section is self-contained and covers one customization: custom decorators and styles, spellchecking, how block content renders, and extending the input with hotkeys, a paste handler, and block-level validation.

Every pattern extends an existing Portable Text field in your schema. For the baseline configuration these patterns build on, see [Configure the Portable Text Editor](https://www.sanity.io/docs/studio/portable-text-editor-configuration).

## Custom decorators

To render content the way you want it to be shown, you can create custom decorators.

```tsx
import {BulbOutlineIcon} from '@sanity/icons/BulbOutline'
import {defineArrayMember} from 'sanity'

defineArrayMember({
  type: 'block',
  marks: {
    decorators: [
      {
        title: 'Highlight',
        value: 'highlight',
        component: (props) => (
          <span style={{backgroundColor: '#0f0'}}>
            {props.children}
          </span>
        ),
        icon: BulbOutlineIcon,
      },
    ],
  },
})
```

The code example defines a member of an array that enables creating a block with decorators. The decorator in the example sets the properties `value`, `component`, and `icon`; these properties define how the decorator is rendered:

- `value` is the mark name stored in the content when the `highlight` decorator is applied to text.
- `component` is a React component that receives props of type `BlockDecoratorProps`. It renders the children wrapped in a `span` element with a green background color (`#0f0`).
- `icon` sets the icon shown in the editor toolbar, in this case `BulbOutlineIcon`.

## Custom styles

You can create custom styles to change how text blocks are rendered. When you define a styles array, it replaces the standard set of styles available by default; only the Normal style is always kept.

```tsx
import {defineArrayMember} from 'sanity'
import {Card, Text} from '@sanity/ui'

defineArrayMember({
  type: 'block',
  styles: [
    {
      title: 'Section Header',
      value: 'sectionHeader',
      component: (props) => (
        <Card paddingBottom={4}>
          <Text size={4} weight="bold">
            {props.children}
          </Text>
        </Card>
      ),
    },
  ],
})
```

The code example defines a [block](https://www.sanity.io/docs/block-type) array member and adds style options to it:

- The style is `sectionHeader`.
- The child props of the component in the block are rendered as a card with bold text.

## Spellchecking

You can enable and disable the web browser's built-in spell-checker for text blocks. To do so, set `options.spellCheck` to either `true` or `false` for the specified `block` type.

```tsx
import {defineArrayMember} from 'sanity'

defineArrayMember({
  type: 'block',
  options: {
    spellCheck: false,
  },
})
```

The code example defines a `block` array member, and it disables spellchecking text in the block.

## Customizing block content rendering

You can render block content in Sanity Studio using one of the following form components:

- `block`: renders any valid Portable Text block (text or object).
- `inlineBlock`: renders a Portable Text block inline inside a running piece of text.
- `annotation`: renders text with annotated metadata (for example, a URL link to reference an external resource, or a cross-reference to another document).

You can modify specific schema types to customize only the corresponding components. Alternatively, you can modify the studio config or create a plugin to apply the customization to all block content in Sanity Studio.

### Customizing specific block content

To customize a specific block content type, use the `components` property associated with that type. Define a `block` to provide your custom render component for the associated content type.

The following example customizes the rendering of text and image blocks in the `body` field.

```tsx
import {Box} from '@sanity/ui'
import {defineField, defineArrayMember} from 'sanity'

defineField({
  name: 'body',
  title: 'Body',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'image',
      // Replace the preview of all block images
      // with the edit form for that image, bypassing
      // the modal step.
      components: {
        block: (props) => {
          return props.renderDefault({
            ...props,
            renderPreview: () => props.children,
          })
        },
      },
    }),
    defineArrayMember({
      type: 'block',
      // Add extra padding to all text blocks
      // for this type.
      components: {
        block: (props) => {
          return (
            <Box padding={2}>
              {props.renderDefault(props)}
            </Box>
          )
        },
      },
    }),
  ],
})
```

The `defineField` function creates a field called `body`, which is an array of two types of content: `image` and `block`.

The `components` property enables customizing the behavior of the field. 

The `block` function is a component that renders the preview of a text or an image block. It takes `props` as an argument, and it returns a rendered version of the content with additional styling:

- It bypasses the modal step when previewing images.
- It adds extra padding to the text blocks.

### Customizing block content with the studio config

To customize the default rendering of all block content in Sanity Studio, modify the studio config, instead of customizing schemas as shown in the previous section.

The following example reuses the customization described in the previous example, but it sets it in the studio config, instead of the schema type. The studio config applies the customization to any text block or image type rendered as block content.

```tsx
import {Box} from '@sanity/ui'
import {definePlugin, defineField, BlockProps} from 'sanity'

const BlockComponent = (props: BlockProps) => {
  // Add extra padding to all text blocks
  if (props.schemaType.name === 'block') {
    return (
      <Box padding={2}>
        {props.renderDefault(props)}
      </Box>
    )
  }
  // Inline editing of images
  if (props.schemaType.name === 'image') {
    return props.renderDefault({
      ...props,
      renderPreview: () => props.children,
    })
  }
  // Render default for all other types
  return props.renderDefault(props)
}

// The plugin to include in the studio config (sanity.config.ts)
definePlugin({
  name: 'block-content-customizations',
  form: {
    components: {
      block: BlockComponent,
    },
  },
})

// This schema gets the customizations automatically
// added to the 'block' and 'image' types.
defineField({
  name: 'intro',
  title: 'Intro',
  type: 'array',
  of: [
    {type: 'block'},
    {type: 'image'},
  ]
})

```

In the code example:

- `BlockComponent` takes `props` and returns a component that does the following:- Adds extra padding for all text blocks.
- Enables skipping the preview and directly editing images inline.
- Applies the default rendering to all other types.


- The `defineField` function defines a schema field that automatically adds the customizations to the `block` and `image` types.

### Customizing block content with a plugin

Instead of modifying the studio config, you can use the code in the previous example to create a plugin to achieve the same outcome.

The advantage is that you can install and share the plugin across multiple studios and workspaces. 

For more information about creating plugins, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

## Customizing the input

Besides customizing block content, you can also customize `PortableTextInput` to change editing block content in Sanity Studio.

This option enables rendering additional information, such as a word counter, or handling pasted content.

The following example shows a simple implementation where you can modify the input by assigning custom values to the `props` of `PortableTextInput`.

```typescript
import {
  defineField,
  InputProps,
  PortableTextInput,
  PortableTextInputProps,
} from 'sanity'

defineField({
  name: 'body',
  title: 'Body',
  type: 'array',
  of: [
    {
      type: 'block',
    },
  ],
  components: {
    input: (props: InputProps) => {
      return props.renderDefault(props)
      // Alternatively:
      // return <PortableTextInput {...(props as PortableTextInputProps)} />
    },
  },
})

```

Replace the `input` form component with your custom component. To do so, use either a block content schema type definition, or `definePlugin` in the studio config.

### Custom hotkeys

You can also set custom hotkeys by registering a behavior plugin on the editor. Define the key combination with `@portabletext/keyboard-shortcuts`, then use `defineBehavior` to run an editor event when it matches.

The following example implements two hotkeys:

- A hotkey that toggles a custom highlight decorator.
- A hotkey that toggles a link annotation on the selected text.

```tsx
import {useEditor} from '@portabletext/editor'
import {defineBehavior, raise} from '@portabletext/editor/behaviors'
import {createKeyboardShortcut} from '@portabletext/keyboard-shortcuts'
import {BulbOutlineIcon} from '@sanity/icons/BulbOutline'
import {useEffect} from 'react'
import {
  defineArrayMember,
  defineField,
  type PortableTextPluginsProps,
} from 'sanity'

// Platform-aware shortcuts: Ctrl on Windows and Linux,
// Cmd on Apple platforms.
const highlightShortcut = createKeyboardShortcut({
  default: [{key: 'H', ctrl: true, meta: false, alt: false, shift: true}],
  apple: [{key: 'H', ctrl: false, meta: true, alt: false, shift: true}],
})

const linkShortcut = createKeyboardShortcut({
  default: [{key: 'L', ctrl: true, meta: false, alt: false, shift: true}],
  apple: [{key: 'L', ctrl: false, meta: true, alt: false, shift: true}],
})

// Toggle the custom 'highlight' decorator on the selected text
const highlightBehavior = defineBehavior({
  on: 'keyboard.keydown',
  guard: ({event}) => highlightShortcut.guard(event.originEvent),
  actions: [() => [raise({type: 'decorator.toggle', decorator: 'highlight'})]],
})

// Toggle a 'link' annotation on the selected text
const linkBehavior = defineBehavior({
  on: 'keyboard.keydown',
  guard: ({event}) => linkShortcut.guard(event.originEvent),
  actions: [
    () => [
      raise({
        type: 'annotation.toggle',
        annotation: {name: 'link', value: {href: ''}},
      }),
    ],
  ],
})

// Keep the array stable so the behaviors aren't
// re-registered on every render
const behaviors = [highlightBehavior, linkBehavior]

// The Studio renders this component inside the editor,
// so useEditor() returns the current editor instance
function CustomHotkeysPlugin() {
  const editor = useEditor()

  useEffect(() => {
    const unregisterBehaviors = behaviors.map((behavior) =>
      editor.registerBehavior({behavior}),
    )
    return () => {
      unregisterBehaviors.forEach((unregisterBehavior) => unregisterBehavior())
    }
  }, [editor])

  return null
}

// The schema type that mounts the plugin
defineField({
  name: 'body',
  title: 'Body',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'block',
      marks: {
        decorators: [
          {
            title: 'Highlight',
            value: 'highlight',
            component: (props) => (
              <span style={{backgroundColor: '#0f0'}}>
                {props.children}
              </span>
            ),
            icon: BulbOutlineIcon,
          },
        ],
      },
    }),
  ],
  components: {
    portableText: {
      plugins: (props: PortableTextPluginsProps) => (
        <>
          {props.renderDefault(props)}
          <CustomHotkeysPlugin />
        </>
      ),
    },
  },
})

```

The example defines a behavior plugin for the Portable Text Editor and mounts it on a schema type.

The shortcuts are platform-aware. `Ctrl+Shift+H` toggles highlighting on the selected text, and `Ctrl+Shift+L` toggles a link. On Apple platforms, both combinations use Cmd instead of Ctrl.

The `guard` function tests each keydown event against the shortcut, and the `actions` array raises the editor event that does the work. Raising an event also cancels the browser's default action for that key combination.

Each behavior reacts to the editor's `keyboard.keydown` event in three steps:

1. The behavior subscribes to every keydown event in the editor.
2. The guard checks the event against the shortcut and returns `false` when it doesn't match, which stops the behavior.
3. The action raises a toggle event, which applies the mark to the selection if it isn't already there, or removes it if it is.

A behavior can't open the Studio's annotation edit form, so an editor sets the `href` on a new link by selecting it in the editor. For more on writing and mounting behavior plugins, see [Create a Portable Text behavior plugin](https://www.sanity.io/docs/studio/pte-plugins).

### Custom paste handler

The following example implements custom paste handling for any clipboard text that is a valid URL. It pastes the content as a `resource` type inline block at the current cursor position.

```tsx
import {
  defineArrayMember,
  defineField,
  InputProps,
  PortableTextInput,
  PortableTextInputProps,
} from 'sanity'

// The custom paste handler function to pass as props
// to PortableTextInput
const onPaste: PortableTextInputProps['onPaste'] = (data) => {
  let url: URL
  const text =
    data.event.clipboardData.getData('text/plain') || ''
  // Check if clipboard data is a URL
  try {
    url = new URL(text)
    // Insert an inline resource object in the text
    return Promise.resolve({
      insert: [
        {
          _type: 'block',
          children: [{_type: 'resource', url: url.href}],
        },
      ],
      // To set a specific location to insert
      // the pasted content, instead of the current
      // cursor position, define a 'path' prop
    })
  } catch (_) {
    return undefined
  }
}

// The block content schema type to use
// for the custom paste handler above
defineField({
  name: 'body',
  title: 'Body',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'block',
      of: [
        {
          type: 'object',
          name: 'resource',
          title: 'Resource',
          fields: [{type: 'url', name: 'url'}],
        },
      ],
    }),
  ],
  components: {
    input: (props: InputProps) => (
      <PortableTextInput {...(props as PortableTextInputProps)} onPaste={onPaste} />
    ),
  },
})

```

In the code example, `onPaste` defines a custom paste handler for the `PortableTextInput` component.

- It checks if the clipboard data is a URL; if so, it inserts the URL as an inline resource object in the text block.
- The default insert location is the current cursor position. It's also possible to assign a different insert location by setting an optional `path` prop.

### Custom block validation

The following example validates a text block: it checks for a set of disallowed content using regex matching on every `span` node of the text block.
To test the example, type "*foo*" inside a text block.

```typescript
import {
  Path,
  PortableTextSpan,
  defineArrayMember,
  defineType,
  isPortableTextSpan,
  isPortableTextTextBlock,
} from 'sanity'

interface DisallowListLocation {
  matchText: string
  message: string
  offset: number
  path: Path
  span: PortableTextSpan
  level: 'error' | 'info' | 'warning'
}

export default defineType({
  name: 'customValidationExample',
  title: 'Custom block validation example',
  type: 'document',
  fields: [
    {
      name: 'blockContent',
      title: 'Block content with custom validation',
      type: 'array',
      of: [
        defineArrayMember({
          type: 'block',
          validation: (Rule) => [
            Rule.error().custom((value, context) => {
              const disallowList: {regExp: RegExp; message: string}[] = [
                {
                  message: 'Use an en dash (–) instead',
                  regExp: new RegExp(/^- /g),
                },
                {
                  message: 'Use a bullet list instead',
                  regExp: new RegExp(/^\* /g),
                },
                {
                  message: 'Avoid using \'foo\'',
                  regExp: new RegExp(/\bfoo\b/g),
                },
              ]
              const {path} = context
              const locations: DisallowListLocation[] = []
              if (path && isPortableTextTextBlock(value)) {
                value.children.forEach((child) => {
                  if (isPortableTextSpan(child)) {
                    disallowList.forEach((entry) => {
                      const matches = isPortableTextSpan(child) && child.text.matchAll(entry.regExp)
                      if (matches) {
                        Array.from(matches).forEach((match) => {
                          locations.push({
                            span: child,
                            matchText: match[0],
                            path: path.concat(['children', {_key: child._key}]),
                            offset: match.index || 0,
                            message: entry.message,
                            level: 'error',
                          })
                        })
                      }
                    })
                  }
                })
              }
              if (locations.length) {
                return {
                  message: `${locations.map((item) => item.message).join('. ')}.`,
                }
              }
              return true
            }),
          ],
        }),
      ],
    },
  ],
})

```

In the code example, the custom validation rule runs on every text block:

- It matches the text of each span child against the regular expressions in the disallow list.
- If it finds any matches, it returns the corresponding messages as a validation error; otherwise, the block is valid.



# Introduction

While the Studio provides an excellent out-of-the-box experience, its true power lies in its extensive customization capabilities. The React-based framework lets you tailor the editorial experience to your specific workflows.

With Sanity Studio customization, you can:

- **Create custom input components** to provide specialized editing interfaces for your content.
- **Design custom document views** and structure to organize content in ways that make sense for your team.
- **Add visual enhancements** with custom icons, theming, and UI components.
- **Extend functionality** with plugins and custom tools that integrate with your existing systems.
- **Localize** the Studio interface to support your global team's preferred languages.

> [!TIP]
> Studio or App SDK?
> Sanity Studio is a fully featured Content Management System (CMS) based on your schemas. While highly customizable and extendable, it does come with the assumptions and all the bells and whistles of a CMS.
> If you rather need a highly specialized workflow or tool to interact with your content without front-loading the entire editorial toolkit, you might consider building a custom application using the [App SDK](https://www.sanity.io/docs/app-sdk).

## Core concepts

Sanity Studio is built from the ground up with customization in mind. Understanding these core customization areas will help you create the perfect editing environment for your team.

### Custom components

The Studio's form builder automatically creates editing interfaces based on your schema definitions, but you can replace any input component with your own custom React component. This allows you to create specialized editing experiences for specific content types or fields.

Custom components can range from simple UI enhancements to complex interfaces that integrate with external services or provide specialized editing capabilities.

#### Develop custom components

[Custom components for Sanity Studio](https://www.sanity.io/docs/studio/intro-to-custom-studio-components)
Change the look and feel of your Studio and craft tailor-made editorial interactions.

[Form components](https://www.sanity.io/docs/studio/form-components)
The Form Components API lets you customize the look and feel of the fields in your studio individually, or at a root level that will affect every field in the Studio. 

#### Example components and tutorials

[Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
Delight your content creators with intelligent inputs for more complex data structures

[Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
Take the guesswork out of creating fields with correct values and automate content creation for authors.

[Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
Make repetitive content creation tasks a breeze by supplying content creators with buttons to populate complex fields.

[Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)
Summarise form progression by decorating the entire editing form for a document with a component loaded at the root level.

[Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
Go beyond a plain radio list of inputs by giving authors more contextually useful buttons to select values from.

[Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
Give content creators quick access to valid values by replacing the default number field input with a list of options.

[Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
Delight your content creators with intelligent inputs for more complex data structures

### Structure builder

The Structure Builder gives you complete control over how documents are organized and presented in the Studio. You can customize document lists, create custom views, build specialized navigation, and design intuitive workflows for your content editors.

With Structure Builder, you can move beyond the default document type lists to create an information architecture that matches your team's mental model of the content.

#### Get started with structure builder

[Structure tool and Structure builder](https://www.sanity.io/docs/studio/structure-introduction)
The Structure tool is included with Sanity Studio and allows you to customize the experience of creating, browsing, and managing documents. 

### Visual customization

Sanity Studio can be visually customized to match your brand or to improve the editing experience. This includes:

- **Theming**: Customize colors, typography, and spacing.
- **Icons**: Replace default icons with custom ones for document types and fields.
- **Sanity UI**: Use the built-in UI component library to create consistent interfaces.
- **Favicons**: Add your own favicon to make the Studio recognizable in browser tabs.

#### Bring your brand to Studio

[Icons](https://www.sanity.io/docs/studio/icons-for-data-types)
Use icons for types to display in the creation dialogue and when you're missing an media preview.

[Favicons](https://www.sanity.io/docs/studio/favicons)
A "favicon" appears in places such as browser tabs, the URL field, and browser bookmarks. Learn how to replace the default Sanity icon with your own.

[Theming Sanity Studio](https://www.sanity.io/docs/studio/theming)
Learn how to customize the styling and branding of your studio

[Sanity UI](https://www.sanity.io/docs/studio/sanity-ui)
Keep your custom studio elements consistent with built-in UI components.

### Tools and plugins

The Studio can be extended with custom tools and plugins that add new functionality to the editing environment:

- **Custom tools**: Create entirely new sections in the Studio for specialized workflows.
- **Plugins**: Install or create plugins that add features like the Dashboard, Comments, or AI Assist.
- **Integrations**: Connect the Studio to external services and systems.

#### Popular tools and plugins

[The Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin)
Quickly test your GROQ queries using this studio plugin.

[AI Assist for Studio](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)
Install and configure the AI Assist plugin

[Explore the Sanity Exchange](https://www.sanity.io/exchange)
Browse more official and third-party plugins and tools on the exchange.

## Limitations

- Custom components should be compatible with Sanity's real-time collaboration system.
- Some advanced customizations may require deeper knowledge of React and Sanity's internal APIs.
- Custom tools and plugins may need to be updated when new versions of the Studio are released.



# Custom components for Sanity Studio

Sanity Studio lets you customize your editorial experience by overriding different parts of the Studio with your own components written in React. The customized components can be split into two main categories: 

- Studio components- Layout
- Navbar
- Tool menu


- Form components- Fields
- Inputs
- Array items
- Preview



## Prerequisites

- A Sanity Studio project. To set one up, see [Installation](https://www.sanity.io/docs/studio/installation).
- Familiarity with writing React components.

## Typical use cases/problems this solves

- Hide certain tools when the Studio is in development mode with a custom `toolMenu`.
- Wrap your Studio with multiple context providers with a custom `layout` component.
- Create a custom `input` to display a range slider on a `number` field, or add a character counter on all `string` fields.

## Studio components

The `studio.components` configuration property accepts replacements for several parts of the Studio UI, such as the `layout`, `navbar`, and `toolMenu`. Studio components can be declared in your root workspace configuration, i.e. the [defineConfig](https://reference.sanity.io/sanity/index/defineConfig/) function, or as part of a plugin config, i.e. the [definePlugin](https://reference.sanity.io/sanity/index/definePlugin/) function.

```javascript
// sanity.config.js
import {MyLayout, MyNavbar, MyToolMenu} from './components'
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...rest of config
  studio: {
    components: {
      layout: MyLayout,
      navbar: MyNavbar,
      toolMenu: MyToolMenu,
    },
  },
})
```

> [!WARNING]
> Gotcha
> **logo is deprecated**
> - Custom `logo` components are no longer rendered.
> - Instead, provide custom components for individual workspace icons in the [Studio configuration](https://www.sanity.io/docs/studio/configuration).

[Studio components](https://www.sanity.io/docs/studio/studio-components)
Learn more about the studio components API.

[Reference: Studio components](https://www.sanity.io/docs/studio/studio-components-reference)
Read the reference docs for the studio components API.

## Form components

The `form.components` property deals with the rendering of form fields and inputs in the Studio. The components available for customizing are `field`, `input`, `item`, and `preview`. Form components can be declared in your root workspace configuration, i.e. the `defineConfig` function, as part of a plugin config, i.e. the `definePlugin` function, or individually on any field in your schemas.

```javascript
// sanity.config.js
import {MyField, MyInput, MyItem, MyPreview} from './components'
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...rest of config
  form: {
    components: {
      field: MyField,
      input: MyInput,
      item: MyItem,
      preview: MyPreview,
    },
  },
})
```

[Form components](https://www.sanity.io/docs/studio/form-components)
Learn more about the form components API.

[Reference: Form components](https://www.sanity.io/docs/studio/form-components-reference)
Read the reference docs for the form components API.

## Composing components with `renderDefault`

The components available in this API are rendered using a middleware pattern. This means that plugin customizations are applied cumulatively in a chain or cascade. Each component declaration receives a callback function named `renderDefault`, which, as the name implies, will defer to the default Studio rendering of the component. When you call `renderDefault`, you also pass along the `props` needed to render the component, with any changes you care to make.

```jsx
import { Stack, Card, Flex, Text } from '@sanity/ui'

// Adds markup and invokes renderDefault()
function MyEnhancedNavbar(props) {
  return (
    <Stack>
      <Card padding={3} tone="caution">
        <Flex justify="center">
          <Text>Important reminder! Remember this banner!</Text>
        </Flex>
      </Card>
      <>{props.renderDefault(props)}</>
    </Stack>
  )
}
```

![Shows a Studio navbar customized to display a yellow background banner on top that says "Important reminder! Remember this banner!"](https://cdn.sanity.io/images/3do82whm/next/6bf579c17450e7521b5cddc92a84e451457f8f47-728x421.png)
*Calling renderDefault after adding our banner markup renders the default studio navbar.*

You may opt not to call `renderDefault` if you want to replace the component in question in its entirety with your own markup, but be aware that doing so in a plugin might result in unexpected behavior as it breaks the middleware chain.

## Related and further reading

- [Studio components](https://www.sanity.io/docs/studio/studio-components)
- [Reference: Studio components API](https://www.sanity.io/docs/studio/studio-components-reference)
- [Form components](https://www.sanity.io/docs/studio/form-components)
- [Reference: Form components API](https://www.sanity.io/docs/studio/form-components-reference)





# Custom authentication

Custom authentication can be configured for the Studio or individual workspaces. Set the `auth` config key with a configuration object that adheres to the [AuthConfig](https://reference.sanity.io/sanity/index/AuthConfig/) signature.

> [!WARNING]
> Studio v6 behavior change
> Changed in Studio v6: Passing a plain array to `auth.providers` now replaces all default providers. The `mode: 'append'` option has been removed. To keep existing providers and add new ones, use the callback form shown in the "Append to existing providers" tab below.

**Limit providers**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ... The rest of the studio config.
  auth: {
    providers: [
      {
        name: 'sanity',
        title: 'Email / Password',
        url: 'https://api.sanity.io/v1/auth/login/sanity',
      },
    ],
  },
})
```

**Append to existing providers**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ... The rest of the studio config.
  auth: {
    providers: (prev) => [...prev,
      {
        name: 'newProvider',
        title: 'My Company SSO',
        url: 'https://url.to.other.login',
      }
    ]
  },
})
```

> [!WARNING]
> Gotcha
> In Studio versions prior to v3.15.0, the recommended way to configure custom authentication included using the `createAuthStore` helper method. This approach will still work, but is considered deprecated in favor of the new method.

## SAML single sign-on (SSO)

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

When you configure SSO with SAML, you receive a code snippet to help you configure the custom authentication section of your Sanity config.

#### SAML and SSO guides

[Setting up single sign-on with SAML](https://www.sanity.io/docs/developer-guides/sso-saml)
This article will take you through the process of setting up SAML (Security Assertion Markup Language) SSO (single sign-on) for your organization.

[Set up SSO authentication with SAML and JumpCloud](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-jumpcloud)
Implement single-sign on for Sanity with JumpCloud

[Set up SSO authentication with SAML and PingIdentity](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-pingidentity)
Implement single-sign on for Sanity with PingIdentity SAML

[Set up SSO authentication with SAML and Azure/Entra ID](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-azure)
Implement single sign-on authentication with the SAML protocol and Microsoft Azure AD/ Entra ID as the identity provider.

## SSO and Media Library

If you use a self-hosted Studio with SSO, you may run into issues accessing Media Library.

To work around this, use one of the following options:

- Use a token-based login method by setting `auth.loginMethod: 'token'`, if your provider supports it.
- Log in to the [Dashboard](https://www.sanity.io/docs/dashboard/dashboard-introduction) prior to accessing Media Library.

We're working to support other methods in the future.



# Custom asset sources

Sanity Studio comes with a rudimentary asset selector out of the box. It lets you browse and select images or files you have already uploaded. You can also add multiple asset sources, or replace the default one, globally or for a specific asset field.

## Asset source plugins

You can find available asset source plugins in the [Sanity Exchange](https://www.sanity.io/exchange) or by searching for them on [npmjs.com](https://www.npmjs.com/search?q=sanity+plugin+asset). Just like other plugins, asset source plugins are installed using your preferred package manager. Some asset source plugins will require that you add some configuration, for example, an API token. 

When adding custom asset source plugins to your studio, the Select button for the upload field will become a drop-down button, showing the multiple sources:

![The image asset selector showing both uploaded images, Unsplash and Cloudinary](https://cdn.sanity.io/images/3do82whm/next/510b684ba065041c2b3ce813a2c19774b4d0915b-2086x1400.png)
*Select image from various asset sources. Here we have installed plugins for Unsplash and Cloudinary.*



## Defining asset sources globally

Assets sources that are distributed as npm packages usually come with a plugin definition for easy setup. 

Let's say you want to add the [Unsplash asset source](https://www.sanity.io/plugins/sanity-plugin-asset-source-unsplash). First, install the plugin by running `npm i sanity-plugin-asset-source-unsplash` in your project folder. Then, in `sanity.config.js`, add the following:

```javascript
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {unsplashImageAsset} from 'sanity-plugin-asset-source-unsplash'
import {schemaTypes} from './schemas'

export default defineConfig({
  name: 'default',
  projectId: '<projectId>',
  dataset: 'YOUR_DATASET',
  plugins: [
    deskTool(),
    unsplashImageAsset(),
  ],
  schema: {
    types: schemaTypes,
  },
})

```

Adding `unsplashImageAsset()` to the plugins array will deal with registering the asset source and adding it to the list of assets sources for images in your project.

![The Studio default dialog for uploading images with the new Unsplash option added](https://cdn.sanity.io/images/3do82whm/next/0c31eb97488f7d043f32a469ebbd0adeaf2b7cbb-589x184.png)

If you want to *only* allow the Unsplash asset source instead of adding it to the default upload option, you can instead import `unsplashAssetSource` and add it to `form.image` as the sole member of the returned array value.

```javascript
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {unsplashAssetSource} from 'sanity-plugin-asset-source-unsplash'
import {schemaTypes} from './schemas'

export default defineConfig({
  name: 'default',
  projectId: '<projectId>',
  dataset: 'YOUR_DATASET',
  plugins: [deskTool()],
  form: {
    image: {
      assetSources: () => [unsplashAssetSource],
      directUploads: false,
    },
  },
  schema: {
    types: schemaTypes,
  },
})

```

> [!WARNING]
> Gotcha
> Many properties of the studio configuration can accept both a static value – an array of asset sources in this case – or a callback function that returns that same value. One crucial difference between the two is that providing a static array of sources will **append** those sources to the list of existing sources that may have been added by plugins or the studio's default settings, while returning an array of sources from the callback function will **replace** the current list of sources.
> The callback is invoked with the current list of sources as the first argument, so to append to the list when using the callback option you might do something like this: `assetSources:(prev)=>[...prev, unsplashAssetSource]`



### Using sources on a single type

You can customize sources for single image or file type field in the schema via the `options.sources` property:

```javascript
{
  name: 'mainImage',
  title: 'Main image',
  type: 'image',
  options: {
    sources: [unsplashAssetSource],
  },
}
```

### Remove the Browse option

You can remove the Browse button on an image field (making the field upload-only) by specifying `options.sources` as an empty array:

```javascript
{
  name: 'uploadedImage',
  title: 'Upload an Image',
  type: 'image',
  options: {sources: []}
}
```

## Anatomy of an asset source plugin

The plugin exports an [AssetSource](https://reference.sanity.io/sanity/index/AssetSource/) object with the following shape:

```javascript
export default {
  name: 'cloudinary', // Unique source name
  title: 'Cloudinary', // Title displayed in lists, buttons etc
  component: Cloudinary, // Selection component
  icon: Icon // Icon for lists, buttons etc.
}

```



## The selection component

The plugin must define a **component** that will let the user select some asset(s) from somewhere.

If the user selects something, the component calls the `props.onSelect` function with an array of asset objects like this:

```javascript
type AssetFromSource = {
  kind: 'assetDocumentId' | 'file' | 'base64' | 'url'
  value: string | File
  assetDocumentProps?: ImageAsset
}
```

An asset can be a URL, user agent File object, base64 encoded binary data or an assetDocumentId. It can have `assetDocumentProps` that will end up as properties on the resulting asset document. The allowed document props are:

#### Properties

**originalFilename** (string)

If you would like to use the original filename, when saving the file etc.

**source** (object)

{name, id, url?} - Optional object identifying the asset in the source, so you can find all assets from that source, or find it back to the specific assets when opening the plugin etc. If set, the object properties  name and id are required, but url is optional. An example for Instagram images: {name: 'instagram', id: '_cjqbJKwZB', url: 'https://www.instagram.com/p/_cjqbJKwZB/'}

**title** (string)

Optional title for the asset.

**description** (string)

Optional description for the asset.

**creditLine** (string)

Optional credit line for the asset. E.g. John Doe by Instragram

**label** (string)

Optional label.

### Component Props

#### Properties

**selectionType** (string, required)

If the opening interface selection type is 'single' or 'multiple'.

**selectedAssets** (array, required)

An array of Sanity assets if they are selected in the opening interface. These are Sanity asset documents.

**onSelect** (function, required)

Accepts an array of asset objects (AssetFromSource[])

When assets are selected and returned to props.onSelect, the Studio will make sure to upload the asset(s). If the selected asset is uploaded previously, the existing asset document and file will be used instead.

**onClose** (function, required)

The component must call props.onClose if the select action is canceled or closed somehow.

**dialogHeaderTitle** (React.ReactNode)

A component that serves as the header element for the dialog window.

**assetType** (string)

Either file or image

## Basic component example

The following code shows how to implement a selection component for an asset source plugin. It's not very useful as it will only allow you to pick one very specific image, but it should serve nicely as an example.

```jsx
import React, {useCallback} from "react";
import {
  Dialog,
  Card,
} from "@sanity/ui";

export default function GitHubAssetSource({ onSelect, onClose }) {
  const handleSelect = useCallback(() => {
    onSelect([
      {
        kind: "url",
        value:
          "https://github.githubassets.com/images/modules/site/sponsors/logo-mona.svg",
        assetDocumentProps: {
          originalFilename: "logo-mona.svg", // Use this filename when the asset is saved as a file by someone.
          source: {
            // The source this image is from
            name: "github.githubassets.com",
            // A string that uniquely idenitfies it within the source.
            // In this example the URL is the closest thing we have as an actual ID.
            id: "https://github.githubassets.com/images/modules/site/sponsors/logo-mona.svg",
          },
          description: "Mona Lisa Octocat",
          creditLine: "By Github.com",
        },
      },
    ]);
  }, [onSelect]);

  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  return (
    <Dialog
      id="github-asset-source"
      header="Select image from Github"
      onClose={handleClose}
      width={4}
      open
    >
      <Card>
        <img
          src="https://github.githubassets.com/images/modules/site/sponsors/logo-mona.svg"
          onClick={handleSelect}
        />
      </Card>
    </Dialog>
  );
}

```

> [!WARNING]
> Gotcha
> **CORS headers for image URLs
> **
> When calling `onSelect` with  `kind: 'url'` the resource must respond with a `access-control-allow-origin` header that allows the image to be read by the Studio host. Using `*` will allow all hosts (including Studio host). 

> [!TIP]
> Protip
> **Best practice**
> When integrating with an external service, be sure to read the usage guidelines for that service or API. Some will require you to honor the credits for the asset, not expose any API keys etc. Use the `assetDocumentProps` for `onSelect` to store any required or relevant information to the resulting asset document. If it is from a service where the asset has an ID and can be displayed in the service, you should use the `source` key for the `assetDocumentProps` to store that information. In that way, you can find back to the original asset.



# Diff components

![Annotated Diff screen for default String, Image, and Portable Text fields](https://cdn.sanity.io/images/3do82whm/next/15d75cf306395056afd671d096f7298ccf75aef0-2842x1590.png)



With Sanity Studio, you can see, in real time, any changes happening within a given field. You can see these changes down to the smallest detail. 

Out of the box, this will render the difference in all basic types - strings, numbers, booleans, arrays and similar. If you have your own custom inputs, the changes will be visible, but will only show the changes between two sets of data. 

Often, the difference between two values might not be enough to showcase exactly what happened inside a custom input. A color field changing between two hexadecimal values might not be human readable. A visualization of the two colors would be helpful to humans. A geopoint is an object containing latitude and longitude numbers. Showing the difference between those two numeric numbers won't mean much, but showing two map pins representing those locations can mean a lot.

The Studio API allows you to write your own custom React components that can visualize these changes in ways that make sense to your editors.

## Anatomy of a Diff Component

The components responsible for showing the change of a value are called "**diff components**". These React components receive a structured `diff` object allowing you to inspect the change values deeply. This object contains data on not only the change itself, but also who made the change and when it was made.

When you compare values over time, different parts of a value may have different authors. For instance, one person may have uploaded an image, while a second person provided the caption, and a third changed a crop. In order to provide this information in a fine-grained manner, the diffs contain **annotations**.

**Annotations** can be used to render the name and avatar of the users who did the changes, and also contain the exact timestamp of the change, should you want very fine-grained control.

## Actions

There are four basic actions that might happen to a value:

- `action: "changed"` - The value changed (from value X to value Y)
- `action: "added"` - The value was added (field or array item appeared)
- `action: "removed"` - The value was removed (field or array item disappeared)
- `action: "unchanged"` - The value was unchanged (item was moved within an array)

These actions can be inspected on the `diff` object provided to the custom diff component. All of these actions should be accounted for in a diff component.

## Adding a custom diff component to a field

You can add a custom diff component to any field using the `diff` property of the field's `components` object.

```jsx
import {CustomStringDiff} from '../src/components/field'

export default {
  name: 'product',
  title: 'Product',
  type: 'document',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      components: {
        diff: CustomStringDiff,
      }
    },
    // ... Additional fields
  ]
}
```

In this example, we assume that our `CustomStringDiff` component is exported from a file located at `../src/components/field/index.jsx` relative to our schema file. A minimalist implementation is shown below.

```jsx
../src/components/field/index.jsx
import {DiffFromTo} from 'sanity'

export function CustomStringDiff({diff, schemaType}) {
  return (
    <DiffFromTo
      diff={diff}
      schemaType={schemaType}
      previewComponent={StringDiffPreview}
      layout="grid"
    />
  )
}

function StringDiffPreview({ value }) {
  return <div style={{borderLeft: '5px solid', padding: '3px', display: 'flex'}}>{value}</div>
}
```

## Creating a basic diff component

The core `sanity` package includes a few helper components and methods to help you build custom diffs. The most basic among these is the `<DiffFromTo />` component. This creates a view that shows an initial state of a field and the new state with an arrow in the middle. You can customize how the those blocks are rendered with a `previewComponent`. 

The following example creates a custom preview for the component to format a country code for a phone number. If a field goes from blank to populated, it will show the addition. If a field goes from populated to empty, it will show a strike-through on the value. If there's a change to the value, it will show a before and after rendered with the preview component. 

```jsx
import {DiffFromTo} from 'sanity'

export function PhoneNumberFieldDiff({diff, schemaType}) {
  return (
    <DiffFromTo
      diff={diff}
      schemaType={schemaType}
      previewComponent={PhoneNumberPreviewComponent}
    />
  )
}

function PhoneNumberPreviewComponent({value}) {
  const prefix = value.countryCode ? `(${value.countryCode}) ` : ''
  const formatted = `${prefix}${value.number}`
  return <span>{formatted}</span>
}

```

## Creating the states of a diff component

In this example, a different layout is used to render each state of the diff. 

> [!WARNING]
> Gotcha
> It might seem strange at first to provide an `unchanged` state for our custom diff. This is to provide for states when the value itself hasn't changed, but data around the value have, such as when an item has been moved in an array.

```jsx
import {DiffCard} from 'sanity'

export function NumberFieldDiff({diff}) {
  const {fromValue, toValue, action} = diff

  // In certain cases the diff component will be used to render the value, even
  // if there are no changes to the actual _value_. For instance, when an item
  // has been moved within the array, but the actual value did not change.
  if (action === 'unchanged') {
    return <div>{fromValue}</div>
  }

  // If we have both a "from" and "to" value, the value changed
  // "from" and "to" can also be read as "previous" and "next"
  if (typeof fromValue === 'number' && typeof toValue === 'number') {
    return (
      <DiffCard diff={diff}>
        {fromValue} → {toValue}
      </DiffCard>
    )
  }

  // If we only have a "previous" value, the value has been unset
  if (typeof fromValue === 'number') {
    return (
      <DiffCard diff={diff}>
        <del>{fromValue}</del>
      </DiffCard>
    )
  }

  // The only remaining option is that the value was added
  return (
    <DiffCard diff={diff}>
      <ins>{toValue}</ins>
    </DiffCard>
  )
}
```

## Helper components

The `sanity` package includes a range of helpful components and methods for building custom diff views. Let's take a closer look at a few of them.

### `<DiffFromTo />`

Many diff components will follow a pattern where they have a "preview" component that renders the value, and presents a "from → to" layout, setting the background to the "user color" for the change and adding a tooltip when hovering the diff that shows the author information.

You can use this pattern to graphically showcase the change. This can help the editor to quickly understand at a glance the change that happened.

```jsx
import {DiffFromTo} from 'sanity'

export const TelephoneFieldDiff = ({diff, schemaType}) => (
  <DiffFromTo
    diff={diff}
    schemaType={schemaType}
    previewComponent={TelephonePreview}
    layout="inline" // "grid" is also an option
  />
)

function TelephonePreview({value}) {
  const formattedNumber = value.toString().replace(/\d{3}(?=.)/g, '$& ')
  return <>{formattedNumber}</>
}

```

### `<ChangeList />`

The pattern mentioned in the section on `<DiffFromTo />` pairs nicely with the `<ChangeList />` helper. Often when displaying a graphical diff using `<DiffFromTo />`, you may also wish to show the individual fields that were changed to create the overall change. The `<ChangeList />` component takes a `diff`, `schemaType`, and an array of `fields` to render individual fields to show.

In this example, we generate a barcode from two fields on an `object` field type. We use the same visual component used in the custom input, but also use `<ChangeList />` to show the individual field changes for `barcode` and `format`.

```jsx
import {DiffFromTo, getDiffAtPath, ChangeList} from 'sanity'
import Barcode from 'react-barcode' 

export function BarCodeDiff({diff, schemaType}) {
  return (
    <div>
      <DiffFromTo
        diff={diff}
        schemaType={schemaType}
        previewComponent={BarCodeDiffPreviewComponent}
        layout="grid"
      />
      <ChangeList diff={diff} schemaType={schemaType} fields={['barcode', 'format']} />
    </div>
  )
}

function BarCodeDiffPreviewComponent({value}) {
  return (
    <div style={{display: 'flex', padding: '5px', justifyContent: 'center', alignItems: 'center'}}>
      <Barcode textAlign="center" value={value.barcode} format={value.format || ''} width={1} />
    </div>
  )
}
```



### `<FromTo />`

When creating a completely custom diff component, you can use the `<FromTo />` component to specify components to be used for the "From" and "To" states of the component. If you opt for this component, you will need to recreate many of the affordances given, such as tooltips, user background colors, and more.

```jsx
import React from 'react'
import {FromTo} from '@sanity/field/diff'

export const SomeDiff = () => (
  <FromTo
    from={<div>Old value</div>}
    to={<div>New value</div>}
    layout="inline" // "grid" is also an option
  />
)
```

### `<DiffCard />`

Renders a container element styled with the appropriate user color, based on the passed diff or annotation.

```jsx
import {DiffCard} from 'sanity'

export const SomeDiff = ({diff}) => (
  <DiffCard as="pre" diff={diff}>
    <code>{JSON.stringify(diff.toValue, null, 2)}</code>
  </DiffCard>
)
```

### `<DiffTooltip />`

Wraps the passed children with a tooltip when hovered, showing information about the actual change - which authors were involved, and when. This component differs slightly in that it can take multiple annotations instead of just a single one, combining the information in a single tooltip. Example:

```jsx
import {DiffTooltip} from 'sanity'

export function MovieReviewDiff({diff}) {
  const {action, fromValue, toValue} = diff
  if (action === 'unchanged') {
    return (
      <div>
        <StarMeter value={fromValue} diff={diff} />
      </div>
    )
  }
  return (
    <div>
      {fromValue && <StarMeter value={fromValue} diff={diff} />}
      {fromValue && toValue && '→'}
      {toValue && <StarMeter value={toValue} diff={diff} />}
    </div>
  )
}

function StarMeter({value, diff}) {
  const {numStars, comment} = value
  return (
    <div>
      {numStars && (
        <DiffTooltip diff={diff}>
          <div>{'★'.repeat(numStars)}</div>
        </DiffTooltip>
      )}
      {comment && (
        <DiffTooltip diff={diff}>
          <div>{comment}</div>
        </DiffTooltip>
      )}
    </div>
  )
}
```

## Helper Hooks

### `useDiffAnnotationColor(diff, path)`

Takes a diff and an optional path as arguments and returns the corresponding user color for it. A user color is an object with the keys `background`, `text` and `border`, each having a hex color as the value. 

```jsx
import {useDiffAnnotationColor} from 'sanity'

export function MovieReviewDiff({diff}) {
  const {background, text} = useDiffAnnotationColor(diff, 'numStars')
  return <div style={{background, color: text}}>Do some fancy logic here</div>
}
```

### `useAnnotationColor(annotation)`

Like `useDiffAnnotationColor`, but takes an annotation directly instead of a diff.

```jsx
import {useAnnotationColor} from 'sanity'

export function PhoneNumberDiff({diff}) {
  // Note: `diff.annotation` might not be what you want for many diff types!
  // See "diff annotations" section for more information
  const {background, text} = useAnnotationColor(diff.annotation)
  return <div style={{background, color: text}}>Do some fancy logic here</div>
}
```

## Shape of the data on a diff object

The `diff` received in diff components vary depending on the data type represented. All diffs share a common set of properties.

- `action` - either `added`, `removed`, `changed` or `unchanged`.
- `type` - the value type, eg `string`, `number` etc
- `fromValue` and `toValue` - holding the previous and next value
- `isChanged` - a boolean indicating whether or not the actual content changed

Unless the action is `unchanged`, the diff will also have an `annotation` property which is often needed to show information about the author of the change, as well as a timestamp and other similar metadata. Make sure you read the section on "diff annotations"!

### Strings

String diffs have an additional `segments` property, which is an array containing parts of the string which have been added, removed or did not change. This is useful when comparing larger chunks of text, as instead of simply saying the paragraph was "replaced", we can say that a single word was changed. It also allows individual segments of the text to be attributed to different authors.

The `<DiffString />` component can help render a visualization of these changes - or you can iterate over the segments and render them yourself should you want to.

### Arrays

An array rarely has any changes done to "itself", apart from perhaps being set from an undefined state to an empty array. What you are usually interested in is the values it holds, and the locations of those items.

Determining if something was added, removed, or moved within the array is done on a best effort basis, and the algorithm attempts to explain the change with as few "operations" as possible.

Why is it a best guess? From a technical perspective, if an item was at index 0 and now appears at index 1, it has "moved". If that "move" was the result of an item being prepended to the array, the more "natural" way of thinking about it is that it did *not* move.

Array diffs contain an `items` property, which itself is an array of changes to the items within the array. Each item has the following properties:

- `hasMoved` - a boolean indicating whether or not the array item moved, based on the algorithm's best guess.
- `fromIndex` and `toIndex` - containing the previous and next location of the item within the array. As noted above, `fromIndex` and `toIndex` can differ without the `hasMoved` property being `true`, because an add/remove operation could have shifted the indexes.
- `diff` - represents the actual item diff

Note that the `annotation` on the array diff is very coarse. When illustrating changes to data inside an Array item, it's best to do that with a diff component for the item with the item's `annotations` and not for the Array.

### Objects

Like arrays, objects rarely have a value of their own, apart from being set from an undefined state to an empty object. To access the individual fields of an object, the `fields` property is itself an object where the key is the name of the field and the value is the diff for that field. Certain underscore-prefixed fields are ignored when calculating the diff (`_id`, `_type`, `_createdAt`, `_updatedAt`, `_rev`).

## Diff annotations

Diff annotations hold fine-grained information on the change, and are present on individual "leaves" of the diff structure. It contains an `author` property (user ID of the person doing the change) and a timestamp for when the change occurred.

At first glance, this may seem like an unnecessary abstraction, but given the granular structure of annotations, we can create a deeper understanding for our editors. 

```json
{
  "asset": {
    "_ref": "image-someHash-1024x768-png"
  },
  "crop": {
    "bottom": 100,
    "top": 100,
    "left": 20,
    "right": 20
  },
  "caption": "Kokos is a miniature schnauzer"
}
```

When comparing two versions of this image data structure, several things can have changed, for instance:

- The asset reference can have changed
- The numbers in `crop` can have appeared
- The caption may have been edited or created

Using the annotation on the image field itself would give you very coarse information, as if a single author performed the entire set of changes. Instead, we want to look at the annotations on the individual fields, and for the string fields even inspect individual segments of the string.

The `getAnnotationAtPath` function allows for easy retrieval of these annotations at depth.

Note that in certain cases it might make sense to *not* be as fine-grained. Theoretically, two users could have edited the `crop` object above - for instance, one user could have increased the width and a different user only modifying the height, thus touching `bottom`/`top` and `left`/`right`, respectively. Showing individual authors for each side of a rectangle *might* be hard to visualize.

## Usage with TypeScript

Writing diff components with TypeScript is fully supported. The diff tools in the `sanity` package includes not only the helper functions, hooks and React components you may want to use, but also a range of type definitions.

When defining a diff component, you can type it as a `DiffComponent`, and specify which type of diff you expect for the component. For instance, a diff for a geopoint object type might look like this:


```typescript
import {ObjectDiff, DiffComponent} from 'sanity'

interface Geopoint {
  lat: number
  lng: number
  alt?: number
}

export const GeopointDiff: DiffComponent<ObjectDiff<Geopoint>> =
  function GeopointDiff(props) {
    const {diff, schemaType} = props
    
    console.log(diff.fromValue) // Geopoint | null | undefined
    console.log(diff.toValue) // Geopoint | null | undefined
    
    return <div>{/* your diff logic here */}</div>
  }

```



# Form components

[Reference: Form API](https://www.sanity.io/docs/studio/form-api-reference)

Custom form components are available in your root studio configuration and plugins via `form.components`, and on individual schema types via the `components` property. It accepts component customizations including:

```javascript
// sanity.config.js
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...rest of config
  form: {
    components: {
      input: MyInput,
      field: MyField,
      item: MyItem,
      preview: MyPreview,
    }
  }
})
```

The props for each component available in the API include a callback function called `renderDefault`. As the name implies, `renderDefault` renders the default component. When you call `renderDefault`, you also pass along the props needed to render the default component. You can modify the props to your liking before passing them along.

```javascript
// ./custom-string.js

import {Stack, Text, Card} from '@sanity/ui'

export function CustomStringInput(props) {
  return (
    <Stack gap={3}>
      {props.renderDefault(props)}
      <Text size={1}>Characters: {props.value?.length || 0}</Text>
    </Stack>
  )
}
```

If you want to completely replace the component in question with your own markup, you can do so by not invoking `renderDefault` in your return. Be aware that doing so in a plugin setup might cause unexpected behavior because of the chainable nature of the components API (discussed in the next section).

## Prerequisites

- A Sanity Studio project.
- Working knowledge of React, since form components are React components.
- Familiarity with Studio configuration and schema types.

## Composing `renderDefault()`

The rendering of components in this API uses a middleware pattern. This means that plugin customizations are applied in a chain. Each plugin may call `props.renderDefault(props)` to defer to default rendering. If any component in the chain fails to invoke the callback function, the chain breaks. To learn more about `renderDefault`, see the [article on the components API](https://www.sanity.io/docs/studio/intro-to-custom-studio-components).

## Input and field components

The `input` and `field` custom components are easiest to understand when examined together. To demonstrate the difference between these two, we’ll take a closer look at the anatomy of a field widget in the Studio. In the illustration below the *field* includes everything within the purple dashed border conveniently marked “field,” while the *input* includes only what’s within the green dashed border marked “input.”

![Diagram showing that only the actual text field a user enters their input into belongs to the Input component, while elements such as title and description belongs in the Field component](https://cdn.sanity.io/images/3do82whm/next/e22848b0c971f0573db5dd83adc65ae1f7e49477-891x336.png)

Often, developers are chiefly interested in customizing the input widget itself and happy to leave the rest to studio defaults. In these cases, you would opt to replace `components.input`. If you do want to control the field in its entirety, you can do so by replacing the `components.field` component.

In the following example, we assign a custom field component (adding a border and transforming the title and description visually) to *all* studio fields. In contrast, we do a check to only assign a custom input component (adding a character count) if the field has the `string` schema type.

```javascript
// sanity.config.js

import {Stack, Text, Card} from '@sanity/ui'
import {defineConfig} from 'sanity'
import schemaTypes from './schemas'

function CustomStringInput(props) {
  return (
    <Stack gap={3}>
      {props.renderDefault(props)}
      <Text size={1} style={{color: 'orange'}}>
        Characters: {props.value?.length || 0}
      </Text>
    </Stack>
  )
}

function CustomField(props) {
  const {description, title, ...restProps} = props
  return (
    <Card border padding={3}>
      <Stack gap={3} marginBottom={3}>
        <Text size={1} weight="bold">
          {title?.toUpperCase()}
        </Text>
        {description && (
          <Text size={1} style={{color: 'green'}}>
            {description}
          </Text>
        )}
      </Stack>
      {props.renderDefault(restProps)}
    </Card>
  )
}

export default defineConfig({
  // ...rest of config
  form: {
    components: {
      field: CustomField,
      input: (props) =>
        props.schemaType?.name === 'string' ? <CustomStringInput {...props} /> : props.renderDefault(props),
    },
  },
})
```

The result in the Studio is that all fields are customized to use the `CustomField` component, which transforms the title and description and adds a border around the field, while only the fields of type `string` are affected by the `CustomStringInput` component, which adds a character count in bright orange.

![Three studio fields named Title, Author, and Slug. All of them have a gray border. The slug and title fields have descriptions in green. The title field alone also has a character count in orange.](https://cdn.sanity.io/images/3do82whm/next/312c8a0db9eb2d8b98061e4d14046b65a0665782-652x438.png)

[Form component reference](https://www.sanity.io/docs/studio/form-components-reference)

## Preview components

The preview component decides how an object, image, or reference value is displayed in list views. The illustration below shows an example of an array of objects, where each object has a `string` and an `image` field defined. The default preview component tries its best to guess which fields should be displayed by introspecting the defined fields of the object. 

![Studio screenshot that indeed shows the studio rendering the previews, presumably by inferring which values to display](https://cdn.sanity.io/images/3do82whm/next/2ed2138b7f654bf3c7c1f55cef16d6d99437857f-2048x652.png)

As with input and field components, it is possible to configure a custom preview component. The custom preview component can be configured either in `sanity.config.js`, in a plugin, or directly in the schema definition. In the following example, we will configure a custom preview component directly in the schema definition.

To keep the example minimal, our custom preview component will be a `div` with a green border that wraps the default preview component rendered using `renderDefault`. However, it is possible to configure a completely custom component and not use `renderDefault`.

The following schema definition is what the illustration above represents. Since we want to configure a custom preview component for each object in the array, we add our component to the `components.preview` property for the object field definition.

```javascript
// ./custom-array.js

import {defineField} from 'sanity'

// Render a div that wraps the default preview component
function MyPreviewComponent(props) {
  return (
    <div style={{border: '1px solid green'}}>
      {props.renderDefault(props)}
    </div>
  )
}

export const arrayOfObjects = defineField({
  type: 'array',
  name: 'arrayOfObjects',
  title: 'Array of objects',
  of: [
    {
      type: 'object',
      name: 'myObject',
      title: 'My object',
      components: {
        preview: MyPreviewComponent, // Add custom preview component
      }, 
      fields: [
        {
          type: 'string',
          name: 'myString',
          title: 'My string',
        },
        {
          type: 'image',
          name: 'myImage',
          title: 'My image',
        },
      ],
    },
  ],
})

```

As you can see in the illustration below, our custom preview component is rendered.

![Shows the array of objects with previews that have a slim green border](https://cdn.sanity.io/images/3do82whm/next/5c13589455833f1a23c96ae0d1aeab71e40a393b-2970x976.png)

## Item components

The item component is the component that represents each item in an array field. The default item component contains a drag handle for sorting, a menu with actions (such as duplicate and delete), and some content. The content, that is, what is between the drag handle and the actions menu, varies based on what type of field(s) the item represents.

In an array of objects, a *preview component* is displayed as content, but in an array of primitive types (e.g., boolean or string), an *input component* is displayed as content.

![Example of items for an object field. The object input is displayed in a dialog when clicking the item.](https://cdn.sanity.io/images/3do82whm/next/91ba5f304ed45332fd694a5ecd7f724d9de6e453-2402x836.png)
*Example of items for an object field. The object input is displayed in a dialog when clicking the item.*

![Example of items for a string field. The string input is displayed inside the item (inline editing, etc.)](https://cdn.sanity.io/images/3do82whm/next/bf1b0854c5d4c915c1efa2daf5b6abd49c1f4a9b-2144x754.png)
*Example of items for a string field. The string input is displayed inside the item (inline editing, etc.)*

## Typical use cases/problems this solves

- Decorate the default component, or modify the props passed to the default component, using `renderDefault`.
- Create your own completely custom component.

## Targeting the whole document form

Registering an `input` component at `form.components.input` applies it to every field across the Studio. To wrap the document form itself, rather than every field within it, use the same registration but check `props.id === 'root'` and `props.schemaType.type?.name === 'document'` inside the component to detect the document root, then return `props.renderDefault(props)` for everything else:

```typescript
import {defineConfig} from 'sanity'
import type {InputProps} from 'sanity'

function CustomDocumentInput(props: InputProps) {
  if (
    props.id === 'root' &&
    props.schemaType.type?.name === 'document'
  ) {
    // Render around the entire document form
    return (
      <div>
        {/* Add header, sidebar, progress indicator, etc. */}
        {props.renderDefault(props)}
      </div>
    )
  }
  return props.renderDefault(props)
}

export default defineConfig({
  // ...
  form: {
    components: {
      input: CustomDocumentInput,
    },
  },
})
```

You can further limit the rendering to specific document types by adding an additional condition that compares the `props.schemaType.name` value. For a complete worked example, see [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component).



# How form paths work

A form path provides a unique and stable address for a value in a Sanity document. This is an important piece of the puzzle that makes Sanity a real-time platform where several people can work on editing the same document simultaneously.

## What are form paths?

Consider a scenario where two people work on the same document. When one of the two users edits the document, the modification is technically represented as a [patch](https://www.sanity.io/docs/studio/from-input-components-to-real-time-safe-patches), a description of the change.

The patch may look something like this:

`set ["name"] to "Buddy"`

The patch goes places:

- It’s sent to the Sanity API.
- It’s applied to the document in the API datastore.
- It’s redistributed to any other user editing the same document.

In this example, the `["name"]` array is a path that always and uniquely points to the `name` field. The contents of this field may change, but its location within the document doesn’t.

The `pets` array in the following example behaves differently:

```json
{"pets": [{"name": "Buddy"}, {"name": "Daisy"}]}
```

To point to Daisy in the array, use the following form path: `["pets", 1, "name"]`. What’s the problem here? Let’s find out.

Consider two editors, Alice and Bob, updating the pet list at the same time: Alice moves Daisy to the top of the list, while Bob renames Buddy to Buds.

The patches that the Studio generates for these two edits are similar to the following pseudocode:

- Bob: `set ["pets", 0, "name"] to "Buds"`
- Alice: `move ["pets", 1, 0]`

If these two patches are received in the order above, everything is fine: Buddy becomes Buds, and Daisy moves to the top of the list.
However, networks make it hard to set the order of actions reliably. For example, latency may cause Bob’s change to be received and processed after Alice’s:

- Alice: `move ["pets", 1, 0]`
- Bob: `set ["pets", 0, "name"] to "Buds"`

If the Sanity data store receives Alice's patch first, Buddy and Daisy swap places, and Daisy is located at array index `0`. When Bob’s patch is processed, Daisy is renamed to Buds, which isn’t what Bob meant to do! And poor Daisy now has to learn again what humans call her! Luckily, there’s a solution.

## Unique key IDs

To prevent this problem, array objects in Sanity must have a unique `_key` value.

This unique key is generated upon object creation. The key uniquely identifies the object it refers to, and it’s immutable throughout the lifetime of the object.

This is the pet array with the key IDs:

```json
{
  "pets": [
    {"_key": "m99vcit2pho", "name": "Buddy"},
    {"_key": "v1uf44s5hu8", "name": "Daisy"}
  ]
}
```

Instead of referencing the object by its index number (which may point to a different object if the order in the array changes), you can specify its unique key ID in the form path.
Here’s how you can use `_key` to point to Buddy’s name in a form path: `["pets", {_key: "m99vcit2pho"}, "name"]`.

The order of the actions no longer matters. If you rerun the previous wrong-order scenario, everything is OK:

- Alice: `move ["pets", 1, 0]`
- Bob: `set ["pets", {_key: "m99vcit2pho"}, "name"] to "Buds"`

When Bob’s patch is received, Buddy has already been moved on the list. However, the datastore now identifies the pet name to update by its `_key`. Daisy is safe!

You probably noticed that Alice’s `move` patch still refers to the object by its array index.
Currently, this is a bit of a gray area: when an editor moves an item to the top of the array, what is their goal? Do they want to move the item *to the top*, or do they want to move it just *one level up from their current position* in the array?

![Alice and Bob both try to move an array member. In the example, Daisy is the array member they want to move.](https://cdn.sanity.io/images/3do82whm/next/804354b8fd7fad8e9a4afced96e6bdeb142ca649-1600x704.png)
*Click to view a larger image*

Although this area is a bit ambiguous, changing the order of the array members doesn’t modify the wrong object, and Daisy is safe!

## Using form paths in Sanity Studio

When customizing Sanity Studio, you may sometimes run into situations where you’d like to point to a specific piece of content in a document. This is when form paths are helpful. For example:

- Reading the value of a node inside the active document being edited.
- Deep linking to a specific form node.
- Opening a dialog to edit an array item. For more information about this topic, see the article about [disclosure elements](https://www.sanity.io/docs/studio/focus-and-ui-state-in-custom-inputs).

To support working efficiently with paths, we offer a set of utility functions in the [@sanity/util](https://www.npmjs.com/package/@sanity/util) package at the `@sanity/util/paths` export.

## Known limitations

This approach has limitations with arrays of primitive values and multidimensional arrays:

- It’s not possible to assign a key to a [primitive value](https://developer.mozilla.org/en-US/docs/Glossary/Primitive). Therefore, arrays of primitive values rely on the array index to locate a specific member. Since index references point to the position of an object in the array, not to the object itself, they’re also more likely to produce unexpected effects when multiple users modify the same array at the same time.
- It’s not possible to assign a key to multidimensional arrays, or arrays of arrays. Instead of creating multidimensional arrays, we recommend defining the data as an array of objects, where a field holds the inner array.

Example:

```json
{
  "multiDimensional": [
    {"_key": "3u285aqn8uo", "inner": [1, 2, 3]},
    {"_key": "0c3afift948", "inner": [4, 9, 3]}
  ]
}
```



# Icons

## Icons

Use icons for types to display in the creation dialogue and when you're missing a media preview.

Helpful icons can improve the editorial experience, and can be applied in several contexts throughout the studio interface, such as in structures created in [Structure Builder](https://www.sanity.io/docs/studio/structure-tool-api), and as [tool icons in the Portable Text Editor](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor).

Each document type can also be assigned an icon to illustrate its purpose. We recommend using an SVG file, but it can be any react component you like.

If you want to have your icons reflect the default style found throughout the Studio, you might opt to install the `@sanity/icons` package. Another popular package is `react-icons` which includes a plethora of open source SVG icons from collections like [Font Awesome](https://fontawesome.com/), [Material Design](https://material.io/), [Typicons](https://s-ings.com/typicons/), and [Github Octicons](https://octicons.github.com/). 

> [!TIP]
> Protip
> You can browse the icons available in `@sanity/icons` [here](https://icons.sanity.build/all). For `react-icons`, go [here](https://react-icons.github.io/react-icons/).

### Example

Install the icon package using your favorite package manager, and use them in your schemas! Just remember that any schema file with icons in them should have a `.jsx` or `.tsx` extension.

#### Example

After including `@sanity/icons` in your `package.json` with `yarn add @sanity/icons` you can add it to your schema:

```javascript
import { PlayIcon } from '@sanity/icons/Play'

export default {
  name: 'movie',
  type: 'document',
  icon: PlayIcon,
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string'
    },
    {
      title: 'Release Date',
      name: 'releaseDate',
      type: 'date'
    }
  ],
  preview: {
    select: {
      title: 'title',
      subtitle: 'releaseDate'
    }
  }
}
```

![A Sanity Studio with sci-fi movies showing unique icons for each document type.](https://cdn.sanity.io/images/3do82whm/next/d82118619cb3bc7caa8ff9fcac6849858048372e-2678x1928.png)
*Here we've added unique icons for each of our document types Movie, Person, Screening.*



# Favicons

## Favicons/"website icon"

![A browser tab bar, illustrating the use of favicons](https://cdn.sanity.io/images/3do82whm/next/bf96f8116045e5fc2e6ebeb032c415ee93ff1819-316x75.png)

A favicon is a small icon that appears in the browser's tab or bookmarks. By default, any Sanity Studio will use the Sanity logo as its favicon.

Using a favicon that aligns with a company or project's logo or brand colors can help enhance the visual identity of a studio, differentiate it from other open tabs, and create a consistent and cohesive look and feel.

## Individual files

To use a custom favicon that works across all platforms (desktop browsers, mobile browsers, bookmarks, and similar), six different files are used:

- `favicon.svg`: a square vector file in SVG format. This format scales well to many different sizes, and can also support different layouts/colors for [light/dark mode](https://owenconti.com/posts/supporting-dark-mode-with-svg-favicons).
- `favicon.ico`: a small, square file in ICO format. This serves as the fallback icon for older browsers and environments that do not support resolving the preferred icon from HTML.
- `favicon-512.png`: a 512x512px image file in PNG format. This is used as the icon defined in a Web Manifest file, and is generally used when bookmarking the studio on mobile devices.
- `favicon-192.png`: a 192x192px image file in PNG format. Holds the same purpose as the 512px variant, but may be used when smaller icons are needed.
- `favicon-96.png`: a 96x96px image file in PNG format. Also referenced by the generated Web Manifest.
- `apple-touch-icon.png`: a 180x180px image in PNG format. This is used when bookmarking the studio on iOS devices.

All these files should be placed in the `static` folder within the studio folder. Once they are in place, the studio should automatically use the new icons, though browsers may keep the old icons in their cache for a while.

## Generating the files

The files can either be created by hand, or you can use a tool such as [create-favicon](https://github.com/rexxars/create-favicon) to generate them all based on a source SVG file:

**npm**

```shell
# From the studio root folder:
npm create favicon -- <path-or-url-to.svg> static
```

**pnpm**

```shell
# From the studio root folder:
pnpm create favicon <path-or-url-to.svg> static
```

**yarn**

```shell
# From the studio root folder:
yarn create favicon <path-or-url-to.svg> static
```

**bun**

```shell
# From the studio root folder:
bun create favicon <path-or-url-to.svg> static
```

## Troubleshooting

If your custom favicons are not showing up:

- Ensure that all files listed above are present in the `static` folder and have the same casing. A common case is that the SVG icon (`favicon.svg`) is missing, in which case it will fall back to the default Sanity favicon.
- When running `sanity build`, inspect the contents of the `dist/static` folder and see which icons are actually there.
- Your browser might be caching the favicons. Try a hard refresh by pressing `Command+Shift+R` on Mac, or `Ctrl+F5` on Windows/Linux. You can also try the "Disable cache" feature in the network tab of your browser development tools.



# Localizing Sanity Studio

You can change the language of the Sanity Studio interface by installing a language plugin. This article covers installing a language for your studio, overriding individual translated strings, and contributing translations back to the community.

To follow the installation steps, you need a studio you can edit locally, including its `sanity.config.ts` file, and a Node.js package manager such as npm. If you don’t have a studio yet, start with [Installation](https://www.sanity.io/docs/studio/installation).

## Studio UI localization

You can install language plugins that change the Sanity Studio interface to your preferred language. This improves accessibility and user-friendliness and is sometimes a requirement for organizations that wish to adopt Sanity as their content platform.

> [!NOTE]
> Localizing UI vs localizing content
> This article is about the language of the Studio user interface. Visit [this article](https://www.sanity.io/docs/studio/localization) to find documentation on different languages in the *content* you manage from Sanity Studio.

Languages for the Sanity Studio interface are available as plugins. All available languages can be found in the [sanity-io/locales](https://github.com/sanity-io/locales) repository on GitHub. This repository is regularly updated through artificial intelligence (AI) and manual reviews by our dedicated maintainers, who diligently assess contributions from AI and humans. You can also add project-specific localization and local overrides using the `i18n.bundles` entry point in the Studio configuration.

The primary official language of Sanity Studio remains American English. However, we encourage and welcome contributions to our community-supported repository. There's a high likelihood your preferred language is already available, and if not, you can request its inclusion. Please visit the repository for more details on [contributing](https://github.com/sanity-io/locales/blob/main/CONTRIBUTING.md#getting-started) or [requesting a language](https://github.com/sanity-io/locales/issues/new?assignees=&labels=&template=new-locale-request.md&title=Locale+request%3A+).

### How to install a language for your Studio interface

Languages are installed as plugins using [npm](https://npmjs.com) or your preferred package manager. To install, for instance, German, run the following command from the root of your Studio project:

**npm**

```shell
npm install @sanity/locale-de-de
```

**pnpm**

```shell
pnpm add @sanity/locale-de-de
```

**yarn**

```shell
yarn add @sanity/locale-de-de
```

**bun**

```shell
bun add @sanity/locale-de-de
```

Once installed, add the plugin to the `plugins` array in your Studio configuration:

```typescript
import {defineConfig} from 'sanity'
import {deDELocale} from '@sanity/locale-de-de'

export default defineConfig({
  // ...
  plugins: [
    // ...
    // Add German Studio interface language
    deDELocale()
  ],
})
```

Your Studio interface language will now be German.

![Sanity Studio localized in German](https://cdn.sanity.io/images/3do82whm/next/97c24cf8634c10df84b55b9d7165f90599938012-2844x1624.png)
*These schema types and fields are in German because they are authored in German in the Studio configuration. Sanity will only translate the core UI of the Studio. You have control over schema and customizations.*

### Overriding translated strings

You can override language strings without going through the process of making an alternate language plugin. This is great if you want to change the label of a button in your Studio. Or perhaps you have installed an incomplete language plugin and need to supply some missing strings. In these scenarios, you can use the `defineLocaleResourceBundle` API.

In the following example, the AI translation of “Inspect” into Norwegian is wrong and missed by the human maintainer. As a local fix, we can define a “locale resource bundle” and add this to the `i18n.bundles` array in the Studio config. The easiest way to find the namespace and key you wish to override is to search for your string in the GitHub [sanity-io/locales](https://github.com/sanity-io/locales) repository and note which file it is stored in, which tells you the namespace, and the key itself.

![Sanity Studio with the document inspector modal open, showing a couple of mistranslated strings](https://cdn.sanity.io/images/3do82whm/next/7369bff5667e9f0259834112238b606c6893f2c0-914x768.png)
*Looks like the AI has been doing some guesswork for the Norwegian Nynorsk bundle. “Inspekter” is not proper Nynorsk!*

```typescript
import {defineConfig, defineLocaleResourceBundle} from 'sanity'
import {nnNOLocale} from '@sanity/locale-nn-no'

const myCustomOverrides = defineLocaleResourceBundle({
  // make sure the `locale` language code corresponds to the one you want to override
  locale: 'nn-NO',
  namespace: 'structure',
  resources: {
    'document-inspector.menu-item.title': 'Inspiser',
    'document-inspector.dialog.title': 'Inspiserer <DocumentTitle/>',
  },
})

export default defineConfig({
  // ...
  plugins: [
    nnNOLocale(),
  ],
  i18n: {
    bundles: [myCustomOverrides]
  }
})

```

![Sanity Studio with the document inspector modal open, now with correct translations](https://cdn.sanity.io/images/3do82whm/next/198682b2d2427df87e7f1c29f65510432807d20d-914x768.png)
*Much better! We should probably make a pull request to fix this for everyone! Let’s get into contributing next.*

## How to contribute to Studio UI localization

We're always looking to make Sanity Studio more accessible and user-friendly, and your contributions can make a big difference. Whether you're a seasoned developer or just starting, helping with translations is a fantastic way to get involved.

If you're fluent in a language other than English, we'd love your help reviewing and improving translations. Your expertise can greatly enhance the experience for users worldwide.

### Requesting a new language

You can request the addition of your preferred language by using the [issue template](https://github.com/sanity-io/locales/issues/new?assignees=&labels=&template=new-locale-request.md&title=Locale+request%3A+). Sanity will bootstrap the new locale with AI and community members can then submit suggested improvements. Language maintainers help in reviewing both AI and human contributions.

### Suggesting and reviewing improvements

Visit our [sanity-io/locales](https://github.com/sanity-io/locales) repository and try out a locale you are fluent in. Submit a pull request (PR) with your suggested improvements following the [contributing guide](https://github.com/sanity-io/locales/blob/main/CONTRIBUTING.md#getting-started). You can also see if there are open PRs involving languages you are fluent in and help review them.

### Quick fix: Use the GitHub built-in editor

If you want to add or change a translated string quickly, the easiest way may be to use GitHub’s built-in editing feature. You can watch the video below to learn how.

![Using the built-in GitHub editor to submit a quick fix to a localization resource](https://youtu.be/eDCLC1vN4ng)

## Become a language maintainer

Interested in playing a bigger role? You can ask to be added as a maintainer to oversee translations for specific languages, where you will be asked to help review PRs that involve your language. See the [sanity-io/locales README](https://github.com/sanity-io/locales#readme) for more.

Sanity will create and keep the language updated using AI. Human contributors such as yourself help maintain the languages by submitting suggested improvements and helping review pull requests from AI and other contributors.

Your contributions improve Sanity Studio and bring together a diverse and global community of users. We appreciate every effort, big or small, and we can't wait to see what you bring to the table!



# New document options

The `newDocumentOptions` API allows you to customize the new document choices users see when they interact with the **Create** buttons in Sanity Studio.

`document.newDocumentOptions` accepts a callback function that returns an array of new document option templates. The callback accepts an array of existing templates, commonly displayed as `prev`, and a context object as arguments. It should return an array of template items.

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'

export default defineConfig({
  /* ... */
  document: {
    newDocumentOptions: (prev, {currentUser, creationContext}) => {
      /* ... */
      return prev
    }
  }
})
```

> [!TIP]
> Pro tip
> It's common to return a subset of existing types by filtering and returning the `prev` array, but you can also add new templates as part of the callback.

## Callback parameters

#### Properties

**prev** (array | TemplateItem[])

An array containing all available template items.

**context** (object | NewDocumentOptionsContext)

Contains details about the context of the new document creation. Useful for comparing details about the current user and where the document creation event was initiated.

### Context properties

#### Properties

**creationContext** (object | NewDocumentCreationContext)

An object containing the type (global, document, or structure) and the schemaType, if one exists. Useful for determining where the document creation action originated.

**currentUser** (object | CurrentUser)

An object containing details about the current user such as id, roles, and email.

## Usage examples

### Example: Limit document types by role

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'

// Create an array of templateId strings.
// These can match initial value templates, or document names.
const contributor_templates = [
  'guide',
  'blogPost',
  'caseStudy',
]

export default defineConfig({
  /* ... */
  document: {
    newDocumentOptions: (prev, {currentUser}) => {

      // Check if the current user is not an administrator
      if (!currentUser?.roles.some((role) => role.name === 'administrator')) {
        return prev.filter(({templateId}) => contributor_templates.includes(templateId))
      }

      // All other users (Administrators) see the full document list
      return prev
    }
  }
})
```

### Example: Hide a specific document type from the global create menu

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'

export default defineConfig({
  /* ... */
  document: {
    newDocumentOptions: (prev, {currentUser, creationContext}) => {
      if (creationContext.type === 'global') {
        // Hide the creation of "settings" documents if the context is global
        return prev.filter((templateItem) => templateItem.templateId != 'settings')
      }
      return prev
    }
  }
})
```

## Common patterns

### Hide a singleton from the create menu

Singleton documents (like site settings) should not appear in the global create menu since only one instance should exist. Filter them out by template ID:

**sanity.config.ts**

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'

export default defineConfig({
  /* ... */
  document: {
    newDocumentOptions: (prev, {creationContext}) => {
      if (creationContext.type === 'global') {
        return prev.filter(
          (templateItem) => !['siteSettings', 'navigation'].includes(templateItem.templateId)
        )
      }
      return prev
    },
  },
})
```

### Hide the create button in a Structure pane

To remove the "+" button from a specific list in the Structure Builder, set `initialValueTemplates` to an empty array. This is useful for read-only lists or filtered views where creating new documents does not make sense:

**structure.ts**

```typescript
// structure.ts (or your structure file)
import {type StructureBuilder} from 'sanity/structure'

export const structure = (S: StructureBuilder) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Published posts')
        .child(
          S.documentTypeList('post')
            .title('Published posts')
            .apiVersion('2025-02-19')
            .filter('_type == "post" && !(_id in path("drafts.**"))')
            .initialValueTemplates([])
        ),
    ])
```

Learn more about Structure Builder configuration in the [Structure Builder reference](https://www.sanity.io/docs/studio/structure-builder-reference).

### Different options for different contexts

The `creationContext.type` parameter tells you where the create action is happening. Use it to show different options in the global menu versus within a Structure pane:

**sanity.config.ts**

```typescript
document: {
  newDocumentOptions: (prev, {creationContext}) => {
    // In the global "+" menu, only show 'article' and 'author'
    if (creationContext.type === 'global') {
      return prev.filter((templateItem) =>
        ['article', 'author'].includes(templateItem.templateId)
      )
    }
    // In Structure panes, show all options
    return prev
  },
}
```

## Related resources

- [Initial Value Templates](https://www.sanity.io/docs/studio/initial-value-templates): define templates with prefilled values for new documents.
- [Structure Builder reference](https://www.sanity.io/docs/studio/structure-builder-reference): configure the Studio layout, including the create button in panes.
- [Document actions](https://www.sanity.io/docs/studio/document-actions): customize the publish, delete, and other actions available on documents.



# Studio components

## Introduction

The `studio.components` config property enables configuration-level customization of your Studio. The following components can be overridden:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {MyActiveToolLayout, MyLayout, MyNavbar, MyToolMenu} from './components/studio'

export default defineConfig({
  // rest of config ...
  studio: {
    components: {
      activeToolLayout: MyActiveToolLayout,
      layout: MyLayout,
      navbar: MyNavbar,
      toolMenu: MyToolMenu,
    }
  }
})
```

> [!TIP]
> Pro tip
> Looking to change the Studio logo? You can customize it with [the icon property in the workspace configuration](https://www.sanity.io/docs/studio/workspaces).

## Customizing components with renderDefault

The props for each component available in the API include a callback function named `renderDefault`. As the name implies, `renderDefault` will render the default component. When you call `renderDefault`, you also pass along the props needed to render the default component. You can modify the props to your liking before passing them along. If you want to completely replace the component in question with your own markup, do not invoke `renderDefault` in your return statement.

```jsx
// MyEnhancedNavbar.jsx
import { Stack, Card, Flex, Text } from '@sanity/ui'

// Adds markup and invokes renderDefault()
function MyEnhancedNavbar(props) {
  return (
    <Stack>
      <Card padding={3} tone="caution">
        <Flex justify="center">
          <Text>Important Message: Please Read!</Text>
        </Flex>
      </Card>
      <>{props.renderDefault(props)}</>
    </Stack>
  )
}

// Completely replaces default navbar
function MySuperiorNavbar() {
  return (
    <Stack>
      <Card padding={3} tone="caution">
        <Flex justify="center">
          {/* Custom navbar stuff goes here */}
        </Flex>
      </Card>
    </Stack>
  )
}
```

For some components, like `navbar` and `layout`, the `renderDefault` callback is the only prop passed along, while other components receive additional props.

For example, this component overrides each tool's title before rendering the default tool menu:

```typescript
function MyToolMenu(props) {
  // ToolMenuProps includes the tools from the project config, each with a title
  const { renderDefault, tools } = props
  // Overwrite each tool's `title` after spreading the props object
  return renderDefault({
    ...props,
    tools: tools.map((tool) => ({ ...tool, title: tool.title.toUpperCase() })),
  })
}
```

This example filters which tools appear in the tool menu:

```typescript
import { isDev } from 'sanity'

function MyToolMenu(props) {
  // ToolMenuProps includes list of installed tools, and more
  const { tools, renderDefault } = props
  // Only show the dev-tool if the isDev variable resolves to true
  const availableTools = isDev ? tools : tools.filter(tool => tool.name !== 'dev-tool')
  return renderDefault({ ...props, tools: availableTools })
}
```

## Composing `renderDefault()`

The rendering of components in this API uses a middleware pattern. This means that plugin customizations are applied in a chain. Each plugin may call `props.renderDefault(props)` to defer to default rendering. If any component in the chain fails to call the callback function, the chain breaks.



# Studio search configuration



The global search for Sanity Studio lets you search your Content Lake dataset for any document that matches your term, or narrow down by filtering your query by schema types. You can activate the global search from the 🔍 icon in the top toolbar, or with the `ctrl/cmd + k` hotkey. 

## Hide documents from global search

There may be instances where you want to keep specific documents from appearing in the search results. To hide search results for a particular document type and remove it as a selectable filter, set the `__experimental_omnisearch_visibility` property to `false` on the document type:

> [!WARNING]
> Experimental feature
> This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be completely accurate.

```javascript
{
  type: 'document',
  name: 'author',
  fields: [
    {name: 'name', type: 'string'},
    // ...
  ],
	// Hide all results for authors (and the author document type filter) in omnisearch
	__experimental_omnisearch_visibility: false,
  // ...
}
```

### Some example use cases:

- You have workflow-related documents that you don’t wish to expose editors to.
- You want to hide documents that are less frequently used by editors.
- You’re an author of a Sanity plugin that registers its own document schema and would prefer it doesn't appear in user's studios.

> [!TIP]
> Protip
> This only affects visibility within the global studio search (‘omnisearch’). Visibility in both reference and cross dataset reference input fields is unaffected.

## Define custom weighting on fields

You can define specific weights on searchable fields for document types.

Search weights are configurable via `options.search.weight`. Here's an example:

```javascript
{
  type: 'document',
  name: 'author',
  fields: [
    {
      name: 'name',
      type: 'string',
      options: {
        search: { weight: 10 },
        // ...
      }
    },
    {
      name: 'description',
      type: 'array',
      of: [{type: 'block'}],
      options: {
        search: { weight: 10 },
        // ...
      }
    }
    // ...
  ],
  // ...
}
```

## Search operators

Studio search supports “Google-style” search operators. In the below query string, `wine`, `good vintage`, and `fruit*` must all be present in order for the query to match. In other words, there is an implicit “or” between the words. Results with `beer` in will be omitted.

```text
wine -beer "good vintage" fruit*
```

> [!WARNING]
> Full-text search size limit
> Studio search uses the `@ match` GROQ expression under the hood, which queries a per-document index capped at 8 MB of text. Content beyond this size is silently excluded from search results.

## Tokenization and word handling

- Text search operates on all tokens that are 1 character or longer, up to 255 character maximum.
- `_` is not a word-breaking character.
- Punctuation is ignored.
- Apostrophes are not word-breaking: `bob` does not match `bob's` or vice versa.
- All words and phrases (with/without wildcards) must appear in at least one attribute value.
- Negations do not match any attribute values.
- Phrases must not match *across* attribute values (i.e. half matching one value and another half matching another value).
- Accents are not folded: `configura` and `configurá` are different tokens and do not match each other, in either direction.

## Exact matching rules

##### Text matching rules

| Scenario | Search Term | Behaviour | Should Match  | Should Not Match |
| --- | --- | --- | --- | --- |
| Numbers | 1234 | Match numbers that are separated by punctuation or space | The number is 1234! | X1234Y |
| Floating point numbers | 3.145 | Matches | The answer is 3.145 | The answer is 3 145 |
| Currency numbers | $100 | Match number only (not currency) | The price is $100 | - |
| Prefix | co* | Match prefix of words | co, covid, corvette | Anything not starting with “co” |
| Prefix + wildcard | co*e | Match prefix and suffix | corvette | Anything not having “co” prefix and “e” suffix |
| Middle wildcard | *co* | Match substrings inside words | corvette, acorn, texaco | Anything not containing the substring “co” |
| Special symbols (not punctuation) | ®️, ™️ | Match exactly | - | - |
| Underscores | foo_bar | Match exactly; underscores are part of the word | foo_bar | foo bar |
| Accented characters | configura | Match the exact characters; accents are not folded | configura | configurá |
| Phrases | “hello world” | Match exactly the sequence of words, ignoring punctuation | "hello world", "hello, world" | hello to the world hey world, hello! |
| Phrases with wildcard at the end | “hello world*” | Match exactly the sequence of words, ignoring punctuation, with wildcard match on the last word | "hello world", "hello worlds" | hello to the world hey world, hello! |
| Negation | foo -bar | Search excludes what you specify |  |  |

## A few things to note

- **Global weight multiplier:** Search weights act as global multipliers across all document types. For instance, if the name field in the customer type has a weight of `2`, and the author type has a weight of `4`, then author documents will rank higher than customer documents for identical name matches in search results.
- **Default search configuration:**- The field designated as the `title` in the preview config automatically receives a search weight of `10`, while the `subtitle` field gets a weight of `5`. 
- Any user-specified weight overrides default settings, ensuring custom search relevance can be achieved as needed.
- If you have customized the preview options using a [prepare function](https://www.sanity.io/docs/studio/previews-list-views) search weights cannot be inferred from the preview configuration so explicit custom field weights must be used if you want to boost specific fields.
- If you have a reusable custom type you need to define search weighting on the type definition itself. 
- Fields marked as `hidden: true` in the preview config are scored lower to push them lower in the result set.



> [!TIP]
> Protip
> Defining custom weights on fields applies only to the global search. The document list search is not affected by search weights. 

## `groqLegacy` search strategy (deprecated)

`groqLegacy` was the default search strategy used by Studio prior to version 6. To switch back to the legacy search strategy, set the `search.strategy` configuration option to `"groqLegacy"` in the `sanity.config.js|ts` file.

```javascript
import { defineConfig } from 'sanity'

export default defineConfig({
 search: {
   strategy: 'groqLegacy'
 },
})
```

The `groqLegacy` search strategy is deprecated, and will be removed in a future release.



# Focus and UI state in custom inputs

Sanity Studio handles most focus management for you, but a custom input has to forward the props it receives. This article covers forwarding focus in primitive, object, and array inputs. It also covers the UI states that travel with focus: opening form nodes, expanding fieldsets, and selecting field groups.

These examples assume you have built a custom input component before. If you haven’t, start with Custom input components. For the props these examples use, see the [Form components API reference](https://www.sanity.io/docs/studio/form-components-reference).

## Handling focus in primitive inputs

When creating a custom input for a number, string, or Boolean value in Sanity Studio, focus management is mostly taken care of. You only need to forward the received `elementProps` to the element that should receive focus. Typically, this is the element in the DOM that represents the input. For example, if you make a custom input that wraps a `<textarea>`, you need to forward the received `elementProps` to the corresponding element in the DOM:

```tsx
function MyCustomInput(props) {
  return (
    <div>
      {/* Forward 'elementProps' here to handle focus correctly */}
      <textarea {...props.elementProps} />
    </div>
  )
}

```

### Assigning a focusable element

If your custom input doesn’t have a corresponding element to receive focus, you can do one of the following:

- Assign the custom input a focusable element.
- Wrap your component in an element that accepts a `tabIndex` attribute to [make the element focusable](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex).

The following example features a number input with two buttons: one to increase, the other to decrease a value. Since it’s not obvious which button should be the focusable element, a possible approach is to wrap them both inside a `div` element with a `tabIndex` attribute [set to zero](https://www.w3.org/WAI/GL/wiki/Creating_Logical_Tab_Order_with_the_Tabindex_Attribute).

In the example, `props.elementProps` includes `value`. Before forwarding `props.elementProps` to the `div` element, you may want to omit `value` since divs don’t have a `value` property.

```tsx
function MyCustomInput(props) {
  return (
    <div tabIndex={0} {...props.elementProps}>
      <button onClick={() => {props.onChange(inc())}}>Increment</button>
      <button onClick={() => {props.onChange(dec())}}>Decrement</button>
    </div>
  )
}

```

## Handling focus in object and array inputs

Focus handling for `object` and `array` inputs works in the same way as for primitive inputs. However, when you implement a custom input you may need to programmatically assign focus in a different order than the default sequential keyboard navigation. For example, in the context of an object input you may want to assign focus to a specific field. Or if you’re making a custom array input, you may want to assign focus to a field that's nested inside an array value.

It can be a bit tricky to get this running right away, so here are some guidelines to help you get it right.

### Object inputs

Programmatically move focus to a field or nested value in an object input.

All object inputs receive three props related to focus:

- `elementProps.onFocus(event)`
- `elementProps.onBlur(event)`
- `onPathFocus(path)`

`elementProps.onFocus` and `elementProps.onBlur` are designed to be passed to the native DOM element that represents the input component, but you can also call them programmatically by passing a focus event as an argument.

[onPathFocus](https://reference.sanity.io/sanity/index/BlockProps/#onpathfocus) takes a relative node path as an argument, and it offers a way to programmatically move focus to a specific member or a nested member.

Example: When a user clicks a button, move the focus to the title field.

```tsx
import {Button} from '@sanity/ui'

function MyObjectInput(props) {
  return (
    <>
      <Button text="Move focus to title" onClick={() => props.onPathFocus(['title'])} />
      {props.renderDefault(props)}
    </>
  )
}

```

> [!NOTE]
> Currently, you can assign focus only to the inputs of the fields inside an object input. You cannot assign focus to an object input as a whole. This behavior might change in the future.

## Handling focus and UI states

When you open or expand a node, Sanity Studio’s built-in form state manager automatically shifts focus to the open or expanded node.

- If the node you’re shifting focus to is inside a modal, the form state manager flags the node as open so that the parent modal opens to reveal the node.
- If the node is inside a fieldset, the form state manager flags the fieldset as expanded.
- If the node is inside a field group, the form state manager automatically selects the field group.

However, sometimes you may want to make sure that a form node is visible to the user, regardless of whether the specified node is inside a fieldset, a field group, or hidden behind a modal. In this case, call `onPathFocus(nodePath)`. The `nodePath` argument that you pass corresponds to the path to the node, relative to the current array or object input.

## Best practices and common pitfalls

### Shifting focus

To shift focus to the desired position, pass a form path to `onPathFocus`: `onPathFocus(['path', 'to', 'node'])`. Don’t call `.focus()` directly on DOM nodes.

### Opening and closing form nodes

Sometimes you may wish to put a field or a sub-field inside a modal that opens based on user interactions such as click to edit, or open an array item to edit it. To do so, object and array inputs receive a callback prop that defines whether a field or an array item should be open or not. If a form node is set to open, the corresponding field/item props have an `open` prop set to `true`, which allows toggling the element visibility when it’s rendered.

**Open and close an object field**

> [!NOTE]
> It’s possible to have only one open node at a time; when you open an element, the action automatically closes any other currently open element. Closing an element opens the corresponding parent node.
> For example, closing a field inside an object input flags the object field as open. This behavior applies to the form as a whole.

An object input receives two props to control the open and closed state of its fields:

- `onFieldOpen(fieldName)`: flags the field as open. Upon the next rendering, the field member receives an `open` prop.
- `onFieldClose(fieldName)`: closes a currently open field. Flags the current node (the object node managed by this object input) as open.

**Open and close an array item**

Studio renders object values and array values differently.

By default, object values are ordered by field, and each field input is rendered from top to bottom. If the type of an object field is another object, the child fields of the parent object are rendered the same way with some left margin to visualize the hierarchy.

By default, array items are rendered differently, because arrays have a different set of affordances, compared to objects:

- You can assign an array any number of items including zero (none); objects always have a fixed set of fields.
- You can reorder array items; object fields have a predefined order defined in the object schema.
- You can insert and remove array items anywhere in the array; object fields are either set or empty (cleared).

Instead of laying out the input components for each array item, the Studio renders a preview of the array item. This produces a more compact view that enables UI affordances such as reordering, inserting, and removing items.

To support editing an array item, the array input takes a prop that you can call to flag that an item can be opened for editing: `onItemOpen`. To close the array item after applying the edits, use `onItemClose`:

- `onItemOpen(path)`: the prop is a function that takes as an argument the *relative path* of the item that you want to open.
- `onItemClose()`: the prop is a function that takes no arguments. Since it’s possible to have only one open item at a time, there’s no need to specify which item to close. The function flags the current node as open. The current node corresponds to the array node managed by this array input.

### Expanding and collapsing form nodes

Whereas open and close allow only one open node at a time, expanding supports multiple open nodes at once. Arrays and objects both support expanding their members.

**Expand and collapse an object field**

To expand a field in an object, pass the name of the field to `onFieldExpand`. To close an expanded field, pass the name of the field to `onFieldCollapse`.

- `onFieldExpand(fieldName)`: flags an object field as expanded.
- `onFieldCollapse(fieldName)`: flags an object field as collapsed.

**Expand and collapse an array item**

Expanding and collapsing array items is useful with arrays that can have multiple items open at once:

- `onItemExpand(itemKey)`: flags the item corresponding to the `itemKey` key as expanded. Upon the next rendering, the expanded item member receives an `expanded: true` prop.
- `onItemCollapse(itemKey)`: flags the item corresponding to the `itemKey` key as collapsed. Upon the next rendering, the item member receives an `expanded: false` prop.

### Expanding and collapsing fieldsets

You can programmatically open and close fieldsets defined in the schema:

- `onFieldSetExpand(fieldsetName)`: flags the fieldset as expanded. Pass the name of the fieldset as an argument. The corresponding `FieldSetMember` receives an `expanded: true` prop.
- `onFieldSetCollapse(fieldsetName)`: flags the fieldset as collapsed. Pass the name of the fieldset as an argument. The corresponding `FieldSetMember` receives an `expanded: false` prop.

### Selecting a field group

You can define one or more field groups for object types. Field groups are filters for fields and fieldsets. To programmatically select a field group, call `onFieldGroupSelect(fieldGroupName)`, and pass the name of the field group that you want to select. It’s possible to reset and to reassign field group selection, but it’s not possible to deselect a selected field group. To reset the field group state, call `onFieldGroupSelect('all-fields')`.



# Real-time safe patches for input components

Sanity Studio is a real-time application. Therefore, signaling changes from the editor to the backend uses a different strategy than the typical one.

## Real-time syncing with mutations and patches

Traditionally, editing content online is based on the following model:

1. The editor interface reads the content to modify from the database.
2. Users introduce changes to their local copy of the document.
3. After completing their edits, users save their work; all the content of the document is sent back to the server and written to the database.

This is akin to downloading an MS Word document to a local computer, editing it, and sending it back to the server when done.

It’s an approach that works well if there’s only one user working on a single document at a time. If you’re collaborating with someone else, this model breaks down. You risk either overwriting someone else’s work or going through a tedious change conflict resolution process before you can save the document without losing any changes.

Sanity applies a different collaboration model:

1. The editor interface (Sanity Studio) loads the content to modify from the database (Content Lake).
2. While users edit local versions of the document, the editor emits fine-grained, computer-readable descriptions of what exactly changed. We call these descriptions *mutations*.
3. The editor collects these mutations and sends them to the server, which then applies them directly to the stored document.
4. Then, the server distributes the mutations to any other collaborators who are working on the same document at the same time.
5. Finally, the editor applies the mutations to each concurrent user’s local version so that everything is in sync.

Because of this model, input components in Sanity are designed to work with granular mutations called *patches*.

### Examples

Following the traditional model, an input component for an object may look like this:

```typescript
function MyObjectInput(props) {
  const {fields, value, onChange} = props

  return (
    <>
      {fields.map((field) => (
        <div>
          <label>
            {field.title}
            <input
              type="text"
              value={value[field.name]}
              onChange={(event) => {
                onChange({...value, [field.name]: event.currentTarget.value})
              }}
            />
          </label>
        </div>
      ))}
    </>
  )
}
```

This model is easy to work with when you keep the input value in a state variable: all you need to do is call `setState` with the emitted value, and feed the state variable back to `<MyObjectInput>`.
However, this model doesn’t work as well in a real-time scenario where you don’t want to send and receive full values, but rather *granular change descriptions* (mutations).

In a real-time environment, the following works better:

```typescript
import {set} from 'sanity'

function MyObjectInput(props) {
  const {fields, value, onChange} = props

  return (
    <>
      {fields.map((field) => (
        <div>
          <label>
            {field.title}
            <input
              type="text"
              value={value[field.name]}
              onChange={(event) => {
                onChange(set(event.currentTarget.value, [field.name]))
              }}
            />
          </label>
        </div>
      ))}
    </>
  )
}
```

As a bonus, to set a new object input value, you don’t need to consider the current one.

## Patch utilities

Sanity offers a set of utilities for composing real-time safe patches when developing object and array inputs. Instead of manually constructing field paths and patches, you can import a set of patch creators from the Sanity package.

The Sanity package exports several patch creators, standalone functions you can use to [declare a granular operation](https://www.sanity.io/docs/content-lake/http-patches):

> [!NOTE]
> `path` can only accept an array of path segments.

### Patches for all data types

**set**

`set(value: any, path?: Path)`: sets the value at the specified path. It overwrites any existing value.

**unset**

`unset(path?: Path)`: unsets any value at the specified path.

**setIfMissing**

`setIfMissing(value: any, path?: Path)`: performs a [setIfMissing](https://www.sanity.io/docs/content-lake/http-patches) patch on the specified path. 

### Patches for arrays

**insert**

`insert(items: any[], position: "before" | "after", path?: Path)`: performs an [insert](https://www.sanity.io/docs/content-lake/http-patches) patch, inserting the `items` provided before or after the node at the specified path.

### Patches for strings

**diffMatchPatch**

`diffMatchPatch(value: string, path?: Path)`: performs a [diffMatchPatch](https://www.sanity.io/docs/content-lake/http-patches) on the string at the specified path.

### Patches for numbers

**inc**

`inc(amount: number, path?: Path)`: performs an [increment](https://www.sanity.io/docs/content-lake/http-patches) operation on the number value at the specified path.

**dec**

`dec(amount: number, path?: Path)`: performs a `decrement` operation on the number value at the specified path.

## Best practices and considerations

### Consider the user's intention for a change

The change event you emit from the input component needs to consider what users want to achieve when they make a change.

For example:

- Do you want the change to only affect what a user sees on their screen, regardless of the corresponding value in the database (which might not be the same as what is displayed to the user)?
Or do you want to modify the most recent value stored in the database, regardless of what is displayed to the user on the screen?
- When a user changes the value of a number, do they want to increase or decrease the original value? Or do they want to set it to a new arbitrary value?

The differences in the outcomes can be subtle. As a rule of thumb, when creating patches, it’s preferable to avoid reading input values locally. This is possible only when using the `insert`, `inc`, and `dec` [patches](https://www.sanity.io/docs/content-lake/http-patches).

Create patches that are as fine-grained as possible. When creating a custom array or object input, you can optionally call [onChange](https://www.sanity.io/guides/usereducer-in-custom-component) with a patch that sets the whole array or object value.

### Avoid array indices

When creating patches that target array elements, avoid targeting the elements with their array index reference. Array indices are unreliable because users may add, remove, and change the order of the items in the array over time. For example: if you have an array with two items `A` and `B` with index `0` and `1`, respectively, their reference array index changes as soon as you or other users modify the order of the elements in the array.

### Diff match patch

Sanity supports [diff-match-patch](https://github.com/google/diff-match-patch), which offers a robust way to describe a change in plain text. Usually, you don’t need to create diff match patches; Sanity Studio does it for you under the hood.

Gotcha: If you implement emitting diff match patches from your custom input, you miss out on built-in optimizations. Therefore, it’s preferable to avoid creating diff match patches from custom input components.



# Sanity UI

When you're creating new tools and custom inputs, it's important for your editor experience to make sure your customizations match the overall design of the studio. To create this consistency, you can use [the Sanity UI component library](https://sanity.io/ui) to create custom experiences without creating custom designs or adding custom CSS.

## Usage of Sanity UI

The Sanity UI package comes bundled for most studio usage, but if you're creating a plugin or tool, you'll want to install the package via NPM.

**npm**

```shell
npm install @sanity/ui
```

**pnpm**

```shell
pnpm add @sanity/ui
```

**yarn**

```shell
yarn add @sanity/ui
```

**bun**

```shell
bun add @sanity/ui
```

From there, you can import the various components into your custom inputs, tools, or widgets. For example, if you wish to apply a tooltip to a string input, you can create a custom input that uses the `Stack`, `Box`, and `TextInput` design primitives to create one with all the design elements of your studio built right in. 

```javascript
// /components/MyCustomStringInput.jsx
import React, {useCallback} from 'react'
import {Stack, Text, TextInput} from '@sanity/ui'
import {set, unset} from 'sanity'

export const MyCustomStringInput = (props) => {
  const {elementProps, onChange, value = ''} = props

  const handleChange = useCallback((event) => {
    const nextValue = event.currentTarget.value
    onChange(nextValue ? set(nextValue) : unset())
	}, [onChange])

  return (
    <Stack gap={2}>
      <TextInput
        {...elementProps}
        onChange={handleChange}
        value={value}
      />
      <Text>Characters: {value.length}</Text>
    </Stack>
  )
}
```

See this guide on [creating custom inputs and tools with Sanity UI](https://www.sanity.io/guides/your-first-input-component-for-sanity-studio-v3).

## Compatibility and versioning

Sanity UI follows semantic versioning, and that guarantee covers the package's documented API: component props, hooks, and refs. Breaking changes to those ship in a major release, and the removed API stays in the TypeScript types as a deprecation message naming its replacement.

The markup a component renders is not part of that contract. DOM structure and internal attributes such as `data-ui` and `data-testid` can change without a major version bump, so code that queries rendered elements or asserts on them can break on a routine upgrade. Depend on the props, hooks, and refs a component documents instead.

> [!WARNING]
> Closed tooltips and popovers stay in the DOM
> From Sanity UI v4, `Tooltip` and `Popover` keep their content mounted while closed, using React's `<Activity>` component to hide it with `display: none`. A check for the presence of an element finds content that isn't visible.
> In unit tests, assert on visibility rather than existence: `expect(screen.getByText('Tooltip content')).not.toBeVisible()` replaces `expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()`. End-to-end assertions change the same way, from `toHaveCount(0)` to `toBeHidden()`. Queries that skip inaccessible elements, such as `getByRole()`, need no change.

To react to clicks outside a component, including one rendered in a portal, pass element refs to the `useClickOutsideEvent` hook rather than looking elements up with `document.querySelector`. The hook tracks the elements the components render, so it keeps working when the markup changes. It replaces `useClickOutside`, which v4 removed.

**components/MyPopoverButton.tsx**

```tsx
import {Box, Button, Text, useClickOutsideEvent} from '@sanity/ui'
import {Popover} from '@sanity/ui/popover'
import {useCallback, useRef, useState} from 'react'

export function MyPopoverButton() {
  const [open, setOpen] = useState(false)
  const buttonRef = useRef<HTMLButtonElement | null>(null)
  const popoverRef = useRef<HTMLDivElement | null>(null)

  const handleClickOutside = useCallback(() => setOpen(false), [])

  // Pass refs to the rendered elements, including the portaled popover card.
  // Passing `false` while closed disables the listener.
  useClickOutsideEvent(open && handleClickOutside, () => [
    buttonRef.current,
    popoverRef.current,
  ])

  return (
    <Popover
      content={
        <Box padding={3}>
          <Text>Popover content</Text>
        </Box>
      }
      open={open}
      portal
      ref={popoverRef}
    >
      <Button onClick={() => setOpen((prev) => !prev)} ref={buttonRef} text="Toggle" />
    </Popover>
  )
}
```

## Full documentation and playground

Sanity UI comes with a full set of UI primitives that can be mixed, matched, and composed into many different design patterns. The full list of components can be found in [the official Sanity UI documentation](https://sanity.io/ui/docs). To get a better feel for creating design patterns, you can also experiment with all the components in this [interactive component playground](https://www.sanity.io/ui/arcade).



# Studio tools

A tool is a top-level view in Sanity Studio that you can access through its menu bar. The most common and built-in tool for the Studio is the Structure tool, which lets you browse, edit, and create documents. You can install tools with plugins or create your own. Tools are tied to the Studio’s routing and can be accessed through predictable URLs.

## Recommended tools

To get started, here are some recommended tools to enhance your Studio experience. You may even have a few installed already.

[Structure](https://www.sanity.io/docs/studio/structure-tool)
Create, browse, and navigate Sanity documents.

[Vision](https://www.sanity.io/docs/content-lake/the-vision-plugin)
Query Sanity’s Content Lake with GROQ.

[Dashboard](https://www.sanity.io/docs/studio/dashboard)
Create a customized dashboard experience with widgets.

[Presentation](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool)
Enable visual editing and interactive live previews in the Studio.

For more tools and plugins from Sanity and the community, browse the [Exchange](https://www.sanity.io/plugins).

## Manage tools and develop your own

[Tools cheat sheet](https://www.sanity.io/docs/studio/tools-cheat-sheet)

[Create a custom tool](https://www.sanity.io/docs/studio/custom-studio-tool)

[Tool API reference](https://www.sanity.io/docs/studio/tool-api-reference)



# Create a custom Studio tool

A tool is a top-level view in the Sanity Studio application that you can access through its menu bar. New to tools? Visit the [Studio tools overview](https://www.sanity.io/docs/studio/studio-tools).

> [!NOTE]
> Right tool for the job?
> Custom Studio tools are great, but they are often used to tackle problems better solved by standalone applications.
> If this sounds like your situation, check out the [App SDK](https://www.sanity.io/docs/app-sdk)!

Tools are great for custom dashboards and user interfaces for exploring and interacting with content. At their most basic, tools are custom React components that interact with data in Sanity.

## Basic configuration

You can add a custom tool by adding its configuration object to the `tools` array in the Studio configuration. A tool needs to have a `name`, `title`, and `component` defined. An `icon` is optional, but recommended. The `title` controls what appears in the menu bar, while the `name` controls the URL segment that the tool routes to.

```tsx
// sanity.config.tsx
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {Card, Text} from '@sanity/ui'
import {DashboardIcon} from '@sanity/icons/Dashboard'
import {schemaTypes} from './schemas'

const myCustomTool = () => {
  return {
    title: 'My Custom Tool',
    name: 'my-custom-tool', // localhost:3333/my-custom-tool
    icon: DashboardIcon,
    component: (props) => (
      <Card padding={4}>
        <Text>My custom tool!</Text>
      </Card>
    ),
  }
}

export default defineConfig({
  name: 'default',
  title: 'Studio with custom tool',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  plugins: [structureTool()],
  tools: [myCustomTool()],
  schema: {
    types: schemaTypes,
  },
})
```

> [!TIP]
> Pro tip
> If you want to use `@sanity/ui` and `@sanity/icons` for your own tools, remember to install them as dependencies in your project:
> `npm install @sanity/ui @sanity/icons` 

## TypeScript

If you're building with TypeScript, then you can use the built-in `Tool` type from the `sanity` package, as well as the `ComponentType` from the `react` package. You can also extend these to support custom options you might have for your tool:

```tsx
// myCustomTool.tsx
import type {ComponentType} from 'react'
import {type Tool} from 'sanity'
import {Card, Text, Stack} from '@sanity/ui'
import {DashboardIcon} from '@sanity/icons/Dashboard'

export interface myCustomToolOptions {
  customString?: string
}

export interface myCustomToolProps<Options = any> {
  component: ComponentType<{
    tool: Tool<myCustomToolOptions>
  }>
}

export const myCustomTool = (options: myCustomToolOptions | void) => {
  return {
    title: 'My Custom Tool',
    name: 'my-custom-tool', // localhost:3333/my-custom-tool
    icon: DashboardIcon,
    component: () => (
      <Card padding={4}>
        <Stack>
          <Text>My custom tool!</Text>
          <Text>{options?.customString}</Text>
        </Stack>
      </Card>
    ),
  }
}
```

## Share custom tools with others

The best way to share a tool is to make it into a plugin. The guides below will help you package and publish your tool as a plugin.

[Developing plugins](https://www.sanity.io/docs/studio/developing-plugins)

[Publishing your plugin](https://www.sanity.io/docs/studio/publishing-plugins)

Have a tool that you've packaged as a plugin that you think the community would like? Share it on the [Sanity Exchange](https://sanity.io/exchange).



# Tools common patterns

Tools are a powerful way to add additional functionality to Sanity Studio. Here are some ways of customizing how tools work in your studio.

## Order tools in the navigation bar

Sometimes you need to change the order that tools appear in the navigation bar. Tools added by plugins in the `plugins` array come first, followed by tools added directly in the `tools` array. A plugin or your own config can override the display order by supplying a `studio.components.toolMenu` component and passing a reordered `tools` array to `renderDefault`. The first tool in the resolved array is the one that opens when your studio loads.

In this example, the `toolMenu` component reorders the resolved tools so a specific tool sits first in the navigation bar, regardless of registration order.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemaTypes'

export default defineConfig({
  name: 'default',
  title: 'example',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  studio: {
    components: {
      toolMenu: (props) => {
        const {tools, renderDefault} = props
        const structureTool = tools.find(({name}) => name === 'structure')
        const otherTools = tools.filter(({name}) => name !== 'structure')

        if (!structureTool) {
          return renderDefault(props)
        }

        return props.renderDefault({
          ...props,
          tools: [structureTool, ...otherTools],
        })
      },
    },
  },
  plugins: [structureTool()],
  tools: [myCustomTool, myOtherCustomTool],
  schema: {
    types: schemaTypes,
  },
})
```

## Configure the default tool

Sometimes you need to order the tools in the navigation bar, but you want a specific tool to open when your studio loads. In this case, use the `tools` property in the configuration to sort the tools array.

In this example, the `(prev, context)` callback pattern [sorts the array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) and places the Vision Tool first.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  name: 'default',
  title: 'example',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  // ... rest of config
  tools: (prev, context) => {
    return prev.sort((a, b) => {
      if (a.name === 'vision') {
        return -1 // Moves 'vision' tool to the top of the list
      }
      return 1
    })
  }
})
```

This changes both the navigation bar order and the tool that opens by default. To control the two independently, keep the `tools` array in the order you want the default tool resolved from, and reorder the menu with a `studio.components.toolMenu` component. For example, combine this approach with a custom `toolMenu` component so the Vision Tool opens when you visit your studio.

## Display a tool only in development environments

Sometimes you need to display a tool only in development environments. Use `process.env.NODE_ENV !== 'production'`, which is true when your studio runs on the local development server and false in a studio you have built or deployed. In this example, your studio displays the Vision and Structure tools in development, but only the Structure Tool in other environments. When only one tool remains, the studio hides the tool menu entirely, so no tool switcher appears in the navigation bar.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

const isDev = process.env.NODE_ENV !== 'production'

export default defineConfig({
  // ...
  plugins: isDev
    ? [structureTool(), visionTool()]
    : [structureTool()],
})
```

## Conditionally render tools based on role

Sometimes you want to display tools for specific user roles. There are a few ways to do this. Filtering the whole `tools` array is the recommended approach when more than one tool is role-gated, because the rules for every tool live in one place. In this example, administrators have access to all tools while all other users can only use the Structure Tool. When only one tool remains, the studio hides the tool menu entirely, so no tool switcher appears in the navigation bar.

**sanity.config.ts**

```typescript
import {defineConfig, userHasRole} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

// define an array of tools
const userTools = ['structure']

export default defineConfig({
  name: 'default',
  title: 'example',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',

  // This studio includes structure, vision, and any plan-specific tools
  plugins: [structureTool(), visionTool()],
  tools: (prev, context) => {
    // Retrieve the current user from the context
    const {currentUser} = context
    // Check if the current user is not an admin
    if (!userHasRole(currentUser, 'administrator')) {
      // return an array that only includes tools in the userTools array
      return prev.filter((tool) => userTools.includes(tool.name))
    }

    // Otherwise, return all tools
    return [...prev]
  },
  // ... rest of config
})
```

To adjust a single tool instead of filtering the whole array, this example limits the Vision Tool to only administrators.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

export default defineConfig({
  name: 'default',
  title: 'example',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',

  plugins: [structureTool(), visionTool()],
  tools: (prev, context) => {
    // Retrieve the current user from the context
    const {currentUser} = context
    const isAdmin = currentUser?.roles.some((role) => role.name === 'administrator')

    // If the user has the administrator role, return all tools.
    // If the user does not have the administrator role, filter out the vision tool.
    return isAdmin ? prev : prev.filter((tool) => tool.name !== 'vision')
  },
  // ... rest of config
})
```

## Additional resources

[Studio tools](https://www.sanity.io/docs/studio/studio-tools)

[Tool API reference](https://www.sanity.io/docs/studio/tool-api-reference)



# Link from custom components

Sanity Studio routes every view to a URL: each tool, each pane, and each open document. When a custom component sends someone elsewhere in the studio, going through that routing is what makes the result behave like a link. Command+click opens it in a new tab, and the address is there to copy.

This guide shows how to link to a document, a new document form, or another tool from your own React components, using `IntentLink`, `StateLink`, and `useIntentLink` from `sanity/router`.

## Prerequisites

- Sanity Studio v3.0.0 or later. `IntentLink`, `StateLink`, and `useIntentLink` have all been exported from `sanity/router` since v3.0.0.
- Familiarity with writing React components.
- A custom component or tool to render the link in. See [Create a custom Studio tool](https://www.sanity.io/docs/studio/custom-studio-tool).

## Use a link, not a click handler

`IntentLink` and `StateLink` render an `<a>` element with a resolved `href`, then handle the click themselves only when the browser would have navigated in the same tab anyway. Everything else falls through to the browser:

- Command+click, Control+click, Shift+click, and Option+click
- Any click that isn't a primary-button click, including middle-click
- Any link with `target` set, such as `target="_blank"`

A `<button>` with an `onClick` handler gives you none of that. It has no `href`, so there is nothing for the browser to open in a new tab and no address to copy, and assistive technology announces it as a button rather than as a link.

> [!WARNING]
> Don't build the href by hand
> `Link` passes its `href` to the router unchanged, so the value has to be a full path that already includes your studio's base path. A studio served at `/studio` needs `/studio/vision`, not `/vision`. `IntentLink` and `StateLink` resolve the base path for you. Reach for `Link` only when you already have a resolved path, such as one returned by `useRouter().resolveIntentLink()`.

## Link to a document

`IntentLink` navigates by intent instead of by path. An `edit` intent names the document you want opened, and Studio resolves it to whichever pane in your structure handles that document type. The document opens where your editors expect it rather than in a bare editor outside your structure.

`id` is required. `type` is optional, but pass it when you know it — without it, Studio fetches the document first to find out its type.

**components/PostLink.tsx**

```tsx
import {IntentLink} from 'sanity/router'

export function PostLink(props: {postId: string; title: string}) {
  return (
    <IntentLink intent="edit" params={{id: props.postId, type: 'post'}}>
      {props.title}
    </IntentLink>
  )
}
```

If a link opens a bare editor whose pane ID starts with `__edit__`, the list you expected to catch the intent isn't declaring intent handling. See [Handle intents in the Structure Tool](https://www.sanity.io/docs/studio/handle-intents-in-structure).

## Link to a new document form

A `create` intent opens the form for a new document. `type` is required, and Studio generates the document ID. Add `template` to start the document from an initial value template.

**components/NewPostLink.tsx**

```tsx
import {IntentLink} from 'sanity/router'

export function NewPostLink() {
  return (
    <IntentLink intent="create" params={{type: 'post'}}>
      New post
    </IntentLink>
  )
}
```

## Link to another tool

Tools are reached by router state rather than by intent. `StateLink` takes the state you want and resolves it to a path, base path included. Set `tool` to the tool's `name` from your studio configuration, and clear that tool's own state so it opens at its root instead of wherever it was last left.

**components/VisionToolLink.tsx**

```tsx
import {StateLink} from 'sanity/router'

export function VisionToolLink() {
  return (
    // Clearing the tool's own state opens it at its root
    <StateLink state={{tool: 'vision', vision: undefined}}>Open the Vision Tool</StateLink>
  )
}
```

To link to the studio's root, pass `toIndex` instead of `state`. Passing both throws an error.

## Use the useIntentLink hook for custom elements

`useIntentLink` does the same resolution and the same click handling as `IntentLink`, without rendering anything itself. Reach for it when the element is already something else: a Sanity UI `Button`, a card, a table row. It returns an `href` and an `onClick`.

**components/EditPostButton.tsx**

```tsx
import {Button} from '@sanity/ui'
import {useIntentLink} from 'sanity/router'

export function EditPostButton(props: {postId: string}) {
  const {href, onClick} = useIntentLink({
    intent: 'edit',
    params: {id: props.postId, type: 'post'},
  })

  return <Button as="a" href={href} onClick={onClick} mode="ghost" text="Edit post" />
}
```

Apply both. `href` is what the browser uses for modifier-key clicks and for the link's address; `onClick` is what routes inside the studio on a plain click. Set one without the other and half the behavior goes missing.

`useStateLink` returns the same pair for router-state links, including links to another tool.

## Next steps

[Handle intents in the Structure Tool](https://www.sanity.io/docs/studio/handle-intents-in-structure)
Declare intent handling so the links you build resolve to the right pane.

[Create a custom Studio tool](https://www.sanity.io/docs/studio/custom-studio-tool)
Build the tool that renders these links.

[Custom components for Sanity Studio](https://www.sanity.io/docs/studio/intro-to-custom-studio-components)
Override parts of the Studio UI with your own React components.

[Studio API reference](https://reference.sanity.io/sanity/)
Generated reference for sanity/router, including Link, IntentLink, and useIntentLink.



# Theming Sanity Studio

The top-level `theme` config property sets the color palette of the Studio. The `@sanity/themer` package generates a complete palette from a handful of colors, so you can brand the Studio without picking every token by hand. It runs locally, in your own project.

The package requires Studio 6 or later and React 19. Studios on Studio 5 or React 18 need to upgrade before they can install it.

> [!WARNING]
> URL imports from themer.sanity.build are deprecated
> If your Studio config imports a theme from a `https://themer.sanity.build/api/hues` URL, migrate to `@sanity/themer/legacy` now. The hosted Themer service is deprecated and the ESM URL import method it relies on will stop working when the service goes offline. The package generates the same colors from the same URL, with no network request at build time. See the migration steps below.

## Generate a theme with the Themer tool

`@sanity/themer/tool` adds a themer sidebar to the Studio. Presets, the accent, text, and background pickers, and a contrast slider preview a theme live across the whole Studio while you browse it, and the sidebar hands you the snippet that makes the theme permanent. Toggle light and dark mode with the regular appearance menu, and the preview follows.

**npm**

```shell
npm install @sanity/themer
```

**pnpm**

```shell
pnpm add @sanity/themer
```

**yarn**

```shell
yarn add @sanity/themer
```

**bun**

```shell
bun add @sanity/themer
```

**sanity.config.ts**

```typescript
import {themerTool} from '@sanity/themer/tool'
import {defineConfig} from 'sanity'

export default defineConfig({
  plugins: [themerTool()],
  // ...rest of the config
})
```

The tool appears in the top-right, alongside the perspective selection and Studio’s help menu.

![A red arrow points to a gear icon in a UI menu bar.](https://cdn.sanity.io/images/3do82whm/next/8c72d02a1cbfa70742cffc11e677082945964bc6-868x650.png)

If the Studio already uses a `buildTheme` theme, pass the same options so the tool starts editing from them: `themerTool({config: {accent: '#1cb485'}})`.

> [!WARNING]
> Experimental
> `themerTool` is marked alpha: it may change or be removed in any release without notice. The `buildTheme` and `@sanity/themer/legacy` APIs below are stable.

## Apply a theme in your config

`buildTheme` returns a theme ready for the `theme` property of a Studio config. It builds the same type of theme as `buildTheme` from `@sanity/ui/theme`, but takes colors instead of design tokens.

**sanity.config.ts**

```typescript
import {buildTheme} from '@sanity/themer'
import {defineConfig} from 'sanity'

export const theme = buildTheme({
  accent: '#f00', // required
  text: '#727892', // optional
  background: {dark: '#0d0e12', light: '#ffffff'}, // optional
  contrast: 85, // optional, 15-100
})

export default defineConfig({
  theme,
  // ...rest of the config
})
```

- `accent` replaces the `blue` scale, which Sanity UI uses for primary buttons, focus rings, and links.
- `text` replaces the `gray` scale: text, icons, borders, and neutral surfaces. When omitted, it is derived from `accent` as a mostly desaturated version of it, the way the stock gray carries a hint of the stock blue.
- `background.dark` replaces `black` and `background.light` replaces `white`, the backgrounds that every other color in the two color schemes blends onto.
- `contrast` controls how strongly text and borders separate from the accent. The default `85` uses the text color as-is, `100` removes its tint entirely, and lower values blend more of the accent into the text scale.

The root export also provides `buildPalette`, which returns the generated palette without building a theme from it, and `presets`, which ships the hosted Themer service presets translated to `buildTheme` options.

## Migrate from a themer.sanity.build URL import

`@sanity/themer/legacy` generates the same colors as the hosted service, with the same `createTheme`, `hues`, and `theme` exports that `https://themer.sanity.build/api/hues` served. Replace the URL import with `buildThemeFromUrl` and pass the same URL as a string.

**sanity.config.ts**

```typescript
// Before:
import {theme} from 'https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8'

// After:
import {buildThemeFromUrl} from '@sanity/themer/legacy'

const theme = buildThemeFromUrl(
  'https://themer.sanity.build/api/hues?preset=verdant&primary=22fca8',
)
```

Configs that pulled `createTheme` and `hues` from the URL import work the same way with `parseHuesFromUrl`:

**sanity.config.ts**

```typescript
import {createTheme, parseHuesFromUrl} from '@sanity/themer/legacy'
import {defineConfig} from 'sanity'

const hues = parseHuesFromUrl('https://themer.sanity.build/api/hues?preset=verdant')

export default defineConfig({
  theme: createTheme({...hues, primary: {...hues.primary, mid: '#22fca8'}}),
  // ...rest of the config
})
```

The hosted presets are addressed by query, exactly like the service: `buildThemeFromUrl('?preset=verdant')`.

Once migrated, remove the two pieces of setup the URL imports needed:

- Any `themer.d.ts` module declarations.
- The `urlImports` config that allowed the URL import.

> [!NOTE]
> One behavioral difference
> The generated theme carries no `__themer` flag. The Studio used that flag to discard the fonts the hosted module bundled, because they had drifted from the Studio's own. With the package, fonts come from the `@sanity/ui` installed next to the Studio, so there is nothing to discard.

### Using `buildLegacyTheme`

Studios carrying a Studio v2 theme can keep it with the `buildLegacyTheme` helper function exported from the `sanity` package.

> [!WARNING]
> Deprecated
> The `buildLegacyTheme` function is deprecated and will be removed in an upcoming major version of Sanity Studio. Use `buildTheme` from `@sanity/themer` instead.

```javascript
import {buildLegacyTheme, defineConfig} from 'sanity'

const props = {
  '--my-white': '#fff',
  '--my-black': '#1a1a1a',
  '--my-blue': '#4285f4',
  '--my-red': '#db4437',
  '--my-yellow': '#f4b400',
  '--my-green': '#0f9d58',
}

export const myTheme = buildLegacyTheme({
  /* Base theme colors */
  '--black': props['--my-black'],
  '--white': props['--my-white'],

  '--gray': '#666',
  '--gray-base': '#666',

  '--component-bg': props['--my-white'],
  '--component-text-color': props['--my-black'],

  /* Brand */
  '--brand-primary': props['--my-blue'],

  // Default button
  '--default-button-color': '#666',
  '--default-button-primary-color': props['--my-blue'],
  '--default-button-success-color': props['--my-green'],
  '--default-button-warning-color': props['--my-yellow'],
  '--default-button-danger-color': props['--my-red'],

  /* State */
  '--state-info-color': props['--my-blue'],
  '--state-success-color': props['--my-green'],
  '--state-warning-color': props['--my-yellow'],
  '--state-danger-color': props['--my-red'],

  /* Navbar */
  '--main-navigation-color': props['--my-black'],
  '--main-navigation-color--inverted': props['--my-white'],

  '--focus-color': props['--my-blue'],
})

export default defineConfig({
  // rest of config...,

  theme: myTheme,
})
```





# The Dashboard tool for Sanity Studio

> [!WARNING]
> Looking for Sanity Dashboard?
> This article is about the Dashboard tool for Sanity Studio. [Go here for documentation for Sanity Dashboard](https://www.sanity.io/docs/dashboard), the unified content operations workspace.

Dashboard is a Sanity Studio tool that allows you to add widgets that display information about your content, project details, or anything else you'd want to put there. You can find widgets on the [Sanity Exchange](https://www.sanity.io/exchange/) and install them in your project using your preferred package manager, such as [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/). You can also write your custom project-specific widgets.

Widgets are useful for displaying stats about your content, listing recently edited or stale documents, portraying the daily cat, or whatever sparks joy for those who log in to the Studio.

The Dashboard tool has been designed to be as generic as possible, making few assumptions about its widgets. The Dashboard itself is mostly concerned about the layout of the configured widgets. The layout and order, as well as the widgets’ configurable options can be set in a simple file.

## Installation

If you have started a project from [sanity.io/templates](https://www.sanity.io/templates) you might already have the Dashboard installed. If you wish to install it in existing projects, you follow the same procedure as for any other package:

1. `cd` to your project’s root folder
2. Install the package

**npm**

```shell
npm install --save @sanity/dashboard
```

**pnpm**

```shell
pnpm add @sanity/dashboard
```

**yarn**

```shell
yarn add @sanity/dashboard
```

**bun**

```shell
bun add @sanity/dashboard
```

3. Add the widget to your studio configuration (typically found in `sanity.config.js|ts` at the root of your project)

```javascript
import { defineConfig } from "sanity";
import { dashboardTool } from "@sanity/dashboard";
export default defineConfig({
    /* ... */
    plugins: [
        dashboardTool({ widgets: []})
    ]
})
```

To verify that all is well, fire up your Studio (`sanity dev`) and point your browser to [http://localhost:3333/dashboard](http://localhost:3333/dashboard). It should show an empty dashboard with a message encouraging you to add some widgets to the dashboard.

> [!WARNING]
> Gotcha
> Sometimes, you want the Dashboard to be the first thing people see when they log in to the Studio, and sometimes the Structure tool. This depends on what comes first in the `plugins`-array in `sanity.config.ts`.

## How to configure the Dashboard

Now, add any widgets you might want. The dashboard plugin provides three widgets out-of-the-box:

```javascript
import { defineConfig } from "sanity";
import {
  dashboardTool,
  sanityTutorialsWidget,
  projectUsersWidget,
  projectInfoWidget,
} from "@sanity/dashboard";


// configure the dashboard tool with widgets
dashboardTool({ 
  widgets: [
    sanityTutorialsWidget(),
    projectInfoWidget(),
    projectUsersWidget(),
  ]
})
```

Widgets can be configured by passing widget-specific config:

```javascript
projectUsersWidget({ layout: { width: 'small' } }),
```

The `widgets` array is how you tell the Dashboard which widgets to render in the order they appear in the array. The ones mentioned above are bundled with Sanity and require no separate installation.

You can play around with the order of the widgets array and see how the layout changes. 

![The Dashboard in Sanity Studio with a feed of tutorials, a project info widget, and a project users widget.](https://cdn.sanity.io/images/3do82whm/next/a8bf55737aad38cd4140f565dd9742745e0ccb32-1217x1046.png)
*The Dashboard with included widgets*

Some widgets have widget-specific options to change aspects of their behavior. If you install the `sanity-plugin-dashboard-widget-document-list` widget mentioned below, it can be configured with:

```javascript
documentListWidget({
  showCreateButton: true,
  limit: 5,
  types: ["my-document-type"],
})
```

You can add multiple instances of a widget with different configurations. So, if you want your dashboard to display both the newest documents across all document types and another widget showing the last edited books, your dashboard config might look like this:

```javascript
export default {
  widgets: [
    documentListWidget({title: 'New', order: '_createdAt desc'}),
    documentListWidget({title: 'Last edited books', order: '_updatedAt desc', types: ['book']}),
  ]
}
```





# Add widgets to dashboard

## Installing widgets for Studio’s dashboard tool

You install Dashboard widgets the same way you'd install any studio plugin, or indeed any other package, using your preferred package manager. 

You can find some popular widgets at the Sanity Exchange.

[Browse widgets →](https://www.sanity.io/plugins?category=dashboardWidget)

For example, if you want to install the cats example widget mentioned below, proceed as follows:

1. Install the widget in the root folder of your project.

```sh
npm install --save sanity-plugin-dashboard-widget-cats
# OR
yarn add sanity-plugin-dashboard-widget-cats
```

2. Update your `sanity.config.js` file to include the widget in your `plugins` array. 

```javascript
import { dashboardTool } from "@sanity/dashboard";
import { catsWidget } from "sanity-plugin-dashboard-widget-cats";

export default defineConfig({
  // ...
  plugins: [
     dashboardTool({
             widgets: [
                 catsWidget(),
             ],
         }
     ),
  ] 
})
```

3. You've now got a cat in your Studio!

## Changing layout

A widget’s size can be defined by adding a `layout` key to the widget entry:

```javascript
dashboardTool({
        widgets: [
            catsWidget({ layout: { width: "small" } }),
        ],
    }
)
```

The accepted values for `width` and `height` are:

- `auto`
- `small`
- `medium`
- `large`
- `full`

![The Dashboard with four empty widgets of different size configurations](https://cdn.sanity.io/images/3do82whm/next/710b00182e719196413eb9a1ffb73d2d86a8bb64-2560x2160.png)
*Some layout examples*

## Configuring widget options

Some widgets allow options to change aspects of their behavior. The configuration options should be part of the widget’s documentation found in its `README.md`. If you install the document-list widget (install `sanity-plugin-dashboard-widget-document-list` with npm or yarn as shown above), it can be configured with:

```javascript
documentListWidget({
  showCreateButton: true,
  limit: 5,
  types: ["my-document-type"],
})
```

Thus, if you want your dashboard to display both newest documents across all document types and another widget showing the last edited books, your dashboard config would look like this:

```javascript
export default {
  widgets: [
    documentListWidget({title: 'New', order: '_createdAt desc'}),
    documentListWidget({title: 'Last edited books', order: '_updatedAt desc', types: ['book']}),
  ]
}
```

![The Studio with two widgets showing the last edited documents and the last edited posts](https://cdn.sanity.io/images/3do82whm/next/393a85b3be27503fa583c5413444d9941be0ddd9-3200x2400.png)
*Document lists with configuration*



# Document actions

Document Actions lets you customize and control operations users can perform on a document. When you create a custom action it will be available in the actions menu in the document editor. See the [DocumentActionComponent](https://reference.sanity.io/sanity/index/DocumentActionComponent/) reference for the full type definition.

![Screenshot of document actions in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/331e0dc1664ce52e79447e409b33a48cd3f0b07d-2304x1400.png)
*You can customize both document actions and badges*

> [!NOTE]
> Unpublish action is now only available from the published perspective
> To unpublish a document, editors must select the `published` perspective (on the Studio or document header level) and can then access the unpublish action from the actions document footer as before.
> This change ensures editors are working with the published version when making `unpublish` decisions, providing better context and reducing potential confusion between draft and published states.

## Get started

To set up a new custom action component you need to complete the following steps:

1. Define an action component
2. Register the action component to the `document.actions` array in your workspace configuration

In this first example we'll make an action component that will display an alert window when clicked. 

### 1. Define a document action component

First, create a file in your local Studio for your action.  Let's call the component `HelloWorldAction` and put it in a file called `actions.js`. 

[Learn about the complete Document Actions API](https://www.sanity.io/docs/studio/document-actions-api)

**hello-action.ts**

```typescript
export function HelloWorldAction(props) { 
  return {
    label: 'Hello world',
    onHandle: () => {
      // Here you can perform your actions
      window.alert('👋 Hello from custom action')
    }    
  } 
}
```

### 2. Register and resolve document actions

Now that you have defined a document action, it can be registered by adding it to `document.actions` in your studio configuration via [defineConfig](https://reference.sanity.io/sanity/index/defineConfig/).

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {schemaTypes} from './schemas'
import {HelloWorldAction} from './actions'

export default defineConfig({
  name: 'default',
  projectId: '<project-id>',
  dataset: 'YOUR_DATASET',

  plugins: [
    deskTool(),
  ],
  document: {
    actions: [HelloWorldAction],
  },
  schema: {
    types: schemaTypes,
  },
})

```

When supplying `document.actions` with a static array of custom actions, the studio will append your customizations to the list of actions provided by plugins and / or the studio defaults.

![Shows the document actions popup menu with our custom action added to the standard list of available actions](https://cdn.sanity.io/images/3do82whm/next/206d8763a36dd1ffaf94d57744f8b1ea2ea9fc3b-557x284.png)

If you want more control over what shows up in the actions menu, you can instead provide a callback function to `document.actions` which should return an array of document action components. The callback will receive as arguments an array of already existing actions, and a context object containing useful info. 

**sanity.config.ts**

```typescript
import {HelloWorldAction} from './hello-action'

export default defineConfig({
  // ... rest of config
  document: {
    actions: (prev, context) => {
      // Only add the action for documents of type "movie"
      return context.schemaType === 'movie' ? [HelloWorldAction, ...prev] : prev;
    },
  },
})
```

![Document actions menu in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/2659ee6276a078696c06612d03a6a98f63a7cc63-557x276.png)
*Putting the custom action first in the returned array also makes it the default option*

[Document API reference](https://www.sanity.io/docs/studio/document-actions-api)
Read more about context properties in the reference docs

## Typical use cases

### Showing actions conditionally

In some situations, a document action may not be relevant, and instead of making it *disabled,* you rather want it to not appear at all. For example, some document actions may only be relevant for certain types. In these cases, check the condition and return `null` from the action component if you want to hide the action.

Here's an example of an imaginary "spellcheck" action that will only appear in menus for documents of type `article`:

**spelling-action.ts**

```typescript
export function SpellCheckArticleAction(props) {
  if (props.type === 'article') {
	return {
		label: 'Spellcheck article'
    //...
  }
    return null
  }
}
```

### Update a value then publish document

Usually a document action provides a way for the user to manipulate the document. To get access to operations that can be done on a document, you can use the `useDocumentOperation` hook from the `sanity` package.

`import {useDocumentOperation} from 'sanity'`

This will give you access to a set of operations that the current document supports. Each operation comes with a `disabled` prop and an `execute` method. 

In this example we update the `publishedAt` value of a document before we publish it. We also provide feedback to the user about the progress of the operation.

Note: Due to current technical limitations, the only way to check whether the publish action has completed is to check for the draft being `null` after the publish action was invoked (i.e., the code in `useEffect()`). We are working on improving this in the future.

**set-publish-action.tsx**

```tsx
import {useState, useEffect} from 'react'
import {useDocumentOperation} from 'sanity'


export function SetAndPublishAction(props) {
  const {patch, publish} = useDocumentOperation(props.id, props.type)
  const [isPublishing, setIsPublishing] = useState(false)

  useEffect(() => {
    // if the isPublishing state was set to true and the draft has changed
    // to become `null` the document has been published
    if (isPublishing && !props.draft) {
      setIsPublishing(false)
    }
  }, [props.draft])

  return {
    disabled: publish.disabled,
    label: isPublishing ? 'Publishing…' : 'Publish & Update',
    onHandle: () => {
      // This will update the button text
      setIsPublishing(true)

      // Set publishedAt to current date and time
      patch.execute([{set: {publishedAt: new Date().toISOString()}}])

      // Perform the publish
      publish.execute()

      // Signal that the action is completed
      props.onComplete()
    },
  }
}
```

### Duplicate a document with changes to selected fields

To create a duplicate document with modifications to specific fields, use the `mapDocument` option. This function receives the duplicated document and returns a modified version. The following example returns a duplicate of the original document with the `slug` field omitted.

```typescript
import {type DuplicateDocumentActionComponent} from 'sanity'

export function createCustomDuplicateAction(
  originalAction: DuplicateDocumentActionComponent,
): DuplicateDocumentActionComponent {
  return function CustomDuplicateAction(props) {
    return originalAction({
      ...props,
      mapDocument: ({slug, ...document}) => document,
    })
  }
}
```

### Selectively replacing built-in actions

Sometimes you may want to replace just one or a few of the default document actions (publish, duplicate, delete) in your Studio instance. Here's an example of how to replace the built-in publish action with your own:

**sanity.config.ts**

```typescript
export default defineConfig({
  // ...rest of config
  document: {
    actions: (prev) =>
      prev.map((originalAction) =>
        originalAction.action === 'publish' ? CustomPublishAction : originalAction
      ),
  },
})
```



### Extending built-in actions

You may want to extend a built-in action while retaining its look and functionality, but don't want to re-construct the entire component. After all, that would require constantly monitoring the built-in action for code changes and updating your custom action.

The following is the most basic implementation, and simply logs to the console while retaining all the functionality of the default Publish action (permissions checking, sync state, validation, etc.). 

**better-publish-action.ts**

```typescript
export function createImprovedAction(originalPublishAction) {
  const BetterAction = (props) => {
    const originalResult = originalPublishAction(props)
    return {
      ...originalResult,
      onHandle: () => {
        // Add our custom functionality
        console.log('Hello world!')
        // then delegate to original handler
        originalResult.onHandle()
      },
    }
  }
  return BetterAction
}

```

This method requires you to call the function with the original action as the only argument.

**sanity.config.ts**

```typescript
import {createImprovedAction} from './actions'

export default defineConfig({
  // ...rest of config
  document: {
    actions: (prev) =>
        prev.map((originalAction) =>
          originalAction.action === 'publish'
            ? createImprovedAction(originalAction)
            : originalAction
        ),
  },
})
```

In this next contrived example, we will extend the Publish action by incrementing a counter on an existing document (`_id: 'publish-counter'`) and then logging the updated counter value to the console:

**custom-dupe-action.ts**

```typescript
export function createAsyncPublishAction(originalAction, context) {
  const client = context.getClient({ apiVersion: '2022-11-29'})
  const AsyncPublishAction = (props) => {
    const originalResult = originalAction(props)
    return {
      ...originalResult,
      onHandle: async () => {
        await client.patch('publish-counter').setIfMissing({counter: 0}).inc({counter: 1}).commit()
        await client
          .fetch("*[_id == 'publish-counter'][0]{counter}")
          .then((res) => console.log(res))
        originalResult.onHandle()
      },
    }
  }
  return AsyncPublishAction
}
```

In order to make the client available, this function expects the `context` object to be forwarded along with the original action.

**sanity.config.ts**

```typescript
export default defineConfig({
  // ...rest of config
  document: {
    actions: (prev, context) =>
      prev.map((originalAction) => (originalAction.action === 'publish' ? createAsyncPublishAction(originalAction, context) : originalAction)),
  },
})
```

You can extend more than just the default behavior. This same approach can be used to add a modal, change the button color or icons, and so on. Let's change the default publish button from this:

![Revised publish buttons: enabled on the left (green) and disabled on the right (grey).](https://cdn.sanity.io/images/3do82whm/next/2fc0ddbe5a0df0d8eed298115ee20d3adf867b08-400x34.png)

to this:

![Revised publish buttons: enabled on the left (red with an open eye icon) and disabled on the right (grey with a closed eye icon).](https://cdn.sanity.io/images/3do82whm/next/41d8b5300f540822b3c9cac48207e2f6334ea8c0-400x34.png)

> [!WARNING]
> Gotcha
> Although you can override anything returned from the default actions, the internals of the component are not accessible. This means you can't access component state, internal functions and variables, etc.

**vis-action.tsx**

```tsx
import {EyeOpenIcon} from '@sanity/icons/EyeOpen'
import {EyeClosedIcon} from '@sanity/icons/EyeClosed'

export function createVisualAction(originalAction) {
  const BetterButtonAction = (props) => {
    const originalResult = originalAction(props)
    return {
      ...originalResult,
      tone: 'critical',
      icon: originalResult.disabled ? EyeClosedIcon : EyeOpenIcon,
    }
  }
  return BetterButtonAction
}

```

### Stateful action components and dialog flows

You can think about the action component as a functional React component and you can use React hooks to give it internal state. This means an action can support all sorts of user interaction, including dialogs. Here's an example of an action that lets the user edit the title from the document actions dropdown:

You can learn more and read about the different kinds of dialogs supported in the [Document Actions API documentation](https://www.sanity.io/docs/studio/document-actions-api).

**dialog-action.tsx**

```tsx
import React from 'react'
import {useDocumentOperation} from 'sanity'

export function DialogAction({id, type, published, draft}) {
  const doc = draft || published

  const [isDialogOpen, setDialogOpen] = React.useState(false)
  const [documentTitle, setDocumentTitle] = React.useState(doc?.title)

  const {patch} = useDocumentOperation(id, type)

  const patchField = (field) => {
    patch.execute([{set: {title: field}}])
  }

  return {
    label: `Edit title`,
    onHandle: () => {
      setDocumentTitle(doc?.title)
      setDialogOpen(true)
    },
    dialog: isDialogOpen && {
      type: 'dialog',
      onClose: () => {
        setDocumentTitle(doc?.title)
        setDialogOpen(false)
      },
      header: 'Edit title field',
      content: (
        <>
          <input
            type="text"
            value={documentTitle}
            onChange={(event) => setDocumentTitle(event.currentTarget.value)}
          />
          <button
            onClick={() => {
              patchField(documentTitle)
              setDialogOpen(false)
            }}
          >
            Update
          </button>
        </>
      ),
    },
  }
}

```







# Release Actions

Release Actions lets you customize and control operations users can perform on a content release. When you create a custom release action it will be available in the actions menu in the release overview and details screens.

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

Prerequisites:

- Custom release actions require Sanity Studio v4.7.0 or later.
- Release actions are part of [Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration). If you're not using content releases as part of your workflow, they won't apply. Instead, check out [document actions](https://www.sanity.io/docs/studio/document-actions).

Limitations:

- At this time, custom release actions cannot extend or overwrite existing actions.

## Create a release action

To set up a new release action component you need to complete the following steps:

1. Define a release action component
2. Register the release action component to the `releases.actions` array in your Studio's workspace configuration.

In this example, we'll create an action that logs details about the release to the console. 

### Create an action component

Start by creating an action component. We recommend using an `actions` directory in your Studio project, or even an `actions/releases` directory if you plan to have multiple document, field, and release actions.

For this example, we'll create the component in our Studio project in `actions/index.ts`.

**actions/index.ts**

```
import type { ReleaseActionComponent } from "sanity";
import { BookIcon } from "@sanity/icons/Book"; // Optionally, you can add icons

export const CustomReleaseAction: ReleaseActionComponent = ({ release, documents}) => {
  return {
    label: 'Log Release Info',
    icon: BookIcon, // Optional, make sure to install and import above
    disabled: false,
    title: 'Log information about this release to the console',
    onHandle: () => {
      console.group(`Release: ${release.metadata.title}`)
      console.log('Release ID:', release._id)
      console.log('Release State:', release.state)
      console.log('Release Type:', release.metadata.releaseType)
      console.log('Documents Count:', documents.length)
      console.log(
        'Documents:',
        documents.map((d) => d.document._id),
      )
      console.groupEnd()
    },
  }
}
```

Release actions accept an object containing the `release` and an array of `documents`. In this example, the action accesses these parameters and logs details about the release and any included documents.

### Add the component to your studio config

Next, import the action component and add it to the `releases.actions` array in your studio's `sanity.config.ts`.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'
import { CustomReleaseAction } from './actions'

export default defineConfig({
  // ...
  releases: {
    actions: [CustomReleaseAction]
  },
  // ...
})
```

### Run the action

Now when you run your Studio locally or deploy the changes, you should see the action available from the "**...**" icon on the release page.

![The release action appears in the actions menu](https://cdn.sanity.io/images/3do82whm/next/81ffeaa9f6e5c3f39c07ddcbc462f2eb4a9d51dc-1504x1256.png)

## Selectively render actions

You can selectively render actions based on the studio context.

### Release context

Retrieve information about the release by accessing the `context.release` object. In this example, the custom action becomes available only if the release is scheduled and contains documents.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'
import { CustomReleaseAction } from './actions'

export default defineConfig({
  // ...
  releases: {
    actions: (prev, context)=>{
      if (context.release.state === 'scheduled' && ctx.documents.length > 0) {
        return [...prev, CustomReleaseAction]
      }
      return prev
    }
  },
  // ...
})
```

In addition to `release.state`, you can access `release.metadata` to check the type, description, and title.

### User context

Another common approach to filtering actions is by user role. In this example, the custom action only displays for users with the *administrator* role.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'
import { customReleaseAction } from './actions'

export default defineConfig({
  // ...
  releases: {
    actions: (prev, context)=>{
      if (context.currentUser?.roles.find(({name}) => name === 'administrator')) {
        return [...prev, customReleaseAction]
      }
      return prev
    }
  },
  // ...
})
```

> [!TIP]
> The pattern of returning `[...prev, newAction]` ensures that any actions added outside of the main config, such as in a plugin, aren't overridden by this assignment.

## Interact with built-in release actions

To call one of the built-in actions from within a custom release action, you can use the Sanity client by importing `useClient`.

In a custom action component:

**actions/index.ts**

```
import { useClient, getReleaseIdFromReleaseDocumentId, type ReleaseActionComponent } from "sanity";
import { BookIcon } from "@sanity/icons/Book"; // Optionally, you can add icons
import { useRouter } from "sanity/router"

export const customReleaseAction: ReleaseActionComponent = ({ release}) => {
  // Get the release ID form the release system document ID
  // using the included helper.
  const releaseId = getReleaseIdFromReleaseDocumentId(release._id)

  // Set up a Sanity client
  const sanityClient = useClient({apiVersion: "2025-09-02"})
  const router = useRouter()

  // Handle the action
  const handleArchiveAndDelete = async () {
    await sanityClient.releases.archive({releaseId})
    await sanityClient.releases.delete({releaseId})

    // If action was on the release detail, navigate back to release's tool root
    // as once deleted, the release detail page will not exist anymore
    router.navigate({})
  }
  
  
  return {
    label: "Archive and delete",
    icon: BookIcon, // Optional, make sure to install and import above
    title: "Archive and delete this release",
    onHandle: handleArchiveAndDelete
  }
}
```

Then, as with the example earlier, add it to your release actions array in the `sanity.config.ts` file.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'
import { CustomReleaseAction } from './actions'

export default defineConfig({
  // ...
  releases: {
    actions: [CustomReleaseAction]
  },
  // ...
})
```





# Custom document badges

A document badge ([DocumentBadgeComponent](https://reference.sanity.io/sanity/index/DocumentBadgeComponent/)) is a small UI component that indicates the status of a document. It currently appears in the Studio next to the toolbar actions. The default set of document badges currently shows `draft` and `published` status.

Depending on how you're implementing your workflows, you may want to control the badges that are displayed here. For example, if you have a workflow that includes reviewing you want to display pending review as a badge here.

![Screenshot from Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/250a4fc9d947827de6e0e1c02777fdec2c2b6908-2304x1400.png)
*Custom badges can be combined with custom document actions*

[Learn more about creating custom workflows →](https://www.sanity.io/docs/studio/document-actions)

## Getting started

[More details in the document badge reference documentation →](https://www.sanity.io/docs/studio/document-badges-api)

In order to implement your own custom badge you need to perform two steps:

1. Create a function that defines the badge
2. Register the badge and resolve which badges should be displayed when

Here's how:

### Define a custom badge

```javascript
export function HelloWorldBadge(props) {
  return {
    label: 'Hello world',
		title: 'Hello I am a custom document badge',
    color: "success"
  }
} 
```

### Register a custom badge

Custom badge definitions like the one above can be added to the `document.badges` property in your workspace configuration.

```javascript
export default defineConfig({
  // ... rest of config
  document: {
    badges: [HelloWorldBadge]
  },
})

```

Adding your badge components as a static array as in the example above will append your custom badges to the list of existing badges, if any. These could be the default set of badges provided by Sanity Studio, and any badges added to the studio via plugins.



The property can also be defined with a callback function that returns an array of badge components. When using the callback option, it's your responsibility to make sure any existing badges are passed along. The callback receives the current array of badges as its first argument and a context object with some useful info as its second.

```javascript
export default defineConfig({
  // ... rest of config
  document: {
    // Use info from the context to decide whether or not
    // to add our badge or just return the current list
    badges: (prev, context) => context.schemaType === 'movie' ? [HelloWorldBadge, ...prev] : prev,
  },
})

```

When editing a document in the studio next time, you should see your badge appear in the toolbar when editing a document:

![Screenshot of document badges in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/1219c0d0fcc87b88c492d5d9712813651d06c8e8-2304x1400.png)
*Default and custom badges*



# Localization

> [!NOTE]
> Localizing UI vs localizing content
> This article is about how to localize the *content* you manage in Sanity Studio. To learn about how to change the UI language of your studio, [visit this article](https://www.sanity.io/docs/studio/localizing-studio-ui), or [visit this article](https://www.sanity.io/docs/apis-and-sdks/iiif-api-reference) if you want to learn about adding internationalization to your plugins.

## Best practice

Localization in Sanity is performed by storing language data as a value of a field in a document.

We recommend using these two optional plugins to simplify creating and maintaining localized documents and fields in Sanity Studio.

- For **translated documents**, we recommend the [@sanity/document-internationalization](https://github.com/sanity-io/plugins/tree/main/plugins/%40sanity/document-internationalization) plugin, which will relate translations as references and handle setting a “language” field value on documents.
- For **translated fields**, the [internationalized-array plugin](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-internationalized-array) can be used with any field type and scales to as many languages as you may need to author.

## Methods of localization

Sanity allows you to model translated content as it makes the most sense to your workflow and content structure. There are two main approaches:

- **Field level localization**  - A single document with content in many languages
- Requires you to publish content in all languages simultaneously
- Achieved by creating an array that generates a field for each language value
- Best for documents that have a mix of language-specific and common fields


- **Document level localization**  - A unique document version for every language
- Allows the option to publish each language version independently
- References join language versions together
- Best for documents that have unique, language-specific fields and no common content across languages
- Best for translating content using Portable Text



Your preferred method will depend on your use case, content model, and publishing workflow. Each document’s schema plays a role in deciding the appropriate localization strategy, so you may use both in a single project.

We offer simple plugins for both strategies to improve the authoring experience in Sanity Studio.

## Sanity Studio walkthrough

![Localization approaches in Sanity Studio](https://www.youtube.com/watch?v=6acLvAvvG2w)

## Example repository

This [Course Platform Demo](https://github.com/sanity-io/demo-course-platform) is a Sanity Studio and Next.js front-end showcasing internationalized schema, popular plugin configuration, and how to query for localized content.

## Field-level translations

### Localized arrays

*Any array field where each item stores the content and the language as field values – this input is customised by the internationalized-array plugin*

You may prefer to create localized fields in an array structure for projects with many languages. Arrays [use fewer unique attributes](https://www.sanity.io/docs/content-lake/attribute-limit) than objects using this method.

Here is a quick explanation of how language objects impact attributes.

An object for a string field with three languages creates these attributes:

```
title
title.en
title.fr
title.es
```

You create another unique attribute in your dataset for every new language you add.

An array of objects to store both a language and field value could create attributes like this:

```
title
title[]
title[]._key
title[].language
title[].value
```

Using the `language` field to store the language and `value` to store the field’s content, you can add many more languages without using more attributes.

The built-in array component is not best suited to authoring like this – as every array item needs to open in a popup dialog – but there is a solution.

### Plugin for localized arrays

The [internationalized-array plugin](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-internationalized-array) has a custom UI that can be used for any field type and renders each field input without a popup dialog.

It stores the language in a `language` field.

#### Querying localized arrays with GROQ

Now performing the same query for name and title but with the title stored in an array, using the [internationalized-array plugin](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-internationalized-array).

```groq
*[_type == "presenter"][0]{
  name,
  title
}
```

You will receive this data:

```json
{
  "name": "Rune Botten",
  "title": [
    {
      "_type": "internationalizedArrayStringValue",
      "_key": "IW92vi98KcGFhIzeUDasfasxu",
      "language": "en",
      "value": "Rune is a solution architect at Sanity.io"
    },
    {
      "_type": "internationalizedArrayStringValue",
      "_key": "IW92vi98KcGFhIzeUDagasxu",
      "language": "es",
      "value": "Rune trabaja como arquitecto de soluciones en Sanity.io"
    },
    {
      "_type": "internationalizedArrayStringValue",
      "_key": "Edafwevi98KcGFhIzeUDkxxu",
      "language": "no",
      "value": "Rune jobber som løsningsarkitekt hos Sanity.io"
    }
  ]
}
```

To avoid over-fetching, update the query to: 

1. Filter this array to just the language field `language` you need 
2. Only return the `value` field

```groq
*[_type == "presenter"][0]{
  name,
  "title": title[language == "en"][0].value
}
```

Now the returned data is filtered down to just what you need:

```json
{
  "name": "Rune Botten",
  "title": "Rune is a solution architect at Sanity.io"
}
```

You can use the `coalesce()` [GROQ function](https://www.sanity.io/docs/specifications/groq-functions) to fall back to another value if the targeted one is not yet set:

```groq
*[_type == "presenter"][0]{
  name,
  "title": coalesce(
    title[language == "en"][0].value,
    title[language == "nl"][0].value,
    "Missing translation"
  )
}
```

For the most flexibility, use variables so that your query remains the same but will adapt to whichever parameters you pass into it.

```groq
*[_type == "presenter"][0]{
  name,
  "title": coalesce(
    title[language == $language][0].value,
    title[language == $baseLanguage][0].value,
    "Missing translation"
  )
}
```

## Document-level translations

You might have more complex publishing workflows that field-level translations are too simple to solve. You could be working in a base language and want to publish that content as soon as it is ready, then publish translations as they become available from other editors or external translation services. Or you may have content that exists only in a certain locale. It might make the most sense to model localized content as separate documents.

In this example, we have a `lesson` document type where every field is unique to that language variant, so it makes sense to store them as separate documents.

*Visual representation of a content model where every field is text, and so a unique document should exist for each language*

### Schema for document-level translations

The simplest way to achieve this is to have a language field on documents and set this to whichever language the document's contents correspond to.

```typescript
// ./schemas/articleType.ts

import {defineType, defineField} from 'sanity'

export const articleType = defineType({
  title: "Article",
  name: "article",
  type: "document",
  fields: [
    defineField({
      name: "language",
      type: "string",
      options: {
        list: [
          {title: 'English', value: 'en'},
          {title: 'Spanish', value: 'es'}
        ]
      }
    }),
    defineField({
      name: "title",
      type: "string",
    }),
    defineField({
      name: "body"
      type: "array",
      of: [{type: 'block'}],
    })
  ]
})
```

You can then filter queries for specific locales, thus only presenting the relevant localized content in your front ends.

Using our [Document Actions API](https://www.sanity.io/docs/studio/document-actions) you can further add actions in the Studio for duplicating a document into another locale and then translate the content manually.

Or use [GROQ-powered webhooks](https://www.sanity.io/docs/content-lake/webhooks) to send the document off to a third-party translation service through their API for automated or professional translation. Once the translation is complete, you can re-import it to your Sanity dataset via, for example, a webhook triggered by the translation service.

You can also use the [Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-introduction) to provide segmented navigation to find and organize localized content in the Structure tool if you wish.

*Filtered document lists for authors to quickly find documents of a specific language*

### Plugin for document-level translations

*The document internationalization plugin handles setting a language field and relating translations as references*

An integrated solution is to install the [@sanity/document-internationalization](https://github.com/sanity-io/plugins/tree/main/plugins/%40sanity/document-internationalization) plugin, which provides most of the above in-Studio features with minimal setup. It handles setting a language field on documents and automatically creates a linked document that stores the translations together so they are more easily queried.

### Querying for localized documents with GROQ

How you [query](https://www.sanity.io/docs/specifications/groq-syntax) for translated documents will depend on how you have built references between them. If you use the @sanity/document-internationalization plugin, your query will look like the one below.

In this query, you are looking for a `lesson` type document of a specific language, then find the `translation.metadata` type document which contains a reference to it and other language translations.

```groq
*[_type == "lesson" && language == $language]{
  title,
  slug,
  language,
  // Get the translations metadata
  // And resolve the `value` reference field in each array item
  "_translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
    title,
    slug,
    language
  },
}
```

The plugin’s page contains more details on [how to query for translations in both GROQ and GraphQL](https://github.com/sanity-io/plugins/tree/main/plugins/%40sanity/document-internationalization#querying-with-groq).



## Translating content with the AI Assist plugin

The official [AI Assist plugin](https://www.sanity.io/docs/ai-assist) for Sanity Studio offers Large Language Model-powered content translation at the click of a sparkly button.

![Shows the top-level document menu for AI Assist instructions open with a "Translate document" option highlighted](https://cdn.sanity.io/images/3do82whm/next/bdc59e983472bee6853d4cdb81c1e3e235df74e2-610x217.png)

- [Translating content with AI Assist](https://www.sanity.io/docs/studio/ai-assist-content-translation)

## Translation service plugins

In addition to plugins to assist with authoring localized content in Sanity Studio, we offer some adapters to popular translation service providers:

- [Transifex plugin](https://www.sanity.io/plugins/sanity-plugin-transifex)
- [Smartling plugin](https://www.sanity.io/plugins/sanity-plugin-studio-smartling)

## Customizing the internationalized array plugin

The `sanity-plugin-internationalized-array` plugin supports several configuration options beyond the basic setup. This section covers the most common customization patterns.

### Load languages from an external source

Instead of hardcoding a language list, you can load languages dynamically from an API or from documents in your dataset. Pass an async function to the `languages` option:

**sanity.config.ts**

```typescript
// sanity.config.ts
import {internationalizedArray} from 'sanity-plugin-internationalized-array'

export default defineConfig({
  plugins: [
    internationalizedArray({
      languages: async () => {
        // Fetch from an external API
        const response = await fetch('https://example.com/api/languages')
        return response.json()
      },
      // Or load from documents in your dataset:
      // languages: async (client) => {
      //   return client.fetch('*[_type == "language"]{id, title}')
      // },
      fieldTypes: ['string', 'text'],
    }),
  ],
})
```

### Filter languages by market

Use the `select` option to let editors filter the available languages based on a market or region. This is useful when different markets use different subsets of languages:

**sanity.config.ts**

```typescript
internationalizedArray({
  languages: [
    {id: 'en', title: 'English'},
    {id: 'fr', title: 'French'},
    {id: 'de', title: 'German'},
    {id: 'es', title: 'Spanish'},
  ],
  select: {
    options: [
      {title: 'Europe', languages: ['en', 'fr', 'de']},
      {title: 'Americas', languages: ['en', 'es']},
    ],
  },
  fieldTypes: ['string'],
})
```

### Configure the add language button

Control where the "Add translation" button appears using the `buttonLocations` option. Available locations are `field` (below the field), `unstable__fieldAction` (in the field action menu), and `document` (in the document actions):

**sanity.config.ts**

```typescript
internationalizedArray({
  languages: [...],
  fieldTypes: ['string'],
  buttonLocations: ['field', 'unstable__fieldAction'],
  buttonAddAll: false, // Hide the "Add all languages" button
})
```

### Complex field types

The plugin supports complex field types including Portable Text, references, and custom objects. Add these types to the `fieldTypes` array:

**sanity.config.ts**

```typescript
internationalizedArray({
  languages: [...],
  fieldTypes: [
    'string',
    'text',
    // Portable Text (block content)
    defineField({
      name: 'blockContent',
      type: 'array',
      of: [{type: 'block'}],
    }),
    // References
    defineField({
      name: 'relatedArticle',
      type: 'reference',
      to: [{type: 'article'}],
    }),
  ],
})
```

### Control automatic language reordering

By default, the plugin reorders internationalized array items to match the order of the `languages` option whenever you open a document whose stored order differs. The reorder is written as a patch, so on a published document it creates a draft and a history entry, even though no content changed.

To keep the stored order and stop the plugin from patching on open, set `restoreOrder` to `false`. This option requires `sanity-plugin-internationalized-array` v5.2.0 or later:

**sanity.config.ts**

```typescript
internationalizedArray({
  languages: [
    {id: 'en', title: 'English'},
    {id: 'fr', title: 'French'},
  ],
  fieldTypes: ['string'],
  restoreOrder: false, // Default is true
})
```

If you keep `restoreOrder` at its default, the patch happens once per document, not on every open. After you publish the reordered draft, the stored order matches the `languages` option, so opening the document again leaves it unchanged.

For the full list of configuration options, see the [plugin README on GitHub](https://github.com/sanity-io/plugins/blob/main/plugins/sanity-plugin-internationalized-array/README.md).

## Migrating from v4 to v5/v6

Version 5 of the internationalized array plugin introduced a breaking change: the language identifier moved from the `_key` field to a dedicated `language` field. The `_key` field now holds a random, stable key for array operations. This change fixes issues with copy/paste, reordering, and Portable Text fields.

### What changed

In v4, the language was stored in the array item key:

**v4 (before)**

```json
// v4 format
{
  "_key": "en",
  "value": "Hello world"
}
```

In v5, the language has its own field:

**v5 (after)**

```json
// v5 format
{
  "_key": "a1b2c3d4",
  "language": "en",
  "value": "Hello world"
}
```

### Update your GROQ queries

After migrating, update any GROQ queries that filter by `_key` to use the `language` field instead:

**GROQ query migration**

```groq
// Before (v4)
*[_type == "product"]{ title[_key == "en"][0].value }

// After (v5)
*[_type == "product"]{ title[language == "en"][0].value }
```

For detailed migration steps and a data migration script, see the [migration guide in the plugin README](https://github.com/sanity-io/plugins/blob/main/plugins/sanity-plugin-internationalized-array/README.md#migrating-from-v4-to-v5).



# Content Releases configuration

Content Releases lets you organize and schedule updates across multiple documents. You can plan, preview, and validate significant changes in advance, so related updates publish together without conflicts.

This document explores configuring Content Releases in Sanity Studio. For details on using Content Releases, or interacting with the API, follow these links:

[User guide](https://www.sanity.io/docs/user-guides/content-releases)
View common tasks and workflows using the Content Releases interface

[Release actions](https://www.sanity.io/docs/studio/release-actions)
Create custom actions that display alongside the default Content Release actions.

[Content Releases API](https://www.sanity.io/docs/content-lake/content-release-document-flow)
Query and programmatically interact with releases and document versions

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

## Prerequisites

- [Sanity Studio](https://www.sanity.io/docs/studio/installation) v3.77.0 or later, where Content Releases is enabled by default.
- Sanity Studio v3.80.0 or later to export or import a dataset that contains releases.
- API version `v2025-02-19` or later on any client that queries or previews release content.

## Setup and configuration

Content Releases is enabled by default for studios running version 3.77.0 or later. Update any official plugins and dependencies, such as AI Assist, the Vision Tool, and any presentation-related plugins, to ensure compatibility.

Running the Scheduled Publishing plugin alongside Content Releases adds a second set of scheduling controls to the Studio, which makes it unclear which system publishes a document. Sanity recommends migrating to Content Releases and disabling Scheduled Publishing.

### Limit release count per workspace

Release quotas apply at both the organization and dataset level. To limit the number of active releases an individual workspace or studio can create, set the `releases.limit` value in your studio's configuration file. When unset, the workspace imposes no limit of its own.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...
  releases: {
    limit: 2
  }
})
```

This limits creation of new releases beyond the limit in Studio. It does not prevent creation from the API or other inputs.

### Disable releases

To disable Content Releases for your studio, update your configuration file. Scheduled Drafts uses the same underlying tool, so to remove it as well, also set `scheduledDrafts: {enabled: false}`.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...
  releases: {
    enabled: false
  },
  scheduledDrafts: {
    enabled: false
  }
})
```

### Limit releases to certain users

Users of [content resources and custom roles](https://www.sanity.io/learn/course/introduction-to-users-and-roles/custom-roles-and-resources) can restrict access for:

- Editing documents *in* releases by using a filter like `_id in path("versions.**")` for any release or `_id in path("versions.rA29bfjqa.**")` for documents in a specific release.
- Performing release actions such as creating, publishing, and archiving releases by using a filter like `_id in path("_.releases.**")` for any release or `_id == "_.releases.rA29bfjqa"` for a specific release.

### Disable drafts

Some organizations prefer to only allow edits in Content Releases, and disable draft documents completely. When drafts are disabled, documents can then only be edited inside a release, or outside a release if their schema type has `liveEdit: true`. To disable drafts, set the `document.drafts.enabled` setting to `false` in your studio's `sanity.config.ts`.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...
  document: {
    drafts: {
      enabled: false
    }
  }
})
```

## Limitations

- In Sanity versions prior to 3.80.0, dataset imports failed on datasets that contain versions, with the error `Invalid document version ID "versions.<id>": "versions" is a reserved prefix`. Update to 3.80.0 or later before you export or import a dataset.
- When you schedule a release, we perform checks in the background to ensure reference integrity between documents. These checks do not take into account [cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-references).
- GROQ-powered webhooks can trigger on version documents from API version `v2025-02-19` onward. Enable the "Trigger webhook when versions are modified" setting in sanity.io/manage, or set `includeAllVersions: true` via the Webhooks API. Version and draft documents are ignored by default.
- Use the `client.releases` methods in `@sanity/client` 7.8.0 or later to create, edit, schedule, publish, archive, and delete releases, and `client.createVersion()` / `client.unpublishVersion()` for document versions. See [Content Releases and versions with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-releases).
- API changes supporting Content Releases introduced changes to perspectives. [See the changelog](https://www.sanity.io/changelog/676aaa9d-2da6-44fb-abe5-580f28047c10) for details on breaking changes.

## Presentation and Visual Editing

Content Release previews in Presentation work with front ends that use Loaders. This includes `@sanity/core-loader`, `@sanity/react-loader`, `@sanity/svelte-loader`, and packages that rely on them such as `next-sanity` (with a loader or `defineLive`) and `@nuxtjs/sanity`.

Configure your clients to use the `v2025-02-19` version of the API to enable previewing.

For applications configured with official loaders and the Presentation Tool, Presentation previews Content Releases as expected. The preferred path is [Presentation Tool and Loaders](https://www.sanity.io/docs/visual-editing-reference-overview). For custom implementations, see the [Content Release API cheat sheet](https://www.sanity.io/docs/apis-and-sdks/content-releases-cheat-sheet).

Follow our guides for [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing) to configure the Presentation Tool in your application.

## Supported plugins

Official plugins have been updated to support Content Releases. We recommend updating to the latest versions of any official plugins to ensure full compatibility.

[Migrate plugins to support Content Releases](https://www.sanity.io/docs/developer-guides/migrating-plugins-to-support-content-releases)
This guide provides advice on migrating custom and third-party plugins to support releases.



# Enable and configure Comments

Comments are available for all paid plans in Sanity Studio. This article walks Studio maintainers through enabling and configuring comments for their projects.

[Comments for Sanity Studio](https://www.sanity.io/docs/studio/comments)
Tour the Comments feature for Sanity Studio

[Enabling Tasks for Sanity Studio](https://www.sanity.io/docs/studio/configuring-tasks)
Enable and configure Tasks for Sanity Studio

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

## Prerequisites

- Sanity Studio v3.40.0 or later (latest is always recommended)
- Project on a supported plan

> [!NOTE]
> Permissions needed for tasks and comments
> All roles need to have these permissions to be able to use comments and tasks fully:
> **Management permissions**
> "Project details" `read` => For feature flag
> "Project members" `read` => @mention members
> "Project datasets" `read` => View all comments with count
> **Content permissions**
> "All documents" `read` on **main** dataset(s) used in your Studio/workspaces

## Enabling and disabling comments

Comments are enabled by default for all paid plans. To disable comments, set `document.comments.enabled` to `false` in your Studio configuration file:

```typescript
export default defineConfig({
  // ... rest of config
  document: {
    comments: {
      enabled: false,
    },
  },
});
```

> [!WARNING]
> Gotcha
> Disabling comments hides them in the Studio, but existing comments persist in the add-on comment dataset.

### Enabling comments for specific document types

To enable comments only for specific document types, use an arrow function:

**sanity.config.ts**

```typescript
export default defineConfig({
  // ... rest of config
  document: {
    comments: {
      enabled: (ctx) => {
        return ctx.documentType == 'whitepaper';
      },
    },
  },
});
```

To enable comments for multiple document types, use an array:

**sanity.config.ts**

```typescript
const COMMENTS_ENABLED = ['article', 'blog', 'whitepaper'];

export default defineConfig({
  // ... rest of config
  document: {
    comments: {
      enabled: (ctx) => {
        return COMMENTS_ENABLED.includes(ctx.documentType);
      },
    },
  },
});
```

## Where are comments stored?

To keep everything neat and tidy, comments are stored parallel to your content in an add-on [dataset](https://www.sanity.io/docs/content-lake/datasets), along with other workflow and collaboration data, such as Tasks.

These datasets:

- Do not count toward the data limit of your current plan.
- Do not incur any extra costs for your project.
- Are listed in the [project management pages](https://www.sanity.io/manage) under **Datasets**, along with all existing datasets for a project.
- Include a distinctive suffix in the dataset name. This is a best-effort attempt, and the result may vary depending on the character length of the name of the related document dataset. Examples:- `<related-document-dataset>-comments`
- `<related-document-dataset>-cmts`
- `<related-document-dataset>-cmt`
- `<related-document-dataset>-c`


- Are searchable: you can query comment datasets with [GROQ](https://www.sanity.io/docs/groq) or [GraphQL](https://www.sanity.io/glossary/graphql). This means they can also be used with [GROQ-powered Webhooks](https://www.sanity.io/docs/content-lake/webhooks).

> [!WARNING]
> Gotcha
> Deleting an add-on comment dataset permanently removes all comments. To restore commenting after deletion, post a new comment in your Studio — that triggers the creation of a new, empty add-on dataset.

## Copy comments to a Cloud Cloned dataset

When you export or [Cloud Clone](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets) a dataset, the comments don't migrate with the data. To copy comments to a new dataset or Studio, you'll need to perform the following steps:

1. Identify the name of the comment dataset you wish to copy.
2. Export the comment dataset.
3. Enable comments in the new primary dataset and identify the name of the comments dataset.
4. Modify the exported comments data file to point to all cross dataset references to the new dataset.
5. Import the comment dataset file into the new comment dataset.
6. Confirm that the process was successful.

For the following examples, we'll use the terms "production" and "staging" to represent the original and new primary datasets. If you haven't already, make sure to Cloud Clone your primary dataset.

### Identify the name of the comment dataset

Comments live alongside regular datasets in an add-on dataset. You can find the name by visiting [manage](https://www.sanity.io/manage), selecting your project, and selecting the datasets screen. You can also run the `dataset list` command in the CLI.

Input

**npm**

```shell
npx sanity@latest dataset list
```

**pnpm**

```shell
pnpm dlx sanity@latest dataset list
```

**yarn**

```shell
yarn dlx sanity@latest dataset list
```

**bun**

```shell
bunx sanity@latest dataset list
```

Response

```sh
production
production-comments
staging
```

Make note of the name of the comments dataset that matches your primary dataset. In this case, `production-comments`.

### Export the comment dataset

Export the comment dataset to a local file using the `dataset export` command.

**npm**

```shell
npx sanity@latest dataset export production-comments production-comments-export.tar.gz
```

**pnpm**

```shell
pnpm dlx sanity@latest dataset export production-comments production-comments-export.tar.gz
```

**yarn**

```shell
yarn dlx sanity@latest dataset export production-comments production-comments-export.tar.gz
```

**bun**

```shell
bunx sanity@latest dataset export production-comments production-comments-export.tar.gz
```

Make note of the file name and location. You'll need it shortly.

### Enable comments in the new dataset

If you haven't already, reload Sanity Studio with the new primary dataset configured.

**sanity.config.ts**

```typescript
export default defineConfig({
  // ... rest of config
  dataset: 'staging',
});
```

If the `dataset list` command does not list a comments add-on dataset for your primary dataset, such as `staging-comments`, you'll need to trigger the creation of the dataset by manually making a comment in Sanity Studio. Once that's done, you should see the new comments dataset in the results of `dataset list`.

Input

**npm**

```shell
npx sanity@latest dataset list
```

**pnpm**

```shell
pnpm dlx sanity@latest dataset list
```

**yarn**

```shell
yarn dlx sanity@latest dataset list
```

**bun**

```shell
bunx sanity@latest dataset list
```

Response

```sh
production
production-comments
staging
staging-comments
```

### Update the export file

The export is compressed. For this step, you need to uncompress the file. You can do so in the terminal with the following command. Make sure to replace `dataset-comments.tar.gz` with the filename of your export.

```sh
tar -xzf dataset-comments.tar.gz
```

Now locate the `data.ndjson` file. Each comment in the `data.ndjson` file uses a [cross dataset reference](https://www.sanity.io/docs/studio/cross-dataset-reference-type) to link the comment to a document in your dataset. Once you have the ID of your new project and the name of your new dataset, update this file and **replace every instance of the old projectId and dataset name with the new ones**. For example, here's a snippet of a line from the ndjson file with the key lines highlighted.

**example.json**

```json
{
  "_createdAt": "2025-03-03T18:17:29Z",
  // ...
  "target": {
    "document": {
      "_dataset": "production", // update with new dataset name, if needed
      "_projectId": "3do82whm", // update with new projectId
      "_ref": "9a6d2a23-b480-45a0-9427-29ab5409ee95",
      "_type": "crossDatasetReference",
      "_weak": true
    },
    // ...
  }
}

```

How you replace them all is up to you, but a find/replace with the editor of your choice is the least-involved option.

Once you're finished, you can move on to the next step. No need to recompress the directory.

### Import the comments dataset

Use the `dataset import` command to import the local comments export into the new comments dataset.

**npm**

```shell
npx sanity@latest dataset import production-comments-export-folder staging-comments
```

**pnpm**

```shell
pnpm dlx sanity@latest dataset import production-comments-export-folder staging-comments
```

**yarn**

```shell
yarn dlx sanity@latest dataset import production-comments-export-folder staging-comments
```

**bun**

```shell
bunx sanity@latest dataset import production-comments-export-folder staging-comments
```

### Confirm the import

Reload and visit your Studio. Confirm that the imported comments are as expected and delete the test comment if you created one earlier.



# Configuring Tasks

The Tasks feature for Sanity Studio enables your content creation team to collaborate more effectively right where the work is done. The feature is enabled by default for any eligible project, but can be disabled with a single line of configuration, should you wish to do so.

[Tasks workflow in Sanity Studio](https://www.sanity.io/docs/studio/tasks)
Get to know the Tasks feature in Sanity Studio

[Comments in Sanity Studio](https://www.sanity.io/docs/studio/configuring-comments)
Learn how to set up and use the Comments feature for collaborative content creation

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

## Enable and configure tasks in your Studio

Tasks are enabled by default for all eligible projects. If you’d rather opt out for now, you can do so by adding the following property to your main Studio configuration:

```tsx
// ./sanity.config.ts|js

export default defineConfig({
  // ... rest of config
  tasks: { enabled: false },
})
```

## Where are tasks stored?

To keep everything neat and tidy, tasks are stored parallel to your content in a complimentary dataset, along with other workflow and collaboration data, such as Comments.

**These datasets:**

- Do not count toward the data limit of your current plan.
- Do not incur any extra costs for your project.
- Are listed in the [project management pages](https://www.sanity.io/manage) under **Datasets**, along with all existing datasets for a project.
- Include a distinctive suffix in the dataset name. This is a best-effort attempt, and the result may vary depending on the character length of the name of the related document dataset. Examples:- `<related-document-dataset>-comments`
- `<related-document-dataset>-cmts`
- `<related-document-dataset>-cmt`
- `<related-document-dataset>-c`


- Are searchable: you can query comment datasets with [GROQ](https://www.sanity.io/docs/groq) or [GraphQL](https://www.sanity.io/glossary/graphql).

## Permissions for tasks and comments

All roles need to have these permissions to be able to use comments and tasks fully:

**Management permissions**
"Project details" `read` => For feature flag
"Project members" `read` => @mention members
"Project datasets" `read` => View all comments with count

**Content permissions**
"All documents" `read` on **main** dataset(s) used in your Studio/workspaces



# Scheduled drafts

Sometimes you want to schedule a single draft to go live, but don't need the full power of content releases. Scheduled drafts allows content editors to schedule, and lock, a single document. It shows up as a special type of content release and is visible for other editors to see. If you need to schedule many drafts at once, [Content Releases](https://www.sanity.io/docs/user-guides/content-releases) may be a better option.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

Prerequisites:

- Studio v4.14.0 or later is required to use this feature.
- When using the API to create scheduled drafts, API version `v2025-02-19` or later is required.
- [Drafts](https://www.sanity.io/docs/content-lake/drafts) must be enabled in your Studio configuration. This is the default settings, so no changes are needed unless you've previously disabled the drafts feature.

This guide covers common usage, how to disable the feature, and how to interact with scheduled drafts programatically.

## Basic usage

To learn more about scheduling drafts, viewing scheduled drafts, and the workflow within Studio's interface, visit the [scheduled drafts user guide](https://www.sanity.io/docs/studio/scheduled-drafts-user-guide).

## Configure document actions

Like other [document actions](https://www.sanity.io/docs/studio/document-actions), you can control the criteria for when an action is displayed. In the case of scheduled drafts, check against the action's name: `SchedulePublishAction`.

### Disable by schema type

In this example, documents of type 'movie' won't display the schedule draft action.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...
  document: {
    actions: (prev, {schemaType}) => {
      if (schemaType === 'movie'){
        return prev.filter((action) => action.displayName !== 'SchedulePublishAction')
      }
      return prev
    }
  }
})
```

### Restrict to specific user roles

In this example, only administrator users can schedule drafts.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...
  document: {
    actions: (prev, {curentUser}) => {
       if (currentUser?.roles.find(({name}) => name !== 'administrator')) {
        return prev.filter((action) => action.displayName !== 'SchedulePublishAction')
      }
      return prev
    }
  }
})
```

## Disable scheduled drafts studio-wide

If you'd like to disable the ability for editors to create scheduled drafts, modify your `sanity.config.ts` file to include the following.

**sanity.config.ts**

```
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...
  scheduledDrafts: {
    enabled: false
  }
  // ...
})
```

## Query all scheduled drafts

Scheduled drafts are essentially single-document content releases. You can query them in GROQ with the following query:

**GROQ**

```groq
releases::all()[metadata.cardinality == "one" && state == "scheduled"]{
  "scheduledDraftDocs": *[
    sanity::partOfRelease(string::split(^._id, ".")[2])
  ]
}.scheduledDraftDocs[]
```

## Schedule drafts programmatically

The scheduled drafts feature is a part of Content Releases. It uses the actions API to create releases and version documents, then schedule them for publishing. 

> [!NOTE]
> The code in this example requires `@sanity/client` version v7.9.0 or later.

You can mimic the way Studio creates scheduled drafts by:

1. Creating a release with the `metadata` of `releaseType: 'scheduled'`,  `cardinality: 'one'`, and a `publishedAt` time in the future.
2. Create a version document on the release associated with the draft document's ID.
3. Schedule the release.

Note that you must already have a draft, or should create one. Here's an example using the Sanity client. 

You can then call the `createNewScheduleDraft` function with the documentId and publish time to schedule the draft.

```
import { createClient } from '@sanity/client'

const sanityClient = createClient({
    projectId: 'projectId',
    dataset: 'dataset',
    apiVersion: '2025-02-07',
    token: 'token',
})

const createNewScheduledDraft = async (documentId: string, publishAt: Date) => {
    const newScheduledDraftRelease = await sanityClient.releases.create({
        metadata: {
            title: 'New Scheduled Draft',
            releaseType: 'scheduled',
            cardinality: 'one', // this marks the release as a scheduled draft
            intendedPublishAt: publishAt.toISOString(),
        },
    })
  const scheduledDraftReleaseId = newScheduledDraftRelease.releaseId

    await sanityClient.createVersion({
       publishedId: documentId,
       // create a new scheduled draft of the current draft
       baseId: `drafts.${documentId}`,
       releaseId: scheduledDraftReleaseId,
    })

    await sanityClient.releases.schedule({
        releaseId: scheduledDraftReleaseId,
        publishAt: publishAt.toISOString(),
    })

    return scheduledDraftReleaseId
}
```

> [!WARNING]
> Scheduling drafts in bulk
> Each scheduled draft costs three write requests: create the release, create the version document, and schedule the release. Scheduling dozens of drafts in a loop can exceed the [per-IP rate limit](https://www.sanity.io/docs/content-lake/api-cdn) of 25 mutations per second and return `429 Too Many Requests`. The client [does not retry these requests](https://www.sanity.io/docs/apis-and-sdks/js-client-advanced).
> If you schedule drafts from a script, send the calls through a rate-limited queue, like the one in [Importing data](https://www.sanity.io/docs/content-lake/importing-data). If the documents can share a publish time, a single Content Release schedules the whole set as one unit instead.

Updates to the Sanity client to streamline this process will come in the future. [Subscribe to the Changelog](https://www.sanity.io/docs/changelog) for updates.

## Scheduled drafts publishing flow

Scheduled drafts share the a similar state-change flow as Content Releases, where the release document moves through various states. For more details, see the [Content Releases release state documentation](https://www.sanity.io/docs/content-lake/content-release-document-flow).

## Permissions

Scheduled drafts are built on Content Releases: each scheduled draft is a single-document release. To schedule a draft of a document, your role must have publish permissions on that document type. Roles without publish permissions cannot schedule drafts.

For details on configuring custom roles, restricting access to releases, and the underlying permission model, see [Content Releases configuration](https://www.sanity.io/docs/studio/content-releases-configuration).



# Scheduled publishing (deprecated)

> [!WARNING]
> Scheduled publishing is deprecated
> Scheduled publishing has been deprecated as of October 2025.
> We recommend moving to [Scheduled drafts](https://www.sanity.io/docs/studio/scheduled-drafts) for scheduling individual documents, or [Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration) for building coordinated releases.
> Scheduled Publishing is not enabled by default. It can be enabled in the config by setting `scheduledPublishing: { enabled: true }`. Conversely, you can remove or disable the feature by setting `enabled` to `false` or removing the configuration setting.
> Scheduled Publishing uses the [Sanity Scheduling API](https://www.sanity.io/docs/scheduling-api), which is available on [Growth plans and above](https://www.sanity.io/pricing).

![Shows the Scheduled publishing interface in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/ab8b7f0a2c183d32ef85d36d8886139dddd981e0-2772x1624.png)

![Shows a scheduled post being edited in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/a4fd9f8169b9e464534257bcf643730c1021260c-2140x1526.png)

## Features

### Create and edit schedules directly from the document editor 

- Create and edit schedules for the document you're working on.
- See current schedule status and potential validation issues.

### View all your schedules with our dedicated tool 

- Filter all schedules by status or use the calendar to browse by date.
- Edit, delete, and immediately publish schedules.
- Automatically validate upcoming schedules, and identify issues before they're published.
- Easily identify who created a schedule.

### View schedule dates in any remote time zone

![Shows a modal dialog for selecting time zones](https://cdn.sanity.io/images/3do82whm/next/c37180974a616b4364f90b8b0ad0d1cdaf077510-1514x840.png)

- Change the time zone you want to preview schedules in by clicking the 🌎 Time Zone button when visible. Great when you need to coordinate with a global team or want to time publication to specific regions. 
- Easily select time zones by city, time zone abbreviation, or name search.- Selected time zones are automatically stored in your local storage for future use.



## Getting started

If you are starting from scratch, skip the following section on uninstalling the plugin and cleaning up old configuration and jump directly to the [next section on how to configure or disable Scheduled Publishing](https://www.sanity.io/docs/studio/scheduled-publishing).

### Uninstall the Scheduled Publishing plugin

If you are already using the Scheduled publishing plugin, the first step is to remove it and [update your Studio to the latest release](https://www.sanity.io/docs/studio/upgrade). If you already updated your Studio you might have gotten an alert about this.

![Shows an in-studio alert about the plugin deprecation](https://cdn.sanity.io/images/3do82whm/next/d86b559a72fc3845d9036dcf2373b3f090897471-829x389.png)

Run the following command in your project root to uninstall the plugin:

```sh
npm uninstall @sanity/scheduled-publishing
```

Next, remove the plugin from your Studio configuration. Typically you'll find this in `./sanity.config.ts|js.` Find and delete the following lines from your configuration:

**sanity.config.ts**

```typescript
import {scheduledPublishing} from '@sanity/scheduled-publishing'

export default defineConfig({
  // ...
  plugins: [
    scheduledPublishing()
  ],
})
```

Your plugin declaration might be a bit more expansive if you've defined a custom time format for the plugin. Delete it all!

**sanity.config.ts**

```typescript
import {scheduledPublishing} from '@sanity/scheduled-publishing'

export default defineConfig({
  // ...
  plugins: [
    scheduledPublishing({
      inputDateTimeFormat: 'MM/dd/yyyy h:mm a',
    }),
  ],
})
```

> [!TIP]
> Pro tip
> You might also have defined some custom document actions and badges to support Scheduled Publishing. You can keep these around, and they'll continue to work after migrating to the core Studio functionality. Refer to the section on [document actions and badges](https://www.sanity.io/docs/studio/scheduled-publishing) further on in this article.

### Add new configuration for Scheduled Publishing

Note that, while very similar to the plugin config, this goes into the top level of your Studio configuration. Setting `enabled` to `false` will opt you out of using scheduled publishing for the project.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ....
  scheduledPublishing: {
    enabled: true,
    inputDateTimeFormat: 'MM/dd/yyyy h:mm a',
  },
})
```

As before, you can add a custom time format if you so wish. If left unspecified, the format will default to `dd/MM/yyyy HH:mm`.

## Document actions and badges

You can further enhance your Scheduled Publishing experience with custom document actions and badges. 

### Configure the document action 

This example assumes you've customized your [document actions](https://www.sanity.io/docs/studio/document-actions) and would like to show the Schedule button on `movie` documents only.

The Schedule document action allows users to both create and edit existing schedules directly from the form editor. It is added to all document types by the plugin, so you should remove it from types that should NOT have it.

**sanity.config.ts**

```typescript
import {defineConfig, ScheduleAction} from 'sanity'

export default defineConfig({
  // ...
  document: {
    actions: (previousActions, {schemaType}) => {
      /*
       * Please note that this will only alter the visibility of the button in the studio.
       * Users with document publish permissions will be able to create schedules directly
       * via the Scheduled Publishing API.
       */
      if (schemaType.name !== 'movie') {
        // Remove the schedule action from any documents that is not 'movie'.
        return previousActions.filter((action) => action !== ScheduleAction)
      }
      return previousActions
    },
  },
})
```

Note that `ScheduleAction` is now imported from the core `sanity` package.

### Configure the document badge 

This example assumes you've customized your own [document badges](https://www.sanity.io/docs/studio/document-badges-api) and would like to only show the Scheduled badge on `movie` documents.

The Scheduled document badge indicates whether the current document is scheduled and, if so, when it will be published. It is added to all document types by the plugin, so you should remove it from types that should NOT have it.

**sanity.config.ts**

```typescript
import {defineConfig, ScheduledBadge} from 'sanity'

export default defineConfig({
  // ...

  document: {
    badges: (previousBadges, {schemaType}) => {
      if (schemaType.name !== 'movie') {
        // Remove the schedule badge from any documents that aren't 'movie'.
        return previousBadges.filter((badge) => badge !== ScheduledBadge)
      }
      return previousBadges
    },
  },
})
```

Note that `ScheduledBadge` is now imported from the core `sanity` package.

## Frequently asked questions

### What's the relationship between Schedules and my dataset?

Schedules sit adjacent to your dataset and can be managed using the [Scheduling API](https://www.sanity.io/docs/http-reference/scheduling) (which this plugin does for you).

Schedules are a unique resource and are linked to, but do not exist within your Sanity project and dataset. It's important to understand the following behavior:

- As schedules are not contained within a project's dataset, you cannot query them via GROQ or GraphQL.
- Deleting a dataset will immediately delete all schedules.
- Deleting a project will immediately delete all schedules.
- `sanity dataset export` will not include schedules and `sanity dataset import` does not support importing schedules.
- Server-side copying of datasets does not include schedules.
- When a project is disabled or blocked, all scheduled publishes will invariably fail as mutations will not be allowed on the dataset.

More information can be found in the [Scheduling API](https://www.sanity.io/docs/http-reference/scheduling) article.

### Will scheduled documents with validation errors publish?

**Yes.** Documents scheduled to publish in future will do so, even if they contain validation errors. This also applies to scheduled documents that you manually opt to publish immediately via the tool.



# Manage notifications

You can enable/disable email notifications for your account from the Dashboard, or from [sanity.io/manage](https://www.sanity.io/manage).

Comment notifications are account-wide for a user, and will apply to all organizations and studios. Notifications for other users are unaffected.

![User settings menu interface](https://cdn.sanity.io/images/3do82whm/next/dbd6f2edbe43bd08a878ef59f9402fc8a29a86fe-1012x632.png)

1. Select the **user avatar** to open a popover menu. In the dashboard, this is located in the bottom-left corner. In Manage or standalone studios, this is located in the top-right corner.
2. Select **Account settings** to navigate to the setting page for your user account.

Once on the settings page, toggle the setting under "Comment notifications".

![a screen that says comment notifications on it](https://cdn.sanity.io/images/3do82whm/next/694f64c1b5190a9a88bdfcd61ee9c16200796913-1874x368.png)





# Introduction

By combining the tool with the builder API, Studio provides a way to organize your content and create intuitive workflows for your content editors. With the Structure Builder API, you can customize how lists, documents, views, and menus are organized within Studio. 

Here are some ways you can use the Structure tool with the Structure Builder API:

- **Customize document browsing** by organizing content into logical groups, making it easier for editors to find what they need.
- **Create specialized document views** that provide contextual information or alternative ways to interact with your content.
- **Build custom editing workflows** that guide editors through complex content creation processes.
- **Design intuitive navigation** that reflects the structure of your content model.

> [!TIP]
> Where's the desk?
> In earlier versions of Sanity, Structure was called the "Desk" tool. You may still see reference to this in filenames or tutorials around the web.

## Requirements

- New projects come pre-configured with the Structure tool. For existing projects, you'll need to [install it by updating your project's configuration file](https://www.sanity.io/docs/studio/structure-tool).

## Core concepts

### Structure Builder API

Customizing the structure tool centers around using the Structure Builder API. It uses a structure builder object (often displayed as `S`) to chain builder methods. For example:

```typescript
 export default defineConfig({
  // ...
  plugins: [
    structureTool({
      structure: (S) =>
        S.list()
          .title('Document Types')
          .items([...S.documentTypeListItems()]),
    }),
  ],
})
```

The most common builder methods are:

- `S.list()`: creates a list (a container of items).
- `S.listItem()`: creates an item in a list.
- `S.documentTypeList()`: list of documents of a given schema type.
- `S.document()`: a single document editor node.
- `S.divider()`: adds a visual divider.

### Collapsable panes

Collapsable panes are the building blocks of the Structure tool's interface. These panes have a title and contain a list of document types, a list of documents, a form, or a custom component. They can be collapsed to make more space within the window, providing a flexible way to navigate complex content structures.

Panes can be nested, with child panes opening to the right of their parent. This creates a visual hierarchy that helps editors understand where they are in the content structure.

### Pane types

There are four main types of panes you can work with:

#### List

A list contains one or more list items and is generally considered to be static. It's useful for displaying a fixed set of options, such as document types within your schema.

#### Document list

Optimized for displaying a collection of documents, a document list keeps itself updated in real-time as documents are created, modified, or deleted. It uses GROQ filters to determine which documents to display and supports infinite scrolling for large collections.

#### Document (and views)

A document pane displays a single document and can include multiple views, such as the default form view and custom views you create. Each view can show different aspects of the document or provide specialized interfaces for working with the content.

### Child resolvers

Child resolvers are functions that determine what should be displayed when a user navigates to a specific item. They allow you to create dynamic, nested structures that respond to user actions and content changes.

#### Get started with structure builder

[Structure Builder tutorial](https://www.sanity.io/docs/studio/structure-builder-introduction)
This multi-part tutorial series explores a collection of structure scenarios.

## Limitations

- The Structure tool's document list has a limited view of 2000 documents. If you find yourself running into this limitation, consider customizing your Structure configuration to organize documents into narrower categories.
- Custom views cannot directly modify document content outside of the standard form fields without additional configuration. For highly complex custom views, consider using the App SDK instead.
- Complex custom structures may impact performance, especially in projects with large numbers of documents.



# Get started with Structure Builder API

![Screenshot showing different types of panes that Structure Builder can modify: static list, document list, and document.](https://cdn.sanity.io/images/3do82whm/next/47b805f2167a3da99af4c265fabc36fba3be7f84-1152x700.png)

Structure Builder is an API meant to help reorganize flows and documents inside of Sanity Studio. This article introduces the central ideas and definitions needed to understand how to use the API. If you want to jump ahead, [check out the reference documentation](https://www.sanity.io/docs/studio/structure-builder-reference) for all API methods and functionality.

Structure builder is useful whenever you want to control how documents are grouped and listed in the studio or for adding additional in-studio previews or content to documents.

> [!TIP]
> Protip
> Looking for quick examples of common use cases? See the [Structure Builder cheat sheet](https://www.sanity.io/docs/studio/structure-builder-cheat-sheet).

#### Structure builder series

[1. Introduction and concepts](https://www.sanity.io/docs/studio/structure-builder-introduction)
(You're here)

[2. Set up Structure Builder in your project](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view)
In this article, we'll explore how to initialize Structure Builder and override the default title of the "Content" list.

[3. Create a link to a single edit page in your main document type list](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list)
In this article, we'll explore how to add a link to a single document from the main document type list.

[4. Manually group items in your document list](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane)
In this article, we'll manually group a few singleton "site setting" documents. 

[5. Dynamically group documents](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter)
In this article, we'll use the documentList() method to dynamically group documents with a GROQ filter.

[6. Create a custom document pane](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder)
In this article, we'll look at adding a custom document view to view the JSON data for our posts.

## How does structure builder work?

The Structure Builder API is a collection of classes with methods that you can chain and nest to express how documents should be organized in the Sanity Studio.

**structure.ts**

```typescript
export const structure = (S) => 
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Settings')
        .child(
          S.document()
            .schemaType('siteSettings')
            .documentId('siteSettings')
        ),
      ...S.documentTypeListItems()
    ])
```

Notice how the methods are added to each other. For a detailed explanation of this code, [read the next article on setting up Structure Builder](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view). 

## What are collapsable panes?

When working with the Structure Builder API, you'll primarily be modifying or creating collapsable panes. These panes are the parts within the Structure tool with a title and contain a list of document types, a list of documents, a form, or a custom component. If you make the window narrower, add more panes in the viewport, or click the title area, they will fold down to make more space within the window. This gives authors a quick way to focus on the right things and keep a visual trail of where they are in the different hierarchies.

There are often items within a pane that may open a new pane. The new pane will open to the right, and the current pane will stack to the left. In the Structure Builder API, the pane immediately following another is commonly referred to as a `child`. The initial pane shown when the Structure tool opens is called the `root` pane.

![An annotated screenshot showing a pane with list, divider, list item, and list item icon](https://cdn.sanity.io/images/3do82whm/next/21a287beeb6d4647c6ed76f5942b2aeb274d67a4-1152x700.png)
*The buildup of a collapsible pane*

## Pane types

There are four types of panes:

- List
- Document list
- Document
- Custom component

### List

A list contains one or more list items and is generally considered to be static. An example of this pane type is a list of document types within your schema. 

While generally used for static items, a pane can perform asynchronous calls before determining its items. It can be useful if you need more control over how a small set of items should be rendered in the list. If you're listing documents, you should generally always use a *document list*.

### Document list

Optimized for displaying a list of documents, as the name implies. It differs from a regular list in that it does not simply fetch a list of documents on load but also keeps that list up to date with any changes: documents that match its filter that are deleted will disappear, newly created documents will appear, and changes to the titles will be reflected in real-time.

A document list is given a GROQ filter and then builds an optimized query based on the filter and the pane ordering. It then implements an "infinite scrolling" pattern that lazy-loads the properties needed to display the screen's documents.

A *document type list* is a subset of a document list, which collects documents where the `_type` property matches a given value (in the schema definition, this is the `name` you set for the `type: 'document'`). Document types may also have [Initial Value Templates](https://www.sanity.io/docs/studio/initial-value-templates) attached to them and certain [orderings of their documents](https://www.sanity.io/docs/studio/sort-orders).

### Document (and views)

A document pane (and its corresponding *document node* in structure terms) is a component that holds a document’s values and different states (*published*, *draft*, *historical*, *displayed*). Typically, this will be the editor form with the input fields for the document's data. 

It can also be a component that you import and configure with your structure definition. Typically, a view is helpful when you want to contextualize your document values somehow. It can be used for making different types of previews, statistics, checklists, alternative ways of interacting with the document values, or anything you can build with React.

### Custom component

You may also implement your pane using a custom React component. The component node can be given a set of options passed as an `options` property to the actual React component being rendered.

The component is rendered inside the shell of a pane, with the pane header, menu, and actions available for configuration. You will find more information on how to use this in the [reference documentation](https://www.sanity.io/docs/studio/structure-builder-reference) for the structure builder.

## Path resolution and URL structure

You can open the same document from multiple paths through a structure. This creates an interesting challenge. Sometimes, you know only the document's ID you want to open, but not necessarily with which of the different paths makes sense to open it. In these cases, the API will make a best-effort calculation to figure this out for you, while the fallback will be to open the document to the right of the root pane.

The Structure Builder API also gives you ways to set a default configuration for a document node that's opened outside of a path. This is useful when you want to ensure that a certain document type always has a set of views accessible.

## Child resolvers

Each pane is represented in the URL by an ID. When the studio is first loaded – and on subsequent navigation – the Structure tool looks at the segmented ID in the URL and tries to resolve each ID into a structure node.

It does this by calling the *child resolver* on the parent node. For example, a common pattern is the document type list leading to a document editor. When an editor clicks on any item within the document type list, it will render a document editor as a child of that list. This is usually represented in the URL by something like `documentType;documentId` - for instance, `book;game-of-thrones` represents the `book` type and a document with an ID of `game-of-thrones`.

The Structure tool will call the child resolver of the root node in the structure with an argument containing the first segment (`book`), which will return a document-type list. When that is returned, it will call the child resolver of the document type list with the next segment in the URL (`game-of-thrones`). This will return the document editor pane for this specific document. 

Child resolvers don't *necessarily* care about the ID of the child. In these cases, it's better to define a static structure node instead of a function *returning* that structure node since this will help the Structure tool make certain assumptions.

## URL state

Most states within the structure are represented in the URL bar. This is why you have to specify an `id` – often implicit when setting a `title` – for lists, document nodes, or components. These identifiers are semi-colon-separated in the URL path. Other states within a document node – such as views – can also be parameterized in the URL bar.

This makes it possible to share the Structure tool's exact state more easily between multiple tabs, windows, or users. It also gives you browser history so that editors can use their browser's history affordances to go between different UI states.

## Next steps

Now that you have a solid foundation of the concepts and definitions that make Structure Builder work, let's look at implementing a [basic override of the default structure in a studio](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view).





# Override default list views

![A list view in the Desk tool that shows a new main list title reading "Base" instead of "Content."](https://cdn.sanity.io/images/3do82whm/next/f60a94d0d8c101e25e7afc0afae7f6e731e33164-2482x1378.png)

In this article, we'll use the Structure Builder API to modify the default list view for a Sanity Studio. If you're unfamiliar with the concepts behind Structure Builder, be sure [to read the introductory article](https://www.sanity.io/docs/studio/structure-builder-introduction).

#### Structure builder series

[1. Introduction and concepts](https://www.sanity.io/docs/studio/structure-builder-introduction)
This is an introduction to important concepts for the Structure Builder API.

[2. Set up Structure Builder in your project](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view)
(You're here)

[3. Create a link to a single edit page in your main document type list](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list)
In this article, we'll explore how to add a link to a single document from the main document type list.

[4. Manually group items in your document list](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane)
In this article, we'll manually group a few singleton "site setting" documents. 

[5. Dynamically group documents](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter)
In this article, we'll use the documentList() method to dynamically group documents with a GROQ filter.

[6. Create a custom document pane](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder)
In this article, we'll look at adding a custom document view to view the JSON data for our posts.

## Setting up Structure Builder for your project

The studio comes with a default structure when it's installed. In order to override the default behavior we'll provide a structure resolving function to the configuration of the `structureTool`-plugin. 

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'

export default defineConfig({
  name: 'default',
  title: 'structure-builder-playground',
  projectId: '<projectId>',
  dataset: 'YOUR_DATASET',
  plugins: [
    structureTool({
      structure: (S) =>
        S.list()
          .title('Base')
          .items([...S.documentTypeListItems().reverse()]),
    }),
  ],
  schema: {
    types: schemaTypes,
  },
})

```

The `structureTool`'s `structure` property accepts a callback function that receives the builder class, conventionally referred to as capital `S`, as as well as a `context` argument that contains details from Studio like the current user, client, and more. See the [Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-reference) for details. In the example above we list out the different types of the project, just like the studio would do by default, except in reversed order.

> [!TIP]
> Protip
> The code can live anywhere in your project. In the previous example we put the structure resolving function directly into the main configuration, but this gets unwieldy fast. A better solution is to externalize structures into their own file or several files.
> For simple structures, having it in the root in one file makes sense. For larger customizations with multiple components, it's considered a best practice to move this code into its own folder.

## Defining a new default structure 

Let's clean up the previous example a bit by moving our structure into a separate file. At the root of your project, create a new file called `structure.ts`.

We'll define our structure function as a named export and import it in `sanity.config.ts`.

**structure.ts**

```typescript
import {type StructureBuilder, type StructureResolver} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([...S.documentTypeListItems().reverse()])
```

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemas'
import {myStructure} from './structure'

export default defineConfig({
  // ...rest of config
  plugins: [
   structureTool({
      structure: myStructure,
    }),
  ],
})

```

Let's break these methods down.

### `S.list()`

The `.list()` method generates a new generic list. Since it's not a child node, it will appear in the first pane of the studio. In most use cases, the `documentTypeList` or `documentList` will be preferred, since they have additional convenience methods. For the first pane of studio, the generic list works the best.

### `.title()`

The `.title()` method exists nested on methods that create various types of panes and accepts a string as its argument. In this case, we'll change the title of our initial panel to be "Base" instead the default "Content."

> [!TIP]
> Protip
> Each panel should have an ID defined. If a title is provided, but no ID, the ID will be generated from the title of the pane.
> This ID will be used to generate routes in the studio.

If we stop here, the studio will now load. It will contain an initial panel with no content. We need to tell our generic list what items should be listed.

### `.items()`

The `.items()` method is used to define the contents of a list panel. The method will take an array of items to populate the list. Typically, it will accept a complementary method from Structure Builder, such as `listItem` or `documentListItem`. In this case, the initial panel of the studio should populate with a list of document types. 

### `S.documentTypeListItems()`

The `documentTypeListItems()` method will find all the document types defined in the `schema` section of your `sanity.config.ts`, and display links to lists of documents that are of that type.

## Next steps

From here, there's a working instance of Structure Builder in the project. In the next tutorial, we'll look at [adding a link to edit a specific document](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list) in the first panel we just rebuilt.



# Create a link to a single edit page in your main document type list

In some cases, to make a strong editing experience, it's important to create a document type that only serves one document instead of a list of documents. In this article, we'll create the often-used "site settings" pattern that implements a schema to control global variables for our front-end site. To create this pattern, we'll use the Structure Builder API to create a singleton document type.

If you're not familiar with the Structure Builder API, be sure to read through the other articles in this series.

#### Structure builder series

[1. Introduction and concepts](https://www.sanity.io/docs/studio/structure-introduction)
The Structure tool is included with Sanity Studio and allows you to customize the experience of creating, browsing, and managing documents. 

[2. Set up Structure Builder in your project](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view)
In this article, we'll explore how to initialize Structure Builder and override the default title of the "Content" list.

[3. Create a link to a single edit page in your main document type list](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list)
(You're here)

[4. Manually group items in your document list](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane)
In this article, we'll manually group a few singleton "site setting" documents. 

[5. Dynamically group documents](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter)
In this article, we'll use the documentList() method to dynamically group documents with a GROQ filter.

[6. Create a custom document pane](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder)
In this article, we'll look at adding a custom document view to view the JSON data for our posts.

## Creating the site settings schema and document

Before adjusting the studio's main document type list, we'll create a document type and a specific document. For this example, we'll keep it simple, but any global variable you need can be stored in a schema like this. Start by creating a new schema named `siteSettings.ts` in the `/schemas` directory.

**schemas/siteSettings.ts**

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'siteSettings',
  title: 'Site Settings',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Site Title',
      type: 'string'
    }),
    defineField({
      name: 'description',
      title: 'Site Description',
      type: 'text'
    })
  ]
})
```

Be sure to import and specify this in your project's `/schemas/index.ts` file. The example shows the schema setup from the default blog template you can pick when setting up a new project with the Sanity CLI, and which we'll be using as our example studio going forth. Importing and adding your `siteSettings` schema should work the same even if your setup looks different.

**schemas/index.ts**

```typescript
import blockContent from './blockContent'
import category from './category'
import post from './post'
import author from './author'
import siteSettings from './siteSettings'

export const schemaTypes = [
  post,
  author,
  category,
  blockContent,
  siteSettings,
]
```

At this point, we have a `siteSettings` document type but no documents. We also have the ability to create multiple site settings documents. This is potentially dangerous and confusing for our editors. 

## Adding the document to the first panel

![A screenshot illustrating a new "Settings" list item that has a child pane of a single document instead of a list of settings documents.](https://cdn.sanity.io/images/3do82whm/next/cd356a83a0bd6dd6bed5651dde2c3e0305674e7c-2482x1378.png)

To add a single document to the first panel, we'll edit the `structure.ts` file that we created in [this article](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view).

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Site Settings')
        .child(
          S.document()
            .schemaType('siteSettings')
            .documentId('siteSettings')),
      ...S.documentTypeListItems(),
    ])
```

[In the last article](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view), we overrode the title of our list but showed all of our Document Types with no modifications in the `.items()` method. Now, we need to modify the array that the `.items()` method uses. 

### `S.listItem()`

Since the items will be displayed in array order, we'll start our array with our new custom item. To make a custom list item, we'll use [the .listItem() method](https://www.sanity.io/docs/studio/structure-builder-reference) on the main Structure Builder object.

The `listItem()` method has multiple nested methods that we'll use to define its properties. We'll define the item's title with the `.title()` method. There's an optional `.id()` method, as well, but by default, the ID can be built from the title.

### `.child()`

The `.child()` method will define what the next pane contains when an editor clicks on this item. In our case, we want it to be a single document with a specific schema type and ID.

### `S.document()`

The `.document()` method allows us to specify which document and schema type will be the focus of the next pane. If there's already a document that you want to use, you can use its `_id` value in the `.documentId()` method. By putting a string in this method, it will create a document with that ID if it doesn't already exist. In our case, we'll use the string `siteSettings` to make things as human-readable as possible.

### Listing out all the document types

We still need to show any other document type items in our list. To do this, instead of simply calling the `S.documentTypeListItems()` method like we did in the last article, we need to put each of that method's array items into our current array. To do this, we use [the JavaScript Spread operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax): `...`.

When we do this, however, we see the flaw in our plan: The Site Settings document type is listed in this list and our manually defined item.

## Removing singleton document types from the main document type list

To remove our site settings document type from our main list, we need to run a JavaScript filter against our document types. Luckily, we're already spreading all those items.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Site Settings')
        .child(
          S.document()
            .schemaType('siteSettings')
            .documentId('siteSettings')),
      ...S.documentTypeListItems().filter(listItem => !['siteSettings'].includes(listItem.getId()))
    ])
```

The `filter()` method takes an anonymous function as its argument and passes each array item as a property of the function. In our filter function, we'll check to see if each `listItem` has an ID that matches our current string using the `getId()` method on the item. To set this up for more excluded document types, we can make this an array.

## Next steps

We now have a working singleton in our main list pane. In the next article, we'll take a look at [manually grouping multiple list items](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane) to create sections that will make an editor's life easier.





# Manually group items in a pane

![A screenshot of the structure builder](https://cdn.sanity.io/images/3do82whm/next/16010730db213f3f7f4200b06ed84e54b7c33886-1439x764.png)
*Breaking our site settings into specific use-case documents for metadata, colors, and navigation.*

We've now learned how to override our studio's default structure and make a list of custom items. Now, let's look at how we can group our single documents in a manually-created group to open a secondary list for our settings.

If you're unfamiliar with setting up the Structure Builder API, be sure to check out the previous articles in this series.

#### Structure builder series

[1. Introduction and concepts](https://www.sanity.io/docs/studio/structure-builder-introduction)
This is an introduction to important concepts for the Structure Builder API.

[2. Set up Structure Builder in your project](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view)
In this article, we'll explore how to initialize Structure Builder and override the default title of the "Content" list.

[3. Create a link to a single edit page in your main document type list](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list)
In this article, we'll explore how to add a link to a single document from the main document type list.

[4. Manually group items in your document list](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane)
(You're here)

[5. Dynamically group documents](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter)
In this article, we'll use the documentList() method to dynamically group documents with a GROQ filter.

[6. Create a custom document pane](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder)
In this article, we'll look at adding a custom document view to view the JSON data for our posts.

## Creating our new singletons

We'll create a list of "Settings Documents" to allow our editors clear, structured navigation through all the different global settings our frontend will require.

Before we change our structure to group our new documents, we need to create two new singletons. Review [this article's steps on creating a singleton](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list) and create a "Colors" and "Main Navigation" document. These can have whatever schema makes sense for your site (or just a title, if you want to get to this article's main topics). These documents should have a type of `colors` and `navigation` and matching IDs.

## Adjusting the site settings child to show a custom list instead of the settings document

Now that we have more than one document governing our site's settings, it would make sense to group these into one pane instead of having three individual items in our first panel.

To do this, we'll change the `.child()` method on our "Settings" list item to reflect a new list instead of a document.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Settings')
        .child(
          S.list()
            // Sets a title for our new list
            .title('Settings Documents')
            // Add items to the array
            // Each will pull one of our new singletons
            .items([
              S.listItem()
                .title('Metadata')
                .child(S.document().schemaType('siteSettings').documentId('siteSettings')),
              S.listItem()
                .title('Site Colors')
                .child(S.document().schemaType('colors').documentId('colors')),
              S.listItem()
                .title('Main Navigation')
                .child(S.document().schemaType('navigation').documentId('navigation')),
            ])
          ),
      // We also need to remove the new singletons from the main list
      ...S.documentTypeListItems().filter(
        (listItem) => !['siteSettings', 'colors', 'navigation'].includes(listItem.getId())
      ),
    ])
```

Each singleton document is now a specific item under the "Settings Documents" list. They each also need to be removed from the main document type list, as well. To do that, add the singletons' IDs to the array used to filter the `S.documentTypeListItems()`.

## Next steps

Now that we have a manually grouped set of settings for our site, let's [add a set of dynamic groups](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter) to filter documents by category or author.



# Dynamically group list items with a GROQ filter

![A screenshot illustrating a "Filtered Posts" list that allows an editor to filter by category or author](https://cdn.sanity.io/images/3do82whm/next/4663eac51572134b8723b4ebae92dc31e8ec853b-1854x1010.png)

It's often useful to group documents automatically by some field's value or a combination of field values. Common examples are grouping documents by author, publishing date periods, editorial status, category, or even the dominant background color in a document’s main image. In this article, we'll create lists of filtered blog posts to allow for quicker discovery and editing.

If you're unfamiliar with setting up the Structure Builder API, be sure to check out the previous articles in this series.

#### Structure builder series

[1. Introduction and concepts](https://www.sanity.io/docs/studio/structure-builder-introduction)
This is an introduction to important concepts for the Structure Builder API.

[2. Set up Structure Builder in your project](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view)
In this article, we'll explore how to initialize Structure Builder and override the default title of the "Content" list.

[3. Create a link to a single edit page in your main document type list](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list)
In this article, we'll explore how to add a link to a single document from the main document type list.

[4. Manually group items in your document list](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane)
In this article, we'll manually group a few singleton "site setting" documents. 

[5. Dynamically group documents](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter)
(You're here)

[6. Create a custom document pane](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder)
In this article, we'll look at adding a custom document view to view the JSON data for our posts.

## Setting up the schema

In this article, we'll need some basic schema for a blog. For the sake of simplicity, we'll use the default schema that comes from creating a new Sanity project from the Sanity CLI.

To get the schema, run `npx sanity init` and create a new project. When prompted, select `yes` to `Use default dataset configuration?` and `Blog` from the `Select project template` options.

This will give you a project structure that contains the schema for `post`, which contains references for `author` and `category` schema. Combining this with the singletons made in the previous articles, we should have a desk structure that looks like this:

![A list of menu items under "Base": Settings, Post, Author, Category, each with a folder icon.](https://cdn.sanity.io/images/3do82whm/next/e1c56e3e9f676cba374476185a66508d564688af-1277x361.png)
*Studio with a desk structure containing Settings, Post, Author, and Category document types*

## Creating a manual group for two filters

Before we create filters, we'll first create a manual group to house our two dynamic lists. For a review, read this article on [creating a manual group with Structure Builder](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane).

Next, we'll create a new `listItem()` for our "Base" list. We'll give it the title "Filtered Posts" and a `.child()` node that will be a static list with the title "Filters."

This list will have two items, our filtered lists "Posts by Category" and "Posts by Author."

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Filtered Posts')
        .child(
          S.list()
            .title('Filters')
            .items([
              S.listItem().title('Posts By Category').child(),
              S.listItem().title('Posts By Author').child(),
            ])
          ),
      // The rest of this document is from the original manual grouping in this series of articles
      ...S.documentTypeListItems().filter(
        (listItem) => !['siteSettings', 'navigation', 'colors'].includes(listItem.getId())
      ),
      S.listItem()
        .title('Settings')
        .child(
          S.list()
            .title('Settings Documents')
            .items([
              S.listItem()
                .title('Metadata')
                .child(S.document().schemaType('siteSettings').documentId('siteSettings')),
              S.listItem()
                .title('Site Colors')
                .child(S.document().schemaType('colors').documentId('colors')),
              S.listItem()
                .title('Main Navigation')
                .child(S.document().schemaType('navigation').documentId('navigation')),
            ])
        ),
    ])
```

This will create a list of two items. Neither of those items will have children yet. To populate them, we'll use dynamic lists using [GROQ queries](https://www.sanity.io/docs/groq-reference).

## Creating dynamic children with an `S.documentList()` and a GROQ filter

To grab blog posts by category, we need to create a child for our `listItem` that will pull a `documentTypeList`. This list will show all categories in the dataset.

This will create a list of items with a document type that matches the string `'category'`. From here, we need to fill in what this item's child will be. In our case, we want to create a list of all the documents that match the category clicked. Replace the `child` after the “Posts By Category” `title` as shown below.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Filtered Posts')
        .child(
          S.list()
            .title('Filters')
            .items([
              S.listItem()
                .title('Posts By Category')
                .child(
                  S.documentTypeList('category')
                  .title('Posts by Category')
                  .child(categoryId => 
                    S.documentList()
                      .title('Posts')
                      .filter('_type == "post" && $categoryId in categories[]._ref')
                      .params({ categoryId })
                    )
                  ),
              S.listItem().title('Posts By Author').child(),
            ])
        ),
     // .. rest of structure
    ])
```

The `.child()` method can accept an anonymous "arrow function", which will have the `_id` of the current item passed into it. From there, we need to define what type of child we're creating. 

### `S.documentList()`

The `.documentList()` method will pull a list of documents given a filter. It accepts most of the same chained methods as the `.list()` method but has a few special methods. 

### `.filter()`

The `.filter()` method is not the normal JavaScript filter method. In this case, it's a function that will accept a GROQ query as a string and return an array of documents that match that query. We can optionally chain a `.parameter()` method to pass a parameter into our query. In this case, the `categoryId` from our current function scope.

The GROQ query here will match all documents with a `_type` of `post`, containing the `$categoryId` as a reference in its `categories` array.

At this point, we have the post documents that match our query pulling into the next pane.

![A multi-column content management interface with "Filtered Posts", "Posts By Category", and "A category" selected, displaying a post titled "Testing a category".](https://cdn.sanity.io/images/3do82whm/next/3a4cf4a9c2cf455f39f1c4ed14922b65d7a3dfd3-1551x492.png)
*A list of documents matching our category filter in the final desk pane.*

### Adding "Posts by author" child node

Now, let's do the same process to pull posts by author reference into the "Post by Author" node.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Filtered Posts')
        .child(
          S.list()
            .title('Filters')
            .items([
              S.listItem()
                .title('Posts By Category')
                .child(
                  S.documentTypeList('category')
                  .title('Posts by Category')
                  .child(categoryId => 
                    S.documentList()
                      .title('Posts')
                      .filter('_type == "post" && $categoryId in categories[]._ref')
                      .params({ categoryId })
                    )
                  ),
              S.listItem()
                .title('Posts By Author')
                .child(
                  S.documentTypeList('author')
                    .title('Posts by Author')
                    .child(authorId =>
                      S.documentList()
                      .title('Posts')
                      .filter('_type == "post" && $authorId == author._ref')
                      .params({ authorId })
                    )
                ),
            ])
        ),
     // .. rest of structure
    ])
```

![Screenshot of a user interface showing a content filtering workflow, with 'Filtered Posts', 'Posts By Author', and 'Bryan Robinson' selected, displaying one post titled 'Testing a category by Bryan Robinson'.](https://cdn.sanity.io/images/3do82whm/next/d7b136d69db1d0f49a963a2fcddb84bcdc3a2ceb-1551x483.png)
*A group of documents with Bryan Robinson as the author, grouped by our new filter.*

## Renaming the "Post" document type list item

Now that we have a "Filtered Posts" group, let's rename our "Post" document type list. To do this, we'll create a new manual list item in the "Base" list group. For a more in-depth explanation, see [this article on creating singleton documents](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list). In this example, we'll create a new `listItem()` for the post document type, give it a new title, and create a child panel with a document list filtering all posts. From there, we'll add the `'post'` ID to our exclusion filter for all other document types in this list.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Filtered Posts')
        .child(/* Dynamic lists created earlier in here */ ),
      S.listItem()
        .title('All Posts')
        .child(
          // Create a list of all posts
          S.documentList()
            .title('All Posts')
            .filter('_type == "post"')
        ),
      ...S.documentTypeListItems().filter(
        (listItem) => !['post','siteSettings', 'navigation', 'colors'].includes(listItem.getId())
      ),
      S.listItem()
        .title('Settings')
        .child(
          S.list()
            .title('Settings Documents')
            .items([
              S.listItem()
                .title('Metadata')
                .child(S.document().schemaType('siteSettings').documentId('siteSettings')),
              S.listItem()
                .title('Site Colors')
                .child(S.document().schemaType('colors').documentId('colors')),
              S.listItem()
                .title('Main Navigation')
                .child(S.document().schemaType('navigation').documentId('navigation')),
            ])
        ),
    ])
```

This is beginning to look finished. We can help increase an editor's understanding of the grouping by adding dividers between the various sections.

## Create visual sections in the base list with `.divider()`

![A UI showing a "Base" menu with "Filtered Posts" selected and "Filter Posts by" options for category and author.](https://cdn.sanity.io/images/3do82whm/next/9fbffa62ad72a2aed01541f7abed0c59610eab66-896x408.png)
*Static dividers help create a flow for editors to know what document types go together.*

To group things together, we'll use the `S.divider()` method in our `.items()` array. We want to group "All Posts" and "Filtered Posts" together, then allow the rest of our document types to flow in the middle, then our "Settings." To do this, we'll insert the divider method in the order we want it to appear.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Filtered Posts')
        .child(/* Dynamic lists created earlier in here */ ),
      S.listItem()
        .title('All Posts')
        .child(
          // Create a list of all posts
          S.documentList()
            .title('All Posts')
            .filter('_type == "post"')
        ),
      S.divider(),
      ...S.documentTypeListItems().filter(
        (listItem) => !['post','siteSettings', 'navigation', 'colors'].includes(listItem.getId())
      ),
      S.divider(),
      S.listItem()
        .title('Settings')
        .child(
          S.list()
            .title('Settings Documents')
            .items([
              S.listItem()
                .title('Metadata')
                .child(S.document().schemaType('siteSettings').documentId('siteSettings')),
              S.listItem()
                .title('Site Colors')
                .child(S.document().schemaType('colors').documentId('colors')),
              S.listItem()
                .title('Main Navigation')
                .child(S.document().schemaType('navigation').documentId('navigation')),
            ])
        ),
    ])
```

## Final code

Putting together all the examples from this series of articles we get the following desk structure.

**structure.ts**

```typescript
import {type StructureResolver, type StructureBuilder} from 'sanity/structure'

export const myStructure: StructureResolver = (S: StructureBuilder) =>
  S.list()
    .title('Base')
    .items([
      S.listItem()
        .title('Filtered Posts')
        .child(
          S.list()
            .title('Filters')
            .items([
              S.listItem()
                .title('Posts By Category')
                .child(
                  S.documentTypeList('category')
                  .title('Posts by Category')
                  .child(categoryId => 
                    S.documentList()
                      .title('Posts')
                      .filter('_type == "post" && $categoryId in categories[]._ref')
                      .params({ categoryId })
                    )
                  ),
              S.listItem()
                .title('Posts By Author')
                .child(
                  S.documentTypeList('author')
                    .title('Posts by Author')
                    .child(authorId =>
                      S.documentList()
                      .title('Posts')
                      .filter('_type == "post" && $authorId == author._ref')
                      .params({ authorId })
                    )
                ),
            ])
      ),
      S.listItem()
        .title('All Posts')
        .child(
          // Create a list of all posts
          S.documentList()
            .title('All Posts')
            .filter('_type == "post"')
        ),
      S.divider(),
      ...S.documentTypeListItems().filter(
        (listItem) => !['post','siteSettings', 'navigation', 'colors'].includes(listItem.getId())
      ),
      S.divider(),
      S.listItem()
        .title('Settings')
        .child(
          S.list()
            .title('Settings Documents')
            .items([
              S.listItem()
                .title('Metadata')
                .child(S.document().schemaType('siteSettings').documentId('siteSettings')),
              S.listItem()
                .title('Site Colors')
                .child(S.document().schemaType('colors').documentId('colors')),
              S.listItem()
                .title('Main Navigation')
                .child(S.document().schemaType('navigation').documentId('navigation')),
            ])
        ),
    ])
```

## Next steps

Now that we've created singletons, static lists, and dynamic lists, we need to look at [creating tabs and custom previews for our document views](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder).







# Create custom document views with Structure Builder

![A screenshot illustrating a standard document pane and a custom document pane side by side](https://cdn.sanity.io/images/3do82whm/next/3256c9d6b38c4ade83e2524891c280eb5d2694f8-2482x1378.png)

The Structure Builder API gives you control over how a document node is presented within a collapsable pane. Specifically, it allows you to set up one or more views that either return the default form or a custom React component. Each view receives a collection of props that include the document's values in different states: `draft`, `published`, `historical`, and the currently `displayed` version (for when you have selected a previous revision to a document).

This article will use the Structure Builder API to display the JSON data for a specific document. If you're unfamiliar with setting up a custom structure, [read this article on setting up the basics](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view).

#### Structure builder series

[1. Introduction and concepts](https://www.sanity.io/docs/studio/structure-builder-introduction)
This is an introduction to important concepts for the Structure Builder API.

[2. Set up Structure Builder in your project](https://www.sanity.io/docs/studio/set-up-structure-builder-to-override-the-default-list-view)
In this article, we'll explore how to initialize Structure Builder and override the default title of the "Content" list.

[3. Create a link to a single edit page in your main document type list](https://www.sanity.io/docs/studio/create-a-link-to-a-single-edit-page-in-your-main-document-type-list)
In this article, we'll explore how to add a link to a single document from the main document type list.

[4. Manually group items in your document list](https://www.sanity.io/docs/studio/manually-group-items-in-a-pane)
In this article, we'll manually group a few singleton "site setting" documents. 

[5. Dynamically group documents](https://www.sanity.io/docs/studio/dynamically-group-list-items-with-a-groq-filter)
In this article, we'll use the documentList() method to dynamically group documents with a GROQ filter.

[6. Create a custom document pane](https://www.sanity.io/docs/studio/create-custom-document-views-with-structure-builder)
(You're here)

## Set up `structure.ts` to create a new default document node structure

If you've been following the earlier articles in this series, we've set our `structure.ts` file to export a named function that contains our new structure. Alongside this, we'll now export another named function. 

Update `structure.ts` with the following code:

**structure.ts**

```typescript
import type {DefaultDocumentNodeResolver, StructureBuilder, DefaultDocumentNodeContext, StructureResolver} from 'sanity/structure'

export const getDefaultDocumentNode: DefaultDocumentNodeResolver = (S: StructureBuilder, options: DefaultDocumentNodeContext) => {
  return S.document().views([
    S.view.form()
  ])
}
// ...rest of structure from previous steps
```

Then, in `sanity.config.ts`, import this function and add it to the `structureTool` configuration object under the key `defaultDocumentNode`.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {structure, getDefaultDocumentNode} from './structure'
import {schemaTypes} from './schemas'

export default defineConfig({
  name: 'default',
  projectId: '<projectId>',
  dataset: 'YOUR_DATASET',
  plugins: [
    structureTool({
      structure: deskStructure,
      defaultDocumentNode: getDefaultDocumentNode,
    }),
  ],
  schema: {
    types: schemaTypes,
  },
})

```

In our `getDefaultDocumentNode` function, we return an array of views for all the documents. To start us off, we're only returning the default form view. Let's look at the structure builder methods in more detail.

### `S.document()`

The `.document()` method creates the way the Structure tool displays documents. In this example, it changes how all documents are rendered.

### `.views()`

The `.views()` method accepts an array of view elements which can be created using either `S.view.form()` or `S.view.component()`. The view elements define the items that show up in the document’s tab list.

## Adding a second view to all documents

To add a second view, we'll add a second item to the array inside the `.views()` method. For this, we'll use the `.view.component()` method to use a custom component.

**structure.ts**

```typescript
import type {DefaultDocumentNodeResolver, StructureBuilder, DefaultDocumentNodeContext, StructureResolver} from 'sanity/structure'
import {JsonPreview} from './components'

export const getDefaultDocumentNode: DefaultDocumentNodeResolver = (S: StructureBuilder, options: DefaultDocumentNodeContext) => {
  return S.document().views([
    S.view.form()
    S.view.component(JsonPreview).title('JSON')
  ])
}
// ...rest of structure from previous steps
```

**components.tsx**

```tsx
import {type UserViewComponent} from 'sanity/structure'

export const JsonPreview: UserViewComponent = (props) => (
  <>
    <h1>JSON Preview</h1>
  </>
)

```

### `.view.component()`

The `.view.component` method takes a custom React component as an argument.  The component can be chained with other methods such as `.title()` to provide a title for the new view.

### .defaultPanes()

Allows configuring documents to open with multiple views displayed as split panes by default.

**structure.ts**

```typescript


// In defaultDocumentNode resolver
export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, {schemaType}) => {
  if (schemaType === 'article') {
    return S.document()
      .views([
        S.view.form().id('editor'),
        S.view.component(LivePreview).id('preview').title('Preview'),
        S.view.component(JSONView).id('json').title('JSON')
      ])
      .defaultPanes(['editor', 'preview'])  // Form + Preview side-by-side
  }
  return S.document()
}
```

**components.tsx**

```tsx
import {type UserViewComponent} from 'sanity/structure'

export const JsonPreview: UserViewComponent = (props) => (
  <>
    <h1>JSON Preview</h1>
  </>
)

export const LivePreview: UserviewComponent = (props) => (
  <>
    <h1>JSON Preview</h1>
  </>
)
```

### Custom component: `JsonPreview()`

Our custom React component is called `JsonPreview`. Custom components have the following props:

- `document` – an object containing the various document states and their data
- `documentId` – the ID of the current document
- `schemaType` – the schema type of the current document 

In this example, we'll only need the `document` object, but to start, let's render an `h1` with the string `JSON Data`. We now have two tabs across the top of our documents.

![UI displaying "All Posts" with "Testing a category" selected, showing "JSON Data".](https://cdn.sanity.io/images/3do82whm/next/69073d66cac0d519615e300f73689e6691aedda4-1707x443.png)
*A blog post showing the H1 "JSON Data"*

## Displaying dynamic data from the document

![UI displaying JSON data for a post titled "Testing a category".](https://cdn.sanity.io/images/3do82whm/next/00496aeed472cf5f06050835871914d830c1dd3c-1706x650.png)
*JSON displaying for the current document.*

To pull data into our component, we'll need to select which version of the document we want to use. Luckily, the `document` prop contains the various states of the current document. For our uses, we want to show the JSON data for the currently selected version of the document, so we'll choose the `displayed` data.

**components.tsx**

```tsx
import {type UserViewComponent} from 'sanity/structure'

export const JsonPreview: UserViewComponent = (props) => (
  <>
    <h1>JSON Preview</h1>
    <pre>{JSON.stringify(props.document.displayed, null, 2)}</pre>
  </>
)
```

## Define views for specific schemas or documents

Sometimes you only want certain tabs to display for certain document types – or even individual documents. For this, the `getDefaultDocumentNode()` method comes with two options passed in: `schemaType` and `documentId`. We can use these with a JavaScript conditional to only build our JSON preview for certain documents.

**structure.ts**

```typescript
import type {DefaultDocumentNodeResolver, StructureBuilder, DefaultDocumentNodeContext, StructureResolver} from 'sanity/structure'
import {JsonPreview} from './components'

export const getDefaultDocumentNode: DefaultDocumentNodeResolver = (S: StructureBuilder, options: DefaultDocumentNodeContext) => {
  if (options.schemaType === "post" || options.documentId === "siteSettings") {
    return S.document().views([
      S.view.form()
      S.view.component(JsonPreview).title('JSON')
    ])
  }
}
// ...rest of structure from previous steps
```

**components.tsx**

```tsx
import {type UserViewComponent} from 'sanity/structure'

export const JsonPreview: UserViewComponent = (props) => (
  <>
    <h1>JSON Preview</h1>
  </>
)
```

The default document node resolver will resolve the `S.view.form()` for any document types that haven’t been explicitly overridden.

## Next steps

With all the data available to you in each of your documents, you can put together powerful previews, contextual images, or even custom editor flows for each document or document type.

From here, take a look at the [full reference documentation](https://www.sanity.io/docs/studio/structure-builder-reference) for everything you can do with the Structure Builder API, and build something useful to you or your editors. 





# Handle intents

Intents are Studio's internal routing mechanism. When a user clicks a search result, follows an **Open in Studio** link from Visual Editing, or uses a **Create new** button, Studio fires an intent (like `edit` or `create`) with parameters such as the document ID and type. The Structure Tool resolves that intent by finding the right pane in your structure.

This guide explains how Studio decides which pane handles an intent, and shows how to declare intent handling on lists that don't match on their own.

## Prerequisites

- A studio with the Structure Tool installed. New projects include it; for existing projects, [install it by updating your project's configuration file](https://www.sanity.io/docs/studio/structure-tool).
- Familiarity with panes, lists, and child resolvers, as covered in [Structure tool and Structure builder](https://www.sanity.io/docs/studio/structure-introduction).

## How Studio matches an intent to a pane

Studio matches an intent to a pane in two independent ways. Either one is enough.

First, a pane can declare what it handles with `canHandleIntent`. Lists built with `S.documentTypeList()` and `S.documentTypeListItem()` get a default implementation that reads the schema types named in the list's filter and matches when the intent's `type` parameter is one of them. Any filter that names a type counts, such as `_type == "post" && defined(publishedAt)` — not only the default filter.

Second, the intent resolver checks the pane directly, whatever its `canHandleIntent` says. A `documentList` matches when its schema type equals the intent's `type` parameter and its filter is still exactly `_type == $type`.

Panes that match neither way need `canHandleIntent` to declare which intents they handle: a custom `S.list()`, or a `documentList` whose filter doesn't name a schema type.

Adding a custom `.child()` resolver to a `documentTypeList` clears the default `canHandleIntent`, because Studio can't guarantee the new child handles the intent. The list keeps matching through the second route anyway, since `.child()` changes neither the schema type nor the filter. Change the filter as well and both routes drop out, so you have to declare `canHandleIntent` yourself.

What `.child()` does change is what opens. The intent routes to a pane for the target document ID, and your resolver decides what that pane shows. If it returns a list instead of a document node, the document editor doesn't open, and `canHandleIntent` won't change that.

## Common symptoms of missing intent handling

If a list in your structure matches neither way, and you notice any of these, missing `canHandleIntent` could be the cause:

- **Documents open in the wrong pane.** The document opens as a bare editor instead of navigating to the correct location in your structure.
- **Open in Studio links from Visual Editing don't route correctly.**
- **Search results land in the wrong place.** Global search can bypass your custom structure entirely.
- **Create new buttons may not work as expected.** Custom structures that replace `documentTypeList` can lose the built-in create intent handling. A `documentTypeList` that keeps its default filter and adds a `.child()` resolver has a different cause: the intent routes correctly, but the child resolver decides what opens.

When no pane in your structure matches, Studio opens the document in a fallback editor outside your structure. You can recognize it by the pane ID in the URL, which starts with `__edit__`.

## Add intent handling

Add `canHandleIntent` to any list that matches neither way. The function receives the intent name and parameters, and returns `true` if the pane should handle the intent:

**structure.ts**

```typescript
// structure.ts
import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S) =>
  S.list()
    .title('Content')
    .items([
      // Built-in documentTypeList: intent handling works automatically
      S.documentTypeListItem('author').title('Authors'),

      // Custom child resolver: needs canHandleIntent
      S.listItem()
        .title('Blog Posts')
        .schemaType('post')
        .child(
          S.documentTypeList('post')
            .title('Blog Posts')
            .child((documentId) =>
              S.document()
                .documentId(documentId)
                .schemaType('post')
            )
            .canHandleIntent((intentName, params) =>
              ['create', 'edit'].includes(intentName) && params.type === 'post'
            )
        ),
    ])
```

> [!WARNING]
> Custom lists need intent handling
> A custom `S.list()`, or a `documentList` whose filter doesn't name a schema type, matches no intents by default. Without `canHandleIntent`, search results, Visual Editing links, and **Create new** buttons open the fallback editor instead of your structure.

For the full `canHandleIntent` signature and parameters, see the [Structure Builder API Reference](https://www.sanity.io/docs/studio/structure-builder-reference).

## Next steps

- [Create Studio edit intent links](https://www.sanity.io/docs/visual-editing/studio-edit-intent-links): build the URLs that fire an `edit` intent, including the ones the Vision Tool renders next to `_id` and `_ref` values.
- [Get started with Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-introduction): build the lists, panes, and child resolvers that intent handling applies to.
- [Link to documents and tools from a custom component](https://www.sanity.io/docs/studio/link-from-custom-components): fire `edit` and `create` intents from your own React components.



# Cheat sheet

> [!TIP]
> Pro tip
> Structure Builder can do so much more than the examples on this page show.
> Get a deeper understanding of Structure Builder by reading the [introduction guide](https://www.sanity.io/docs/studio/structure-builder-introduction) and [API Reference documentation](https://www.sanity.io/docs/studio/structure-builder-reference) to configure initial value templates and more.

In order to use these code examples, you will need to configure the `structureTool` plugin in your `sanity.config.ts` file like below:

```typescript
// ./sanity.config.ts

import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'

import {structure} from './structure'

export default defineConfig({
  // ...all other settings
  plugins: [
    structureTool({ structure }),
    // ...all other plugins
  ],
})
```

## All document schema types

Your imported `structure` configuration should have the following set up at a minimum: a list, with a title, and an array passed into `items()`.

The following examples you will paste into this root-level `items()` method.

```typescript
// ./structure/index.ts

import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S) =>
  S.list().title('Base').items(
    S.documentTypeListItems() // <= example code goes here
  )
```

*documentTypeListItems() renders a document list for every document schema type in your workspace*

### Filtered list of all document schema types

The `documentTypeListItems()` method from above will render a list for every document schema type that is registered in the Studio config. Used together with some clever filtering, this method alone will take you a long way in setting up your document type list to your preference.

In the example below, the `siteSettings` document schema type is filtered out, but all other document types would be listed. Then we insert a divider, and finally the `siteSettings` schema document list is manually inserted.

```typescript
// ./structure/index.ts

import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S) =>
  S.list()
    .title('Base')
    .items([
      // list all document types except 'siteSettings'
      ...S.documentTypeListItems().filter(
        (item) => item.getId() !== 'siteSettings',
      ),
      S.divider(),
      // then add the 'siteSettings' type separately
      S.documentTypeListItem('siteSettings').title(
        'Site settings',
      ),
    ])

```

*In this example "Site settings" is filtered out of the default list, and manually placed below a dividing line*

## All documents of a specific type

`documentTypeListItem()` is a “batteries included” method for showing a list of documents of a given type. Works great for showing complete lists of documents with a custom title. A common usage would be wanting to pluralize the type name in the title.

```typescript
S.documentTypeListItem('lesson').title('Lessons')
```

This would be inserted into the `items()` method like this:

```typescript
// ./structure/index.ts

import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S) =>
  S.list()
    .title('Base')
    .items([
      S.documentTypeListItem('lesson').title('Lessons')
    ])
```

*documentTypeListItem returns an unfiltered list of all documents of this type*

### A note on more complex examples

As these examples grow more complicated, you may wish to extract them into “helper functions” so they can be more easily reused.

Also, going forward, assume the example code is to be inserted into the array passed into the root-level `items()`, as we will exclude the boilerplate code for brevity.

```typescript
// ./structure/index.ts

import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S) =>
  S.list()
    .title('Base')
    .items([
      // ⬇ From now on, we will just show this bit
      S.documentTypeListItem('lesson').title('Lessons')
      // ⬆ Replace this with the example code
    ])
```

## Filtered lists of documents

To show documents of a single type, with an additional GROQ filter applied, you will first need to create a `listItem` which has a `documentList` as its `child`. The document list must have an API version if it contains a filter.

These lists are “static” because the values being passed into the filter are known ahead of time.

```typescript
S.listItem()
  .title(`English lessons`)
  .child(
    S.documentList()
      .apiVersion('2024-06-01')
      .title(`English lessons`)
      .schemaType('lesson')
      .filter('_type == "lesson" && language == "en"'),
  )
```

*This filtered list only shows a subset of documents that satisfy the filter*

You may choose to map over an array of items and use a params method to modify the results of each filtered list.

```typescript
const languages = [
  {id: 'en', title: 'English'},
  {id: 'es', title: 'Spanish'},
]

...languages.map((language) =>
  S.listItem()
    .title(`${language.title} lessons`)
    .child(
      S.documentList()
        .apiVersion('2024-06-01')
        .title(`${language.title} lessons`)
        .schemaType('lesson')
        .filter('_type == "lesson" && language == $language')
        .params({language: language.id}),
    ),
)
```

*Multiple, unique filtered lists have been created by mapping over an array*

### Dynamic filtered lists of documents

Say you have a document type `post` which has an array of references to the document type `category`.

In the example below are unfiltered document lists to show all documents of those types, and then a top-level list of all category documents, but instead of rendering those documents as a child element, the ID of each document is used to create a filtered list of every post type document that has a reference to that category.

```typescript
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('category').title('Categories'),
S.listItem()
  .title('Posts By Category')
  .child(
    S.documentTypeList('category')
      .title('Posts by Category')
      .child((categoryId) =>
        S.documentList()
          .apiVersion('2024-06-01')
          .title('Posts')
          .filter('_type == "post" && $categoryId in categories[]._ref')
          .params({categoryId}),
      ),
  )
```

*Here a list of category documents is used to create filtered lists of post documents that have that category*

You may also want to check out this [guide on parent child relationships](https://www.sanity.io/docs/developer-guides/parent-child-taxonomy) for a more complex setup which includes initial value templates so that new documents created within these lists have filtered values preset.

## Grouped and nested document lists

Some document types may not need to be accessed as often and so to reduce visual noise may be better grouped together into a single menu item.

```typescript
S.listItem()
  .title('Website')
  .child(
    S.list()
      .title('Website')
      .items([
        S.documentTypeListItem('siteSettings').title('Site Settings'),
        S.documentTypeListItem('redirects').title('Redirects'),
        S.documentTypeListItem('labels').title('Labels'),
      ]),
  )
```

*These three document lists have been placed within a parent list item*

## Singleton documents

The Structure Builder is how you create “singleton” documents with a predetermined ID in Sanity Studio. To create an item with the correct icon and the narrower height which list items have (compared to the taller height of a document item), the code example below wraps the `editor()` method in a list item of its own. It should inherit the correct icon of the document schema type, and when clicked create or edit a document with the provided ID.

```typescript
S.listItem()
  .id('siteSettings')
  .schemaType('siteSettings')
  .title('Site Settings')
  .child(
    S.editor()
      .id('siteSettings')
      .schemaType('siteSettings')
      .documentId('siteSettings')
  )
```

*This list item does not render a document list, it shows a single document that contains the ID determined in the structure configuration*

## Custom structure by user role

The structure configuration contains a second parameter (context) which contains all sorts of valuable information about the current state of the Studio, including the logged-in user and their roles.

In this example, a different set of items is displayed to an Administrator than a user of any other roles.

```typescript
import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S, context) =>
  S.list()
    .title('Base')
    .items(
      context.currentUser?.roles.find((role) => role.name === 'administrator')
        ? [
            S.documentTypeListItem('post').title('Posts'),
            S.documentTypeListItem('category').title('Categories'),
            S.divider(),
            S.documentTypeListItem('siteSettings').title('Site Settings'),
          ]
        : [
            S.documentTypeListItem('post').title('Posts'),
            S.documentTypeListItem('category').title('Categories'),
          ],
    )
```

*If the logged-in user was not an Administrator, they would not be shown the divider or "Site Settings" list*



# Structure tool

The Structure tool is included with Sanity Studio and allows you to customize the experience of creating, browsing, and managing documents.

![Default Structure tool layout](https://cdn.sanity.io/images/3do82whm/next/25f3e527146f39ec5abdd8549a51d42cd3b6aeb8-3798x2250.png)

## Install

New projects come pre-configured with the Structure tool. For existing projects, or if it isn’t part of your Studio configuration, you can install it by updating your project’s configuration file.

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'

export default defineConfig({
  // ...
  plugins: [structureTool()],
})

```

> [!NOTE]
> Is Structure a tool or a plugin?
> Wondering why you’re adding a tool to the `plugins` array? Plugins are containers for shared tools, components, and other Studio configuration settings.

You can configure the Structure tool beyond the default settings by passing a configuration object to `structureTool`. The [Structure tool API](https://www.sanity.io/docs/studio/structure-tool-api) reference describes the list of available configuration options.

> [!WARNING]
> Gotcha
> The Structure tool’s document list has a limited view of 2,000 documents. If you find yourself running into this limitation, consider customizing your Structure configuration with [Structure Builder](https://www.sanity.io/docs/studio/structure-builder-introduction) to lay out documents in a more categorized way.

## Customize

The Structure tool includes Structure Builder, an API that allows you to customize the way lists, documents, views, and menus are organized within Studio.

![Customized Structure tool screenshot](https://cdn.sanity.io/images/3do82whm/next/801e3897cceea68de13a93cb8cbed2fc5cea982c-2288x1388.png)

Start customizing your Studio with the [Introduction to Structure Builder](https://www.sanity.io/docs/studio/structure-builder-introduction) series.

## Additional resources

[Structure Tool API](https://www.sanity.io/docs/studio/structure-tool-api)
Explore the Structure tool's API surface.

[Structure Builder cheat sheet](https://www.sanity.io/docs/studio/structure-builder-cheat-sheet)
Explore solutions to common Structure Builder use cases.

[Structure Builder API reference](https://www.sanity.io/docs/studio/structure-builder-reference)
Explore the Structure Builder's API surface.

[Studio Tools](https://www.sanity.io/docs/studio/studio-tools)
Explore other Studio tools.



# Reference

This is the complete reference documentation for Structure Builder. This API lets you configure how the Sanity Studio's Structure tool organizes lists, documents, views, menus, and [initial value templates](https://www.sanity.io/docs/studio/initial-value-templates). 

The Structure Builder API is designed as a collection of methods that can be chained and passed in as arguments/parameters. To learn about the central concepts of Structure Builder, go to the [introduction](https://www.sanity.io/docs/studio/structure-builder-introduction) article and dive deeper into the API in [the TypeScript reference documentation](https://www.sanity.io/docs/reference/api/sanity/structure/structureTool).

> [!TIP]
> Protip
> Looking for quick examples of common use cases? See the [Structure Builder cheat sheet](https://www.sanity.io/docs/studio/structure-builder-cheat-sheet).

## Configuring the structure

You can build custom structures by passing the structure to the [structureTool](https://reference.sanity.io/sanity/structure/structureTool/) configuration. To do this, define the structure resolver function ([StructureResolver](https://reference.sanity.io/sanity/structure/StructureResolver/)), which receives the [StructureBuilder](https://reference.sanity.io/sanity/structure/StructureBuilder/) instance, by convention referred to as `S`, as its first argument, and a [StructureResolverContext](https://reference.sanity.io/sanity/structure/StructureResolverContext/) object as its second.

```javascript
// sanity.config.js

import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schema'

export default defineConfig({
  name: 'default',
  title: 'My Cool Project',
  projectId: 'my-project-id',
  dataset: 'production',
  plugins: [
    structureTool({
      structure: (S, context) => {
        console.log(context) // returns { currentUser, dataset, projectId, schema, getClient, documentStore }
        return S.documentTypeList('post')
      },
    })
  ],
  schema: schemaTypes
})



```

Where you define your structure is up to you - you could define it inline (as in the above example), or you could place it in a separate file and import/use it in your structure tool config:

```javascript
// src/structure.js

export const structure = (S) => S.documentTypeList('post')

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { structure } from './src/deskStructure'

export default defineConfig({
  // ...
  plugins: [
    structureTool({
      structure
    })
  ]
})
```

In the Structure Builder API, you'll find “convenience methods.” We recommend using these unless you want more fine-grained control.

*Overview annotating what the different parts are*

## Context properties

#### Properties

**dataset** (string)

Name of the current dataset

**projectId** (string)

Unique ID for the project

**schema** (object | Schema)

The schema registry of your project. Use `schema.get("schemaTypeName") to retrieve any schema by name.

**currentUser** (object | CurrentUser )

An object with info about the currently logged in user.

**getClient** (function | SanityClient)

Callback function that returns a configured client

**documentStore** (object)

**perspectiveStack** (array)

The stacked array of perspective ids ordered chronologically to represent the state of documents at the given point in time. Can be used as the perspective param in the client to get the correct view of the documents.  

Example values: ["published"] | ["drafts"] | ["releaseId2", "releaseId1", "drafts"]

**i18n** (object)

Contains information about the current and avaliable locale configuration.

## Lists

These methods define how lists and list items appear in the collapsible panes within the Studio’s structure tool. There are methods you can consider as “primitives” and methods that take a document schema type and automatically configure menus, initial value templates, and similar from the schema configuration.

> [!TIP]
> Protip
> You should generally opt for `documentTypeList` and `documentList` when you can since these give you good defaults and sets up a lot of things automatically for you.

list(): List

*A list goes into a collapsible pane. It also has a title.*

A primitive for defining the list content of the collapsible pane, including its title and representation in the URL bar. Typically used when you want to group different list items within a pane. See the [ListBuilder](https://reference.sanity.io/sanity/structure/ListBuilder/) reference for the full type definition.

> [!TIP]
> Protip
> Are you getting the error `Structure node id cannot contain character ...`? This is typically caused when the `title` contains a character that is not in the domain of (or cannot be automatically converted to the domain of) web-safe characters.
> The solution is to explicitly specify an `id` that includes only web-safe characters.

### Methods

**id(id): List**

Set the id for the list.

Parameters:
- **id** (string): Identifier for the list used to reflect the current structure state in the studio’s URL. Derived automatically from .title() if not explicitly defined.

**title(title): List**

Set the title for the list.

Parameters:
- **title** (string): The title to use for the list

**items(items): List**

Set the list items to display.

You would typically use a method that returns an array of list items, such as documentTypeListItems, or list item methods inserted into their own array, such as listItem and documentListItem.

Parameters:
- **items** (array): An array of list items.

**showIcons(showIcons): List**

Set whether or not to show the icons of the list items.

Parameters:
- **showIcons** (boolean): Hides the list items icons if set to false.

**initialValueTemplates(templateItems): List**

Sets which initial value templates should be available for this pane (which items appear when using "new document" on the pane).

Use S.initialValueTemplateItem(templateId, parameters) to get a reference to a template. By passing an object of parameters, you can contextualize the template for the specific pane. See the initial value template documentation for more information.

Parameters:
- **templateItems** (InitialValueTemplateItem[]): Array of initial value template items.

**menuItemsGroups(groups): List**

Defines which groups of menu items should be available for the pane. This also defines the order of the groups.

You can either build these groups by using the builder method:
[S.menuItemGroup().id('some-id').title('Some title')]

or by passing an array of objects containing id and title properties:
[{id: 'some-id', title: 'Some title'}]

Parameters:
- **groups** (MenuItemGroup[]): Array of menu item groups to use.

**menuItems(menuItems): List**

Sets the list of menu items to appear in the pane menu.

Parameters:
- **menuItems** (MenuItem[]): Array of menu items to use. Use the menu item builder method S.menuItem() to build these.

**defaultLayout(layout): List**

Sets the default layout for this list. Currently the only supported layouts are default and detail.

Parameters:
- **layout** (string): Either default or detail.

**child(child): List**

Sets which structure node to use as the child of this list, when an item in the list is selected.

Can either be a structure node (any list, document, component etc) or a child resolver - a function that either syncronously or asyncronously resolves to a structure node.

Read more about child resolvers in the conceptual guide for the structure builder.

Parameters:
- **child** (node | function)

**canHandleIntent(intentChecker): List**

Sets the method used to determined whether or not the pane can handle an intent of a certain type.

The intent checker receives three arguments: intentName, params and context. It should return a boolean indicating whether or not it can handle the intent.

intentName is generally create or edit.

params usually contains id and type, representing the document ID and schema type to be created. Often it will also have template, a string representing the ID of an initial value template.

context is an object containing pane and index

Parameters:
- **intentChecker** (function): Intent checker function

**getCanHandleIntent(): function**

Returns the configured intent checker

**getChild(): node | function**

Returns the configured child or child resolver for this pane

**getDefaultLayout(): string**

Returns the defined default layout for this pane (if any)

**getId(): string**

Returns the configured ID for this pane, if any

**getInitialValueTemplates(): InitialValueTemplateItem[]**

Returns the list of configured initial value templates, if any

**getItems(): node[]**

Returns the list of configured list items, if any

**getMenuItemGroups(): MenuItemGroup[]**

Returns an array of the configured menu item groups, if any

**getMenuItems(): MenuItem[]**

Returns an array of the configured menu items, if any

**getShowIcons(): boolean**

Returns whether or not the pane is configured to show icons for the list items

**getTitle(): string**

Returns the configured title for the pane, if any

documentTypeList(schemaType): DocumentList

Convenience method for `documentList()`. Returns a list node for the specified document type with its configuration for list items, menus, initial value templates, and views. 

#### Arguments

#### Properties

**schemaType** (string, required)

The schema type name of an existing document type that's defined in the Studio’s schema file.

#### Methods

[See the methods summary for documentList](https://www.sanity.io#methods-38c3f64ec08f)

#### Example

```javascript
// src/structure.js (.ts)

// Exports a list of documents with the schema type “post”
export const structure = (S) => S.documentTypeList('post')


```

documentList(): DocumentList

A variant of the list type is made specifically for displaying a list of documents ([DocumentListBuilder](https://reference.sanity.io/sanity/structure/DocumentListBuilder/)). It lets you define which documents to list by using a GROQ filter expression (`filter`) plus optional parameters (`params`).  Note that this list type does not have an `items()` method - if you want to use specific list items, use the [list()](https://www.sanity.io#list-ab6eb182896e) method instead.

*A document list lists documents*

#### Methods

[Shares methods from list](https://www.sanity.io#list-ab6eb182896e)

**filter(filter): DocumentList**

Filters the document list based on a GROQ filter expression.

Parameters:
- **filter** (string): GROQ-filter used to determine which documents to display. Does not support joins, since they operate on individual documents, and will ignore order-clauses and projections.

**params(params): DocumentList**

Sets the parameters to use when executing the query specified by the provided GROQ-filter

Parameters:
- **params** (object): An object of key/value pairs. Keys should not include the $ prefix used in the filter.

**apiVersion(apiVersion): DocumentList**

Sets the API version to use for the given filter. Available since studio version v2.20.0.

Parameters:
- **apiVersion** (string): API version to use, eg v2021-06-07

**schemaType(schemaType): DocumentList**

Sets the schema type of the documents expected to be in the list. This helps provide context for the tooling, but is not strictly required. Set this if you are expecting a single document type to be returned.

Parameters:
- **schemaType** (string)

**defaultOrdering(orderings): DocumentList**

Sets the default ordering for this document list:
documentList.defaultOrdering([{field: 'priority', direction: 'desc'}])

Parameters:
- **orderings** (SortItem[])

**getFilter(): string**

Returns the configured GROQ-filter for this list, if any

**getParams(): object**

Returns the configured parameters for the GROQ-filter, if any

**getApiVersion(): string**

Returns the configured API version for this list, if any

**getSchemaType(): string**

Returns the configured schema type for this pane, if any

**getDefaultOrdering(): SortItem[]**

Returns the configured default ordering for this document list, if any

#### Example

```javascript
// ./structure.js (.ts)

export const structure = (S) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Future projects')
        .schemaType('sampleProject')
        .child(
          S.documentList()
            .title('Future projects')
            .filter('_type == "sampleProject" && publishedAt > now()')
        )
      ])
```

> [!WARNING]
> Gotcha
> Selecting a custom sort order in the Studio will override `defaultOrdering` and retain that custom sort order in local storage. If your `defaultOrdering` configuration doesn't appear to be working, try clearing your local storage or opening the Studio in a different browser.

#### Examples

```javascript
// src/structure.js

export const structure = (S) => S.documentTypeList('sampleProject')
  S.list()
    .title('Content')
    .items([
      S.listItem('category')
        .title('Projects by category')
        .child(
          S.documentList()
            .title('Projects by category')
            .schemaType('sampleProject') // Because we want menu items for “sampleProjects”
            .filter('_type == "category"')
            .child(id => // Returns the id for the selected category document
              S.documentList()
                .title('Projects by category')
                .schemaType('sampleProject')
                .filter('_type == "sampleProject" && $id in categories[]._ref')
                .params({id}) // use the id in the filter to return sampleProjects that has a reference to the category
            )
        )
      ])
```



#### Example

```javascript
export const structure = (S) =>
  S.list()
    .title('Content')
    .items([
      /* list item(s) goes here */
    ])
```

## List items

listItem(): ListItem

A primitive for defining a list item ([ListItemBuilder](https://reference.sanity.io/sanity/structure/ListItemBuilder/)) within a list in a collapsible pane. Usually used within the array of `S.list().items([/* here */])`.

By using the `child` method on the item, you can control what opens in the next pane when you interact with a `listItem`. If not defined, it will use the parents' child resolver to determine the next pane.

### Methods

**id(id): ListItem**

Sets the ID for this list item.

Setting an ID is highly recommended, but if no ID is provided it will be inferred from the title of the list item.

Parameters:
- **id** (string): ID of this list item. If you are intending to open a document, this should generally be a document ID.

**title(title): ListItem**

Set the title of the list item.

Parameters:
- **title** (string): Title of the list item

**icon(icon): ListItem**

Set an icon for the list item.

Parameters:
- **icon** (function): React component to use as icon.

**child(child): ListItem**

Sets the child that should be rendered if this item is selected. If you have many list items that should resolve to the same type of child, you should usually set the child resolver on the parent (usually a list) and use the ID to resolve the correct child.

Parameters:
- **child** (node | function): Either a child node (list, document, component etc) or a child resolver function that returns a structure node.

**schemaType(schemaType): ListItem**

Sets the schema type of this list item, if any. Only used if the list item represents a document.

Parameters:
- **schemaType** (string): Schema type name

**showIcon(showIcon): ListItem**

Decide whether or not to show the icon for the list item.

Parameters:
- **showIcon** (boolean)

**getId(): string**

Get the id of the list item, if any

**getTitle(): string**

Gets the title of the list item, if defined

**getChild(): node | function**

Get the child or child resolver of the list item, if defined

**getSchemaType(): string**

Gets the schema type for the list item, if defined

**getShowIcon(): boolean**

Gets whether or not the icon is shown.

documentListItem(): DocumentListItem

Convenience method for returning a list item representing a document.

Prefer this over a regular *listItem* when manually building a list of items representing documents.

#### Methods

[Shares the same methods as listItem](https://www.sanity.io#methods-7566a867c0a3).

documentTypeListItem(schemaType): ListItem

Convenience method for returning a list item representing a document type. In other words, if you want a list item that opens a list of documents of a specific type when clicked, use this. It will automatically configure the title, icon, schema type, and similar. 

#### Arguments

#### Properties

**schemaType** (string)

Name of the schema type.

documentTypeListItems(): ListItem[]

Convenience method. Returns an array of list items for all defined document types in your schema, and configure them with the correct titles, icons, initial value templates and similar.

#### Example

```javascript
export const structure = (S) =>
  S.list()
    .title('Content')
    .items(
      // List all document types except "siteSettings"
      S.documentTypeListItems().filter(
        item => item.getId() !== 'siteSettings'
      )  
    )
```

## Dividers

divider(): void

Inserts a visual divider in a list.

![The section divider](https://cdn.sanity.io/images/3do82whm/next/5111066a15571e6a71ef8f17364cdef9b56a5d03-664x236.png)

### Methods

**title(title): Divider**

Set the title of the divider.

Parameters:
- **title** (string): Title of the divider

**i18n(i18n): Divider**

Set the i18n key and namespace used to populate the localized title.

Parameters:
- **i18n** (object): The key and namespace used to populate the localized title

**getTitle(): string**

Gets the title of the divider, if defined

**getI18n(): object**

Gets the internationalized title of the divider, if defined

#### Example

```javascript
//sanity.config.js

import {structure} from './structure'

export default defineConfig({
  ...
  plugins: [
    structureTool({
      structure
    }),
  ],
  schema: schemaTypes
})

// ./structure.js (.ts)

export const structure = (S) =>
  S.list()
    .title('Content')
    .items([
      // Make a singleton of the document with ID “siteSettings”
      S.documentListItem()
        .id('siteSettings')
        .schemaType('siteSettings'),
      // Add a visual divider
      S.divider()
        .title('Divider title')
        .i18n({title: {key: 'text-divider-title', ns: structureLocaleNamespace}}),
      // Add the rest of the document types, but filter out the siteSettings type defined above
      ...S.documentTypeListItems().filter(
        item => item.getId() !== 'siteSettings'
      )
    ])

```



## Document nodes

The document node type represents (as the title implies) a document. Often, it is referred to as "the editor node" since it's common for it to render a form allowing you to edit the document in question. However, it can also render other "views" of the document, which is why it has a more generic name.

The following methods let you configure what happens when you open a document. If no views are specified for a document node, it will return the default form view.

document(): Document

A primitive for defining a document node. See the [DocumentBuilder](https://reference.sanity.io/sanity/structure/DocumentBuilder/) reference for the full type definition.

### Methods

**id(id): Document**

Set the id for the document node. Usually picking something like document or documentView is enough to differentiate it. Alternatively, you can use the document ID.

Parameters:
- **id** (string): Identifier for the document node used to reflect the current structure state in the studio’s URL.

**title(title): Document**

Set the title for the document. Leave blank to use the document title.

Parameters:
- **title** (string): The title to use for the document

**documentId(documentId): Document**

Sets the document ID this document node represents

Parameters:
- **documentId** (string): Document ID to use for this node

**schemaType(schemaType): Document**

Sets the schema type for this document

Parameters:
- **schemaType** (string): The schema type name

**initialValueTemplate(templateId, parameters): Document**

Sets which initial value template should be used for this document (if any).

Parameters:
- **templateId** (string): ID of the initial value template to use
- **parameters** (object): Object of key-value pairs to be sent to the initial value template

**views(views): Document**

Defines which views should be rendered for this document. If not defined, it will use the default form view as the only view.

Currently, there are two view types: form and component.

The S.form method renders the form for a given document, allowing it to be edited.

A component renders a custom React component. See the document node views documentation for example usage.

Parameters:
- **views** (View[]): Array of views to use for this document node

**child(child): Document**

Sets which structure node to use as the child of this document, in the case where a view allows navigating to one.

Can either be a structure node (any list, document, component etc) or a child resolver - a function that either synchronously or asynchronously resolves to a structure node.

Read more about child resolvers in the conceptual guide for the structure builder.

Parameters:
- **child** (node | function)

**getId(): string**

Returns the configured ID for this pane, if any

**getTitle(): string**

Returns the configured title for the pane, if any

**getDocumentId(): string**

Returns the configured document ID for this document node

**getSchemaType(): string**

Returns the configured schema type for this editor, if any

**getInitialValuesTemplate(): string**

Returns the configured initial value template ID, if any

**getInitialValueTemplateParameters(): object**

Returns the configured parameters for the initial value template, if any

**getViews(): View[]**

Returns an array of the configured views, if any

**getChild(): node | function**

Returns the configured child or child resolver for this pane

documentWithInitialValueTemplate(templateId, parameters): Document

Convenience method for building a document node with a specific initial value template and set of parameters. Automatically configures the document with the correct schema type.

Returns a document node and as such, has the same builder methods as [document()](https://www.sanity.io#document-cbba11c7a572). 

## Default document node

Many of the nodes in the structure builder (such as the document type list)  automatically render a document node as a child if none is provided. While you can take full control of every node in the structure, this is sometimes a bit tedious if you usually return the same document node for every document or schema type.

The structure definition allows for defining a function which is called when the "default" should be resolved.  

> [!WARNING]
> Gotcha
> **Note that this is** **not** a function exposed on the Structure Builder API itself - rather, it is a function you declare and export for the Structure Builder to use as a fallback.

While the structure definition you provide is exported as the *default* export, you can also export a function named *getDefaultDocumentNode* that will be called in these situations:

getDefaultDocumentNode(options): Document

Export a function named `getDefaultDocumentNode` that returns an `S.document()` node to set the default configuration for document nodes (also often referred to as an "editor node" since it often contains the form view used to edit a document).

### Arguments

#### Properties

**options** (object)

Object of contextual information that can be used to determine which properties the document node should have. Properties:

schemaType - The value of a document’s _type.

documentId - The ID of the document.

#### Example

```javascript
// sanity.config.ts (.js)

import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {structure, defaultDocumentNode} from './structure'
import { schemaTypes } from './schema'

export default defineConfig({
  name: 'default',
  title: 'My Cool Project',
  projectId: 'my-project-id',
  dataset: 'production',
  plugins: [
    structureTool({
      structure,
      defaultDocumentNode,
    }),
  ],
  schema: schemaTypes
})

// ./structure.js (.ts)
import { WebPreview, JsonView } from './previews'

export const structure = (S, context) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Settings')
        .child(
          S.document()
            .schemaType('siteSettings')
            .documentId('siteSettings')
        ),
      ...S.documentTypeListItems()
    ])

export const getDefaultDocumentNode = (S, {schemaType}) => {
 // Conditionally return a different configuration based on the schema type
 if (schemaType === "post") {
   return S.document().views([
     S.view.form(),
     S.view.component(WebPreview).title('Web')
   ])  
 }
 return S.document().views([
   S.view.form(),
   S.view.component(JsonView).title('JSON')
 ])
}

export default S.defaults()
```

## Document node views

Within a document pane, there can be one or more *views*. If there are more than one view node, they will appear as tabs. The views can be built using methods exposed on `S.view` - eg `S.view.form()`

*A document can contain several different views that can be split into multiple panes.*

### View types

form(): FormView

The form method returns the default form-based editor for the specified document type. See the [FormViewBuilder](https://reference.sanity.io/sanity/structure/FormViewBuilder/) reference for the full type definition.

#### Methods

**id(id): FormView**

ID for the view, used for the URL

Parameters:
- **id** (string): ID of the view

**title(title): FormView**

Sets the title of the view. Appears when there are more than a single view for a document node.

Parameters:
- **title** (string): The title for the component.

**icon(icon): FormView**

Sets an icon for the views tab

Parameters:
- **icon** (function): React component to use as icon

**getId(): string**

Returns the ID of the view, if any

**getTitle(): string**

Returns the title of the view, if any

**getIcon(): function**

Returns the defined icon for this view, if any

#### Example

```javascript
S.view.form()
```

component(reactComponent): ComponentView

The `component` method takes a React component and renders it into the document view ([ComponentViewBuilder](https://reference.sanity.io/sanity/structure/ComponentViewBuilder/)). It's most often used for showing the values of a document in alternative ways to the default document form.

#### Methods

**id(id): ComponentView**

ID for the view, used for the URL

Parameters:
- **id** (string): ID of the view

**title(title): ComponentView**

Sets the title of the view. Appears when there are more than a single view for a document node.

Parameters:
- **title** (string): The title for the component.

**canHandleIntent(intentChecker): ComponentView**

Sets the method used to determined whether or not the pane can handle an intent of a certain type. This method is especially useful for routing to custom components from global search results or other links.

The intent checker receives three arguments: intentName, params and context. It should return a boolean indicating whether or not it can handle the intent.

intentName is generally create or edit.

params usually contains id and type, representing the document ID and schema type to be created. Often it will also have template, a string representing the ID of an initial value template.

context is an object containing pane and index

Parameters:
- **intentChecker** (function): Intent checker function

**component(component): ComponentView**

Sets the React component

Parameters:
- **component** (function | Component): The React component to render. The component is rendered with the following props: document published The published values of the document. Returns null if the document isn't published. draft The values from the draft version of the document. Returns null if there isn't a draft, that is, no changes made since last publish, or if the document is in its initial state. historical The values for the selected document revision in the history view. displayed The values for the currently selected document state, in this order of presedence: initial, historical, draft, published. documentId - The ID of the document schemaType - The schema type of the document

**icon(icon): Component**

Sets an icon for the views tab

Parameters:
- **icon** (function): React component to use as icon

**options(options): ComponentView**

Sets additional user-defined options that will be passed to the React component as the options property.

Parameters:
- **options** (object): Arbitrary key-value pairs of options for the React component

**getId(): string**

Returns the ID of the view, if any

**getTitle(): string**

Returns the title of the view, if any

**getComponent(): function | Component**

Returns the defined React component to use for the view, if any

**getIcon(): function**

Returns the defined icon for this view, if any

**getOptions(): object**

Returns the user-defined options for this view, if any

#### Example

```jsx

export const structure = (S) =>
  S.documentTypeList('sampleProject')
    .child(id =>
      S.document()
        .schemaType('sampleProject')
        .documentId(id)
        .views([
          // The default form for editing a document
          S.view.form(),
          
          // Render the current selected document’s values as JSON
          S.view.component(({document}) => (
            <pre>{JSON.stringify(document.displayed, null, 2)}</pre>
          )).title('View JSON')
        ])
    )

```

## Menus

*A menu is located at the top right corner in each collapsible pane*

Each pane may have a menu in its upper right corner. This is where you usually find ordering options and affordances for adding new documents. Menus can also hold initial value templates. The convenience list methods will automatically set up default menus for you (some of these may also require that you add `schemaType()`). See the [MenuItemBuilder](https://reference.sanity.io/sanity/structure/MenuItemBuilder/) reference for the full type definition.

menuItem(): MenuItem

### Methods

**title(title): MenuItem**

Sets the title for the menu item.

Parameters:
- **title** (string): Title of the menu item

**icon(icon): MenuItem**

Sets the icon for the menu item.

Parameters:
- **icon** (function): React component to use as the icon

**action(action): MenuItem**

Sets the action that should be performed when the menu item is selected. Can either be a function or a string.

If specifying a function, that function will be called with any configured params as the first argument when the item is clicked.

If specifying a string, the menu will attempt to look for an object named actionHandlers on the React pane component holding the menu, and find a method with a matching name there. The function will be called with any configured params as the first argument when the item is clicked.

Parameters:
- **action** (string | function)

**params(params): MenuItem**

Sets an arbitrary object of parameters to pass to the action handler defined by action(), when clicked.

Parameters:
- **params** (object): Arbitrary set of key-value parameters for the action

**intent(intent): MenuItem**

Sets an intent that should be performed when clicking this menu item.

An intent is an object with a type key with either the value create or edit, and an optional set of parameters specified as params.

For the edit intent, the type and id properties are required (document type name and document ID, respectively): {type: 'edit', params: {id: 'documentID', type: 'person'}}

For the create intent, specifying a type is required. If you want to use a specific Initial Value Template, you can specify a template parameter with the ID of the template you want to use: {type: 'create', params: {type: 'person', template: 'book-author'}}

If you want to pass arbitrary parameters to the initial value template, you can use the array form of specifying parameters, where the first element of the array defines the actual intent parameters, while the second element defines the arbitrary template parameters: {type: 'create', params: [{type: 'person', template: 'book-author'}, {some: 'template-param'}]}

Note that intent and action are mutually exclusive.

Parameters:
- **intent** (Intent)

**showAsAction(showAsAction): MenuItem**

Sets whether or not the menu item should be displayed as an action instead of being part of the dropdown menu.

Actions appear next to the pane title and must be given an icon, since they have no room for a title unless hovered.

Parameters:
- **showAsAction** (boolean)

**getTitle(): string**

Returns the title of the menu item.

**getIcon(): function**

Returns the icon for the menu item.

**getAction(): function | string**

Returns the defined action for the menu item, if any

**getParams(): object**

Returns the parameters for the menu item.

**getIntent(): Intent**

Returns the intent for the menu item.

**getShowAsAction(): boolean**

Returns whether or not the menu item is set to show as an action or not.

orderingMenuItem({title, by}): MenuItem

Convenience method for populating the menu for selecting different fields and directions for ordering a list of documents.

#### Arguments

#### Properties

**title** (string, required)

Title of the ordering to show in the menu

**by** (array, required)

An array of objects consisting of the field to sort by and the direction of the ordering (asc or desc). See example below.

#### Example

```javascript
 S.documentList()
  .title("Products")
  .filter("_type == $type")
  .params({ type: "product" })
  .menuItems([
    ...S.documentTypeList("product").getMenuItems(),
    S.orderingMenuItem({title: 'Title ascending', by: [{field: 'title', direction: 'asc'}]}),
    S.orderingMenuItem({title: 'Title descending', by: [{field: 'title', direction: 'desc'}]})
  ])
```

orderingMenuItemsForType(typeName): MenuItem[]

Convenience method for returning an array of menu items used to order documents of a given schema type, based on the orderings defined in the schema definition.

#### Arguments

#### Properties

**schemaType** (string, required)

Schema type name to return orderings for

menuItemsFromInitialValueTemplateItems(items): MenuItem[]

Convenience method for returning an array of menu items used to create new documents based on the given initial value template items.

Note: You should consider using the `initialValueTemplates()` method on the pane instead of this method unless you want the menu items to be nested inside the actual menu.

#### Arguments

#### Properties

**items** (InitialValueTemplateItem[], required)

Initial value template items. Use S.initialValueTemplateItem() to generate these.

#### Example

```javascript
 S.documentList()
  .title('Craft beer & cider')
  .filter('_type in $types && isCraft == true')
  .params({types: ['beer', 'cider']})
  .menuItems(
    S.menuItemsFromInitialValueTemplateItems([
      S.initialValueTemplateItem('beer', {isCraft: true}),
      S.initialValueTemplateItem('cider', {isCraft: true}),
    ])
  )
```

#### Hide menu checkmark / selection indicator

Use the `hideSelectionIndicator` boolean.

```typescript
S.menuItem()
   .title('No checkmark')
   .icon(EmptyIcon)
   .action(() => console.log('you clicked!'))
   .params({hideSelectionIndicator: true}),
```

#### Check if menu item is selected

Use the `action` callback and the `isSelected` parameter.

```typescript
S.menuItem()
  .title('View Mode: Default')
  .icon(DocumentIcon)
  .group('view')
  .params({value: 'default'})
  .action((params) => console.log('default', params?.isSelected, params)),
```



# Introduction

Studio plugins provide a way to reuse pieces of Studio configurations across multiple studios and workspaces, while also helping you organize your studio features and reduce clutter in your configuration.

You can even use plugins from the community to add new schema types, input components, tools, and other features to enhance your content editing experience without having to build everything from scratch.

Here are some ways you can use Studio plugins:

- Add specialized input components like color pickers, map interfaces, multi-select arrays, and more.
- Create custom tools that appear in the Studio navigation.
- Add internationalization support for multiple languages.
- Share complex schema types. For example, the experimental [@sanity/presets](https://www.npmjs.com/package/@sanity/presets) package ships ready-made types for pages, links, images, SEO metadata, and rich text.

You can find a collection of official and community plugins on the [Sanity Exchange](https://www.sanity.io/plugins)., and in the [official plugins repo](https://github.com/sanity-io/plugins).

## Requirements

- Most plugins require Sanity Studio v3 or later. We suggest updating to v4+.

## Core concepts

Understanding how plugins work in Sanity Studio will help you both use existing plugins and develop your own. If you're using any official plugins like Vision or Presentation, you may have already seen these concepts in action.

### Installation and configuration

Plugins for Sanity Studio are installed like any other dependency using your package manager. After installation, import the plugin and add it to the `plugins` array in your studio configuration.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {colorInput} from '@sanity/color-input'

export default defineConfig({
  // ...
  plugins: [colorInput()],
})
```

Many plugins accept configuration options that can be passed when initializing the plugin:

**sanity.config.ts**

```typescript
export default defineConfig({
  // ...
  plugins: [
    customPlugin({ 
      customOption: true
    }
  ]
})
```

#### Learn more

[Installing and configuring plugins](https://www.sanity.io/docs/studio/installing-and-configuring-plugins)
Learn how to extend and customize Sanity Studio with plugins.

[Explore available plugins](https://www.sanity.io/plugins)
Browse official and third-party plugins on the Exchange.

### Plugin development

Plugins are created using the `definePlugin` function, which accepts most of the same properties as the `defineConfig` API. This allows you to encapsulate specific functionality and configuration in a portable way.

**myPlugin.ts**

```typescript
import { definePlugin } from 'sanity'

export const myPlugin = definePlugin({
  name: 'my-custom-plugin',
  // Add schema types, tools, components, etc.
})
```

Plugins can also include other plugins, allowing you to build features on top of each other in a modular way.

#### Learn more

[Developing plugins](https://www.sanity.io/docs/studio/developing-plugins)
Package plugins in a reusable and shareable way.

[Plugins API](https://www.sanity.io/docs/studio/plugins-api-reference)
Extend the capabilities of your studio using plugins

### Publishing plugins

When you've developed a plugin that you want to share with others, you can publish it as an npm package. The recommended approach is to use [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit), which handles bundling and other package preparation tasks.

#### Learn more

[Publishing plugins](https://www.sanity.io/docs/studio/publishing-plugins)
Publish a plugin for distribution

### Internationalization

Plugins can support multiple languages through Sanity's internationalization API. This allows plugin UI elements to be displayed in the user's preferred language, enhancing the user experience for international teams.

#### Learn more

[Internationalizing plugins](https://www.sanity.io/docs/studio/internationalizing-plugins-ui)
This article provides a guide for plugin authors to add localization capabilities to their Sanity Studio plugins.

## Limitations

- Some areas of the Studio UI may not fully support plugin customization. 
- Plugins must be compatible with the version of Sanity Studio you're using.
- Plugins with complex dependencies may increase your Studio bundle size.



# Installing and configuring plugins

## Prerequisites

- A Sanity Studio project.
- Node.js and a package manager, such as npm or yarn, installed.

## Install

Plugins for Sanity Studio are installed like any other dependency in your project, using your package manager of choice, such as [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com). For the remainder of this article, we'll assume you're using npm.

After installation, a plugin must be imported from the package and added to the `plugins` array in the Studio configuration, commonly found at the root of your project in a file named `sanity.config.js` or `.ts`.

### Example: install @sanity/color-input

Let’s install [@sanity/color-input](https://www.sanity.io/plugins/color-input). It adds `color` as a schema type and provides a handy color picker to select the color value.

> [!WARNING]
> Gotcha
> Whether you are installing a plugin or looking at the source code to learn how it works, make sure you are looking at the correct version. Some older plugins may contain versions tied to older versions of Sanity Studio (like v2).

Navigate to your project's root folder and run the following command in your terminal to install the plugin:

**npm**

```shell
npm install @sanity/color-input
```

**pnpm**

```shell
pnpm add @sanity/color-input
```

**yarn**

```shell
yarn add @sanity/color-input
```

**bun**

```shell
bun add @sanity/color-input
```

Open your project configuration and import the `colorInput` function from the plugin package. Add the `colorInput()` function call to the `plugins` array. Take care to include the parentheses, as shown in the example. Finally, add a `color` field to any of your schemas.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {colorInput} from '@sanity/color-input'

export default defineConfig({
  // Here we add the @sanity/color-input plugin to the Studio
  plugins: [colorInput()],

  // example schema
  schema: {
    types: [
      {
        type: 'document',
        name: 'color-demo',
        title: 'Document with color field',
        fields: [
          {
            // The 'color' schema type was added by the plugin
            type: 'color',
            name: 'mySwatch',
            title: 'Swatch',
          },
        ],
      },
    ],
  },
})
```

You should be rewarded with a color picker widget in your Studio.

![Color picker field in Sanity Studio showing a selected swatch.](https://cdn.sanity.io/images/3do82whm/next/99b77ec52cd2eac4377a09cc2b461219f42fb342-517x237.png)

## Configure

Some plugins are configurable. Configuration is provided as an argument to the plugin function and supplied when the function is added to the `plugins` array.

### Example: configure Vision

Let's use the [Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin) as an example. It adds the GROQ Vision tool to the Studio, and there's a good chance you already have it configured with the default settings. It optionally accepts a configuration object. In this example, we set a default API version and dataset.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {visionTool} from '@sanity/vision'

export default defineConfig({
  // ...
  plugins: [
    visionTool({
      defaultApiVersion: 'v2025-08-19',
      defaultDataset: 'production',
    })
  ],
})
```

## Get organized

Plugins are snippets of Studio configuration. As your Studio configuration grows, it can be helpful to organize distinct features into plugins. This reduces clutter and avoids huge inline functions in `sanity.config.ts`. It also makes your config portable.

## Next steps

With your plugins installed and configured, learn how to create your own or dig deeper into Studio configuration.

[Developing plugins](https://www.sanity.io/docs/studio/developing-plugins)
Package plugins in a reusable and shareable way.

[Configuration](https://www.sanity.io/docs/studio/configuration)
Learn how to configure Sanity Studio with JavaScript or TypeScript.

[The Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin)
Quickly test your GROQ queries using this studio plugin.



# Developing plugins

Plugins let you package Studio configuration into reusable modules that you can share across projects or with the community. In this article, you'll learn how to turn existing Studio configuration into a plugin with `definePlugin`, develop and test a plugin package locally, publish it to npm, and submit it to the Sanity Exchange.

## Prerequisites

- A Sanity Studio project for developing and testing your plugin.
- Node.js and npm installed.
- An npm account, if you plan to publish your plugin to npm.

## Config to plugin

Most of the properties from the [defineConfig API](https://www.sanity.io/docs/studio/configuration) can be expressed as a plugin with the [definePlugin](https://reference.sanity.io/sanity/index/definePlugin/) function.

Plugins can also have plugins. This can be a nice way to indicate interdependencies in plugins, and build features on top of each other in a portable way.

### Example

Given the following Studio configuration, let's move the code related to `productionUrl` into a plugin:

```javascript
// before: sanity.config.js
import {defineConfig} from 'sanity'

export default defineConfig({
  // ...

  plugins: [],

  document: {
    productionUrl: async (prev, { document }) => {
      // assume there is 20+ lines of code here
      const useCustomUrl = !!document?.slug?.current;
      if(useCustomUrl) {
        return `https://some-custom-url.xyz/${document.slug.current}`
      }
      return prev
    }
  }
})
```

Start by extracting the relevant code into a new file and replace `defineConfig` with `definePlugin`.

```javascript
// after: productionUrlPlugin.js
import {definePlugin} from 'sanity'

export const productionUrlPlugin = definePlugin({
  name: 'custom-production-url',

  // code remains exactly the same, but is now contained by the plugin
  document: {
    productionUrl: async (prev, {document}) => {
      // assume there is 20+ lines of code here
      const useCustomUrl = !!document?.slug?.current;
      if (useCustomUrl) {
        return `https://some-custom-url.xyz/${document.slug.current}`
      }
      return prev
    }
  }
})
```

Then import the plugin and add it to the `plugins` array in `defineConfig`:

```javascript
// after: sanity.config.js
import {defineConfig} from 'sanity'
import {productionUrlPlugin} from './productionUrlPlugin'

export default defineConfig({
  // ...
   
  // now we have the productionUrl plugin neatly wrapped up in a plugin
  plugins: [productionUrlPlugin()],
})
```

## Studio plugin to package

Some plugins are so useful that you want to use them in multiple Studios or share them with everyone. For that, you first need to create an npm package, then publish it to npm.

By organizing your code using `definePlugin`, you are already halfway there! The other part of the equation is all about creating an npm package repository.

There are many ways to go about this, but we highly recommend [@sanity/plugin-kit](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit) as a way to get started.

### Let @sanity/plugin-kit do the work

> [!NOTE]
> Opinionated
> [@sanity/plugin-kit](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit) is an opinionated way to create an npm package for Sanity. It aims to be a one-stop shop for creating a Sanity plugin package. It will handle a lot of the tedium that goes into preparing a package, such as bundling the plugin as a modern ESM package. You do not need to use it if you prefer other ways to work with npm package repositories.

#### Initialize a new package

To create a new plugin package, run the following command in a shell:

**npm**

```shell
npx @sanity/plugin-kit init <plugin-name>
```

**pnpm**

```shell
pnpm dlx @sanity/plugin-kit init <plugin-name>
```

**yarn**

```shell
yarn dlx @sanity/plugin-kit init <plugin-name>
```

**bun**

```shell
bunx @sanity/plugin-kit init <plugin-name>
```

This will initialize a new plugin package in a new directory named after your plugin, and will prompt for various details that will go into `package.json`.

At this point, you have a fully functioning Sanity plugin package that can be tested in your Studio.

See the manual pages for the init command with `npx @sanity/plugin-kit init --help` for available options.

#### Test your plugin in a Studio

In your plugin package directory run:

**npm**

```shell
npm run link-watch
```

**pnpm**

```shell
pnpm run link-watch
```

**yarn**

```shell
yarn run link-watch
```

**bun**

```shell
bun run link-watch
```

This will set up your plugin to build whenever the code changes, and publish the package to a local [yalc](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit#q-why-use-yalc) repository.

> [!TIP]
> Protip
> [yalc](https://github.com/wclr/yalc) is a replacement for `npm link` that makes testing plugins locally easier.

In the command log, there should be a note that reads something like this:

```sh
# To test this package in another repository directory run:
npx yalc add <sanity-plugin> && npx yalc link <sanity-plugin> && npm install
```

Copy the command, paste it into your Studio directory shell, and run it. This will install the plugin from the local yalc repository, which will be updated whenever the plugin code changes.

You can now import the plugin from your package in `sanity.config.js`, start the Studio and it should appear there. It will look something like this:

```javascript
// sanity.config.js
import {defineConfig} from 'sanity'

// export name depends on what is exported from index.ts in the plugin
import {myPlugin} from '<sanity-plugin>'

export default defineConfig({
  // ...
   
  plugins: [myPlugin()],
})
```

> [!WARNING]
> Gotcha
> The default plugin implementation created by plugin-kit only logs “hello” in the browser console, so take a look there if you are not seeing any changes.

For more, see the `@sanity/plugin-kit` [testing guidelines](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit#testing-a-plugin-in-sanity-studio).

#### Add your plugin code

Now you can add your plugin code. Remember to add any dependencies used to `package.json`.

`src/index.ts` is the entry point to the plugin and the place where `definePlugin` is configured.

Changes you make will be reloaded in the Studio as long as you have the `link-watch` command running.

## Publish a package

After developing and testing a plugin, it is time to publish it to npm.

In a package using `@sanity/plugin-kit`, the `prepublishOnly` script will ensure that the package is validated and builds according to plugin-kit expectations. These checks are in place to prevent an array of common errors from slipping through when publishing.

### The manual way

If you are comfortable with publishing from your local development environment, in your package directory run:

```sh
npm publish
```

This will build and publish your package to npm. It will ask for a one-time code if you have two-factor authentication enabled on your npm account (you should).

When manually publishing like this, you are responsible for bumping the `version` field in `package.json` manually between releases. You also have to manually tag and create a release on GitHub if you want that sort of thing.

> [!WARNING]
> Gotcha
> Scoped packages (package names that start with `@`, for example `@orgOrNpmUser/package`) are private by default. This implies:
> - Only users with access to the organization (or user) can download the package.
> - You have to be a paid npm user to be allowed to publish.
> To make your package public (which will circumvent the above limitations), add the following to your `package.json`:
> `"publishConfig": { "access": "public" }`

### The opinionated semantic-release way

> [!NOTE]
> Opinionated
> This section uses a [@sanity/plugin-kit](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit) preset template to do most of the work. It puts certain guardrails on the development process to lower the chance of a faulty publish event. Feel free to make changes to the setup, look to it for inspiration, or completely disregard it.

If you want to do automated releases using GitHub Actions, `@sanity/plugin-kit` has this covered via the injectable `semver-workflow` preset.

Consider using this preset if you want the following:

- [husky](https://github.com/typicode/husky) for pre-commit hooks to ensure that:- All commits follow [conventional-commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format
- All files in a commit pass ESLint


- [semantic-release](https://semantic-release.gitbook.io/semantic-release/) automation for npm publish
- [GitHub workflow](https://docs.github.com/en/actions/using-workflows) (Action) that does continuous integration and has publish-on-demand support

That said, all of these can be opted out of by reverting the changes you don’t care for.

Before continuing, ensure that your package has no local changes, so it is easy to check what changes are applied to your code.

In your plugin directory run:

```sh
npx @sanity/plugin-kit inject --preset-only --preset semver-workflow && npm i
```

This command will configure the plugin package with files and dependencies that accommodate an automated plugin workflow on GitHub.

Keep in mind that this setup is tailored to the needs of the Ecosystem team at Sanity. Feel free to modify any and all files injected by the preset, or use it as a basis for creating your own workflow.

For more on this, refer to the [semver-workflow preset](https://github.com/sanity-io/plugins/blob/main/packages/@sanity/plugin-kit/docs/semver-workflow.md) docs.

## Submit your plugin to the Exchange

If your plugin is intended for external use, you can submit it to the [Sanity Exchange](https://www.sanity.io/exchange). The Exchange is powered by a Sanity Studio, and you can log in with an existing Sanity account by visiting [community.sanity.tools](https://community.sanity.tools).

Once logged in, you're presented with the community studio. To get started:

1. Select "**Help**."
2. Select the "**Make your first contribution**" guide, and read through it.
3. Select the category of contribution you'd like to make, and follow the in-studio descriptions to fill in the contents.

![Screenshot of the Sanity community studio showing where to start a plugin contribution.](https://cdn.sanity.io/images/3do82whm/next/062dc3b03354dcb705540e1c88605e95ddb723f7-2506x1940.png)

## Next steps

Now that you know how to build and publish a plugin, explore these related topics.

[Installing and configuring plugins](https://www.sanity.io/docs/studio/installing-and-configuring-plugins)
Learn how to extend and customize Sanity Studio with plugins.

[Configuration](https://www.sanity.io/docs/studio/configuration)
Learn how to configure Sanity Studio with JavaScript or TypeScript.

[Custom components for Sanity Studio](https://www.sanity.io/docs/studio/intro-to-custom-studio-components)
Change the look and feel of your Studio and craft tailor-made editorial interactions.



# Publishing plugins

## Publish a package

After [developing and testing](https://www.sanity.io/docs/studio/developing-plugins) a plugin, it is time to publish it to npm.

If you use `@sanity/plugin-kit` for your package, the `prepublishOnly` script will ensure that the package is validated and builds according to `plugin-kit` expectations. These checks are in place to prevent an array of common errors from slipping through when publishing.

### The manual way

If you are comfortable with publishing from your local development environment, in your package directory run:

```sh
npm publish
```

This will build and publish your package to npm. It will ask for a one-time code if you have two-factor authentication enabled on your npm account (you should).

When you manually publish like this, you are responsible for bumping the `version` field in `package.json` manually between releases. You also have to manually tag and create a release on GitHub if you want that sort of thing.

> [!WARNING]
> Gotcha
> Scoped packages (package name starts with `@`, for example `@orgOrNpmUser/package`) are private by default. This implies:
> - Only users with access to the organization (or user) can download the package.
> - You have to be a paid npm user to be allowed to publish.
> To make your package public (which will circumvent the above limitations), add the following to your `package.json`:
> `"publishConfig": { "access": "public" }`

### The opinionated semantic-release way

> [!NOTE]
> Opinionated
> This section uses a [@sanity/plugin-kit](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit) preset template to do most of the work. It puts certain guardrails on the development process to lower the chance of a faulty publish event. Feel free to make changes to the setup, look to it for inspiration, or completely disregard it.

If you want to do automated releases using GitHub Actions, `@sanity/plugin-kit` has this covered via the injectable `semver-workflow` preset.

Consider using this preset if you want the following:

- [husky](https://github.com/typicode/husky) for pre-commit hooks to ensure that:- All commits follow [conventional-commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format
- All files in a commit pass ESLint


- [semantic-release](https://semantic-release.gitbook.io/semantic-release/) automation for npm publish
- [GitHub Actions workflow](https://docs.github.com/en/actions/using-workflows) that does continuous integration and has publish-on-demand support

That said, all of these can be opted out of by reverting the changes you don’t care for.

Before continuing, ensure that your package has no local changes, so it is easy to check what changes are applied to your code.

In your plugin directory, run:

```sh
npx @sanity/plugin-kit@latest inject --preset-only --preset semver-workflow && npm i
```

This command will configure the plugin package with files and dependencies that accommodate an automated plugin workflow on GitHub.

Keep in mind that this setup is tailored to the needs of the Ecosystem team at Sanity. Feel free to modify any and all files injected by the preset, or use it as a basis for creating your own workflow.

For more on this, refer to the [semver-workflow preset](https://github.com/sanity-io/plugins/blob/main/packages/@sanity/plugin-kit/docs/semver-workflow.md) docs.



# Internationalizing plugins

> [!NOTE]
> Looking for Studio localization docs?
> This article is aimed at plugin authors who wish to add localization capabilities to their plugins. If your aim is rather to enable a new language in your Studio UI, visit [this article](https://www.sanity.io/docs/studio/localizing-studio-ui).

The `v3.23.0` release of Sanity Studio includes the tools maintainers need to internationalize their Studio code, as well as a range of tools for plugin developers to enable i18n in their plugins. This article will introduce you to the core concepts and tooling with examples to get you started.

Before proceeding, make sure you are running the latest version of Sanity Studio. Your Studio needs to be `v3.23.0` or later to work with the internationalization (i18n) APIs.

> [!WARNING]
> Gotcha
> **Minimum Sanity peer dependency**
> Since there is no easy way to make these changes backward compatible, your plugin will now have to bind to a minimum version of Sanity where i18n is introduced. As this may be considered a breaking change, consider implementing [semantic versioning](https://semver.org/) if not already in use.

## Glossary

- **Locale**: “English (US)”, or “Norwegian (Bokmål)”. Has an ID (`en-US`, `nb-NO`), a title, and an icon. In most cases it should also have one or more *resource bundles* defined (strings).
- **Locale namespace**: For example, “studio”, “desk”, or “vision”. This makes it simpler to use the translation in a plugin (no need to prefix all strings), and allows for dynamic loading of namespaces when needed.
- **Resource bundles**: represents the strings available for a locale/namespace combination. The “resources” (strings) can be defined statically or as an async function, allowing for dynamic imports. The Studio only loads/merges resources for a namespace/locale when used.

## Defining the resource bundle

Start by creating a “resource bundle”, and define a namespace for your plugin using the [defineLocaleResourceBundle](https://www.sanity.io/docs/reference/api/sanity/defineLocaleResourceBundle) helper function. A resource bundle is an object specifying a namespace and locale for a localization, as well as pointing to where the files containing your localized strings (or resources) can be found. By convention the namespace should be the same as the name of your plugin, e.g., `@sanity/vision` or `@sanity/google-maps-input`. The locale should be specified following the [BCP-47 naming convention](https://en.wikipedia.org/wiki/IETF_language_tag) extended to include both language (e.g., `en` for English) and area (e.g., `US` for the USA). See the list of [available locales](https://github.com/sanity-io/locales) for reference.

```tsx
import {defineLocaleResourceBundle} from 'sanity'

export const googleMapsInputResourceBundle = defineLocaleResourceBundle({
  locale: 'en-US',
  namespace: '@sanity/google-maps-input',
  resources: () => import('./resources'),
})
```

If you aim to add support for multiple locales, you should export an array of these bundles with different `locale` properties.

The `resources` key is a function that resolves to an object of resources, which are the files containing your translated strings. This allows for only loading the namespace and locale combination if the user has chosen the given locale and is currently using the plugin that depends on it.

### The resources file

The `resources` file should have a default export that exposes an object of key-value pairs. The keys should be identical for each supported locale, while the translated strings are kept in values. E.g.:

```tsx
export default {
  /** --- PUBLISH ACTION --- */
  /** Tooltip when action is disabled because the studio is not ready.*/
  'action.publish.disabled.not-ready': 'Operation not ready',

  /** Label for action when there are pending changes.*/
  'action.publish.draft.label': 'Publish',

  /** Label for the "Publish" document action while publish is being executed.*/
  'action.publish.running.label': 'Publishing…',

  /** Label for the "Publish" document action when there are no changes.*/
  'action.publish.published.label': 'Published',

  /** Label for the button to rev up the engine of your Tardis.*/
  'action.time-travel': 'Allons-y!'
}
```

### Naming conventions for resource keys

While you are technically free to name your keys however you like, we do have a few recommendations to keep everything consistent and easy to reason about:

- Keys should be in lowercase and kebab-case, not camelCase.
- Keys are namespaced, so you don’t need to worry about conflicts outside of your plugin.
- Put a comment on top of each key explaining what it does/what it means. Helps translating the key, both for humans and/or an AI.
- Use separate keys for aria labels, suffixed with `-aria-label`.

### Registering the resource bundle

In your `definePlugin` call, include an `i18n.bundles` property that points to your resource bundles:

```tsx
import {definePlugin} from 'sanity'
import {mySwedishBundle, myNorwegianBundle} from './i18n'

export const myPlugin = definePlugin(() => ({
  // ...rest of plugin config
  i18n: {
    bundles: [mySwedishBundle, myNorwegianBundle],
  },
}))
```

## Translating the user interface

The workhorse of the i18n toolkit is the `useTranslation` hook. Initialize it with your plugin namespace and use it to retrieve localized string values in your plugin user interface.

### Basic translation

```tsx
import {useTranslation} from 'sanity'
import {travelInTimeAndSpace} from 'tardis'

function MyComponent() {
  // Argument is the locale namespace - if omitted, it will use the `studio`
  // namespace, which is probably not what you want. Specifying a namespace
  // might become a requirement in the future. For now, it's good practice.
  const {t} = useTranslation('myPlugin')

  return <button onClick={travelInTimeAndSpace}>{t('action.time-travel')}</button>
}
```

### Interpolation

Interpolation is how you dynamically integrate variable data, like perhaps a username or file type, into translated strings. Surrounding a term with a double set of curly braces will mark it as a `{{placeholder}}` for interpolation. You’d then supply the term to be interpolated as a second argument to the `t` function.

```tsx
// resources.ts
export default {
  // ...
  'greetings.my-name-is': 'My name is {{userName}}'
}

// MyComponent.tsx
function MyComponent() {
  const {t} = useTranslation('myPlugin')
  return <div>{t('greetings.my-name-is', {userName: 'Kokos'})}</div>
}
```

This object can take any number of key:value-pairs, allowing for quite complex string-crafting.

> [!TIP]
> Protip
> Note that values passed for interpolation should generally never be language-specific terms. For instance, sending `Image` or `File` to a message that reads `{{assetType}} not found` will not work in other languages, i.e. `Kunne ikke finne Image` is not valid Norwegian. See the section below titled [Context](https://www.sanity.io/docs/studio/internationalizing-plugins-ui#bb5f1b7d0720) for more info.

### Pluralization/counts

That second argument object to the `t` function is no one-trick pony. In addition to accepting interpolation variables, passing a `count` parameter to the `t` function will allow it to be pluralized:

```tsx
// resources.ts
export default {
  'search.result-summary_zero': 'Nothing matched your query',
  'search.result-summary_one': 'Found one match for your query',
  'search.result-summary_other': 'Found {{count}} matches for your query',
}

// MyComponent.tsx
function MyComponent() {
  const {t} = useTranslation('myPlugin')
  // ⬇ returns 'Found 4 matches for your query'
  return <div>{t('search.result-summary', {count: 4})}</div>
}
```

Note that the underscore works as a delimiter for matching terms. We’ll see the same pattern when working with variants in the next section. For more information, consult the [i18next documentation](https://www.i18next.com/translation-function/plurals).

### Context

Similarly to the count feature, the context feature allows you to create several variants of a translated term. Pass a string to the `context` parameter, and it will be matched against the underscore-delimited suffix of the keys:

```tsx
// resources.ts
export default {
  // ...
  'error.asset-not-found_image': 'Image not found',
  'error.asset-not-found_file': 'File not found',
  // Fallback in case context cannot be found:
  'error.asset-not-found': 'Asset not found',
}

// MyComponent.tsx
function MyComponent(props) {
  const {t} = useTranslation('myPlugin')
  // ⬇ returns either 'file' or 'image' depending on the asset type
  const assetType = props.document._type === 'sanity.imageAsset' ? 'image' : 'file'
  return <div>{t('error.asset-not-found', {context: assetType})}</div>
}
```

### Using React components as part of strings

In certain cases, you may need to use a component as part of the string. For instance, formatting/highlighting a part to indicate that it is user input, or showing a localized timestamp with an aria-label and a computer-readable ISO-timestamp attached to it.

For this, you can use the `Translate` component. Note that it is heavier to execute and should therefore only be used when necessary.

```tsx
// resources.ts
export default {
  'event-listing.summary': '{{host}} is organizing <Emphasis>{{name}}</Emphasis> at <Location/>, <Time />'
}

// EventListing.tsx
import {useRelativeTime, useTranslation, Translate} from 'sanity'

function EventListing(event) {
  const {t} = useTranslation('myPlugin')
  const time = useRelativeTime(event.isoTime)
  return (
    <Translate
      t={t}
      i18nKey="event-listing.summary"
      values={{host: event.host, name: event.name}}
      components={{
        Time: () => <time dateTime={event.isoTime}>{time}</time>,
        Location: () => <a href={event.location.url}>{event.location.name}</a>,
        Emphasis: ({children}) => <em>{children}</em>
      }}
    />
  )
}
```

> [!WARNING]
> Gotcha
> The tags in `components` can only receive a single prop: `children`. All other props should be passed to the `Translate` component when defining it. This helps minimize the parsing logic, and keeps it safer in terms of injections.

## I18n outside of React

The available and chosen languages are currently resolved as part of the *workspace matcher*, which means in some areas of the Studio user interface, i18n is not yet supported.

When you need to use i18n outside of React, such as in validation or structure definitions, we pass an `i18n` “source” through context, which has a `t` function available for use. If you encounter areas that do not have access to this context where you would need it, please let us know so we can find a suitable workaround or find a way to pass it down.

```jsx
defineField({
  type: 'string',
  name: 'Model',
  validation: (Rule) =>
    Rule.custom((value, context) => {
      if (!value || !value.startsWith('Model ')) {
        return context.i18n.t('some-namespace:some-error-message', {
          modelValue: value,
        })
      }
      return true
    }),
})
```

### Localizing validation messages directly in your schema

It’s worth noting that you can also add localized strings directly in the schema if you know what languages should be supported at authoring time.

```jsx
import {defineType, defineField} from 'sanity'

export const gallery = defineType({
  name: 'gallery',
  type: 'document',
  fields: [
    defineField({
      name: 'photos',
      type: 'array',
      of: [{type: 'image'}],
      validation: Rule => Rule.required().min(4).max(100).error({
        'en-US': 'Needs at least 4, at most 100 photos',
        'nb-NO': 'Kan ikke ha flere enn 100, og ikke færre enn 4'
      })
    }),
  ]
})
```

## Hooks

We are working on exposing more hooks, and some of these might change names. Currently, they are:

- [useTranslation](https://www.sanity.io/docs/reference/api/sanity/useTranslation): described above in some detail.
- [useCurrentLocale](https://www.sanity.io/docs/reference/api/sanity/useCurrentLocale): returns a `Locale`, which contains the locale `id` and `title`. Useful if you want to send this as part of a request to a server or something to localize the response. Also, for passing into the `Intl.X` APIs.
- [useListFormat](https://www.sanity.io/docs/reference/api/sanity/useListFormat): provides cached access to `Intl.ListFormat` instances based on the passed options and the current locale.
- [useNumberFormat](https://www.sanity.io/docs/reference/api/sanity/useNumberFormat): provides cached access to `Intl.NumberFormat` instances based on the passed options and the current locale.
- [useFormattedDuration](https://www.sanity.io/docs/reference/api/sanity/useFormattedDuration): not strictly an i18n API, but is localized. Give it a duration in milliseconds and it will format it with localized units.

### Debugging

If you want to see which parts of the user interface are localized and which are not, you can run the Studio with `SANITY_STUDIO_DEBUG_I18N=triangles npm run dev`. In this mode, all strings that have been localized will be framed by little ◤ “Triangles” ◢, and your console will have all sorts of useful information logged to help you locate missing or incorrect translations.



# Reference

The `plugin` configuration property accepts an array of plugin definitions. The plugin configuration accepts most of the same properties as the workspace config API, the notable exceptions being `dataset`, `projectId`, `auth`, and `theme`.

> [!TIP]
> Protip
> While entirely optional, wrapping your plugin configuration object with the `definePlugin()` helper function (exported from `sanity`) will make most editors show helpful type information and autocomplete suggestions even if you're not using TypeScript!
> The helper can also accept a function that returns a plugin configuration object. This allows you to make the plugin configurable, by passing arguments to the plugin configuration factory. For example: `definePlugin((options) => ({ ... }))`, where options are configuration options you want to use to allow end users to modify the plugin.

```javascript
import { definePlugin } from 'sanity'

export const previewUrlPlugin = definePlugin({
  name: 'preview-url-plugin',
  document: {
    productionUrl: async (prev, { document }) => {
      const slug = document.slug?.current
      return slug ? `https://some-custom-url.xyz/${slug}` : prev
    },
  },
})
```

## Properties

#### Properties

**name** (string, required)

Unique identifier for the plugin.

**document** (object | DocumentPluginOptions)

Accepts custom components for document actions and badges, as well as a custom productionUrl resolver and default configuration for new documents. Read more about the document API.

**form** (object | SanityFormConfig)

Extensions and customizations to the Studio forms. Accepts configurations for image and file asset sources as well as custom components to override the default Studio rendering. Read more about the form API.

**plugins** (array | PluginOptions[])

Studio plugins: takes an array of plugin declarations that can be called with or without a configuration object. Read more about plugins.

**tools** (array | Tool[])

Studio tools: takes an array of tool declarations that can be called with or without a configuration object. Read more about the tool API.

**schema** (object | SchemaPluginOptions)

Schema definition: takes an array of types and an optional array of templates (initial value templates). While defining a schema is not required, there are few things inside the Studio that work without one. Read more about the schema API.

**studio** (object | StudioComponentsPluginOptions)

Accepts a components object which will let you override the default rendering of certain bits of the studio UI. Read more about studio components.

**onUncaughtError** (function)

Accepts a callback function containing an error: Error and an errorInfo: ErrorInfo arguments. Commonly used by plugin developers to implement customized error handling, external logging, and telemetry.

A complete list of plugin options is available in the [type reference documentation](https://reference.sanity.io/sanity/index/PluginOptions/).

## Handling errors

As of `sanity@v6.4.0`, plugin and tool authors get a new `@beta` hook, **useStudioErrorHandler()**, for delegating unrecoverable request errors to the Studio's shared error UI instead of reinventing it. The developer handles what they can locally (inline errors, toasts, fallbacks) and delegates what they can't.

It exposes two methods, both delegating to the same native Studio Error UI.

### `attempt(fn, options?)`: for retryable requests

Wraps a request so the dialog's **Try again** re-invokes it. Resolves with the first successful attempt; the `fn` receives the attempt number.

```tsx
import {useEffect, useState} from 'react'
import {useStudioErrorHandler, useClient} from 'sanity'

function MemberList() {
  const client = useClient({apiVersion: '2025-02-19'})
  const {attempt} = useStudioErrorHandler()
  const [members, setMembers] = useState<Member[]>()

  useEffect(() => {
    attempt(
      // Each invocation issues a *fresh* request — this is what
      // "Try again" re-runs.
      (attemptNumber) => {
        if (attemptNumber > 1) console.debug(`retry #${attemptNumber}`)
        return client.fetch<Member[]>('*[_type == "member"]')
      },
      {retryable: true},
    ).then(setMembers)
  }, [attempt, client])

  // ...render `members`
}
```

> [!WARNING]
> Gotcha
> **The fn must create the request when called.** Retry works by invoking it again. Passing an already-started promise means "Try again" just re-awaits the same settled rejection and the dialog reappears.

```typescript
// ✓ fresh request per attempt
attempt(() => client.fetch(query), {retryable: true})

// ✗ request already fired; retry does nothing useful
const promise = client.fetch(query)
attempt(() => promise, {retryable: true})
```

`attempt()` also accepts an observable, but this observable **must complete**. Don't pass `client.listen()` or a long-lived `Subject`, as that would never resolve.

```typescript
// ✓ single-shot request observable
await attempt(() => client.observable.fetch(query), {retryable: true})
```

### `handle(err)`: for non-retryable rejections

A drop-in promise rejection handler: pass it directly to `.catch(handle)`. Claimable errors surface the Studio error dialog and leave the promise pending; unclaimable errors are re-thrown to the next `.catch()`, so downstream handlers still see caller-domain errors like validation failures, permission errors, and 404s.

Because `handle` receives an error rather than a re-runnable request, the dialog can't offer **Try again**. Use `attempt()` when the request is safe to re-run.

```typescript
client
  .create(doc)
  .catch(handle)
  .catch((err) => {
    // Caller-domain errors (validation, permissions, 404, ...)
  })
```



# Installation

Sanity AI Assist puts the power of large language models (LLMs) right at your fingertips in the Studio, where your content lives. Write reusable instructions in natural human language to a document-aware AI assistant that can handle chores and repetitive tasks while you focus on the creative stuff.

> [!NOTE]
> Paid feature
> This article is about a feature currently available for all projects on the [Growth plan](https://www.sanity.io/pricing) and up.

[Create and run instructions with AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-working-with-instructions)

[Common instructions for AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-cheat-sheet)

[Content translation with AI Assist](https://www.sanity.io/docs/studio/ai-assist-content-translation)

[AI Assist plugin page](https://www.sanity.io/plugins/ai-assist)

If you're interested in running programmatic AI instructions, check out [Agent Actions](https://www.sanity.io/docs/agent-actions).

## Installing the AI Assist plugin

AI Assist is a [plugin](https://www.sanity.io/plugins/ai-assist) for Sanity Studio and is installed using your favorite package manager, such as `npm`, `yarn`, or `pnpm`. It’s a good idea to ensure your studio is up to date while you’re at it. AI Assist requires your studio to be v3.26.0 or later to work.

**npm**

```shell
npm install sanity@latest @sanity/assist

```

**pnpm**

```shell
pnpm add sanity@latest @sanity/assist

```

**yarn**

```shell
yarn add sanity@latest @sanity/assist

```

**bun**

```shell
bun add sanity@latest @sanity/assist

```

### Add the plugin to your studio configuration

Once installed in your project, you must activate the plugin by importing it and adding it to your main studio configuration. In `sanity.config.ts`, add `assist` to the `plugins` array:

```tsx
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist(),
  ]
})

```

We’ll look at some configuration options for the `assist` plugin further on in this article, but for now, this is everything you need to get started.

#### Additional configuration settings

You can also configure AI Assist for more control.

```typescript
assist({
  // Showing defaults
  assist: {
    localeSettings: () => Intl.DateTimeFormat().resolvedOptions(),
    maxPathDepth: 4,
    temperature: 0.3
  },
})
```

- `localeSettings`: Enables the AI to understand natural language date and time, and know what timezone the language refers to. See the next section for more details.
- `maxPathDepth`: The max depth for document paths AI Assist can write to. Increase if you need deeper traversal, but large and complex schemas may result in decreased performance.
- `temperature` (from 0 to 1): Influences how much the output of an instruction will vary between runs. Higher values result in more varied results, while lower values are more repeatable.

#### Date and datetime

Starting from v3.0.0, AI Assist can write to date and datetime fields. Instructions can use language like "tomorrow at noon" or "next year," and when AI Assist writes to the field, it will be converted to a field-compatible value.

Language about time is `locale` and `timeZone` dependent. By default, instructions will use the locale and timezone provided by the browser (`Intl.DateTimeFormat().resolvedOptions()`).

Alternatively, you can configure the plugin per user with an `assist.localeSettings` function that should return `LocaleSettings`.

Example

```typescript
assist({
  assist: {
    localeSettings: ({user, defaultSettings}) => {
      if (user.roles.some((role) => role.name === 'administrator')) {
        // forces locale and timeZone for admins
        return {
          locale: 'en-US',
          timeZone: 'America/New_York',
        }
      }
      // defaultSettings is the same as using:
      // const {locale, timeZone} = Intl.DateTimeFormat().resolvedOptions()
      return defaultSettings
    }
  }
})

```

For a list of allowed values for these parameters, see the following resources:

- `locale`: [Mozilla on Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/getCanonicalLocales)
- `timeZone`: [Wiki on time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)

### Enabling the AI Assist API

After installing the AI Assist package and importing and adding the plugin to your studio configuration, you need to create a token to allow the plugin to access the AI Assist API. This needs to be done by a project member with token creation permissions (typically someone with an admin or developer role):

1. Start the Studio and open any document.
2. Select the **sparkle icon** (✨) in the document header near the close document button, or in the top-right corner of any field when hovering the field. If you have custom actions enabled, you may see a popup where you must then select **manage instructions**.

![select the sparkle AI assist icon](https://cdn.sanity.io/images/3do82whm/next/aacf1c89699c0bbb483a6647c879587b2f139a1e-818x380.png)

Selecting the AI Assist button will open an inspector pane to the right side of the current document with a button prompting you to **Enable Sanity AI Assist**.

![Enable AI assist example panel.](https://cdn.sanity.io/images/3do82whm/next/a8a01ad145fae14a70bd229333f0e03c644b7ec2-2000x861.png)

Click the **Enable Sanity AI Assist** button to create a token and enable AI Assist for everyone accessing the project.

You will find that a new API token entry for your project named “Sanity AI” has been created in your project's API settings, which you can examine at [sanity.io/manage](https://sanity.io/manage).

AI Assist will now work for any dataset in your project.

> [!TIP]
> Protip
> You can revoke this token at any time to disable the Sanity AI Assist service. A new token has to be generated via the plugin UI for it to work again.

At this point, AI Assist should be operational and ready for your perusal. You might want to take a detour and check out the article linked below for a closer look at where and how you can interact with the assistant in the Studio interface, or keep reading to learn more about how AI Assist works.

[Create and run instructions with AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-working-with-instructions)

## Schema configuration

By default, unless otherwise specified, AI Assist is enabled for all compatible fields and document types. We will look at how you can selectively exclude fields or document types from being affected by the assistant further on in the article.

## Supported field types

AI Assist can use most fields in your schema as a context in an instruction.

These are the field types it can write content to, including custom schema types based on the following:

- String and text
- Objects and the fields within them
- Arrays with inline objects and references
- Portable Text, including formatting and custom blocks
- Image assets (and image fields)
- References (requires additional configuration)
- Booleans
- Numbers
- Slugs
- URLs
- Date and DateTime

### Conditionally hidden and read-only fields

Any field that has `hidden` or `readOnly` set to `true` when the relevant instruction starts running will be skipped. An important word in that previous statement is “starts.” If a field has its `hidden` or `readOnly` value changed while the assistant is doing its thing, the new value will not be considered for that running process, even if the assistant has yet to reach that field.

AI Assist does not re-evaluate the `hidden` and `readOnly` status of fields after the instruction has started running. This means that even if a field has its `hidden` property changed from `true` to `false` as a side effect of something the assistant does, it will still regard that field as hidden, even if the status was changed before the assistant "gets to it."

Fieldsets with `hidden` and `readOnly` states are also accounted for.

### Unsupported fields

There are some field types that AI Assist can use as context but not write content for:

- Geolocation
- Cross Dataset References
- File assets

### Image asset generation

AI Assist can create assets for images configured with a prompt field.

Image generation can be done directly using the **Generate image from prompt** command on the prompt field or indirectly whenever an AI Assist instruction modifies the image prompt field.

To enable image generation for an image field, you must:

- Set `options.aiAssist.imageInstructionField` to a child-path relative to the image
- Have a `string` or `text` field that corresponds to the `imageInstructionField` path

This will add a "Generate image from prompt" instruction to the image prompt field. Executing this instruction will generate an image.

```typescript
defineType({
  type: 'document',
  name: 'article',
  fields: [
    defineField({
      type: 'image',
      name: 'articleImage',
      fields: [
        defineField({
          type: 'text',
          name: 'promptForImage',
          title: 'Image prompt',
          rows: 2,
        }),
      ],
      options: {
        aiAssist: {
          imageInstructionField: 'promptForImage',
        },
      },
    })
  ]
})
```

An image will be generated each time an AI Assist instruction modifies the image prompt field. This modification could come from a document instruction, an instruction for the image field or parent object, or directly on the image prompt field.

### Enabling automatic image captions

In addition to generating images from a prompt field, the assistant can also be set to generate descriptions from image assets. To enable the assistant to auto-generate descriptions that can be used for alt text or captions, supply a valid field path in `options.aiAssist.imageDescriptionField`.

```tsx
defineType({
  type: 'document',
  name: 'article',
  fields: [
    defineField({
      type: 'image',
      name: 'articleImage',
      fields: [
        defineField({
          type: 'text',
          name: 'alt',
          title: 'Alternative text',
          rows: 2,
        }),
      ],
      options: {
        aiAssist: {
          imageDescriptionField: 'alt',
        },
      },
    })
  ]
})

```

This will add a **Generate image description** instruction to the configured field (`alt` in this example) that will produce a description of the image.

![The AI assist interface with 'generate caption' selected.](https://cdn.sanity.io/images/3do82whm/next/9a51f1a050f5202704f78a17ed43aad51575c46b-2000x1503.png)

> [!TIP]
> Limited by AI Assist's caption defaults?
> If AI Assist's default behavior is too limiting for your needs, you can also describe images with the [Transform Agent Action](https://www.sanity.io/docs/agent-actions/transform-quickstart). Use Transform with the [image-description operation](https://www.sanity.io/docs/agent-actions/transform-cheatsheet) to prompt the AI with additional instructions.

### Enabling support for related content in references

> [!WARNING]
> Deprecation notice
> The **Embeddings Index API** is deprecated and will be sunset in a future release. It has been replaced with the new [Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings) feature, now natively available within Sanity datasets.
> At this time, we do not have a replacement solution available for using references with AI Assist.

To work with a `reference` field, the AI Assist plugin must consult an embedding index that includes the types it will refer to. To learn about embedding indexes and how to set them up, visit [this article](https://www.sanity.io/docs/content-lake/embeddings-index-api-overview).

You can manage your indexes directly in the Studio using the [Embeddings Index Dashboard plugin](https://github.com/sanity-io/embeddings-index-ui#embeddings-index-api-dashboard-for-sanity-studio). Once you have an index configured, you can enable `reference` fields for AI Assist by setting `options.aiAssist.embeddingsIndex` to whatever you named your index.

```tsx
import { defineField } from 'sanity'

defineField({
  type: 'reference',
  name: 'articleReference',
  title: 'Article reference',
  to: [{ type: 'article' }],
  options: {
    aiAssist: {
      embeddingsIndex: 'all-our-stuff-index'
    },
  },
})

```

Reference fields with this option set can have instructions attached and will be included when running instructions for object fields and arrays. An example instruction might look like this:

```text
Given <Document field: Title> suggest a related article

```

AI Assist will use the embeddings index, filtered by the types specified in the field declaration, to look up contextually relevant references. One or more references can be added for arrays or Portable Text fields with references.

## Selectively exclude fields and document types

AI Assist defaults to inclusivity and will target every supported field it comes across. This may not always be desirable, so it comes with the option to exclude fields and document types selectively by setting the `options.aiAssist.exclude` option to `true`.

### Disable AI Assist for a schema type

```tsx
// disable AI assistance wherever it is used,
// ie: as field, document, array types
defineType({
  name: 'policy',
  type: 'document',
  options: {
    aiAssist: {exclude: true}
  },
  fields: [
    // ...
  ]
})

```

### Disable for a nested field type

```tsx
// this disables AI assistance only for the specific field
defineType({
  name: 'product',
  type: 'object',
  fields: [
    defineField({
      name: 'sku',
      type: 'string',
      options: {
        aiAssist: {exclude: true},
      },
    }),
  ],
})

```

### Disable for an array type

```tsx
// disables AI assistance for the specific array member
// if all types in the `of` array are excluded, the array type is also considered excluded
defineType({
  name: 'myArray',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'someType',
      options: {
        aiAssist: {exclude: true}
      }
    })
  ]
})

```

## The AI Context document type

This plugin adds an `AI Context` document type.

If your Studio uses [Structure Builder](https://www.sanity.io/docs/studio/structure-builder-introduction) to configure the studio structure, you might have to add this document type to your structure.

The document type name can be imported from the plugin:

```tsx
import {contextDocumentTypeName} from '@sanity/assist'

// add to your structure
S.documentTypeListItem(contextDocumentTypeName)

```

## Troubleshooting

> [!NOTE]
> Caveats
> Large Language Models (LLMs) are a new technology. Constraints and limitations are still being explored, but some common caveats to the field that you may run into using AI Assist are:
> - Limits to instruction length: Long instructions on deep content structures may exhaust model context
> - Timeouts: To be able to write structured content, we're using the largest language models. Long-running results may time out or intermittently fail
> - Limited capacity: The underlying LLM APIs used by AI Assist are resource constrained

There are limits to how much text the AI can process for an instruction. Under the hood, AI Assist will add information about your schema, which adds to what's commonly called “the context window.”

If you have a very large schema (many document and field types), it can be necessary to exclude types to limit how much of the context window is used for the schema itself.

We recommend excluding all types that would rarely benefit from automated workflows. A quick win is typically to exclude array types. It can be a good idea to exclude most non-block types from Portable Text arrays. This will ensure that AI Assist outputs mostly formatted text.

### Third-party sub-processors

A list of third-party sub-processors, as well as details on the terms of use for our AI products are [available here](https://www.sanity.io/legal/tos-ai).



# Translation

AI Assist is a powerful tool for projects that need to serve content in multiple languages. To reflect the two most popular strategies for content localization in Sanity projects, two distinct APIs are ready to help you with document-level or field-level translation. Let’s look at each in turn.

> [!NOTE]
> Paid feature
> This article is about a feature currently available for all projects on the [Growth plan](https://www.sanity.io/pricing) and up.

[Install and configure AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)

[Create and run instructions with AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-working-with-instructions)

[Common instructions for AI Assist](https://www.sanity.io/docs/user-guides/ai-assist-cheat-sheet)

[Localization](https://www.sanity.io/docs/studio/localization)

## Full document translation

This workflow assumes one document per language. It is designed to work especially well with the Sanity-maintained [Document internationalization](https://www.sanity.io/plugins/document-internationalization) plugin, but it will work without it if, for some reason, you aren’t willing or able to install said plugin.

### Configuring the AI Assist plugin for full document translation

To set up your project for full document translation with AI Assist, you need to pass a configuration object to the `assist()` plugin declaration in `sanity.config.js|ts`.

```typescript
plugins: [
  assist({
    translate: {
      // Style guide for the translation agent. Max 2000 chars.
      styleguide: 'Be extremely formal and precise. Mimic Spock from Star Trek.',
      document: {
        // The name of the field that holds the current language
        // in the form of a language code e.g. 'en', 'fr', 'nb_NO'.
        // Required
        languageField: 'language',
        // Optional extra filter for document types.
        // If not set, translation is enabled for all documents
        // that have a field with the name defined above.
        documentTypes: ['article', 'blogpost'],
      }
    }
  })
]
```

The `languageField` should correspond with the `name` of a field present in any document type that should be translation-enabled. AI Assist will use the value of this field to determine the language to which it should translate the relevant document.

```typescript
export default {
  name: 'article',
  title: 'Article',
  type: 'document',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string',
    },
    {
      name: 'language',
      title: 'Language',
      type: 'string',
      options: {
        list: [
          {title: 'English', value: 'en_US'},
          {title: 'Norwegian Bokmål', value: 'nb_no'},
          {title: 'Esperanto', value: 'eo'},
          {title: 'Lojban', value: 'jbo'},
          {title: 'Toki Pona', value: 'tok'},
        ],
      },
    },
    {
      name: 'body',
      title: 'Body',
      type: 'array',
      of: [{type: 'block'}],
    },
  ],
}

```

Any document type that is enabled for translation will now have a translation instruction added to its document-level AI Assist menu. This instruction is not available to edit for the user, but in all other regards works and behaves the same as user-created instructions.

![Shows the top-level document contextual menu for AI Assist](https://cdn.sanity.io/images/3do82whm/next/3bb2bd5b1c6eeec67cb3b8bc71adf2df7bffbf37-617x446.png)

After first setting the desired language, selecting the **Translate document **instruction will put the assistant to work. Within a few moments, you should have a translated document. Note that the assistant will replace the current field values with the translated values, so unless your aim was to replace the original, you probably should make a copy before running the translation.

> [!WARNING]
> Gotcha
> As with all current large language model based tools, AI Assist should never be relied on for critical content without a human being reviewing the results. It’s pretty good, but it does make mistakes!

![Shows multiple fields with a purple icon indicating the assistant is currently working on that field](https://cdn.sanity.io/images/3do82whm/next/86984b4d10f801dd710674ded6d5d76e94956ab1-1084x808.png)

While the assistant is working, you’ll see purple spinning AI presence icons indicating which fields are currently being translated. The assistant can work on several fields simultaneously, as shown in the screenshot above.

### Dynamic style guides

As of 4.1.0, it is also possible to provide a `styleguide` async function. This is useful when you’d like to allow users to modify the style guide from within the Studio. The function is passed a context object with a Sanity client, the current `documentId`, and `schemaType`.

Consider caching the results, as the function is invoked every time translate runs.

This example fetches a singleton document:

```typescript
assist({
  translate: {
    styleguide: ({client, documentId, schemaType}) => client.fetch('* [_id=="styleguide.singleton"][0].styleguide')
  },
})
```

## Field-level translations

Another popular strategy for multi-language content wrangling in Sanity is to keep all the different language variants in the same document, using objects with a set of fields of the same type to represent each translation.

```typescript
{
  type: 'document',
  name: 'article',
  fields: [
    {
      type: 'object',
      name: 'localeTitle',
      fields: [
        {type: 'string', name: 'en', title: 'English'},
        {type: 'string', name: 'de', title: 'German'},
      ]
    }
  ]
}
```

This method is greatly facilitated by using the Sanity-maintained [Internationalized Array](https://www.sanity.io/plugins/internationalized-array) plugin, and AI Assist affordances for field-level translation have been designed to work with the same setup and configuration this plugin presumes.

![Shows a field named "Subtitle" with two fields marked with NB and EN respectively, each containing a string in that respective language](https://cdn.sanity.io/images/3do82whm/next/1e8f550c25d4acddbaf48b5537f9bef1233b5b00-1338x418.png)

Setting up AI Assist to support field-level translation for this workflow is done in the `translate.field` configuration property. A minimal example for the schema above might look something like this:

```typescript
assist({
  translate: {
    field: {
      documentTypes: ['article'],
      languages: [
        { id: 'en', title: 'English' },
        { id: 'de', title: 'German' },
      ],
    },
  },
});

```

`documentTypes` expects an array of document names for which the translation instruction should be activated. `languages` expects an array of language definitions, which should consist of an `id` in the form of a locale code and a human-readable `title` for rendering labels and such in the UI. An async callback function can also be used to return the same structure of data.

```typescript
assist({
  translate: {
    field: {
      languages: async () => {
        const response = await fetch('https://example.com/languages');
        return response.json();
      },
    },
  },
});

```

The async function contains a configured Sanity client as its first argument, allowing you to store language options as documents. Your query should return an array of objects with an `id` and `title`.

```typescript
assist({
  translate: {
    field: {
      languages: async (client) => {
        const response = await client.fetch(
          `*[_type == "language"]{ id, title }`
        );
        return response;
      },
    },
  },
});

```

Additionally, you can pick specific fields from a document to pass into the query. For example, if you have a concept of "markets" where only certain language fields are required in certain markets.

In this example, each language document has an array of strings named `markets` to declare where that language can be used. And the document being authored has a string field named `market`.

```typescript
assist({
  translate: {
    field: {
      selectLanguageParams: {
        market: 'market',
      },
      languages: async (client, { market = `` }) => {
        const response = await client.fetch(
          `*[_type == "language" && $market in markets]{ id, title }`,
          { market }
        );
        return response;
      },
    },
  },
});

```

### Custom language fields

As mentioned, the translation capabilities of AI Assist have been designed to work with the content paradigm recommended by the official Sanity-maintained plugins for working with multi-language content: the [Document internationalization](https://www.sanity.io/plugins/document-internationalization) and [Internationalized Array](https://www.sanity.io/plugins/internationalized-array) plugins. If following the conventions of these plugins is not feasible for your project, you have the option of tailoring the relationship and structure between language fields in your setup using the `translationOutputs` property.

By providing a function to `translate.field.translationOutputs` you can manually map the structure of your internationalized fields.

This function is invoked when an editor uses the **Translate fields…** instruction, and determines the relationships between document paths: Given a document path and a language, it should return the sibling paths into which translations are output.

`translationOutputs` is invoked once per path in the document (limited to a depth of 6), with the following arguments:

- `documentMember`: the field or array item for a given path; contains the path and its schema type
- `enclosingType`: the schema type of the parent holding the member
- `translateFromLanguageId`: the languageId for the language the user wants to translate from
- `translateToLanguageIds`: all languageIds the user can translate to

The function should return an array that contains all the paths where translations from `documentMember` (in the language given by `translateFromLanguageId`) should be output.

The function should return `undefined` for all `documentMember` values that should not be directly translated, or that are nested fields under a translated path.

### Default function

The default `translationOutputs` is available using `import {defaultLanguageOutputs} from '@sanity/assist'`.

### Example

Given the following document:

```typescript
{
  titles: {
    _type: 'languageObject',
    en: {
      _type: 'titleObject',
      title: 'Some title',
      subtitle: 'Some subtitle'
    },
    de: {
      _type: 'titleObject',
    }
  }
}
```

When translating from English to German, `translationOutputs` will be invoked multiple times.

The following parameters will be the same in every invocation:

- `translateFromLanguageId` will be `'en'`
- `translateToLanguageIds` will be `['de']`

`documentMember` and `enclosingType` will change between each invocation and take the following values:

1. `{path: 'titles', name: 'titles', schemaType: ObjectSchemaType}`, `ObjectSchemaType`
2. `{path: 'titles.en', name: 'en', schemaType: ObjectSchemaType}`, `ObjectSchemaType`
3. `{path: 'titles.en.title', name: 'title', schemaType: StringSchemaType}`, `ObjectSchemaType`
4. `{path: 'titles.en.subtitle', name: 'subtitle', schemaType: StringSchemaType}`, `ObjectSchemaType`
5. `{path: 'titles.de', name: 'de', schemaType: ObjectSchemaType}`, `ObjectSchemaType`

To indicate that you want everything under `titles.en` to be translated into `titles.de`, `translationOutputs` needs to return `[{id: 'de', outputPath: ['titles', 'de']}]` when invoked with `documentMember.path: 'titles.en'`.

The function to enable this behavior might look like this:

```typescript
function translationOutputs(
  member,
  enclosingType,
  translateFromLanguageId,
  translateToLanguageIds
) {
  const parentIsLanguageWrapper =
    enclosingType.jsonType === 'object' &&
    enclosingType.name.startsWith('language');

  if (parentIsLanguageWrapper && translateFromLanguageId === member.name) {
    return translateToLanguageIds.map((translateToId) => ({
      id: translateToId,
      // in this example, member.path is 'titles.en'
      // so this changes titles.en -> titles.de
      outputPath: [...member.path.slice(0, -1), translateToId],
    }));
  }

  // ignore other members
  return undefined;
}

```

## Adding translation actions to fields

By default, **Translate document** and **Translate fields…** instructions are only added to the top-level document instruction menu.

These instructions can also be added to fields by setting `options.aiAssist.translateAction: true` for a field or type.

This allows editors to translate only parts of the document, and can be useful to enable for `internationalizedArray` or `locale` wrapper object types.

For document types configured for full document translations, a **Translate** action will be added. Running it will translate the field to the language set in the language field.

For document types configured for field translations, a **Translate fields…** action will be added. Running it will open a dialog with language selectors.

```typescript
defineField({
    name: 'subtitle',
    type: 'internationalizedArrayString',
    title: 'Subtitle',
    options: {
        aiAssist: {
            translateAction: true
        }
    },
})
```



# Custom field actions

In this guide, you will create a field action that uses [Agent Action Transform](https://www.sanity.io/docs/agent-actions/transform-quickstart) to fix the spelling of a field.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

Prerequisites:

- AI Assist plugin (`@sanity/assist`) v4.3.0 or higher is required to enable custom field actions.
- A studio configured with the AI Assist plugin. See the following guide for details on installation and setup.
- Available usage to run an Agent Action Transform query.

[Install and configure Sanity AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist)
How to install and configure the AI Assist plugin for Sanity Studio.

## Add your first field action

You configure field actions in the `assist` plugin configuration. Open the `sanity.config.ts` file in your studio and add the `fieldActions` key to your `assist` configuration.

**sanity.config.ts**

```
import { defineConfig } from "sanity"
import { assist } from "@sanity/assist"

export default defineConfig({
// ... other settings
  plugins: [
    assist({
      fieldActions: {
        // <-- Field actions are configured here.
      }
    })
  ]
})
```

For this example, you'll need a few imports as well. Update your config to include the new imports. The dependencies should already be a part of your studio.

**sanity.config.ts**

```
import { defineConfig } from "sanity"
import { assist, defineAssistFieldAction } from '@sanity/assist'
import { useMemo } from 'react'
import { useClient } from 'sanity'

export default defineConfig({
// ... other settings
  plugins: [
    assist({
      fieldActions: {
      }
    })
  ]
})
```

### Set up `useFieldActions`

Start by defining `useFieldActions`. It is called for the document itself and for all fields. It can call React hooks. Actions returned by the hook are added to the corresponding document or field menu. It is recommended to wrap the returned actions in `useMemo`.

**sanity.config.ts**

```
import { defineConfig } from "sanity"
import { assist, defineAssistFieldAction } from '@sanity/assist'
import { useMemo } from 'react'
import { useClient } from 'sanity'

export default defineConfig({
// ... other settings
  plugins: [
    assist({
      fieldActions: {
        title: "My Actions", // Optional: sets the group title
        useFieldActions: (props) => {
          const {
            actionType
          } = props
          return useMemo(() => {
            if (actionType === 'field') {
              return [
                defineAssistFieldAction({
                  title: "Fix spelling",
                  onAction: async () => {
                    // ... Action logic here
                  }
                })
              ]
            }
            return []
          }, [actionType])
        }
      }
    }),
  ]
})
```

This checks if the action, stored as `actionType`, is a field or the document, then returns an array of actions to apply to the field's action menu.

The `defineAssistFieldAction` helper adds a single action. `onAction` *cannot* call hooks. If you need any state from a hook, it should be pre-assembled in `useFieldActions` before returning `useMemo`.

### Gather props and use the client to call Transform

This action uses Agent Action Transform, so you need a client instance, as well as some contextual details to pass in to Transform. 

1. Destructure additional props that you need for Transform. `useFieldActions` gives you access to details about the schema, the type of action, the path to a field (if available) and more.
2. Set up the client with `useClient`. You already imported `useClient` in a previous step. Note: Agent Actions require using the `vX` API version at this time.
3. Configure Transform to pass the existing contents of the field to the AI, then replace it with the response.

Here's the final code:

**sanity.config.ts**

```
import { defineConfig } from "sanity"
import { assist, defineAssistFieldAction } from '@sanity/assist'
import { useMemo } from 'react'
import { useClient } from 'sanity'

export default defineConfig({
// ... other settings
  plugins: [
    assist({
      fieldActions: {
        title: "My Actions", // Optional: sets the group title
        useFieldActions: (props) => {
          const {
            actionType,
            schemaId,
            documentIdForAction,
            path,
            getConditionalPaths
          } = props
          const client = useClient({apiVersion: 'vX'})
          return useMemo(() => {
            if (actionType === 'field') {
              return [
                defineAssistFieldAction({
                  title: "Fix spelling",
                  onAction: async () => {
                    await client.agent.action.transform({
                      schemaId,
                      documentId: documentIdForAction,
                      instruction: "fix any spelling mistakes",
                      instructionParams: {
                        field: { type: 'field', path }
                      },
                      target: path.length ? {path} : undefined,
                      conditionalPaths: { paths: getConditionalPaths()}
                    })
                  }
                })
              ]
            }
            return []
          }, [actionType, schemaId, documentIdForAction, path, getConditionalPaths, client])
        }
      }
    }),
  ]
})
```

### Run the field action

Save and run your studio, and you should see the field action in the AI Assist menu for any supported fields.

![Animated gif of a user running the fix spelling field action](https://cdn.sanity.io/images/3do82whm/next/a168ebfd6080a42a5f5a9d6b00911a12a8efe194-800x281.gif)

## Contextually-aware example

The following example adds a "Fill field" action to all fields in the document by calling [Agent Action Generate](https://www.sanity.io/docs/agent-actions/generate-quickstart).

The action will:

- Create the document as a draft if it does not exist, respecting initial values (`targetDocument`)
- Use existing document state to determine what should be put in the field (`instruction`, `instructionParams`).
- Pass the current readOnly and hidden state currently used by the document form to the Agent Action, so it respects it (`conditionalPaths`).
- Output to the field the action started from (`target.path`).

**plugin.ts**

```typescript
assist({
  fieldActions: {
    title: 'Custom actions',
    useFieldActions: (props) => {
      const {
        documentSchemaType,
        actionType,
        schemaId,
        getDocumentValue,
        getConditionalPaths,
        documentIdForAction,
        path,
        schemaType,
      } = props

      // hook usage has to happen outside onAction, so preassemble state in useFieldActions and pass to useMemo
      const client = useClient({apiVersion: 'vX'})

      return useMemo(() => {
        if (actionType === 'document') {
          // in this case we dont want a document action
          return []
        }

        return [
          defineAssistFieldAction({
            title: 'Fill field',
            icon: EditIcon,
            onAction: async () => {
              await client.agent.action.generate({
                schemaId,
                targetDocument: {
                  operation: 'createIfNotExists',
                  _id: documentIdForAction,
                  _type: documentSchemaType.name,
                  initialValues: getDocumentValue(),
                },
                instruction: `
                        We are generating a new value for a document field.
                        The document type is ${documentSchemaType.name}, and the document type title is ${documentSchemaType.title}
                        The document language is: "$lang" (use en-US if unspecified)
                        The document value is:
                        $doc
                        ---
                        We are in the following field:
                        JSON-path: ${path.toString()}
                        Title: ${schemaType.title}
                        Value: $field (consider it empty if undefined)
                        ---
                        Generate a new field value. The new value should be relevant to the document type and context.
                        Keep it interesting. Generate using the document language.
                     `,
                instructionParams: {
                  doc: {type: 'document'},
                  field: {type: 'field', path},
                  lang: {type: 'field', path: ['language']},
                },
                target: {
                  path,
                },
                conditionalPaths: {
                  paths: getConditionalPaths(),
                },
              })
            },
          }),
        ]
      }, [
        client,
        documentSchemaType,
        schemaId,
        getDocumentValue,
        getConditionalPaths,
        documentIdForAction,
        actionType,
        path,
        schemaType,
      ])
    },
  },
})
```

## Define helpers

The following are the available helpers for defining actions. You've seen the first one in use above.

### `defineAssistFieldAction`

Adds a single action that will appear in the document/field action menu.

`onAction` *cannot* call hooks. If state from hook is needed, it should be pre-assembled by `useFieldActions`

```
defineAssistFieldAction({
  title: 'Do something',
  icon: ActionIcon,
  onAction: async () => {
    //perform actions
  },
})
```

### `defineAssistFieldActionGroup`

Adds a group to hold one or more actions (or nested groups).

By default, any actions returned by `useFieldActions` will be grouped under `title`.

```
useFieldActions: (props) => {
  return [
    defineAssistFieldAction({/* ... */}), 
    defineAssistFieldActionGroup({
      title: 'More actions',
      children: [
        defineAssistFieldAction({/* ... */}),
      ],
    })
  ]
}
```

#### Only groups in `useFieldActions`

If `useFieldActions` *only* returns groups, the default wrapper group will be omitted. This allows full control over each group title.

### `defineFieldActionDivider`

Adds a divider between actions or groups. Takes no arguments:

```
useFieldActions: (props) => {
  return useMemo(() => [
    defineAssistFieldAction({/* ... */}),
    defineFieldActionDivider(),
    defineAssistFieldAction({/* ... */}),
  ], [])
}
```

## `useUserInput`

For certain actions, it is useful to have the user provide additional information or details that can be used as parameters for the action.

`useUserInput` returns a `getUserInput` function that can be called and awaited to return input from the user.

The `getUserInput` function takes input configuration and will display an input dialog to the user. When the user completes the dialog, the user-inputed text will be available (or undefined if the user closed the dialog).

```
assist({
  fieldActions: {
    title: 'Custom actions',
    useFieldActions: (props) => {
      const getUserInput = useUserInput()

      return useMemo(
        () => [
          defineAssistFieldAction({
            title: 'Do something with user input',
            onAction: async () => {
              const inputResult = await getUserInput({
                title: 'What do you want to do?', // dialog title
                inputs: [
                  {
                    id: 'topic',
                    title: 'Topic',
                  },
                  {
                    id: 'facts',
                    title: 'Facts',
                    description: 'Provide additional facts that will be used by the action',
                  },
                ],
              })
              if (!inputResult) {
                return // user closed the dialog
              }

              //use the result from each input
              //const [{result: topic}, {result: facts}] = inputResult
            },
          }),
        ],
        [getUserInput],
      )
    },
  },
})
```



# Field action patterns

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

AI Assist custom field actions allow you to hook into the AI assist menu to add your own programmatic actions. 

Prerequisites:

- Install and configure [AI Assist](https://www.sanity.io/docs/studio/install-and-configure-sanity-ai-assist) in your studio.
- Review the [field action guide](https://www.sanity.io/docs/studio/ai-assist-field-actions).
- Get to know [Agent Actions](https://www.sanity.io/docs/agent-actions/introduction).

## Basic setup

Each example below includes the field action and the config example. Set up configuration examples in your studio config. For example:

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          // add field action setup here
        }
      }
    })
  ]
})
```

For each example, remember to update any paths and filenames to match your project structure. You may also need to edit individual fields and paths to fit your schema's needs.

## Auto-fill any field

This action will generate a new value for the document or field it is invoked for. The value will be contextually based on what is already in the document, and use the language in a `language` field (if present).

**actions/autoFill.ts**

```
import {type AssistFieldActionProps, defineAssistFieldAction} from '@sanity/assist'
import {useMemo} from 'react'
import {EditIcon} from '@sanity/icons/Edit'
import {pathToString, useClient} from 'sanity'

export function useAutoFillFieldAction(props: AssistFieldActionProps) {
  const {
    actionType,
    documentIdForAction,
    documentSchemaType,
    getConditionalPaths,
    getDocumentValue,
    path,
    schemaId,
    schemaType,
  } = props

  const client = useClient({apiVersion: 'vX'})

  return useMemo(() => {
    return defineAssistFieldAction({
      title: actionType ? 'Autofill field' : 'Autofill document',
      icon: EditIcon,
      onAction: async () => {
        await client.agent.action.generate({
          schemaId,
          targetDocument: {
            operation: 'createIfNotExists',
            _id: documentIdForAction,
            _type: documentSchemaType.name,
            initialValues: getDocumentValue(),
          },
          instruction: `
            We are generating a new value for a document field.
            The document type is ${documentSchemaType.name}, and the document type title is ${documentSchemaType.title}
            The document language is: "$lang" (use en-US if unspecified)
            The document value is:
            $doc
            ---
            We are in the following field:
            JSON-path: ${pathToString(path)}
            Title: ${schemaType.title}
            Value: $field (consider it empty if undefined)
            ---
            Generate a new field value. The new value should be relevant to the document type and context.
            Keep it interesting. Generate using the document language.
                     `,
          instructionParams: {
            doc: {type: 'document'},
            field: {type: 'field', path},
            lang: {type: 'field', path: ['language']},
          },
          target: {
            // `mixed` will append on array-like fields and set on non-array fields (the default option).
            // Optionally change this to `set` or `append` to force patch behavior across all field types.
            operation: 'mixed',
            path,
          },
          conditionalPaths: {
            paths: getConditionalPaths(),
          },
        })
      },
    })
  }, [
    client,
    actionType,
    documentIdForAction,
    documentSchemaType,
    getConditionalPaths,
    getDocumentValue,
    path,
    schemaId,
    schemaType,
  ])
}
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
import { useAutoFillFieldAction } from 'actions/autoFill'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          const autoFill = useAutoFillFieldAction(props)
          return useMemo(() => {
            return [autoFill]
          }, [autoFill])
        }
      }
    })
  ]
})
```

## Fill document from user input

This action will generate a new value for the document based on user provided input. It uses the `getUserInput` feature.

**actions/fillDocumentFromInput.ts**

```
import {type AssistFieldActionProps, defineAssistFieldAction, useUserInput} from '@sanity/assist'
import {useMemo} from 'react'
import {ComposeIcon} from '@sanity/icons/Compose'
import {useClient} from 'sanity'

export function useFillDocumentFromInput(props: AssistFieldActionProps) {
  const {
    actionType,
    documentIdForAction,
    documentSchemaType,
    getConditionalPaths,
    getDocumentValue,
    schemaId,
  } = props

  const client = useClient({apiVersion: 'vX'})
  const getUserInput = useUserInput()

  return useMemo(() => {
    if (actionType !== 'document') {
      return undefined
    }
    return defineAssistFieldAction({
      title: 'Fill document...',
      icon: ComposeIcon,
      onAction: async () => {
        const userInput = await getUserInput({
          title: 'What should the document be about?',
          inputs: [
            {
              id: 'topic',
              title: 'Instruction',
              description:
                'Describe what the document should be about. ' +
                'Feel free to provide material for that will be used to create the document.',
            },
          ],
        })

        if (!userInput) {
          return undefined // user closed the dialog
        }

        const [{result: instruction}] = userInput
        await client.agent.action.generate({
          schemaId,
          targetDocument: {
            operation: 'createIfNotExists',
            _id: documentIdForAction,
            _type: documentSchemaType.name,
            initialValues: getDocumentValue(),
          },
          instruction: `
            Populate a document in full, based on a user instruction.
            The document type is ${documentSchemaType.name}, and the document type title is ${documentSchemaType.title}
            Pay attention to the language used in the user description, and use the same language for the document content.
            ---
            The user instruction is:
            ${instruction}
            ---
                     `,
          target: {
            operation: 'set',
          },
          conditionalPaths: {
            paths: getConditionalPaths(),
          },
        })
      },
    })
  }, [
    actionType,
    client,
    documentIdForAction,
    documentSchemaType,
    getConditionalPaths,
    getDocumentValue,
    getUserInput,
    schemaId,
  ])
}
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
import { useFillDocumentFromInput } from 'actions/fillDocumentFromInput'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          const fillDocument = useFillDocumentFromInput(props)
          return useMemo(() => {
            return [fillDocument]
          }, [fillDocument])
        }
      }
    })
  ]
})
```

## Generate image from user input

This action will generate an image based on input from the user.

**actions/generateImageFromInput.ts**

```
import {
  type AssistFieldActionProps,
  defineAssistFieldAction,
  isType,
  useUserInput,
} from '@sanity/assist'
import {useMemo} from 'react'
import {ComposeIcon} from '@sanity/icons/Compose'
import {useClient} from 'sanity'

export function useGenerateImageFromInput(props: AssistFieldActionProps) {
  const {
    documentIdForAction,
    documentSchemaType,
    getConditionalPaths,
    getDocumentValue,
    path,
    schemaId,
    schemaType,
  } = props

  const client = useClient({apiVersion: 'vX'})
  const getUserInput = useUserInput()

  return useMemo(() => {
    // only add to image fields
    if (!isType(schemaType, 'image')) {
      return undefined
    }

    return defineAssistFieldAction({
      title: 'Generate image...',
      icon: ComposeIcon,
      onAction: async () => {
        const userInput = await getUserInput({
          title: 'Describe the image',
          inputs: [
            {
              id: 'image-description',
              title: 'Image description',
            },
          ],
        })
        if (!userInput) {
          return undefined // user closed the dialog
        }

        const [{result: instruction}] = userInput

        await client.agent.action.generate({
          schemaId,
          targetDocument: {
            operation: 'createIfNotExists',
            _id: documentIdForAction,
            _type: documentSchemaType.name,
            initialValues: getDocumentValue(),
          },
          instruction: `
            Create an image based on the following instruction:
            ${instruction}
            ---
                     `,
          target: {
            operation: 'set',
            path: [...path, 'asset'],
          },
          conditionalPaths: {
            paths: getConditionalPaths(),
          },
        })
      },
    })
  }, [
    client,
    documentIdForAction,
    documentSchemaType,
    getConditionalPaths,
    getDocumentValue,
    getUserInput,
    path,
    schemaId,
    schemaType,
  ])
}
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
import { useGenerateImageFromInput } from 'actions/generateImageFromInput'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          const generateImage = useGenerateImageFromInput(props)
          return useMemo(() => {
            return [generateImage]
          }, [generateImage])
        }
      }
    })
  ]
})
```

## Summarize the document to a field

This action reads the contents of the document, then summarizes it into the selected field.

**actions/summarizeDocument.ts**

```
import {type AssistFieldActionProps, defineAssistFieldAction, isType} from '@sanity/assist'
import {useMemo} from 'react'
import {EditIcon} from '@sanity/icons/Edit'
import {isArrayOfObjectsSchemaType, pathToString, SchemaType, useClient} from 'sanity'
import {useToast} from '@sanity/ui/toast'

export function useSummarizeDocument(props: AssistFieldActionProps) {
  const {
    actionType,
    documentIdForAction,
    getConditionalPaths,
    getDocumentValue,
    path,
    schemaId,
    schemaType,
  } = props

  const client = useClient({apiVersion: 'vX'})
  const {push: pushToast} = useToast()

  return useMemo(() => {
    if (actionType !== 'field') {
      return undefined
    }

    const isSupportedType =
      isType(schemaType, 'string') || isType(schemaType, 'text') || isPortableTextArray(schemaType)
    if (!isSupportedType) {
      return undefined
    }

    const lastSegment = path.slice(-1)[0]
    if (
      typeof lastSegment !== 'string' ||
      !['summary', 'description'].some((contains) => lastSegment.toLowerCase().includes(contains))
    ) {
      return undefined
    }

    return defineAssistFieldAction({
      title: 'Summarize document',
      icon: EditIcon,
      onAction: async () => {
        if (!getDocumentValue()?._createdAt) {
          pushToast({
            title: 'Document is new',
            description:
              'The document is new, without meaningful content to summarize. Make an edit and try again.',
          })
          return
        }

        await client.agent.action.generate({
          schemaId,
          documentId: documentIdForAction,
          instruction: `
                        Given the following document:
                        $doc
                        ---
                        We are in the following field:
                        JSON-path: ${pathToString(path)}
                        Title: ${schemaType.title}
                        Description: ${schemaType.description ?? 'n/a'}
                        ---
                        Generate a summary of the document that is contextually relevant for the field.
                     `,
          instructionParams: {
            doc: {type: 'document'},
          },
          target: {
            operation: 'set',
            path,
          },
          conditionalPaths: {
            paths: getConditionalPaths(),
          },
        })
      },
    })
  }, [
    actionType,
    client,
    documentIdForAction,
    getConditionalPaths,
    getDocumentValue,
    path,
    schemaId,
    schemaType,
    pushToast,
  ])
}

export function isPortableTextArray(type: SchemaType) {
  return isArrayOfObjectsSchemaType(type) && type.of.some((t) => isType(t, 'block'))
}
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
import { useSummarizeDocument } from 'actions/summarizeDocument'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          const summarize = useSummarizeDocument(props)
          return useMemo(() => {
            return [summarize]
          }, [summarize])
        }
      }
    })
  ]
})
```

## Fix spelling

This action corrects the spelling in a field, including nested fields.

**actions/fixSpelling.ts**

```
import {type AssistFieldActionProps, defineAssistFieldAction} from '@sanity/assist'
import {useMemo} from 'react'
import {TranslateIcon} from '@sanity/icons/Translate'
import {useClient} from 'sanity'

export function useFixSpelling(props: AssistFieldActionProps) {
  const {documentIdForAction, getConditionalPaths, path, schemaId} = props

  const client = useClient({apiVersion: 'vX'})

  return useMemo(() => {
    return defineAssistFieldAction({
      title: 'Fix spelling',
      icon: TranslateIcon,
      onAction: async () => {
        await client.agent.action.transform({
          schemaId,
          documentId: documentIdForAction,
          instruction: 'Fix any spelling mistakes',
          // no need to send path for document actions
          target: path.length ? {path} : undefined,
          conditionalPaths: {paths: getConditionalPaths()},
        })
      },
    })
  }, [client, documentIdForAction, getConditionalPaths, path, schemaId])
}
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
import { useFixSpelling } from 'actions/fixSpelling'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          const spelling = useFixSpelling(props)
          return useMemo(() => {
            return [spelling]
          }, [spelling])
        }
      }
    })
  ]
})
```

## Translate to a user-defined language

This action takes user input for the language, and translates the document or field's contents to that language.

**actions/translateToAny.ts**

```
import {type AssistFieldActionProps, defineAssistFieldAction, useUserInput} from '@sanity/assist'
import {useMemo} from 'react'
import {TranslateIcon} from '@sanity/icons/Translate'
import {useClient} from 'sanity'

export function useTranslateToAny(props: AssistFieldActionProps) {
  const {documentIdForAction, getConditionalPaths, path, schemaId} = props

  const client = useClient({apiVersion: 'vX'})
  const getUserInput = useUserInput()

  return useMemo(() => {
    return defineAssistFieldAction({
      title: 'Translate to language...',
      icon: TranslateIcon,
      onAction: async () => {
        const userInput = await getUserInput({
          title: 'Translate',
          inputs: [
            {
              id: 'instruction',
              title: 'Language',
              description: 'Which language do you want to translate to?',
            },
          ],
        })

        if (!userInput) {
          return undefined // user closed the dialog
        }

        const [{result: userLanguage}] = userInput

        await client.agent.action.translate({
          schemaId,
          documentId: documentIdForAction,
          // this is just fox example purposes: it is reccomended to use real language ids, not ones provided by a user string
          toLanguage: {
            title: userLanguage,
            id: userLanguage,
          },
          // no need to send path for document actions
          target: path.length ? {path} : undefined,
          conditionalPaths: {paths: getConditionalPaths()},
        })
      },
    })
  }, [client, documentIdForAction, getConditionalPaths, path, schemaId, getUserInput])
}
```

**sanity.config.ts**

```
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
import { useTranslateToAny } from 'actions/translateToAny'

export default defineConfig({
  // ... rest of your config
  plugins: [
    // ... rest of plugins,
    assist({
      fieldActions: {
        title: "My Actions",
        useFieldActions: (props) => {
          const translate = useTranslateToAny(props)
          return useMemo(() => {
            return [translate]
          }, [translate])
        }
      }
    })
  ]
})
```



# Comments

![Shows a comment about to be posted](https://cdn.sanity.io/images/3do82whm/next/83eeef7375c7da21cd2c3a162ed97e5de6d3c502-352x139.png)

Comments for Sanity Studio enables effective collaboration workflows right where the work is done. Leave comments on specific document fields or even single words in Portable Text, *@mention* your colleagues, and streamline your content workflow without ever leaving the Studio.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

[Enable Comments for Sanity Studio](https://www.sanity.io/docs/studio/configuring-comments)
Learn how to enable and configure Comments for Sanity Studio

[Enabling Tasks for Sanity Studio](https://www.sanity.io/docs/studio/configuring-tasks)
Enable and configure Tasks for Sanity Studio

## Comments workflow

Once Comments has been enabled for your project, open any document in your studio to start exploring how they work. If someone has already left comments on any field in the document, you will notice a small speech bubble icon 💬 adorning the input showing how many comments have been posted. If no comments have yet been posted, hover any field to bring up the speech bubble to leave the first!

### Leaving comments

Hover over any comment-enabled field and click on the comment icon 💬 to open a popover dialog, then type your comment in the input field and hit **Send** to post it.



To mention a colleague, type **@** followed by their name. A list of users with access to the document will appear. Click on the user you want to mention, and they will receive an email notification.

Your comment will now be visible to others with access to the document, and any mentioned users will receive a notification by email.

![Shows a string field with an icon indicating it has 1 comment attached](https://cdn.sanity.io/images/3do82whm/next/333aac237fe95c447fa1ea002f0ead1396c31096-501x158.png)

Unlike their closely related cousin [Tasks](https://www.sanity.io/docs/studio/tasks), comments are always directly coupled with a specific piece of content in your studio. Comments can be attached to any compatible field, or even to distinct sentences or words within Portable Text!



Clicking the 💬 comments icon on a field will open the comment inbox for the document so you can easily browse through existing comments. Comments are neatly grouped into the fields they correspond to.



### Resolving comments

When a comment has been addressed or is no longer relevant, you can mark it as resolved. To do this, click on the **Resolve** option in the popover menu that appears when hovering. Resolved comments will be hidden from the main view but can still be accessed in the **Resolved Comments** list.

![Comment being resolved](https://cdn.sanity.io/images/3do82whm/next/8acabde302218caf03c6d7f865c28bdfaf8e9bef-315x311.png)
*Shows a comment being resolved*

### Reactions, editing, and deleting comments

In addition to resolving comments, the popover menu includes a few more options. You can leave a reaction emoji for effective communication, copy a direct link to the comment, and you have options to edit or delete your comment. These options all work as you'd expect.

![Shows options for reacting to, editing, and deleting comments](https://cdn.sanity.io/images/3do82whm/next/d11b21191a257b198bf9616caf0e514dd109e9cb-649x175.png)

## Comment notifications

You'll receive notifications when tagged in a comment. You can adjust notifications in your user settings, as shown in the [Notifications](https://www.sanity.io/docs/studio/studio-notifications) documentation.

> [!NOTE]
> Incorrect links with external studios?
> If your studio is hosted externally, it must be added to the Studio's list for the project in [sanity.io/manage](https://www.sanity.io/manage) in order for notification links to point to the correct studio. 



# Task

Tasks for Sanity Studio are perfect for collaborating on content with your team, or even for solo content creators who need to keep track of their outstanding to-dos in the same environment where the work is to be done. Assign tasks to the appropriate team member, and they will get a notification alerting them to the new item in their inbox. Keep the discussion going in dedicated comment threads for every task, and tag in those who might be missing out with *@mention*s.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

[Configuring Tasks](https://www.sanity.io/docs/studio/configuring-tasks)
Enable and configure the Tasks feature in Sanity Studio

[Comments in Sanity Studio](https://www.sanity.io/docs/studio/configuring-comments)
Learn how to set up and use the Comments feature for collaborative content creation

## Working with tasks

### Find your tasks inbox

Your tasks inbox is located in the top-right corner of your Studio. Look for the checkmark icon in the navbar, to the left of the presence avatars and help menu. Here, you’ll find any new tasks assigned to you, any in-progress tasks that you’ve subscribed to, and all open tasks for the currently active document, whether or not you’ve been tagged in yet.

![Shows the tasks inbox in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/a215b192a21a60726137274df4e2e44ffccc6390-360x448.png)

### Create a task

Click the link aptly labeled **+ New task** to create a new task. You can give your task a due date, and assign it to the appropriate person who will then receive a notification email. You can also *@mention* Studio users to notify them that their input is requested.

> [!TIP]
> Pro tip
> Memo to self? Assigning a task to yourself, or @mentioning yourself in a task will not trigger any notifications, so talking to yourself in the Studio is perfectly fine, and won’t flood your inbox.

![Shows an unpublished task with a due date requesting a review from a colleague on the target article](https://cdn.sanity.io/images/3do82whm/next/b2cb65d7be8edd2e277891c0564c82f6510abeb0-359x642.png)

You can also choose to attach your task to a target document or leave it empty if that’s more appropriate. Adding a target document facilitates discovery and contextualizing, and will also put a handy notice next to the publish button for the relevant document, listing unfinished tasks.

### Comment on tasks

Tasks can have comment threads attached so you can keep related discussions in one easy-to-find place. Just as with comments elsewhere, you can *@mention* your team members to let them know about discussions they should be aware of.

![Shows a comment in a task thread tagging a team member with a @mention](https://cdn.sanity.io/images/3do82whm/next/bd10f58b6cd0d3683a2d0815aee22df7a7438601-359x254.png)

### Resolve tasks

Once dealt with, a task can be satisfactorily checked off your to-do list. Resolved tasks are still available by accessing the list of **Done** tasks at the bottom of your inbox.

![Shows a popover allowing users to mark a task as done](https://cdn.sanity.io/images/3do82whm/next/bf65a2958195f26ab55fb6dada339e3febca1850-370x217.png)



# Copy and paste fields

The field copy-and-paste feature in Sanity Studio enables you to copy and paste field values or entire documents within your Studio. This feature can be a significant time saver when you need to duplicate content or move it between different document types.

You can access these specialized copy-and-paste actions in the following ways:

- Through the **Field Actions** menu on individual fields.
- Using the standard **Ctrl/Cmd+C** and **Ctrl/Cmd+V** keyboard shortcuts on supported field types.

## Copy and paste fields

To copy and paste individual fields within a document:

1. Hover over a field to reveal the **Field Actions** menu.

![The Field Actions menu open on a field, showing the Copy field option](https://cdn.sanity.io/images/3do82whm/next/2885ad78b489aa44a290a88452e726116f2c5e28-492x235.png)

1. Select **Copy field** to copy the contents of that field.
2. Navigate to another field of the same type and select **Paste field** in the **Field Actions** menu to paste the copied content.

Additionally, certain field types support using the standard **Ctrl/Cmd+C** and **Ctrl/Cmd+V** keyboard shortcuts for copying and pasting:

- Array fields
- Object fields
- Reference fields
- Image and file fields

Using keyboard shortcuts can be a quick way to duplicate content within these field types.

## Copy and paste documents

To copy and paste entire documents:

1. Open the **Document Actions** menu and select **Copy document** to copy the current document to your clipboard.

![The Document Actions menu with options for copying and pasting documents](https://cdn.sanity.io/images/3do82whm/next/37300fc59a415e495fe7baaafdcae1bddeeae6d9-491x330.png)

1. Navigate to the document list where you want to create a new document.
2. Create a new document.
3. Select **Paste document** from the **Document Actions** menu or use the keyboard shortcut **Ctrl/Cmd+V**.

Another advantage of the copy/paste workflow over using the **Duplicate** action is that you can paste documents across different document types. The Studio will try to map the fields from the source to the destination document.

## Examples

Here are some examples where copy-and-paste for fields can come in handy.

### Copying between array types

There might be cases where it's more efficient to copy existing items from an array into a new one and edit them. For example, if you use array fields to build things like landing pages and newsletters, and want to keep the same structure or have minor variations between them.

**Note that pasting into an array will replace all the items in it. **However, if you do this accidentally, you can use **Review changes** and restore to the content you want to keep.

### Copying between object types

Say you have an `object` field of type `bio` with the fields `name`, `image`, and `history`. If you copy that entire object and paste it into an object field of type `author` which has the fields `name` and `image`, the Studio will transfer over the field values that are in common between the two types (`name` and `image`) and discard the field that doesn't exist in the destination (`history`).

### Copying between document types

Similarly, if you copy a whole document of type `author` and paste it into a document of type `person`, the Studio will copy over any fields that the two document types have in common (e.g., `name` and `image`). Fields that do not exist in the destination type (e.g., `publicationsList` in `author`) will be discarded.

If only some of the copied fields exist in the destination, you'll see a "Could not paste all values" warning describing what couldn't be transferred. If none of the copied fields exist in the destination — or there's nothing on your clipboard when you try to paste — you'll get a "Nothing to paste" notification instead.

## Limitations

There are a few known limitations to be aware of with the new copy-and-paste feature:

- When pasting a reference, Studio checks that the referenced document exists, that its type is allowed by the target field, and that it satisfies any filter set on the field. Pasting is asynchronous for this reason, and you'll see an error if the reference can't be used in the target field.
- For images and files, Studio fetches the asset document to check its MIME type against the target field's `accept` option, and blocks the paste if the type isn't accepted.
- When focused inside a text input, copy and paste is handled by the input's own clipboard event management to avoid interfering with native editing behaviors such as undo and redo.
- Pasting a whole array field into another array field replaces the entire array rather than appending to it. A single copied array item, by contrast, is appended to the end of the target array. This replace behavior might be unexpected for users accustomed to appending when pasting in other contexts.
- Arrays of anonymous object types, sometimes referred to as inline objects, are supported — Studio resolves the item's type from the array's member types.



# Compare document versions

## Prerequisites

- [Sanity Studio](https://www.sanity.io/docs/studio/installation) v3.78.0 or later, which added the document comparison view.
- A document with at least two versions to compare, such as a draft and a published document.
- To compare a version that belongs to a release, [Content Releases](https://www.sanity.io/docs/user-guides/content-releases) must be enabled in your studio.

## Side-by-side comparison

Use the document comparison view to compare document versions. This includes drafts, published, and release versions. To get started, open a document in Sanity Studio that contains multiple document versions.

1. In the top right corner of the document view, click the **...** icon.
2. Select **Compare versions**.

![The document actions menu in Sanity Studio, opened from the top right of the document pane, with the Compare versions option in the list.](https://cdn.sanity.io/images/3do82whm/next/cdb7012fcd612207338ade2426a87d897c623d57-1474x896.jpg)
*Select Compare versions from the More options menu.*

The document comparison view opens over your studio window. It contains a version selector and two panels, one for each version you compare.

> [!WARNING]
> Gotcha
> This view only works when multiple document versions exist. If **Compare versions** is disabled, its tooltip reads "There are no other versions of this document to compare." — make a change to a draft or release version in addition to the published version.

![The document comparison view in Sanity Studio, with the Published version in the left panel and the Draft version in the right panel, and the Overview field differing between them.](https://cdn.sanity.io/images/3do82whm/next/4f834f0e91d3b28a0861c0fad7303e5d3808e982-2962x1710.png)
*The document comparison panel.*

Differences between the fields in the right version are highlighted in yellow. You may recognize this from other history or diff tools. In the screenshot, the **Overview** field has a yellow highlight along its edge to indicate changes.

You can adjust the compared versions with the version selector at the top of the window.

![The version selector at the top of the document comparison view, open and listing the Published and Draft options.](https://cdn.sanity.io/images/3do82whm/next/5c140b6852622c79f2e4a310db0f211b7729d910-1706x872.png)
*The version selector in the document comparison view.*

> [!TIP]
> Protip
> You cannot leave comments or tasks from within the comparison view. It's best to save major changes and workflows for the document view.

## Advanced Version Control

> [!WARNING]
> Experimental feature
> This functionality is likely to change as we improve and expand it. Let us know if you have any feedback.

Advanced Version Control adds inline diff annotations to fields, letting editors see how content has changed between versions while they work on it. This functionality is available for `string` fields and Portable Text fields.

![Screenshot of string field in Sanity Studio show diff annotation from "Fall Collection 2025" to "Autumn Collection 2025"](https://cdn.sanity.io/images/3do82whm/next/103c95376415df0aab808acad6a5847a803baa10-1320x240.png)

### Switching on Advanced Version Control

To switch on Advanced Version Control, set the `advancedVersionControl.enabled` configuration option to `true`. This feature can be switched on or off for different workspaces.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  advancedVersionControl: {
    enabled: true,
  },
  // …
})
```

Editors must also switch on inline annotations per document. In the document's **Show more** menu (the **...** button in the top right of the document pane), select **Inline changes**. The setting persists as you navigate.



# Content Releases

Content Releases lets you organize and schedule updates across multiple documents. You can plan, preview, and validate significant changes in advance, then publish them together.

Content Releases provide several key benefits:

- **Coordinate updates:** Simultaneously manage and publish updates across multiple pages and channels, ensuring consistency.
- **Reduce manual effort and risk:** Automate scheduling to minimize manual tracking and prevent errors or conflicting changes.
- **Gain confidence with previews and validation:** Preview and validate scheduled releases to guarantee readiness before going live.

For developer documentation on how to configure, integrate, and interact with Content Releases programmatically, go here:

[Configure Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration)
Configure the studio and visual editing experience

[Content Releases API](https://www.sanity.io/docs/content-lake/content-release-document-flow)
Programmatically manage Content Releases with the API and clients.

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

> [!NOTE]
> Scheduled Drafts is also available
> For teams without access to Content Releases, or if you don’t need to schedule groups of documents to go out at once, the [Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts-user-guide) feature is also available.

## Before you begin

Content Releases requires Sanity Studio v3.77.0 or later, where it is enabled by default. Official plugins, such as AI Assist, the Vision Tool, and presentation-related plugins, also need to be up to date. If you're experiencing issues using Content Releases, check with your administrator and direct them to the [Studio configuration](https://www.sanity.io/docs/studio/content-releases-configuration).

## The Content Releases workflow

Content Releases introduces the concept of a **release**. Releases are a way to group multiple document changes together into a single unit that can be previewed, validated, scheduled, and published as one.

The most basic workflow is as follows:

1. Create a release.
2. Add documents to a release to create new document versions.
3. Make changes to the documents.
4. Publish the release.

### Release types

When you [create a release](https://www.sanity.io/docs/user-guides/content-releases), you must choose a release type. There are three available types:

- **As soon as possible** (ASAP): You plan for these changes to go live as soon as they're ready. They'll have a prominent **Run release** action available on the release details screen.
- **At time**: You have a planned date and time in mind. You'll be able to schedule these for a specific time from the release details screen.
- **Undecided**: You're unsure, or don't want to set a type. These will also hide the publish and schedule actions behind the release's **More options** menu to prevent accidental publishes.

The type dictates the order a release shows up in Studio to reflect when it will publish compared to other releases, but you can [change the type](https://www.sanity.io/docs/user-guides/content-releases) at any time from the release detail screen.

> [!NOTE]
> Release quotas
> Your plan dictates how many active releases your organization can have at a time. Any release that isn't **Archived** or **Published** is considered active, including scheduled releases that have yet to publish.

### The document view

![content releases document screen](https://cdn.sanity.io/images/3do82whm/next/9b0426bfd894bfeae9393ab1af169efde4bd21fb-2142x1820.jpg)
*The document screen*

When you're working on a release, the document screen displays details about **versions** of the document. Document version names correspond to release names. Published and drafts are always enabled, but additional versions are displayed as documents are added to releases.

Much like each published document can only have one draft, each release can only contain one version of a document.

Select a version name to switch between versions. Right-click the name to reveal a menu with options, including: copy versions between releases, or discard a version.

> [!TIP]
> Protip
> The release color highlights the global toolbar and document list to remind you that you're working on a specific release. **ASAP** releases are orange, **At time** releases are purple, and **Undecided** releases are gray.

### The releases view

![the content releases screen](https://cdn.sanity.io/images/3do82whm/next/c0c85652fcd67d3ab43e416b74dca7390085aa8f-1180x780.png)
*The releases screen*

The releases screen displays any upcoming releases. Bold dates in the calendar indicate releases with date estimates. You can also see the number of changes in each release, and warnings if there are validation errors.

### Global perspective

The global perspective is your view into the state of all documents relative to the selected release. By selecting a release, you're viewing not only its changes, but all changes in published documents and to-be-published documents. You can hide individual releases from view, if they are higher in the list than your selected release, or view just the Published perspective.

![The global release picker with the "hide release" tooltip displayed](https://cdn.sanity.io/images/3do82whm/next/ac8acdb5a932ccb29e7cc78de7e2765a2cb3723b-1761x719.png)

> [!WARNING]
> Gotcha
> Does it seem like all documents are read-only? You might be in the **Published** perspective. Select **Draft** or a release from the document screen to make changes to a document.

### How do drafts fit in with releases?

You can work directly on a draft and publish it without creating a release. You can also work on a draft, then copy it to a release.

One important thing to keep in mind. Publishing a release will not reset a draft. If you created a draft and made changes, then copied it to a release, that draft still exists. When you run a release, the confirmation dialog offers an **Update existing drafts** option. Selecting it discards the existing drafts of documents in the release so drafts match what was published. Unpublished draft changes are lost. There are two ways you can keep these leftover drafts in check:

- If you know you're working on a release, start the changes in the release. This way a draft document is never created.
- After copying a draft to a release, return to the draft document and discard the draft version.

## Technical limits

Content Releases is designed to work with most workflows, but you may experience issues with exceptionally large documents and releases.

- A single release can contain a maximum of 1,000 documents.
- The total size of all of your JSON documents combined in a release cannot exceed 100 MB. This is the size of the document's JSON data itself, not linked assets like images or files.
- Releases publish documents in batches based on size and reference connections. For larger releases, there may be small delays when individual documents go live. To avoid this, smaller releases of dependent documents can help ensure they release at the same time.
- Releases are published one at a time and are ordered by the time they will be published. If releases will be published at the same time, their order will be chosen at random.
- If a release is blocked by another release, it waits up to 10 minutes before the release is marked as failed. If multiple large releases are scheduled for the same time, consider staggering their release times.

## Create a release

To add new documents and changes to a release, you first need to create a release.

![The Studio toolbar with the release dropdown open, showing Published, Drafts and the list of releases](https://cdn.sanity.io/images/3do82whm/next/11b8bc5d691bf1b1629566f75fe5ba181ddeec70-488x738.png)
*Select the release dropdown*

1. Locate the **calendar** icon in the top right corner of the toolbar.
2. Select the **down arrow** icon to reveal a list of releases.
3. Select **New release** to create a new release.1. Select an approximate time of release.
2. Enter a release title (optional).
3. Enter a description for the release (optional).
4. Select **Create release**.



![The Create a new release dialog with fields for release time, title, and description](https://cdn.sanity.io/images/3do82whm/next/dc780f442bb5fa52f63adfeab058d7ecd08bdc82-1410x962.jpg)
*Create a new release*

You can change these values later by navigating to the release on the **Releases** screen.

> [!TIP]
> Protip
> You can also create a release from the **Releases** screen by selecting **New release** in the top right corner.

## Add a document to a release

When a document is part of a release, it's no longer connected to changes in drafts or the published document. It's like a snapshot in time that has its own future. Keep this in mind when interacting with different versions of the same document.

There are multiple ways to add a document to an existing release.

### Add a document from the releases screen

1. Navigate to the **Releases** screen by selecting the **calendar** icon in the top right of Studio.
2. Select the **release name** to navigate to its detail screen.
3. At the bottom of the list of documents, select **Add document**.
4. Search for and select a document.

### Add a document from the document screen

1. Ensure you are in a release perspective by pinning a release. You'll know you've pinned a release if the release name is next to the **calendar icon** in the toolbar.
2. In a document's editor view, select the **Add to release** button in the top bar. This button and bar should match the color scheme associated with the release perspective.

Alternatively, you can right-click a release label at the top of a document and select **Copy version to** to copy the selected document version to a release.

> [!NOTE]
> Adding a document to a release uses the published version
> When adding a document to a release, unless you are using the **Copy version to** method, the published version will be used as the basis for the new version.
> To use a draft or different release version, use the **Copy version to** method.

Once a document is part of a release, you'll be able to edit the release version by ensuring the release is selected at the top of the document.

## Remove a document from a release

Removing a document from a release discards any changes unique to that version. This action won't remove the document from other releases.

There are three ways to remove a document from a release.

### Remove a document from the releases screen

1. Select the **release name** to navigate to its details screen.
2. Identify the document you want to remove and select the **"..." icon** to reveal additional options.
3. Select **Discard version** and confirm the selection when prompted.

### Remove a document from the document screen

1. Confirm you are in the perspective for the desired release. You should see the release name next to the calendar icon in the toolbar, as well as the highlighted release name at the top of the document.
2. At the bottom right of the document screen, select the **"..." icon**.
3. Select **Discard version** and confirm the selection when prompted.

### Remove a document from the version menu

1. On the document header, find the chip with the version you want to discard.
2. Right-click the chip to open the context menu.
3. Select **Discard version** and confirm the selection when prompted.

## Copy a document from one release to another

You can copy a document version to a different release from the document view.

![Version action user interface](https://cdn.sanity.io/images/3do82whm/next/7f067c31c8845d0650b1a15320125eb13b5a6bd9-1608x820.jpg)
*Right-click a version name to reveal the version action menu.*

1. Navigate to the document you want to copy.
2. Right-click the release name you want to copy from.
3. Hover over **Copy version to**.
4. In the popover menu, select the destination release.

## Unpublish a document as part of a release

Sometimes you want a release to unpublish, or remove a live document. This converts a published document back to a draft once the release is published.

1. Add the document to a release.
2. In the bottom right corner of the document screen, select the **"..." icon**.
3. Select **Unpublish when releasing** and confirm the selection when prompted.

![Document screen popover menu](https://cdn.sanity.io/images/3do82whm/next/a4f0e9c5e58bcf2fa020b8996ce7e5b9d26f06de-846x400.jpg)
*Unpublish when releasing*

When unpublishing a document, the contents of the published document are used to create a new draft document associated with the published ID.

If a draft document with the same published ID as the version document already exists, it will remain and the unpublished contents will be lost.

All strong references will be converted to weak references on *unpublish*. If the draft document is subsequently re-published, those references will be converted back to strong references.

## Discard a draft version

To discard a document version, follow the steps listed in *Remove a document from a release*.

To discard changes from the **Draft** version, select the **More options** at the bottom right of the document screen and select **Discard changes**.

## Publish a release

After creating a release, you can choose to publish it on demand or schedule a publish.

1. Navigate to the **release screen** for the release you want to publish.
2. Select **Run release** and confirm. For **At time** and **Undecided** releases, this action is in the release's **More options** menu rather than the primary button.

## Schedule a release

To schedule a release, first set a release time and date. You can do this when creating a release, or by selecting the **release time** label and selecting **At time** from the **release** screen. You can adjust this time later if needed.

![The release screen with the release time label selected and a date and time picker open](https://cdn.sanity.io/images/3do82whm/next/75b7b10a61d63b82240f3937c2a8a65774719131-1816x986.jpg)
*Set an estimated release time*

Next, select **Schedule release** in the bottom left of the **release** screen.

![The release screen with the Schedule release button in the bottom left](https://cdn.sanity.io/images/3do82whm/next/aadb1c9274e77f04d984ada034df5c50e90fb9e3-2134x1232.jpg)
*Schedule release*

Confirm the release time and date, then select **Yes, schedule**.

> [!WARNING]
> Gotcha
> Setting a release time alone does not schedule the release. You must set a time, and schedule the release using the **Schedule release** button.

While a release is scheduled, its version documents are locked. Select **Unschedule release** before editing a document in the release or adding another one.

## Unschedule a release

To unschedule a release, select the **Unschedule release** button in the bottom right of the release screen.

## Archive a release

The **Archived** tab lists both archived releases and releases that have already published. Archiving is a separate action you can take on a release that hasn't published yet, to take it out of the active list while preserving it for reference.

> [!WARNING]
> Gotcha
> You cannot archive a scheduled release. First unschedule it, then archive it.

There are two ways to manually archive a release.

### Archive a release from the releases screen

1. Select the **"..." icon** for the release you want to archive.
2. Select **Archive release**.

### Archive a release from the release detail screen

1. In the bottom right, next to the Publish / Schedule button, select the **"..." icon**.
2. Select **Archive release**.

## Unarchive a release

You may unarchive an archived, unpublished release. Published releases cannot be unarchived.

There are two ways to manually unarchive a release.

### Unarchive a release from the releases screen

1. Select the **"..." icon** for the release you want to unarchive.
2. Select **Unarchive release**.

### Unarchive a release from the release detail screen

1. In the bottom right select the **"..." icon**.
2. Select **Unarchive release**.

## Change the release type

Release order is determined by when the release will be live, with exceptions for *ASAP* and *Undecided*. This is the release type.

- ASAP releases come first, in order of creation.
- Dated releases come next, ordered by date.
- Undecided releases come last, ordered by creation.

To change the order of a release, change the date and time associated with it.

## Pin a release (global perspective)

Pinning a release sets the global perspective in Studio. This is indicated by the color change in the toolbar, as well as the highlighted release name throughout Studio.

You can only pin one release at a time.

![A pinned release tinting the Studio toolbar and document list, with the release name highlighted](https://cdn.sanity.io/images/3do82whm/next/800c52d96c7fcf8e28cf092e8c869fa4d5331e3d-1180x345.png)
*A pinned release highlights the Studio experience*

There are three ways to pin a release.

### Pin a release from the toolbar

1. In the top toolbar, select the dropdown arrow next to the **calendar icon**. If a release is currently pinned, the arrow will display next to the pinned release.
2. Select the **release name** for the release.

### Pin a release from the releases screen

1. Locate the release to pin.
2. Select the **pin** **icon** to the left of the release name.

### Pin a release from the release detail screen

1. Navigate to the release you want to pin.
2. Select the pin icon on the top left, above the release name.

## Document status in lists

Document lists show a status icon for each document, describing it relative to the pinned release.

A document with a version in the pinned release shows that release's icon: a bolt for ASAP, a clock for timed, and a question mark for undecided. A document with no version in that release shows no icon.

When no release is pinned, the icons describe the document itself. An outlined ring means the document has a draft, and a filled circle means it's published. A document that has never been published shows no icon.

Hover the icons on any document to list its versions, ordered published first, then drafts, then releases.

## View release history

You can view past releases, including unpublished ones, from the **Archived** tab on the **main releases screen**. Published and archived releases are retained for a limited period based on your plan's retention window, after which they're automatically removed.

## Edit properties of an existing release

You can edit the name, estimated release time, or description directly on the **release** screen.

To change the title or description, select the field and begin typing.

To change the estimated publish time, select the **release time** label and choose a new time.

> [!WARNING]
> Gotcha
> You can edit the name and description of scheduled releases, but in order to change the schedule date or time you first need to **unschedule** the release.

## Hide releases from the global perspective view

When viewing a future release, you can choose to hide earlier releases from the global perspective view. This lets you hide document changes made by specific releases, while still previewing a subset of changes across releases.

![The release dropdown with open and closed eye icons controlling release visibility](https://cdn.sanity.io/images/3do82whm/next/2ff2a670d4b12d4cd311d5170b82e65273622949-1128x734.jpg)
*Toggle release visibility*

1. To hide versions from a specific release, first set your global perspective.
2. In the release dropdown view, select the **open eye icon** next to any release you want to hide.
3. To reveal a hidden release, select the **closed eye icon**.

## Preview releases in Presentation

If your team has enabled Presentation, you can preview a release by **pinning it** and then selecting the Presentation Tool in Studio. You'll know it's been pinned if the name displays alongside the calendar icon instead of **Drafts**.

Keep the release layering concept in mind, and use the *hide release* feature to customize your preview perspective.

## Revert a release

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You can return to the state prior to when a release was published by reverting the release. When reverting, you can either revert the release immediately or create a new release, which you can then review and schedule.

![The Revert release button in the Content Releases interface.](https://cdn.sanity.io/images/3do82whm/next/215e2906bb39a536daa7e33435188e1ebbe207e4-876x440.png)

1. Navigate to the releases screen by selecting the **Calendar** icon from the perspective picker in the top bar.
2. Select **Archived** to view published and archived releases.
3. Select the release you want to revert to navigate to the release.
4. In the lower right, select the **Revert release** button.

When you revert a release, any new documents that didn't exist outside of the release will be reverted to drafts in your dataset.

## Duplicate a release

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

You can build off of an existing release by duplicating it. This is useful in scenarios where you want to work beyond a current release, but want any scheduled changes to carry over. There are two ways to duplicate a release.

### Duplicate a release from the releases screen

1. Select the **"..." icon** for the release you want to duplicate. You can duplicate an active or scheduled release. **Duplicate release** isn't available for releases that have already published or been archived.
2. Select **Duplicate release**.

### Duplicate a release from the release detail screen

1. In the bottom right, next to the Publish / Schedule button, select the **"..." icon**.
2. Select **Duplicate release**.

## Release layering

Release layering is the concept of displaying documents based on where a release falls in the release timeline and which perspective is active.

This allows editors to preview document changes across multiple releases. You can see a simplified version of this in how *drafts* override published documents in Presentation.

In Studio, release layering works on a timeline. The type and time of release indicates where a release falls on the timeline. You already know *published* and *draft*, but there are also *as soon as possible (ASAP)*, *timed*, and *undecided*.

The layer follows this order, starting at 1 and adding changes.

1. Published
2. Draft
3. As soon as possible (ASAP)
4. Timed (A planned time in the future)
5. Undecided

When viewing a release with an undecided release time, you will see all changes in other documents from drafts, ASAP releases, and timed releases stacked atop published documents—plus any changes on the undecided release(s). These views of your content in Studio are the *global perspective.*

> [!NOTE]
> Documents display based on release order
> The global view and document list will show changes across releases based on the layering order, but when viewing a version of a document, you'll only see that version's changes. References to other documents will display their content in relation to where their release, and your active release, sit in the layering order.
> This only applies to other documents. Your selected document will always show the contents of the selected release or perspective (if drafts or published is selected).



# Scheduled drafts

This guide is an overview of the scheduled drafts interface and workflow. For technical details on enabling/disable scheduled drafts in your studio, see the [scheduled drafts configuration documentation](https://www.sanity.io/docs/studio/scheduled-drafts).

> [!NOTE]
> Permissions required
> If you're on a custom role, **Schedule Publish** needs more than access to the document you're scheduling. Each scheduled draft is a single-document release, so your role also needs content resources covering drafts, release versions, and the release itself. See [Permissions for Studio features](https://www.sanity.io/docs/user-guides/roles).

## Schedule a draft

You can schedule a draft from the document pane. 

1. From an existing document draft, select the document actions menu **"..."** next to the publish button.
2. Then, select **Schedule Publish** and set a date and time in the future before confirming.

![a screenshot of a page showing the document actions menu open](https://cdn.sanity.io/images/3do82whm/next/f7e38afc9d16dc19a6760372f9077ef3d8853b58-1322x572.png)

When a document contains a scheduled draft, you'll see a new pill alongside the perspectives at the top of the document pane.

![A screenshot of the draft after scheduling](https://cdn.sanity.io/images/3do82whm/next/bf70da433918067cc57562053a854d8d865e0973-1672x228.png)

## View scheduled drafts

You can quickly jump from a document to the scheduled drafts page by right-clicking the "scheduled publish" pill at the top of the document, and selecting "**View scheduled drafts**"

![The schedule publish drop down menu](https://cdn.sanity.io/images/3do82whm/next/01373bb506bde27afa9ca034d810b85b518d77b9-1650x618.png)

You can also navigate there by first going to the content releases screen.

![a screenshot of Studio that shows the scheduled draft view](https://cdn.sanity.io/images/3do82whm/next/68ce8fdcb31e3f125b9784c2e1b2abcc7f3356d7-2658x1384.png)

1. Select the **calendar icon** in the perspective picker to navigate to the content releases list.
2. Select the **Releases** dropdown and select **Drafts**.

From here, you can edit individual draft schedules, delete drafts, or immediately publish them from the **"..."** menu in each row as shown at (1) in the screenshot below.

![a screenshot of the scheduled draft edit actions menu](https://cdn.sanity.io/images/3do82whm/next/e74dc9b32f2e677080c9b7c5065b8d0d69ef7914-2012x618.png)

## Edit a scheduled draft's contents

You can only have one scheduled draft scheduled per document at a given time. To make changes to the contents of a draft, you can right click the **“Scheduled publish”** pill at the top of the document and select **“Edit schedule”**. You can also use the **"..."** menu from the scheduled drafts list. 

![The schedule publish drop down menu](https://cdn.sanity.io/images/3do82whm/next/01373bb506bde27afa9ca034d810b85b518d77b9-1650x618.png)

You can now make changes to the scheduled draft’s content. Click **“Schedule Publish”** document action, which will ask for confirmation of the publish date and time, and re-schedule the draft’s publish.

Use this same approach to edit only the date and time to publish the scheduled draft.



# View incoming references

You can see which documents reference the open document by using the incoming reference pane. Developers can also add [incoming reference fields](https://www.sanity.io/docs/studio/incoming-reference-decoration) to your forms directly.

## Open the incoming reference pane

Use the incoming reference pane to see any documents that reference the open document. To get started, open a document in Sanity Studio.

![A screenshot of the document UI with the "..." menu expanded to reveal the incoming references menu item.](https://cdn.sanity.io/images/3do82whm/next/7af1e732310d2f4d4c2e08129a3085a66968a28f-1734x1310.png)

1. In the top left right corner of the document view, select the **"..."** icon.
2. Next, select "**Incoming references**".

This opens the incoming references pane where you can see each referencing document, grouped by type. Select a document to open the referencing document in a new pane.





# Common keyboard shortcuts

Sanity Studio includes keyboard shortcuts to help you work more efficiently. These shortcuts provide quick access to common actions like navigation, document editing, and tool-specific features.

- The ⌘ symbol represents the Command key on Mac.
- Ctrl represents the Control key on Mac, Windows, and Linux.

## Global navigation

These shortcuts work throughout Sanity Studio regardless of which tool or document you're working in.

- Open global search: `⌘+K` (Mac) / `Ctrl+K` (Windows/Linux)
- Close search or dialogs (when active): `Escape`
- Confirm input in location and URL fields: `Enter`

## Document actions

Use these shortcuts when working with documents in the Structure tool.

- Delete document: `Ctrl+Option+D` (Mac) / `Ctrl+Alt+D` (Windows/Linux)
- Inspect document: `Ctrl+Option+I` (Mac) / `Ctrl+Alt+I` (Windows/Linux)
- Save document: `⌘+S` (Mac) / `Ctrl+S` (Windows/Linux) *Note: Documents are automatically saved as you edit them. You do not need to save them manually.*

## Portable Text Editor

These shortcuts are available when editing rich text content in Portable Text fields.

### Basic formatting

- Toggle bold: `⌘+B` (Mac) / `Ctrl+B` (Windows/Linux)
- Toggle italic: `⌘+I` (Mac) / `Ctrl+I` (Windows/Linux)
- Toggle underline: `⌘+U` (Mac) / `Ctrl+U` (Windows/Linux)
- Toggle inline code formatting: `⌘+'` (Mac) / `Ctrl+'` (Windows/Linux)
- Toggle fullscreen mode: `⌘+Enter` (Mac) / `Ctrl+Enter` (Windows/Linux)

You can also use a variety of text-based shortcut behaviors. See the [Portable Text Editor configuration](https://www.sanity.io/docs/studio/portable-text-editor-configuration) documentation for more details.

## Vision tool (GROQ Playground)

- Execute GROQ query: `⌘+Enter`:  (Mac) / `Ctrl+Enter` (Windows/Linux)

## Presentation tool (Visual Editing)

- Toggle visual editing overlay: Hold `Option` (Mac) / `Alt` (Windows/Linux)

## Shopify plugin

If you have the [Shopify plugin](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify) installed, these additional shortcuts are available.

- Shopify delete action: `Ctrl+Option+D` (Mac) / `Ctrl+Alt+D` (Windows/Linux)
- Shopify link action: `Ctrl+Option+E` (Mac) / `Ctrl+Alt+E` (Windows/Linux)



# Studio schema configuration

The top level `schema` configuration accepts an object with two properties: `templates` and `types:`

- The `templates` property accepts an array of Initial Value Template configuration objects or a callback function returning the same.
- The `types` property accepts an array of schema definition objects or a callback function returning the same. 

In both cases, the callback function is called with the current value as the first argument and a context object as the second. Thus, you can access schema definitions and Initial Value Templates implemented by plugins.

#### Properties

**templates** (array | function)

An array of initial value templates, or a callback function that resolves to the same.

**types** (array | function)

An array of schema definitions or a callback function that resolves to the same.

The `templates` property is discussed in greater detail [in this article](https://www.sanity.io/docs/studio/initial-value-templates), and a reference article can be found [here](https://www.sanity.io/docs/studio/initial-value-templates-api). The rest of this article will deal with the default set of schema types supported in the Sanity Studio.

All schema types are listed below or in the documentation menu.

[Array](https://www.sanity.io/docs/studio/array-type)
Schema type for arrays of other types.

[Block](https://www.sanity.io/docs/studio/block-type)
Schema type for block which provides a rich text editor for block content.

[Boolean](https://www.sanity.io/docs/studio/boolean-type)
Schema type reference for expressing truthy values.

[Cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-references)
All you need to know about creating references across datasets.

[Date](https://www.sanity.io/docs/studio/date-type)
Schema type reference for the Date type.

[Datetime](https://www.sanity.io/docs/studio/datetime-type)
The schema type for expressing an exact date and time. 

[Document](https://www.sanity.io/docs/studio/document-type)
Schema type reference for expressing documents.

[File](https://www.sanity.io/docs/studio/file-type)
Schema type reference for the File type.

[Geopoint](https://www.sanity.io/docs/studio/geopoint-type)
Schema type reference for the geopoint type.

[Image](https://www.sanity.io/docs/studio/image-type)
Schema type for uploading, selecting, and editing images. 

[Number](https://www.sanity.io/docs/studio/number-type)
Schema type reference for the Number type.

[Object](https://www.sanity.io/docs/studio/object-type)
Schema type to create custom types to use in a document.

[Reference](https://www.sanity.io/docs/studio/reference-type)
A schema type for referencing other documents.

[Slug](https://www.sanity.io/docs/studio/slug-type)
A schema type for slugs is typically used to create unique URLs.

[String](https://www.sanity.io/docs/studio/string-type)
A schema type for strings and a selectable lists of strings.

[Span](https://www.sanity.io/docs/studio/span-type)
Schema type reference for the Span type.

[Text](https://www.sanity.io/docs/studio/text-type)
Schema type reference for the Text type.

[URL](https://www.sanity.io/docs/studio/url-type)
Schema type reference for the URL type.

[Global document reference](https://www.sanity.io/docs/studio/global-document-reference-type)
Reference documentation for the `globalDocumentReference` schema type.

## Properties

#### Properties

**type** (string, required)

Name of any valid schema type. This will be the type of the value in the data record.

**name** (string, required)

The field name. This will be the key in the data record.

**title** (string)

Human readable label for the field.

**hidden** (boolean | () => boolean)

Takes a static or a callback function that resolves to a boolean value and hides the given field based on it. You can use this property for conditional fields.

**readOnly** (boolean | ()=>boolean)

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description** (string)

Short description to editors how the field is to be used.

**deprecated** (object)

Marks a document type or a field as deprecated. This will render the field(s) as read-only with a visual deprecation message defined by the reason property.

Example: deprecated: { reason: 'no longer used' }

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**options** (object)

A unique set of options depending on the type. See the individual schema type references for available options.

**validation** (RuleBuilder)

Enables adding one or more validation rules to the field. See the validation guide for more details, the section below for common validation methods, and the individual schema type references for additional methods.

### Validation

#### Properties

**required()**

Ensures the field exists.

Example: (Rule) => Rule.required()

**either([rule, rule, ...])**

Accepts an array of rules. If any are truthy, the validation passes.

Example: (rule) => rule.either([rule.required().min(1), rule.custom((_, context) => context.document?.category !== 'bicycle')])

**all([rule, rule, ...])**

Accepts an array of multiple rules, all of which must be true for the validation to pass.

Example: (rule) => rule.all([rule.required(), rule.custom((value, context) => { ... })])

**custom(value, context)**

Allows for custom validation rules. Receives the field value and the context. Must return true if validation passes, or an error message if validation fails.

Example: rule => rule.custom(value => { ... })



**Note**: The properties listed above are common for all data types. For a more thorough description of how to use them, see the individual schema type references.


## Schema organization tips

The studio loads all schemas defined under `schema.types` in `studio.config.js`.

```javascript
//sanity.config.js
import {defineConfig} from 'sanity'

export default defineConfig({
  /* ... */
  schema: {
    types: [
      {
        title: "My Example Document Type",
        name: "exampleDocumentType",
        type: "document",
        fields: [
          {
            title: "Greeting",
            name: "greeting",
            type: "string"
          }
        ]
      }  
    ]
  }
})

```

To keep things organized, consider keeping the types array in a separate file and import it into `studio.config.js`. 

```javascript
//schemaTypes.js
export const schemaTypes = [
  {
    title: "My Example Document Type",
    name: "exampleDocumentType",
    type: "document",
    fields: [
      {
        title: "Greeting",
        name: "greeting",
        type: "string"
      }
    ]
  }  
]

//sanity.config.js
import {defineConfig} from 'sanity'
import {schemaTypes} from './schemaTypes'

export default defineConfig({
  /* ... */
  schema: {
    types: schemaTypes
  }
})

```

You should also consider using the [defineType](https://reference.sanity.io/sanity/index/defineType/), [defineField](https://reference.sanity.io/sanity/index/defineField/) and [defineArrayMember](https://reference.sanity.io/sanity/index/defineArrayMember/) helper functions when working with schemas. These will give you better IDE auto-suggestions and provide type-safety when used in TypeScript files. Using these functions is *completely optional.*

```javascript
import {defineType, defineField, defineArrayMember} from 'sanity'

export const someDocumentType = defineType({
  title: "Some Document Type",
  name: "exampleDocumentType",
  type: "document",
  fields: [
    defineField({
      title: "String array",
      name: "strings",
      type: "array",
      of: [
        defineArrayMember({ type: "string" })  
      ]
    })
  ]
})  

```

## Plugins

Plugins may also provide types. They will be available in the studio exactly like studio configured types. 

Using plugins to organize your code can be helpful as the studio codebase grows.

The official [@sanity/presets](https://www.npmjs.com/package/@sanity/presets) package (currently experimental) is one example. It ships ready-made schema types for pages, links, images, SEO metadata, and rich text.

```javascript
// pluginWithSchema.js
import {definePlugin, defineType, defineField} from 'sanity'

export const pluginWithSchema = definePlugin({
  name: 'plugin-with-schema',
  schema: {
    types: [
      defineType({
        title: "Plugin object",
        name: "exampleObject",
        type: "document",
        fields: [
          defineField({
            title: "Title",
            name: "title",
            type: "string"
          })
        ]
      })    
    ]
  }
})

//sanity.config.js
import {defineConfig} from 'sanity'
import {pluginWithSchema} from './pluginWithSchema'

export default defineConfig({
  /* ... */
  plugins: [pluginWithSchema()]
})

```



# Array

![Screenshot of an array from Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/befae1ca226723422da18e04b241f25c7b0a466a-3456x2100.png)
*An array of references*

An ordered list of data. The `of` property specifies which value types the array may hold. See the [ArrayDefinition](https://reference.sanity.io/sanity/index/ArrayDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Value must be set to array.

**name** (required)

Required. The field name. This will be the key in the data record.

**of** (required)

Defines which types are allowed as members of the array.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**initialValue**

The initial value that will be used when creating new arrays from this type. Can be either the literal array value or a resolver function that returns either the literal value or a promise resolving to the initial value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

## Options ([ArrayOptions](https://reference.sanity.io/sanity/index/ArrayOptions/))

#### Properties

**sortable** (boolean)

Controls whether the user is allowed to reorder the items in the array. Defaults to true.

**layout** (string)

If set to tags, renders the array as a single, tokenized input field. This option only works if the array contains strings.

If set to grid it will display in a grid.

If the array uses the list option, it will display the values as a vertical list of checkboxes. Use grid layout to place the checkboxes horizontally.

**list** (array)

Renders checkboxes for a predefined list of values.

For arrays of primitives the following formats are supported:

[ {value: <value>, title: <title>}, { … } ]

[ <value1>, <value2>, … ]

For arrays of objects the format is

[ {_type: <mandatory-object-type>, _key: <key>, /* optionally any fields that exist in <object-type>*/}, { … } ]

Objects will be rendered using the object types preview config.

**modal** (object)

Controls how the modal (dialog for array content editing) is rendered. Takes an object with type and width property.

type can be dialog or popover, width can be 'auto' or a number.

Default is {type: 'dialog', width: 'auto'}.

**insertMenu** (object)

Allows configuring the insert menu for array items with the following properties:

filter: boolean | 'auto'
Enable or disable filtering of types. Defaults to 'auto' which will enable filtering automatically if more than 5 types are present.

groups: array of group definitions { name: string, title: string, of: string[] }
Groups allowable types for easier access.

showIcons: boolean
Show or hide icons for types.

views: array of view options:{name: 'list'} | {name: 'grid', previewImageUrl: function }

See examples further on in this article.

**disableActions** (array)

Accepts a list of actions that can be selectively disabled from the array inputs action menu. The available options are:

add – Removes the ability to add new items to the array

addBefore – Removes the "Add item before"-menu item from the array item menu

addAfter – Removes the "Add item after"-menu item from the array item menu

remove – Removes the ability to remove items from the array

duplicate – Removes the ability to duplicate array items

copy – Removes the ability to copy items from the array

options: { disableActions: ['add', 'addAfter'] }

## Validation ([ArrayRule](https://reference.sanity.io/sanity/index/ArrayRule/))

#### Properties

**required()**

Ensures that this field exists.

**unique()**

Requires all values within the array to be unique. Does a deep comparison, only excluding the _key property when comparing objects.

**min(minLength)**

Minimum number of elements in array.

**max(maxLength)**

Maximum number of elements in array.

**length(exactLength)**

Exact number of array elements to allow.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

## Options Example

### Example: Customizing the insert menu

The `insertMenu` option allows you to configure several aspects of the array input's insert menu. It accepts the following properties:

#### `showIcons`

Set to `false` to hide the icons for schema types.

```javascript
{
  insertMenu: {
    showIcons: false,
  }
}
```

![Shows array insert menu with and without icons for schema types](https://cdn.sanity.io/images/3do82whm/next/d13f247073ff9d14645f9d85af86e42b6a2afd39-646x545.png)

#### `filter`

Enable or disable filtering of available schema types. Can be set to `true`, `false` or `'auto'` (default). When set to `'auto'` filtering will kick in once the list of allowable types has five or more options.

```javascript
{
  insertMenu: {
    filter: true,
  }
}
```

![Shows array insert menu with a search field to filter types](https://cdn.sanity.io/images/3do82whm/next/292e34dc0ef32251b725bd14e037cade22a6e996-701x599.png)

#### `groups`

Define groups of related schema types for improved findability.

```javascript
{
  insertMenu: {
    groups: [
      {
        name: 'intro',
        title: 'Intro',
        of: ['hero'],
      },
      {
        name: 'storytelling',
        title: 'Storytelling',
      },
      {
        name: 'upsell',
        title: 'Upsell',
        of: ['testimonials', 'hero'],
      },
    ],
  },
}
```

![Shows insert menu with groups of schema types](https://cdn.sanity.io/images/3do82whm/next/28d0139dbbcc9f7feacfc6872bd5e6dcd12bf375-686x613.png)

#### `views`

Allows for selecting between the classic select menu `{ name: 'list' }` or an expanded grid view `{ name: 'grid' }` with optional preview images for each type. If both are included, the first item in the array will be shown by default, and a button to toggle between views will be included.

*The first item in the array is shown by default, and a toggle appears to switch views.*

```javascript
{
  insertMenu: {
    groups: [
      {
        name: 'intro',
        title: 'Intro',
        of: ['hero'],
      },
      {
        name: 'storytelling',
        title: 'Storytelling',
      },
      {
        name: 'upsell',
        title: 'Upsell',
        of: ['testimonials', 'hero'],
      },
    ],
    views: [
      {name: 'list'},
      {name: 'grid', previewImageUrl: (schemaTypeName) => `/static/preview-${schemaTypeName}.png`},
    ],
  },
}
```

If `previewImageUrl'` is not defined, the icon associated with the respective schema types will be shown instead.

*The grid view allows for setting preview images for types. If no image is available, icons are shown instead.*

#### `disableActions`

Allows for selectively disabling and removing actions from the actions menu.
The following actions can be disabled. 

- `add` – Removes the ability to add new items to the array 
- `addBefore` – Removes the "Add item before"-menu item from the array item menu 
- `addAfter` – Removes the "Add item after"-menu item from the array item menu 
- `remove` – Removes the ability to remove items from the array 
- `duplicate` – Removes the ability to duplicate array items 
- `copy` – Removes the ability to copy items from the array

```typescript
{
      name: 'someArrayField',
      options: {
        disableActions: ['add', 'duplicate'],
      },
      title: "Array you can't add elements to",
      type: 'array',
      of: [
        {
          type: 'object',
          name: 'something',
          title: 'Something',
          fields: [{name: 'first', type: 'string', title: 'First string'}],
        },
      ],
    }
```

A few things to note:

- These changes are only about UI affordances, and doesn't imply any form of write protection for the actual data. Items can still be added or removed to an array by sending mutations to the API. 
- This affordance only applies to arrays of objects and arrays of primitive values. Not to portable text arrays. 
- Disabling add will also implicitly disable addBefore and addAfter, thus disable inserting new items to the array entirely (although items can still be inserted via duplicate).



## Additional Examples

### Example: Array of strings

Vanilla, reorderable array of strings:

Input

```javascript
{
  title: 'Names',
  name: 'names',
  type: 'array',
  of: [{type: 'string'}]
}
```

API response

```json
{
  "names": ["Wilma", "Håvard"]
}
```

Tokenized field (tags) is data-wise an ordinary array

Input

```javascript
// Presented as a tokenizing tag-field
{
  title: 'Tags',
  name: 'tags',
  type: 'array',
  of: [{type: 'string'}],
  options: {
    layout: 'tags'
  }
}
```

API response

```json
{
  "tags": ["clever", "unexpected"]
}
```

### Example: Array containing both crew members and cast members

Both `crewMember` and `castMember` are custom types defined in our schema. In this example we want an array of `employees` to be able to contain both crew members or cast members. The resulting array will end up containing inline instances of the actual objects. Note that in a real world example, you may want this to be references to existing cast members or crew members.

Input

```javascript
{
  title: 'Employees',
  name: 'employees',
  type: 'array',
  of: [{type: 'crewMember'}, {type: 'castMember'}]
}
```

API response

```json
[
  {
      "_key": "5ead6b7c7dcc55ae66d504e2d9bfeff5",
      "_type": "castMember",
      "characterName": "Mark Watney",
      "externalCreditId": "53e7e85e0e0a266f9a0029aa",
      "person": {
        "_ref": "person_matt-damon",
        "_type": "reference"
      }
  },
  {
    "_key": "8c1dd384a3ab34befbe5fdd93478fc8e",
    "_type": "castMember",
    "characterName": "Melissa Lewis",
    "externalCreditId": "5466c78eeaeb8172820008e4",
    "person": {
      "_ref": "person_jessica-chastain",
      "_type": "reference"
    }
  },
  {
    "_key": "76a7e8c2547ce445294c581564bc7d75",
    "_type": "crewMember",
    "department": "Camera",
    "externalCreditId": "5607a946c3a3681218003eef",
    "externalId": 1404244,
    "job": "Helicopter Camera",
    "person": {
      "_ref": "person_john-marzano",
      "_type": "reference"
    }
  }
]
```

### Example: Array of references

In this example, we want `castMember` and `crewMember` to be stored as separate documents, and our array should contain references to these documents instead of the actual data.

Input

```javascript
{
  title: 'Employees',
  name: 'employees',
  type: 'array',
  of: [
    {
      type: 'reference',
      to: [
        {type: 'castMember'},
        {type: 'crewMember'}
      ]
    }
  ]
}
```

API response

```json
[
  {
    "_ref": "person_harrison-ford",
    "_type": "reference"
  },
  {
    "_ref": "person_ridley-scott",
    "_type": "reference"
  }
  //...
]
```

### Example: Array of both references and non-references

Arrays can contain mixed types – subject to the [second limitation](https://www.sanity.io/docs/studio/array-type) identified below.  This includes mixing references and non-references (e.g., objects).

Let's consider the previous example once more. This time, we want `castMember` to be stored as a separate document that our array should reference, while `crewMember` is a type where we want to store an inline instance of the actual object.

Input

```javascript
{
  title: 'Employees',
  name: 'employees',
  type: 'array',
  of: [
    {
      type: 'reference',
      to: [
        {type: 'castMember'},
      ]
    },
    {type: 'crewMember'}
  ]
}
```

API response

```json
[
  {
    "_ref": "person_harrison-ford",
    "_type": "reference"
  },
  {
    "_ref": "person_ridley-scott",
    "_type": "reference"
  },
  {
    "_key": "76a7e8c2547ce445294c581564bc7d75",
    "_type": "crewMember",
    "department": "Camera",
    "externalCreditId": "5607a946c3a3681218003eef",
    "externalId": 1404244,
    "job": "Helicopter Camera",
    "person": {
      "_ref": "person_john-marzano",
      "_type": "reference"
    }
  }
  //...
]
```

Notice that `{type: 'crewMember'}` is inside the `of` array but outside the reference.

> [!TIP]
> Protip
> Looking to query an array of mixed references and non-references? You *could* specify what to return from each `_type` in the array (e.g., `crewMember`, `castMember`, etc.) using projections, but you can also distinguish between references and non-references and either return the inline instance of the object or return the referenced document.

```groq
*[] {
  'employees': employees[] {
    _type == 'reference' => @->,
    _type != 'reference' => @
  }
}
```

### Example: Predefined strings

Sometimes you need an array of strings presented as a set of predefined values. By using the list option, the field is presented as an array of check boxes where the editor can toggle which strings are in the array. This handles the array as a set and the ordering is not defined.

Input

```javascript
{
  title: 'Category Set',
  name: 'categorySet',
  type: 'array',
  of: [{type: 'string'}],
  options: {
    list: [
      {title: 'Building', value: 'building'},
      {title: 'Master plan', value: 'masterPlan'},
      {title: 'Infrastructure', value: 'infrastructure'},
      {title: 'Private Home', value: 'privateHome'}
    ]
  }
}
```

API response

```json
{
  "categorySet": ["building", "privateHome"]
}
```

### Example: Predefined objects

Input

```javascript
{
  title: "Example object list",
  type: "array",
  name: "example",
  of: [
    {
      type: "object",
      name: "inline",
      fields: [
        { type: "string", name: "title" },
        { type: "number", name: "amount" }
      ]
    }
  ],
  options: {
    list: [
      { _type: "inline", title: "Big amount", amount: 100 },
      { _type: "inline", title: "Small amount", amount: 1 }
    ]
  }
}
```

API response

```json
{
  "example": [
    {
      "_type": "inline",
      "title": "Big amount",
      "amount": 100,
      "_key": "auto-generated-0"
    },
    {
      "_type": "inline",
      "title": "Small amount",
      "amount": 1,
      "_key": "auto-generated-1"
    }
  ]
}
```

### Example: Unique values

A common use case is to only want unique items in an array. This can be enforced by adding a validation function and using the `unique()` method.

Input

```javascript
{
  title: 'Category Set',
  name: 'categorySet',
  type: 'array',
  of: [{type: 'string'}],
  validation: Rule => Rule.unique()
}
```

API response

```json
{
  "categorySet": ["building", "privateHome"]
}
```

### Example: Custom sort order with custom component

While use a custom component to sort arrays in ways that don't match the data structure. This will only change how the items are displayed in Studio.

**Field example**

```
defineField({
  name: 'someUserChoices',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'aCustomObject' // or other types
    }),
  ],
  options: {
    sortable: false,
  },
  components: {
    input: CustomArrayInput,
  },
}),
```

**Component example**

```
import {ArrayOfObjectsInputProps, ArrayOfObjectsMember} from 'sanity'
import {useMemo} from 'react'

export function CustomArrayInput(props: ArrayOfObjectsInputProps) {
  const {renderDefault} = props
  const sortedObjects = useMemo(() => {
    const value = (props.value || []) as WhateverYourArrayTypeIs[] 
    return value
      .sort( // add a sort function for the arrays  )
      .map((entry) => entry._key)
  }, [props.value])

  const members = props.members || []
  const membersByKey = members.reduce(
    (acc, member) => {
      acc[member.key] = member
      return acc
    },
    {} as Record<string, ArrayOfObjectsMember>,
  )

  const sortedMembers = sortedObjects
    .filter((key) => key)
    .map((key) => membersByKey[key]) as ArrayOfObjectsMember[]
  // Note the replaced `members` with `sortedMembers`
  return <div>{renderDefault({...props, members: sortedMembers || []})}</div>
}
```

## Why is the `_key`?

When adding data with `type: 'object'` to an array, each item gets assigned a persistent and unique `_key` property. This is to ensure that each item can be addressed uniquely in a collaborative, real time setting. This allows one user to edit an array item while another user simultaneously reorders the array.

> [!WARNING]
> Gotcha
> When using the `initialValue` property in Sanity Studio to initialize a field with a predefined array of objects, setting the `_key` property of those objects manually will not work.
> This: `{ type: 'array', initialValue: [{_key: 'monday', day: 'Monday'}] }`
> Will result in this: `[{_key: '<random string>', day: 'Monday'}]`

### Two Limitations

1. Due to a limitation in the data store, arrays may not currently contain arrays. Solve this by wrapping nested arrays in objects.
2. It is not possible to define arrays that contains **both** object types and primitive types. Arrays that hold values of *primitive* types (e.g. *strings* or *numbers*) cannot be addressed uniquely by a key in real time. As a consequence, when defining an array of primitive values, the content studio will switch to a simpler array input widget for editing. This simpler input widget will **not** be able to handle object types, which is why it is not possible to define arrays that contains **both** object types and primitive types. If you should ever need an array that contains both primitive types (e.g., *strings*) and *objects* (e.g., `movie`), you should instead create an object as an item in the array and give it properties that hold the primitive values.

This **will not** work:

```javascript
{
  type: 'array',
  of: [
    {
      type: 'actor', /* This is an object type */
      title: 'Actor'
    },
    {
      type: 'string', /* Will not work! */
      title: 'Actor name'
    }
  ]
}
```

This **will** work:

```javascript
{
  type: 'array',
  of: [
    {
      title: 'Actor',
      type: 'actor'
    },
    {
      title: 'Actor name',
      type: 'object',
      fields: [
        {
          title: 'Name',
          name: 'value',
          type: 'string'
        } 
      ]
    }
  ]
}
```





# Block

The block type is the basis for Sanity's Portable Text editor. See the [BlockDefinition](https://reference.sanity.io/sanity/index/BlockDefinition/) reference for the full type definition.

![A block field in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/dd4138b9407ac3ebe5939a391d220c040a9263fd-3456x2100.png)
*An example of a block field with a rich block text editor.*

In order to activate the block content editor for Sanity Studio, you must make an *array of blocks*. In the schema, it looks like this in its simplest form:

```javascript
{
  title: 'Content', 
  name: 'content',
  type: 'array', 
  of: [{type: 'block'}]
}
```

In other words, rich text is modeled as an *array of content* following the [specification for Portable Text](https://www.portabletext.org). What is stored in the database is an array of JSON objects describing the rich text content. This JSON data can later be used to [produce HTML, React components, or other formats depending on the target requirements](https://www.sanity.io/docs/developer-guides/presenting-block-text). This provides a lot of flexibility if you should later want to re-use your content across the web, apps, print, set-top-boxes, consoles, etc.

The block text type supports block styles, lists, decorators (bold, italic, etc.), custom content types (embedded objects), inline objects, and even marking up text with arbitrary object data (annotations). [Learn more about how to configure the rich text editor](https://www.sanity.io/docs/studio/portable-text-editor-configuration).

> [!WARNING]
> Gotcha
> You can't currently use `block` as a standalone field outside of an array.

## Properties

#### Properties

**type** (required)

Value must be set to block. Also, blocks only make sense as member of an array, see examples below.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**styles**

This defines which styles that applies to blocks. A style is an object with a title (will be displayed in the style dropdown) and a value, e.g.: styles: [{title: 'Quote', value: 'blockquote'}]. If no styles are given, the default styles are H1 up to H6 and blockquote. A style named normal is reserved, always included and represents "unstyled" text. If you don't want any styles, set this to an empty array e.g.: styles: [].

**lists**

What list types that can be applied to blocks. Like styles above, this also is an array of "name", "title" pairs, e.g.: {title: 'Bullet', value: 'bullet'}. Default list types are bullet and number.

**marks**

An object defining which .decorators (array) and .annotations (array) are allowed. See example below.

**of**

An array of inline content types that you can place in running text from the Insert menu.

**icon**

To return an icon that is shown in the menus and the toolbar.

**description**

Short description to editors how the field is to be used.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**initialValue**

The initial value that will be used when creating new items from this type.

## Options ([BlockOptions](https://reference.sanity.io/sanity/index/BlockOptions/))

#### Properties

**oneLine** (boolean)

Restricts the Portable Text input to a single line when set to true. The parent Portable Text array must consist of a single array member of type block or this option will not be effective.

**spellCheck** (Boolean)

Enables or disables spellchecking in the Portable Text Editor. Defaults to true.

## Validation ([BlockRule](https://reference.sanity.io/sanity/index/BlockRule/))

#### Properties

**required()**

Ensures that this field exists.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

> [!WARNING]
> Gotcha
> A block represents a single paragraph. To make sense, your blocks *must* live inside an **array**.

### Example schema: Default block array

With no custom configuration, the block editor supports:

- Block styles: Normal, Heading 1 to Heading 6, and blockquotes
- Decorators: Strong, emphasis, code, underline and strikethrough
- Lists: bullet list and ordered list
- Link: An annotation that is an object with a `href` with type `url`

Input

```javascript
{
  title: 'Rich text example',
  name: 'myRichTextExample',
  type: 'array',
  of: [{type: 'block'}]
}
```

Response

```json
{
  "myRichTextExample": [{
    "style": "normal",
    "_type": "block",
    "markDefs": [],
    "children": [
      {
        "_type": "span",
        "text": "That was ",
        "marks": []
      },
      {
        "_type": "span",
        "text": "bold",
        "marks": [
          "strong"
        ]
      },
      {
        "_type": "span",
        "text": " of you.",
        "marks": []
      }
    ]
  },
  {
    "style": "normal",
    "_type": "block",
    "markDefs": [],
    "children": [
      {
        "_type": "span",
        "text": "Amazing, actually.",
        "marks": []
      }
    ]
  }]
}
```

#### Example schema: Block array with custom types

This defines a block array that can include both text, actors, and (inline) images.

```javascript
{
  title: 'Rich text',
  type: 'array',
  of: [
    {type: 'block'},
    {type: 'actor'},
    {type: 'image', icon: myIcon}
  ]
}
```

The editor will now get an insertion (`+`) icon in the text editor that can be used to insert actors or images as content blocks in the text. The data stored in the array for these objects are exactly as if they were in a regular array of objects, because they are.

These objects are embedded on the block level, but you may also need objects that appear inline with text useful for stuff like footnotes, ticker-symbols or [sparklines](https://en.wikipedia.org/wiki/Sparkline). Add these to an array under the `of` key in the block type object:

```javascript
{
  title: 'Rich text',
  type: 'array',
  of: [
    {
      type: 'block',
      of: [
        {type: 'footnote'}
      ]
    }
  ]
}

```

### Customizing

Almost every aspect of the block editor and the content it produces is [configurable](https://www.sanity.io/docs/studio/portable-text-editor-configuration). You may want to restrict certain types of decorators or add your own, use your own list styles, annotate text with custom data (e.g. a citation or reference), or support highlighted text.

You can add a `component` property to a block, decorator, or annotation that contains callback functions to control how the content is rendered in the studio, and you can add an `icon` property to render in the tool bar of the editor.

> [!WARNING]
> Gotcha
> Note that customizations made in the studio will not affect how content is rendered elsewhere, such as your front end. That gets handled via [portable text serialization](https://www.sanity.io/docs/developer-guides/presenting-block-text).

```javascript
{
  name: 'customized',
  title: 'Customized block type',
  type: 'array',
  of: [
    {
      type: 'block',
      // ...
      marks: {
        decorators: [
          { title: "Strong", value: "strong" },
          { title: "Emphasis", value: "em" },
          {
            title: "Sup",
            value: "sup",
            icon: () => <div>x<sup>2</sup></div>,
            component: ({ children }) => <sup>{children}</sup>
          },
        ],
      },
      // ...
    }
  ]
}
```

#### Example schema: Block array with custom types

```javascript
{
  name: 'customized',
  title: 'Customized block type',
  type: 'array',
  of: [
    {
      type: 'block',
      // Only allow these block styles
      styles: [
        {title: 'Normal', value: 'normal'},
        {title: 'H1', value: 'h1'},
        {title: 'H2', value: 'h2'}
      ],
      // Only allow numbered lists
      lists: [
        {title: 'Numbered', value: 'number'}
      ],
      marks: {
        // Only allow these decorators
        decorators: [
          {title: 'Strong', value: 'strong'},
          {title: 'Emphasis', value: 'em'}
        ],
        // Support annotating text with a reference to an author
        annotations: [
          {name: 'author', title: 'Author', type: 'reference', to: {type: 'author'}}
        ]
      }
    }
  ]
}

```

> [!TIP]
> Protip
> Looking to [query](https://www.sanity.io/docs/content-lake/how-queries-work) for the occurence of a string in an array of blocks? Try `*[pt::text(body) match "aliens"]` (where `body` is the name of your array).

#### Related articles

[Block Content](https://www.sanity.io/docs/studio/block-content)
Block content allows you to create a rich text experience tailored to the needs of your content.

[Configure the Portable Text Editor](https://www.sanity.io/docs/studio/portable-text-editor-configuration)
Configure the Portable Text Editor: styles, lists, decorators, annotations, custom blocks, tables, and the built-in Markdown and typography behaviors.

[Array](https://www.sanity.io/docs/studio/array-type)
Schema type for arrays of other types.

[Schema](https://www.sanity.io/docs/studio/schema-types)
A schema describes the types of documents and fields editors may author in a Sanity Studio workspace.



# Boolean

A boolean, `true` or `false`. See the [BooleanDefinition](https://reference.sanity.io/sanity/index/BooleanDefinition/) reference for the full type definition.

![Screenshot of a boolean field in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/5780e4036ae661fa86d2749813f81af0ee1dd841-3456x2100.png)
*A boolean field with title and description*

## Properties

#### Properties

**type** (required)

Value must be set to boolean.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal value or a resolver function that returns either a literal value or a promise resolving to the initial value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

## Options ([BooleanOptions](https://reference.sanity.io/sanity/index/BooleanOptions/))

#### Properties

**layout**

Either switch (default) or checkbox

This lets you control the visual appearance of the input. By default the input for boolean fields will display as a switch, but you can also make it appear as a checkbox.

## Validation ([BooleanRule](https://reference.sanity.io/sanity/index/BooleanRule/))

#### Properties

**required()**

Ensures that this field exists.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

Input

```javascript
{
  title: 'Has the movie been released?',
  name: 'released',
  type: 'boolean'
}
```

Response

```json
{
  "_type": "movie",
  "released": true,
  ...
}
```

New documents are created without schema-defined fields. This means that a boolean field in your schema will not immediately result in documents containing the boolean key. The key must be assigned a value for it to appear in a document. Make sure your front-end code treats a missing boolean value as false.

> [!TIP]
> Protip
> In GROQ you can handle missing booleans and false values equally like this `*[_type == 'story' && featured != true]` which would match stories where featured is false or missing (or to be fair, any other value that is not `true`).





# Cross Dataset Reference

Cross Dataset References allow you to connect documents across datasets. While similar to the [reference type](https://www.sanity.io/docs/reference-type), they have their own, distinct schema type of `crossDatasetReference` and the two can not be used interchangeably. 

To learn about how to set up your datasets for cross-dataset referencing, please refer to [the introduction article Cross Dataset References](https://www.sanity.io/docs/studio/cross-dataset-references).

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

> [!WARNING]
> Gotcha
> Cross dataset references can only be dereferenced using GROQ queries. Dereferencing through GraphQL endpoints is not currently supported.

## Properties

#### Properties

**type** (required)

Value must be set to crossDatasetReference.

#### Properties

**name** (required)

The field name. This will be the key in the data record.

#### Properties

**to** (required)

Must contain an array of objects that name all the types from the referenced dataset that should be available in the referencing studio. type and preview are required properties. icon and title are optional properties. For example, [{type: 'someTypeFromAnotherDataset', preview: { select: { title: 'title' }}}]. See more examples below.

Note: While you may refer to several types in your referenced dataset in the to array, you are limited to types from a single dataset for each field.

#### Properties

**dataset** (required)

The name of the referenced dataset.

#### Properties

**studioUrl**

A function that is invoked with the type and id of the referenced document, and returns a string that can be used to construct a URL directly to the item referenced in its studio environment.

Example: 

studioUrl: ({ type, id }) => `https://<your-studio-url>/desk/intent/edit/id=${id};type=${type}/`

#### Properties

**weak**

Default false. If set to true the reference will be made weak. This allows references to point at documents that may or may not exist, such as a document that has not yet been published or a document that has been deleted (or indeed an entirely imagined document).

#### Properties

**title**

Human readable label for the field.

#### Properties

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

#### Properties

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

#### Properties

**description**

Short description to editors how the field is to be used.

#### Properties

**initialValue**

The initial value that will be used when creating new values from this type. Can be either the literal value or a resolver function that returns either the literal value or a promise that resolves to the initial value.

#### Properties

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

## Options

#### Properties

**filter**

Additional GROQ-filter to use when searching for target documents. The filter will be added to the already existing type name clause.

If a function is provided, it is called with an object containing document, parent and parentPath properties, and should return an object containing filter and params. As of v2.4.0 this function can optionally be async and return a Promise that resolves to an object containing filter and params.

Note: The filter only constrains the list of documents returned at the time you search. It does not guarantee that the referenced document will always match the filter provided.

#### Properties

**filterParams**

Object of parameters for the GROQ-filter specified in filter.

## Validation

#### Properties

**required()**

Ensures that this field exists.

#### Properties

**custom(fn)**

Creates a custom validation rule.

## Cross Dataset Reference

A minimal example of a `crossDatasetReference` field:

Input

```javascript
{
  title: `Person in another dataset"`,
  name: 'personReference',
  type: 'crossDatasetReference',
  dataset: 'production',
  to: [
    {
      type: 'person',
      preview: {
        select: {
          title: 'name',
          media: 'image',
        },
      },
    },
  ],
}
```

Response

```json
{
  "_type": "crossDatasetReference",
  "_ref": "person_andrew-stanton",
  "_dataset": "production"
}
```

## Weak reference

Defining the `crossDatasetReference` as `weak`, will unblock publishing of documents that has a (cross-dataset) reference to a non-existing document.

Input

```javascript
{
  title: `Person in another dataset"`,
  name: 'personReference',
  type: 'crossDatasetReference',
  dataset: 'production',
  weak: true,
  to: [
    {
      type: 'person',
      preview: {
        select: {
          title: 'name',
          media: 'image',
        },
      },
    },
  ],
}
```

Response

```json
{
  "_type": "crossDatasetReference",
  "_ref": "person_andrew-stanton",
  "_dataset": "production",
  "_weak": true,
}
```

## Reference  multiple types

The `directors` field is an array that can contain both `person` and `bovinae` (in the rare occasion a cow would direct a movie) references:

Input

```javascript
{
  title: `Person or cow in another dataset"`,
  name: 'personOrCowReference',
  type: 'crossDatasetReference',
  dataset: 'production',
  to: [
    {
      type: 'person',
      preview: {
        select: {
          title: 'name',
          media: 'image',
        },
      },
    },
    {
      type: 'bovinae',
      preview: {
        select: {
          title: 'name',
          media: 'avatar',
        },
      },
    },
  ],
}
```

Response

```json
[
  {
    "_type": "crossDatasetReference",
    "_ref": "person_andrew-stanton",
    "_dataset": "production"
  },
  {
    "_type": "crossDatasetReference",
    "_ref": "bovinae_ferdinand-bull",
    "_dataset": "production"
  }
]
```

## Additional static filter

If providing a target schema type is not enough to provide a meaningful set of search results, you may want to further constrain the search query:

Input

```javascript
{
  title: `Person in another dataset"`,
  name: 'personReference',
  type: 'crossDatasetReference',
  dataset: 'production',
  options: {
    filter: 'role == $role',
    filterParams: {role: 'director'}
  },
  to: [
    {
      type: 'person',
      preview: {
        select: {
          title: 'name',
          media: 'image',
        },
      },
    },
  ],
}
```

Response

```json
{
  "_type": "crossDatasetReference",
  "_ref": "person_steven-spielberg",
  "_dataset": "production",
}
```

## Additional dynamic filter

If you want to further constrain the search result, but need properties from the surrounding document or object/array, you can use the function form for `filter`:

Input

```javascript
{
  title: `Person in another dataset"`,
  name: 'personReference',
  type: 'crossDatasetReference',
  dataset: 'production',
  to: [
    {
      type: 'person',
      preview: {
        select: {
          title: 'name',
          media: 'image',
        },
      },
    },
  ],
  options: {
  filter: ({document}) => {
    // Always make sure to check for document properties
    // before attempting to use them
    if (!document.releaseYear) {
      return {
        filter: 'role == $role',
        params: {role: 'director'}
      }
    }
    
    return {
      filter: 'role == $role && birthYear >= $minYear',
      params: {
        role: 'director',
        minYear: document.releaseYear
      }
    }
  }
}
```

Response

```json
{
  "_type": "crossDatasetReference",
  "_ref": "person_steven-spielberg",
  "_dataset": "production",
}
```

## Nonexistent reference

Sometimes the reference field may show an error message like `<nonexistent reference>`. This usually happens when creating documents with a client library and can mean one of two things:

- The document with the ID you are referencing does not exist
- The field does not allow references to the document type of the document ID you tried to reference





# Date

An ISO-8601 formatted string containing date. E.g. `2017-02-12`. See the [DateDefinition](https://reference.sanity.io/sanity/index/DateDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Required. Value must be set to date.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**placeholder**

Placeholder text that appear within the input when it is empty.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal date string value or a resolver function that returns either a literal date string value or a promise resolving to the initial date string value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

## Options ([DateOptions](https://reference.sanity.io/sanity/index/DateOptions/))

#### Properties

**dateFormat**

Controls how the date input field formats the displayed date. Use any valid Moment format option. Default is YYYY-MM-DD.

## Validation ([DateRule](https://reference.sanity.io/sanity/index/DateRule/))

#### Properties

**required()**

Ensures that this field exists.

**custom(fn)**

Creates a custom validation rule.

**min(minDate)**

Minimum date (inclusive). minDate should be in ISO 8601 format.

**max(maxDate)**

Maximum date (inclusive). maxDate should be in ISO 8601 format.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

![Screenshot of Date field with a title, description, and value.](https://cdn.sanity.io/images/3do82whm/next/a4780c2c8594ddb523fcf824d3cff6c011be05e9-1152x500.png)

The stored date is represented as a string in compliance with [ISO 8601](http://en.wikipedia.org/wiki/ISO_8601) (often described as `YYYY-MM-DD`).

> [!TIP]
> Protip
> If you need to store information about both date and time, use the [datetime](https://www.sanity.io/docs/datetime-type) type instead.

Input

```javascript
{
  title: 'Release date',
  name: 'releaseDate',
  type: 'date'
}
```

Response

```json
{
  "releaseDate": "2017-02-12"
}
```

### Example: All options set

```javascript
{
  title: 'Release date',
  name: 'releaseDate',
  type: 'date',
  options: {
    dateFormat: 'YYYY-MM-DD',
    calendarTodayLabel: 'Today'
  }
}
```



# Datetime

An ISO-8601 formatted string containing date and time stored in UTC. E.g. `2017-02-12T09:15:00Z`. See the [DatetimeDefinition](https://reference.sanity.io/sanity/index/DatetimeDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Value must be set to datetime.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal datetime string or a resolver function that returns either a literal datetime string value or a promise resolving to a datetime string value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**placeholder**

Placeholder text shown in the input when it has no value.

## Options ([DatetimeOptions](https://reference.sanity.io/sanity/index/DatetimeOptions/))

#### Properties

**dateFormat**

Controls how the date input field formats the displayed date. Use any valid Moment format option. Default is YYYY-MM-DD.

**timeFormat**

Controls how the time input field formats the displayed date. Use any valid Moment format option. Default is HH:mm.

**timeStep**

Number of minutes between each entry in the time input. Default is 15 which lets the user choose between 09:00, 09:15, 09:30 and so on.

**allowTimeZoneSwitch** (boolean)

Determines whether the user is allowed to set a personalized time zone for viewing and interacting with the field in Studio. Defaults to true.

**displayTimeZone** (string)

Set a specific time zone to be used when viewing and interacting with the field in Studio. Expects a string in the shape of a valid time zone identifier. Note: the timestamp stored in the dataset is always UTC.

## Validation ([DatetimeRule](https://reference.sanity.io/sanity/index/DatetimeRule/))

#### Properties

**required()**

Ensures that this field exists.

**min(minDate)**

Minimum date (inclusive). minDate should be in ISO 8601 format.

**max(maxDate)**

Maximum date (inclusive). maxDate should be in ISO 8601 format.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

The date+time is represented as a string in a *simplified* extended ISO format ([ISO 8601](http://en.wikipedia.org/wiki/ISO_8601)). This is the same format as [date.toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) and **date.toJSON()** returns.

Input

```javascript
{
  title: 'Launch Scheduled At',
  name: 'launchAt',
  type: 'datetime'
}
```

Response

```json
{
  "launchAt": "2017-02-12T09:15:00Z"
}
```

## Example: All options set

```javascript
{
  title: 'Launch Scheduled At',
  name: 'launchAt',
  type: 'datetime',
  options: {
    dateFormat: 'YYYY-MM-DD',
    timeFormat: 'HH:mm',
    timeStep: 15,
    allowTimeZoneSwitch: true, // default value, could be omitted
    displayTimeZone: 'Europe/Berlin'
  }
}
```



# Document

Everything in the Studio starts with the `document`. A document is what you create and edit in the studio—all the other types you may define live inside the `document`s. In the default studio configuration, the document-types are the ones that will be listed in the content-column. See the [DocumentDefinition](https://reference.sanity.io/sanity/index/DocumentDefinition/) reference for the full type definition.

## Properties

#### Properties

**name** (required)

The field name. This will be the key in the data record.

**type** (required)

Value must be set to document.

**fields** (required)

The fields of this object. At least one field is required. Documented here.

**description**

Show a description to editors with context about the document type.

**fieldsets**

A list of fieldsets that fields may belong to. Documented here.

**groups**

Groups fields into tabs. 

On document: groups: [{name: 'seo', title: 'SEO'}], 

On field: group: 'seo',

For details, see this reference doc.

**initialValue**

The initial value that will be used for all new documents created from this document type. Can be either a literal document value or a function that returns either a literal value or a promise that resolves to a document value.

**liveEdit**

Turns off drafts when set to true. Changes to documents will publish immediately.

**orderings**

A declaration of possible ways to order documents of this type, documented here.

**preview**

Use this to implement an override for the default preview for this type. Documentation here.

**title**

Human readable label for the document.

**readOnly**

If set to true, documents of this type will not be editable in the Studio. You can also return a callback function to use it as a conditional field.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**__experimental_formPreviewTitle**

Hides the document title heading in the studio form pane.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**hidden**

If set to `true`, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**renderMembers**

Custom render function for the document's members (fields and fieldsets).



At its core, a document is a JSON-object that has a unique `_id`, timestamps (`_createdAt`, `_updatedAt`) and revision-marker `_rev`.

> [!TIP]
> Timestamp truthiness
> `_createdAt` and `_updatedAt` are automatically set by the system to the current time when the document is created or updated, respectively. It is possible to provide a custom value when the document is initially created via a `create`, `createIfNotExists`, or `createOrReplace` mutation. Since the timestamps can be set by a client, they should never be assumed to be accurate.

The `document` type is used to define the structure of a document that can be stored in our data store. You can think of a document as an object that, in addition to the fields you define, also has a unique id, (`_id`), a field for tracking created time and last updated time (`_createdAt` and `_updatedAt`) and a revision marker (`_rev`). Only *documents* can be referred to from other documents or retrieved by id and only *document* types will be listed and create-able in the studio.

Apart from the above, documents are defined just like regular objects, so see the documentation of the object type for more info about how to define documents.

Input

```javascript
{
  title: 'Movie',
  name: 'movie',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string'
    },
    {
      title: 'Poster',
      name: 'poster',
      type: 'image'
    },
    {
      title: 'Directors',
      name: 'directors',
      type: 'array',
      of: [{type: 'string'}]
    }
  ]
}
```

Response

```json
{
  "_type": "movie",
  "_id": "2106a34f-315f-44bc-929b-bf8e9a3eba0d",
  // ... _createdAt, _updatedAt, _rev omitted
  "title": "Alien",
  "poster": {... <an image object> ...},
  "directors": ["Ridley Scott"]
}
```





# File

A `file` is a special kind of [object](https://www.sanity.io/docs/object-type) that includes an implicit asset field, which is a reference to a file asset document. This is useful for storing any kind of non-image files (pdf, mpeg, docx etc). See the [FileDefinition](https://reference.sanity.io/sanity/index/FileDefinition/) reference for the full type definition.



> [!WARNING]
> Gotcha
> You shouldn't use the `file` type for images. Use [image](https://www.sanity.io/docs/image-type) instead. Images uploaded as files will not have the associated metadata for images and you won't be able to scale and crop them in the image pipeline.

## Properties

#### Properties

**type** (required)

Required. Value must be set to file.

**name** (required)

Required. The field name. This will be the key in the data record.

**fields**

An array of optional fields to add to the file field. The fields added here follow the same pattern as fields defined on objects. This is useful for allowing users to add custom metadata related to the usage of this file (see example below).

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal value or a resolver function that returns either a literal value or a promise resolving to the initial value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**fieldsets**

Groups fields together in the studio interface. Each fieldset has a `name`, `title`, and optional `options` for collapsing behavior.

**preview**

Configures how the document or object is previewed in lists and references. Accepts `select` and `prepare` properties.

**renderMembers**

Custom render function for the document's members (fields and fieldsets).

## Options ([FileOptions](https://reference.sanity.io/sanity/index/FileOptions/))

#### Properties

**storeOriginalFilename**

This will store the original filename in the asset document. Please be aware that the name of uploaded files could reveal potentially sensitive information (e.g. top_secret_planned_featureX.pdf). Default is true.

**accept**

This specifies which mime types the file input can accept. It functions just like the accept attribute on native DOM file inputs and you can specify any valid file type specifier.

It is recommended to use MIME types ("application/pdf") over file extensions (".pdf") in order for hover notifications to work for drag and drop as browsers do not send the file name while hovering.

**sources**

Lock the asset sources available to this type to a specific subset. Import the plugins by their part name, and use the import variable name as array entries. 

Read more about custom asset sources

**disableNew**

If set to `true`, the option to create new assets is disabled.

**collapsible**

If set to `true`, the field can be collapsed.

**collapsed**

If set to `true`, the field will be collapsed by default.

**columns**

Number of columns to use for the field layout.

**modal**

Controls how the modal (dialog for content editing) is rendered.

**mediaLibrary**

If set to `true`, enables the media library for asset selection.

## Validation ([FileRule](https://reference.sanity.io/sanity/index/FileRule/))

#### Properties

**required()**

Ensures that this field exists.

**assetRequired()**

Like required but more specific. Requires that an actual asset is referenced to validate. Must be used together with required, i.e.: 
validation: (Rule) => Rule.required().assetRequired(),

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

Input

```javascript
{
  title: 'Manuscript',
  name: 'manuscript',
  type: 'file',
  fields: [
    {
      name: 'description',
      type: 'string',
      title: 'Description'
    },
    {
      name: 'author',
      type: 'reference',
      title: 'Author',
      to: {type: 'person'}
    }
  ]
}
```

Response

```json
{
  "_type": "file",
  "asset": {
    "_type": "reference",
    "_ref": "file-5igDD9UuXffIucwZpyVthr0c"
  },
  "description": "First draft",
  "author": {
    "_type": "reference",
    "_ref": "1osKfX-49GLPg-2EeuOe-3ufEFE"
  }
}
```

## Download file

In order to download a file from your front-end you need to append `?dl=<filename-of-your-choice.pdf>` to the file URL. If you leave the filename blank, the original filename will be used if present. If the original filename is not available, the id of the file will be used instead. 

```groq
// GROQ query
*[_type == 'movie'] {
  title,
  "manuscriptURL": manuscript.asset->url
}

// Then you can use the URL in HTML for example like this:
// <a href={`${manuscriptURL}?dl=`}>Manuscript</a>
```

## Uploading files via Drag & Drop or Paste

When you drag and drop files into the Portable Text Editor or an Array field in Sanity Studio, it will automatically pick the most suitable field to add the file to based on the `accept` option configured on the file fields. If multiple fields match the dropped file type, it will use the first matching field.

```javascript
// Field with accept option set to PDF
defineField({
  name: 'pdfFile',
  type: 'file',  
  options: {
    accept: 'application/pdf'
  }
})
```

```javascript
// Field with accept option set to Excel
defineField({
  name: 'excelFile',
  type: 'file',
  options: {
    accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  }
})
```

When dropping an Excel file, it will be added to the `excelFile` field that accepts Excel files.



# Geopoint

An object signifying a global latitude/longitude/altitude coordinate. Longitude and latitude is stored as decimal degrees, while altitude is stored as a floating point representing meters above sea level. See the [GeopointDefinition](https://reference.sanity.io/sanity/index/GeopointDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Value must be set to geopoint.

**name**

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal value or a resolver function that returns either a literal value or a promise resolving to the initial value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**options** (Object)

Visual options for the geopoint field in Studio. See the full list available in the GeopointOptions type.



## Validation ([GeopointRule](https://reference.sanity.io/sanity/index/GeopointRule/))

#### Properties

**required()**

Ensures that this field exists.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

Input

```javascript
{
  title: 'Launchpad Location',
  name: 'location',
  type: 'geopoint'
}
```

Response

```json
{
  "_type": "geopoint",
  "lat": 58.63169011423141,
  "lng": 9.089101352587932,
  "alt": 13.37
}
```

While the `geopoint` type is available in Sanity by default, you will probably want to install a plugin that provides a more visual way to input the coordinates. For instance, you could use the [@sanity/google-maps-input](https://www.npmjs.com/package/@sanity/google-maps-input) plugin:

**npm**

```shell
cd my-project
npm install --save @sanity/google-maps-input
```

**pnpm**

```shell
cd my-project
pnpm add @sanity/google-maps-input
```

**yarn**

```shell
cd my-project
yarn add @sanity/google-maps-input
```

**bun**

```shell
cd my-project
bun add @sanity/google-maps-input
```

Then add the plugin to your `sanity.config.ts|js` with your Google maps API key:

```typescript
import { googleMapsInput } from "@sanity/google-maps-input";

export default defineConfig({
  // ...
  plugins: [
      googleMapsInput({
          apiKey: "my-api-key"
     })
  ] 
})
```

Make sure the key has access to all of the following APIs:

- Google Maps JavaScript API
- Google Places API Web Service
- Google Static Maps API

You can create such keys and grant API access in the [Google Developer Console](https://console.developers.google.com/apis).

> [!WARNING]
> Gotcha
> *Be careful with your API keys*. If you use this functionality, it's a good idea to make your repository private.



# Global document reference

Global document references (GDR) expand the concept of the reference type to support referencing documents in other resources. See the [GlobalDocumentReferenceDefinition](https://reference.sanity.io/sanity/index/GlobalDocumentReferenceDefinition/) reference for the full type definition.

> [!NOTE]
> Global document references are limited to Media Library
> Global document references are currently supported only by [Media Library's](https://www.sanity.io/docs/media-library/introduction) aspects feature.

Like standard [references](https://www.sanity.io/docs/studio/reference-type), global document references can be either *strong* (default) or *weak*. A strong reference ensures that the document it points to exists, and prevents deletion of any document that another document refers to. A weak reference can point to documents that don't exist yet or that have been deleted.

## Global document reference properties

#### Properties

**type** (string, required)

Value must be set to globalDocumentReference.

**name** (string, required)

The field name. This is the key in the data record.

**resourceType** (string, required)

Either dataset or media-library. Media Library aspects currently reference the dataset resource type.

**resourceId** (string, required)

The ID of the target resource. A resourceId is made up of the projectId and the dataset name, connected by a .. A resourceId has the format projectId.datasetName. For example:

w3dbef.production

wm2efj.staging

**to** (array, required)

Must contain an array of objects naming all the types which may be referenced. type and preview are required properties; title and icon are optional. For example: [{type: 'person', preview: {select: {title: 'name'}}}]. For a complete example, see "Global document reference example".

**weak** (boolean)

If set to true, the reference is weak. A weak reference can point to documents that may or may not exist, such as a document that hasn't been published yet or one that has been deleted. Defaults to false.

**title** (string)

Human-readable label for the field.

**options** (object)

Further configure the schema type. See "Global document reference options".

### Global document reference options

#### Properties

**filter** (string)

A GROQ filter string (the contents between the square brackets), such as language == "en-US".

**filterParams** (object)

Object of parameters for the GROQ-filter specified in filter.

## Global document reference example

This example defines a global document reference as a Media Library aspect. The document example shows a simplified `sanity.asset` document from Media Library that contains this aspect:

**aspect.ts**

```typescript
import { defineAssetAspect } from 'sanity'

export default defineAssetAspect({
  name: 'photographer',
  title: 'Photographer',
  type: 'globalDocumentReference',
  description: 'Select the photographer.',
  resourceType: 'dataset',
  resourceId: 'YOUR_PROJECT_ID.DATASET_NAME',
  weak: true,
  to: [
    {
      type: 'photographer',
      preview: {
        select: {
          title: 'name'
        }
      }
    }
  ]
})
```

**Asset document example**

```javascript
{
  _createdAt: '2025-10-27T21:21:41Z',
  _id: '34fMJaofTI5ptNBZFOYoBNfy6NM',
  _rev: 'eefe4de2-7ec8-4307-aecc-1b0e890fa4e6',
  _system: { createdBy: 'gvRshKueQ' },
  _type: 'sanity.asset',
  _updatedAt: '2025-11-05T19:19:24Z',
  aspects: {
    photographer: {
      _ref: 'dataset:YOUR_PROJECT_ID.DATASET_NAME:200e44f2-14a9-4c7a-a621-a4ca4d9b559c',
      _type: 'globalDocumentReference',
      _weak: true
    }
  },
  assetType: 'sanity.imageAsset',
  cdnAccessPolicy: 'public',
  currentVersion: {...},
  title: 'example-image.png',
  versions: [...]
}
```

> [!NOTE]
> Global document references use a compound reference ID
> Global document references use a more complex `_ref` than standard reference types. In the asset document example, the `_ref` value follows this structure: `resourceType:projectId.datasetName:documentId`.

For more details on creating aspects that use global document references and querying Media Library assets, see the [Media Library documentation](https://www.sanity.io/docs/media-library).



# Image

When you create a field with the `image` type, the user is presented with a standard file dialog that allows normal uploads, as well as drag and drop and pasting of images. Arrays of images accept batches of files to be dropped on them. See the [ImageDefinition](https://reference.sanity.io/sanity/index/ImageDefinition/) reference for the full type definition.

When uploading an image the reference to the file itself is not stored in the image field in a given document. Instead, it adds a reference to the asset metadata document. This allows you to separate between context-specific data, like hotspot, crop, and captions – and the image asset itself, which you might want to re-use in many contexts.

Image assets also contain [metadata](https://www.sanity.io/docs/apis-and-sdks/image-metadata) such as Low-Quality Image Previews (LQIP), BlurHash and ThumbHash placeholders, palette information, and original image dimension as well as aspect ratio. 

Have a look at the articles on [presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images) and [image URLs](https://www.sanity.io/docs/apis-and-sdks/image-urls) for how to use images in practice. 

## Properties

#### Properties

**type** (string, required)

Value must be set to image.

**name** (string, required)

The field name. This will be the key in the data record.

**fields** (array)

An array of optional fields to add to the image record. The fields added here follow the same pattern as fields defined on objects. This is useful for adding custom properties like caption, attribution, etc., to the image record itself (see example below).

**title** (string)

A human-readable label for the field. This is what's displayed in Studio. If omitted, the name will be used.

**hidden** (boolean)

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field. (Defaults to false)

**readOnly** (boolean)

If set to true, this field will not be editable in the studio. You can also return a callback function to use it as a conditional field. (Defaults to false)

**description** (string)

A short description to help editors understand how the field is to be used.

**initialValue** (any | resolver function)

The initial value used when creating new values from this type. Can be either a literal value or a resolver function that returns either a literal value or a promise resolving to the initial value. Learn more about initial value templates.

**deprecated** (object)

Marks a field or document type as deprecated in the studio interface and displays a user-defined message. Accepts an object with a single reason key that accepts a string with the reason. 

For example: deprecated: { reason: 'no longer used' }

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**options** (object)

Allows configuration through further options. See the options below.

**validation** (function)

Allows you to specify validation rules. See the validation section below for available validation functions.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**components**

Lets you provide custom components to override the studio defaults in various contexts.

**fieldsets**

Groups fields together in the studio interface. Each fieldset has a `name`, `title`, and optional `options` for collapsing behavior.

**preview**

Configures how the document or object is previewed in lists and references. Accepts `select` and `prepare` properties.

**renderMembers**

Custom render function for the document's members (fields and fieldsets).

## Options ([ImageOptions](https://reference.sanity.io/sanity/index/ImageOptions/))

#### Properties

**metadata** (object)

This option defines what metadata the server attempts to extract from the image. The extracted data is written into the image asset. This field must be an array of strings where accepted values are image, exif, location, lqip, blurhash and palette. Read more about image metadata.

**hotspot** (object | boolean)

Enables the user interface in Studio for selecting what areas of an image should always be cropped, what areas should never be cropped, and the center of the area to crop around when resizing. Accepts an object with a previews array, which accepts an array of objects containing title and aspectRatio keys. See the hotspot example at the end of this page.

The hotspot data is stored on the image field. See the presenting images guide for details on reading hotspot data.

Also accepts a boolean to enable/disable the hotspot option. (Defaults to false)

**storeOriginalFilename** (boolean)

This will store the original filename in the asset document. Please be aware that the name of uploaded files could reveal potentially sensitive information (e.g. top_secret_planned_featureX.pdf). Default is true.

**accept**

This specifies which mime types the image input can accept. Just like the accept attribute on native DOM file inputs, you can specify any valid file type specifier: View available types.

**sources**

Lock the asset sources available to this type to a specific subset. Import the plugins by their part name, and use the import variable name as array entries.

Read more about custom asset sources.

**mediaLibrary** (object)

Contains a single filters array that allows you to define filtered results from Media Library, using a GROQ query syntax. See the example in Configure Studio for Media Library.

For use with Media Library only.

**disableNew** (boolean)

Disables uploading of new assets to the field, limiting selection to existing assets.

**collapsible**

If set to `true`, the field can be collapsed.

**collapsed**

If set to `true`, the field will be collapsed by default.

**columns**

Number of columns to use for the field layout.

**modal**

Controls how the modal (dialog for content editing) is rendered.

## Validation ([ImageRule](https://reference.sanity.io/sanity/index/ImageRule/))

[Learn more about validation](https://www.sanity.io/docs/studio/validation).

#### Properties

**required()** (function)

Ensures that this field exists.

**assetRequired()** (function)

Like required but more specific. Requires that an actual asset is referenced to validate. Must be used together with required, for example:
validation: (Rule) => Rule.required().assetRequired()

**custom(fn)** (function)

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

## Custom asset sources

You can [customize what asset sources are available](https://www.sanity.io/docs/studio/custom-asset-sources) via plugins. This way, you can integrate with your preferred digital asset management system (DAM). Check out the [current list of asset sources](https://www.sanity.io/plugins?category=assetSource).

## Supported image formats

Sanity allows you to upload 256-megapixel archival originals of the image types JPG, SVG, PNG, GIF, or TIFF. These formats can be transcoded into JPG, PNG, GIF, AVIF, and [WebP](https://en.wikipedia.org/wiki/WebP). Learn how in [the chapter on image URLs](https://www.sanity.io/docs/apis-and-sdks/image-urls).

## Examples of image-related data structures

The `image` field type is similar to an object `field`, in that it can have additional fields appended to it using the `fields` configuration.

When an asset is uploaded to an image field, an asset metadata document is created, and a reference to that document is added to the `asset` field within the image field.

### Example of an image type object

Input

```javascript
defineField({
  name: 'poster',
  type: 'image',
  // 👇 Enables crop and hotspot tools
  options: {
    hotspot: true
  },
  // 👇 Optionally append additional fields to the image object
  fields: [
    defineField({
      name: 'caption',
      type: 'string',
    }),
    defineField({
      name: 'attribution',
      type: 'string',
    })
  ]
})
```

Response

```json
{
  "_type": "image",
  "asset": {
    "_type": "reference",
    "_ref": "image-S2od0Kd5mpOa4Y0Wlku8RvXE"
  },
  "caption": "This is the caption",
  "attribution": "Public domain",
  "crop": {
    "top": 0.028131868131868132,
    "bottom": 0.15003663003663004,
    "left": 0.01875,
    "right": 0.009375000000000022
  },
  "hotspot": {
    "x": 0.812500000000001,
    "y": 0.27963369963369955,
    "height": 0.3248351648351647,
    "width": 0.28124999999999994
  }
}
```

### Example of an image asset metadata document

The asset metadata document created when an asset is uploaded includes details such as location, `lqip` (low quality image placeholder), `blurHash`, `thumbHash`, palette, and dimensions.

```json
{
  "_createdAt": "2018-06-27T10:46:48Z",
  "_id": "image-223c27c1f0e75fe1ef494333738e2d16a8539e6a-1365x1364-svg",
  "_rev": "MGbYJ9NCiEIKUXQcjjXmmw",
  "_type": "sanity.imageAsset",
  "assetId": "223c27c1f0e75fe1ef494333738e2d16a8539e6a",
  "extension": "svg",
  "metadata": {
    "dimensions": {
      "aspectRatio": 1.000733137829912,
      "height": 1364,
      "width": 1365
    },
    "location": {
      "_type": "geopoint",
      "lat": 59.92399340000001,
      "lng": 10.758972200000017
    },
    "lqip": "data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAUABQDASIAAhEBAxEB/8QAGQABAAIDAAAAAAAAAAAAAAAAAAYHAwUI/8QAKBAAAQQCAQIEBwAAAAAAAAAAAgEDBAUABhEHExQhQVESIiMxYXGB/8QAFQEBAQAAAAAAAAAAAAAAAAAABAX/xAAgEQACAgEEAwEAAAAAAAAAAAABAgADEQQSITETUXGB/9oADAMBAAIRAxEAPwCqej0eqhVtneWMLx0mOn0GeOfP9Zv2upVFsDcmv3GkCIwoqjbgAqqK5BdFh7RHrpFpRRvEQQ57o88/b8ZJ9ZtQ3KcVZNo07pCqk4I+Q8e/tgrCysSRkfeRL+lFNlSIrbG9EZDfsqizCO3YSBhGrkVDXtkqcKo+mMz7DCCuupkRpeQacUUxjFOQCJDsUo5U9iSnpVtNpRXQxoLo+Gkrw404PxCv8y6N92GTQa45LqmIceQ6PzGLPC+eMYa0DeJU0bHwNz1OYZDzkh9x54lJxwlIiX1VcYxipIPM/9k=",
    "palette": {
      "darkMuted": {
        "background": "#482d2c",
        "foreground": "#fff",
        "population": 15,
        "title": "#fff"
      },
      "darkVibrant": {
        "background": "#68201e",
        "foreground": "#fff",
        "population": 22,
        "title": "#fff"
      },
      "dominant": {
        "background": "#f34b3c",
        "foreground": "#fff",
        "population": 1292,
        "title": "#fff"
      },
      "lightMuted": {
        "background": "#c5837e",
        "foreground": "#000",
        "population": 31,
        "title": "#fff"
      },
      "lightVibrant": {
        "background": "#f9948c",
        "foreground": "#000",
        "population": 3,
        "title": "#fff"
      },
      "muted": {
        "background": "#ac736c",
        "foreground": "#fff",
        "population": 24,
        "title": "#fff"
      },
      "vibrant": {
        "background": "#f34b3c",
        "foreground": "#fff",
        "population": 1292,
        "title": "#fff"
      }
    }
  },
  "mimeType": "image/svg+xml",
  "originalFilename": "logo-s-red-1365x1365.svg",
  "path": "images/3do82whm/production/223c27c1f0e75fe1ef494333738e2d16a8539e6a-1365x1364.svg",
  "sha1hash": "223c27c1f0e75fe1ef494333738e2d16a8539e6a",
  "size": 1378,
  "url": "https://cdn.sanity.io/images/3do82whm/production/223c27c1f0e75fe1ef494333738e2d16a8539e6a-1365x1364.svg",
  "_updatedAt": "2018-07-30T08:07:49.238Z"
}
```

## Uploading images via Drag & Drop or Paste

When you drag and drop images into the Portable Text Editor or an Array field in Sanity Studio, it will automatically pick the most suitable field to add the image to based on the `accept` option configured on the image fields. If multiple fields match the dropped image type, it will use the first matching field.

```javascript
// Field with accept option set to PNG
defineField({
  name: 'pngImage', 
  type: 'image',
  options: {
    accept: 'image/png'
  }
})
```

```javascript
// Field with accept option set to JPEG
defineField({
  name: 'jpegImage',
  type: 'image', 
  options: {
    accept: 'image/jpeg'
  }
})
```

When dropping a JPEG image, it will be added to the `jpegImage` field that accepts JPEG images.

## Hotspot previews

Use the `hotspot.previews` option to define the cropped previews shown in the Studio's hotspot tool.

```
defineField({
  type: 'image',
  name: 'poster',
  options: {
    hotspot: {
      previews: [
        {title: '2:1', aspectRatio: 2 / 1},
        {title: '4:5', aspectRatio: 4 / 5},
        {title: '9:16', aspectRatio: 9 / 16},
      ]
    }
  }
})
```



# Number

A number. See the [NumberDefinition](https://reference.sanity.io/sanity/index/NumberDefinition/) reference for the full type definition.

![A current popularity indicator with a number field](https://cdn.sanity.io/images/3do82whm/next/6a8dc39442ae4dbc12c8579c0dd2c3d54b778c48-1152x474.png)

Any number, e.g. `900`, `900.0`, `9E+2` or `9.0E+2`.

## Properties

#### Properties

**type** (required)

Value must be set to number.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal number value or a resolver function that returns either a literal number value or a promise resolving to a number value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**placeholder**

Placeholder text shown in the input when it has no value.

## Options ([NumberOptions](https://reference.sanity.io/sanity/index/NumberOptions/))

#### Properties

**list**

A list of predefined values that the user may pick from. The array can include numeric values [1, 2] or objects [{value: 1, title: 'One'}, ...].

**layout**

Controls how the items defined in the list option are presented. If set to 'radio' the list will render radio buttons. If set to 'dropdown' you'll get a dropdown menu instead. Default is dropdown.

**direction**

Controls how radio buttons are lined up. Use direction: 'horizontal|vertical' to render radio buttons in a row or a column. Default is vertical. Will only take effect if the layout option is set to radio.

## Validation ([NumberRule](https://reference.sanity.io/sanity/index/NumberRule/))

#### Properties

**required()**

Ensures that this field exists.

**min(minNumber)**

Minimum value (inclusive).

**max(maxNumber)**

Maximum value (inclusive).

**lessThan(limit)**

Value must be less than the given limit.

**greaterThan(limit)**

Value must be greater than the given limit.

**integer()**

Value must be an integer (no decimals).

**precision(limit)**

Specifies the maximum number of decimal places allowed.

**positive()**

Requires the number to be positive (>= 0).

**negative()**

Requires the number to be negative (< 0).

**custom(fn)**

Create a custom validation.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

Input

```javascript
{
  title: 'Current popularity',
  name: 'popularity',
  type: 'number'
}

```

Response

```json
{
  "_type": "movie",
  "popularity": 12.5,
  ...
}
```

> [!WARNING]
> Gotcha
> Never use `number` for storing a phone-number. Minimize pain down the road and use `string` instead.



# Object

The `object` type is the bread and butter of your data model. You use it to define custom types with fields of strings, numbers, and arrays, as well as other object types. See the [ObjectDefinition](https://reference.sanity.io/sanity/index/ObjectDefinition/) reference for the full type definition.

By default, object types cannot be represented as standalone documents in the data store. To define an object type to represent it as a document with an ID, revision, as well as created and updated timestamps, you should define with the [document](https://www.sanity.io/docs/document-type) type. Apart from these additional fields, there's no semantic difference between a document and an object.

> [!TIP]
> Protip
> If you plan to use your schemas with the GraphQL API, you'll need to import object types on the top-level (called “hoisting”). Learn more about how to make “strict schemas” in our [GraphQL documentation](https://www.sanity.io/docs/content-lake/graphql).

## Properties

#### Properties

**type** (required)

Value must be set to object.

**name** (required)

Required. The field name. This will be the key in the data record.

**fields** (required)

The fields of this object. At least one field is required. See documentation below.

**fieldsets**

A list of fieldsets that fields may belong to. Documentation below.

**groups**

Groups fields into tabs. 

On object: groups: [{name: 'seo', title: 'SEO'}], 

On field: group: 'seo',

For details, see this reference doc.

**preview**

Enables specifying a preview option that replaces the default preview for the document type. For more information, see List Previews.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value that will be used when creating new objects from this type. Can be either the literal value or a function that returns either the literal value or a promise that resolves to the initial value.

**components**

Lets you provide custom components to override the studio defaults in various contexts. The components available are field, input, item, preview.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**renderMembers** (function)

Enables developers to inject decoration members (custom UI components) into document forms without persisting data.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

## Options ([ObjectOptions](https://reference.sanity.io/sanity/index/ObjectOptions/))

#### Properties

**collapsible**

If set to true, the object will make the fields collapsible. By default, objects will be collapsible when reaching a depth/nesting level of 3. This can be overridden by setting collapsible: false

**collapsed**

Set to true to display fields as collapsed initially. This requires the collapsible option to be set to true and determines whether the fields should be collapsed to begin with.

**columns**

An integer corresponding to the number of columns in a grid for the inputs to flow between.

**modal**

Controls how the modal (for object content editing) is rendered. The types you can choose between is dialog or popover. Default is dialog. You can also set the width of the modal. 

modal?: {

   type?: 'dialog' | 'popover'

   width?: number | number[] | 'auto'

}

## Validation ([ObjectRule](https://reference.sanity.io/sanity/index/ObjectRule/))

#### Properties

**required()**

Ensures that this field exists.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

## Fields

Fields are what gives an object its structure. Every field must have a name and a type. An object can reference [any field type](https://www.sanity.io/docs/schema-types). You may specify the properties and options that are supported for the given type, e.g.:

```javascript
{
  title: 'Address',
  name: 'address',
  type: 'object',
  fields: [
    {name: 'street', type: 'string', title: 'Street name'},
    {name: 'streetNo', type: 'string', title: 'Street number'},
    {name: 'city', type: 'string', title: 'City'}
  ]
}
```

Once a type is added to the schema, it can be reused as the type for other fields, so lets use it in our screening:

Input

```javascript
{
  title: 'Screening',
  name: 'screening',
  type: 'document',
  fields: [
    // ... 
      {
      title: 'Cinema address',
      name: 'address',
      type: 'address'
    }
    // ... 
  ]
}

```

Response

```json
{
  "_type": "screening",
  "_id": "2106a34f-315f-44bc-929b-bf8e9a3eba0d",
  "title": "Welcome to our premiere of Valerian and the City of a Thousand Planets!",
  //...
  "address": {
    "_type": "address",
    "street": "Dronningens gate",
    "streetNo": "16",
    "city": "Oslo"
  }
  //...
}
```

### Field names

A field name must start with a letter from a-z, and can **can only include:**

- Letters
- Numbers
- Underscores

This means field names can't contain hyphens. We also [recommend](https://www.sanity.io/docs/apis-and-sdks/naming-things) using the camel case naming convention for field names.

### Additional Field options

Sometimes you may have fields which are not meant to be exposed to the editors through the studio, but are populated by backend services or scripts. By setting the `hidden` property to `true`, you can make sure that the field is still included in the schema but not displayed in the studio. Example:

```javascript
{
  title: 'Movie',
  name: 'movie',
  type: 'document',
  fields: [
    // ... other fields
    {
      title: 'Last synchronized',
      name: 'lastSynced',
      description: 'Timestamp the movie was last synced with external service. Not shown in studio.',
      type: 'datetime',
      hidden: true
    }
  ]
}
```

## Fieldsets

Sometimes it makes sense to group a set of fields into a fieldset. Say you want the `social` fieldset, to be grouped together in Sanity Studio like this:

![Screenshot of a fieldset in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/dbdd62dcf92d6305853f1d96f8295610a48b8285-2304x1400.png)
*Example of a fieldset*

Input

```javascript
{
  type: 'object',
  name: 'person',
  fieldsets: [
    {name: 'social', title: 'Social media handles'}
  ],
  fields: [
    {
      title: 'Name',
      name: 'name',
      type: 'string'
    },
    {
      title: 'Twitter',
      name: 'twitter',
      type: 'string',
      fieldset: 'social'
    },
    {
      title: 'Instagram',
      name: 'instagram',
      type: 'string',
      fieldset: 'social'
    },
    {
      title: 'Facebook',
      name: 'facebook',
      type: 'string',
      fieldset: 'social'
    }
  ]
}
```

Response

```json
// Values will still appear at the same level in the data
{
  "name": "Somebody",
  "twitter": "@somebody",
  "instagram": "@somebody",
  "facebook": "somebody"
}
```

Fieldsets takes the same collapsible options as described for objects above, as well as the `hidden` and `readOnly` properties, e.g.:

```javascript
{
  title: 'Social media handles',
  name: 'social',
  hidden: false, // Default value
  readOnly: true,
  options: {
    collapsible: true, // Makes the whole fieldset collapsible
    collapsed: false, // Defines if the fieldset should be collapsed by default or not
    columns: 2, // Defines a grid for the fields and how many columns it should have
    modal: {type: 'popover'} //Makes the modal type a popover
  }
}
```

> [!TIP]
> Tip
> For more advanced form layouts, you can build [custom form components](https://www.sanity.io/docs/studio/form-components).



## Render members

Enables developers to inject decoration members (custom UI components) into document forms without persisting data.

**schema.tsx**

```
import {DecorationComponent} from "./DecorationComponent"

//.... 
defineField({
  name: 'settings',
  type: "object", 
  title: 'Settings',
  renderMembers: (members) => {
    return [
      ...members,
     // Adds the decoration component after all the fields members
      {
        key: 'decoration',
        kind: 'decoration',
        component: () => <DecorationComponent title="My first decoration" />,
      },
    ]
  },
})
```

**DecorationComponent.tsx**

```
import {ConfettiIcon} from '@sanity/icons/Confetti'
import {Card, Text} from '@sanity/ui'

export function Decoration({title}: {title: string}) {
  return (
    <Card padding={2} paddingY={3} radius={2} border>
      <Text>
        {title} {'  '} <ConfettiIcon />
      </Text>
    </Card>
  )
}
```



# Reference

![A GIF of a showing the behaviour of a reference field in Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/61ee34b6a0512c9d187a318eaddea2ffd53af7d6-664x329.gif)
*In a reference field you can search for, browse and select references to other documents, or create new documents of the appropriate type in a new pane*

Relations between documents are modeled using the `reference` type. To model a one-to-many relation, store the references in an array.

References can be either *strong* (default) or *weak*. A strong reference will enforce that the document it points to actually exists, and will not allow deletion of a document that any other document refers to. A weak reference allows pointing to documents that may not exist (yet) or may have been deleted.

> [!WARNING]
> Gotcha
> Whether a reference should be strong or weak is configured by setting the `weak` property on the reference field. Note that merely changing this property won't automatically update reference fields in the data store.

When working in Sanity Studio, the reference input allows you to search for already existing documents, or create and publish new documents of the appropriate type inline from the place of referral. In order to secure referential integrity, the referring document will be blocked from publishing until the new, referenced, document has been published. The exception is if the reference has the property `weak: true`.

> [!TIP]
> Protip
> For a more in-depth discussion on how to think about references in Sanity, we recommend reading the supplementary article [Connected Content](https://www.sanity.io/docs/studio/connected-content).

## Properties

#### Properties

**type** (string, required)

Value must be set to reference.

**name** (string, required)

The field name. This will be the key in the data record.

**to** (array, required)

An array of objects containing a type property that points to the document type that can be referenced. For example: [{type: 'person'}]

**title** (string)

A human-readable label for the field.

**description** (string)

A short description visible to editors that describes how to use the field.

**weak** (boolean)

If set to true, the reference will be made weak. This allows references to point to documents that may or may not exist, such as a document that has not yet been published or one that has been deleted. Defaults to false.

**hidden** (boolean | fn)

If set to true, this field will be hidden in the studio. You can return a callback function to use this as a conditional field. Defaults to false.

**readOnly** (boolean | fn)

If set to true, this field will be readOnly in the studio. You can return a callback function to use this as a conditional field. Defaults to false.

**initialValue**

The initial value that will be used when creating new values from this type. Can be a literal value or a resolver function that returns the initial value or a promise that resolves to the initial value.

**deprecated** (object)

Marks a field as deprecated. Requires a single reason property that displays a user-facing message to explain the deprecation. When used with GraphQL, this is translated to a @deprecated directive. Example: {reason: 'no longer used'}

## Options

#### Properties

**disableNew** (boolean)

Disables inline creation of new documents from the references field. Defaults to false.

**creationTypeFilter** (function)

A callback function that dynamically filters which document types can be created inline from the reference field. The function receives an object containing the current document and an array of the types defined in the to property, and should return a filtered array of types.

The callback is invoked with ({document, parent, parentPath}, toTypes) where document is the current document being edited, and toTypes is an array of type objects from the to property. Return a filtered array to restrict creation options, or return an empty array to hide the create button entirely (equivalent to disableNew: true).

Note: This only affects which types appear in the create menu. It does not restrict which existing documents can be referenced. Use the filter option to constrain referenceable documents.

**filter** (string | function)

Additional GROQ-filter to use when searching target documents. The filter will apply to the already existing type defined in to.

If a function is provided, it is called with an object containing document, parent, and parentPath properties as well as a getClient() method. It should return an object containing filter and params. This can optionally be async and return a promise that resolves this object.

Note: The filter only constrains the list of documents returned at the time you search. It does not guarantee that the referenced document will always match the filter provided.

**filterParams** (object)

Object parameters for the GROQ-filter specified in filter.

**sort** (array)

An array of objects to aid in sorting the available reference results, each with a direction and field property. For example: [{ direction: "asc", field: "title" }].

## Validation

#### Properties

**required()**

Ensures that this field exists

**custom(fn)**

Creates a custom validation rule.

## Reference recipes

Common patterns for reference fields, including ones that the schema cannot change directly.

### Default reference

Define the movie's `director` as a reference to a person:

Input

```javascript
{
  name: 'movie',
  type: 'object',
  fields: [
    {
      title: 'Director',
      name: 'director',
      type: 'reference',
      to: [{type: 'person'}]
    }
  ]
}
```

Response

```json
{
  "_type": "reference",
  "_ref": "ffda9bed-b959-4100-abeb-9f1e241e9445" /* This could be the id of Jessica Chastain */
}
```

### Weak reference

Define the screening's `movie` as a weak reference to a movie, thereby allowing the movie to be deleted without deleting the screening first:

Input

```javascript
{
  name: 'screening',
  type: 'document',
  fields: [
    {
      name: 'movie',
      title: 'Movie',
      type: 'reference',
      weak: true,
      to: [{type: 'movie'}],
      description: 'Which movie are we screening'
    },
  ]
}
```

Response

```json
{
  "_type": "reference",
  "_ref": "93f3af18-337a-4df7-a8de-fbaa6609fd0a" /* Movie id */
  "_weak": true
}
```

### Reference  multiple types

The `directors` field is an array which can contain both `person` and `bovinae` (in the rare occasion a cow would direct a movie) references:

Input

```javascript
{
  title: 'Directors',
  name: 'directors',
  type: 'array',
  of: [
    {
      type: 'reference',
      to: [
        {type: 'person'},
        {type: 'bovinae'}
      ]
    }
  ]
}
```

Response

```json
[
  {
    "_type": "reference",
    /* this could be the id of Yvonne, the escaped cow */
    "_ref": "9b711031-3744-47ab-9bb7-1bceb177d0d0"
  },
  {
    "_type": "reference",
    /* this could be the id of Matt Damon */
    "_ref": "ffda9bed-b959-4100-abeb-9f1e241e9445"
  }
]
```

### Additional static filter

If providing a target schema type is not enough to provide a meaningful set of search results, you may want to further constrain the search query:

Input

```javascript
{
  title: 'Director',
  name: 'director',
  type: 'reference',
  to: [{type: 'person'}],
  options: {
    filter: 'role == $role',
    filterParams: {role: 'director'}
  }
}
```

Response

```json
{
  "_type": "reference",
   /* this could be the id of some director */
  "_ref": "9b711031-3744-47ab-9bb7-1bceb177d0d0"
},

```

### Additional dynamic filter

If you want to further constrain the search result, but need properties from the surrounding document or object/array, you can use the function form for `filter`:

Input

```javascript
{
  title: 'Director',
  name: 'director',
  type: 'reference',
  to: [{type: 'person'}],
  options: {
    filter: ({document}) => {
      // Always make sure to check for document properties
      // before attempting to use them
      if (!document.releaseYear) {
        return {
          filter: 'role == $role',
          params: {role: 'director'}
        }
      }
      
      return {
        filter: 'role == $role && birthYear >= $minYear',
        params: {
          role: 'director',
          minYear: document.releaseYear
        }
      }
    }
  }
}
```

Response

```json
{
  "_type": "reference",
   /* this could be the id of some director,
    * born after the movie was released */
  "_ref": "9b711031-3744-47ab-9bb7-1bceb177d0d0"
}
```

### Additional async filter

If you want to constrain your filter based on factors available elsewhere in your content lake, you can specify your filter as an asynchronous function.

Input

```javascript
{ 
  // Somewhat contrived example that will make the reference field accept any document of a valid type except the most recently published
  name: 'personRef',
  type: 'reference',
  to: [{type: 'director'}, {type: 'actor'}, {type: 'producer'}],
  options: {
    filter: async ({getClient}) => {
      const client = getClient({apiVersion: '2023-01-01'})
      const latestPersonId = await client.fetch(
        '*[title in ["director", "actor", "producer"] && _id in path("*")] | order(_createdAt desc) [0]._id'
      )
      return {
        filter: '_id != $latestPersonId',
        params: {latestPersonId: latestPersonId},
      }
    },
  },
}
```

Response

```json
{
  "_type": "reference",
   /* this could be the id of some director, actor, or producer */
  "_ref": "9b711031-3744-47ab-9bb7-1bceb177d0d0"
}
```

### Disable new document creation

If you wish to disable the inline creation of new document from the reference field. This is done by setting the `disableNew` option to `true`. 

```javascript
{
  title: 'Director',
  name: 'director',
  type: 'reference',
  to: [{type: 'person'}],
  options: {
    disableNew: true,
  }
}
```

### Filter creation types dynamically

When a reference field can point to multiple document types, you may want to control which types can be created inline based on the current document's data. The `creationTypeFilter` option accepts a callback function that receives the current `document` and returns a filtered list of types.

```javascript
defineField({
  name: 'participant',
  title: 'Individual or team participant',
  type: 'reference',
  to: [{type: 'individual'}, {type: 'team'}],
  options: {
    creationTypeFilter: ({document}, toTypes) => {
      if (document.participantType === 'individual') {
        return toTypes.filter((t) => t.type === 'individual')
      }
      if (document.participantType === 'team') {
        return toTypes.filter((t) => t.type === 'team')
      }
      return toTypes
    },
  },
})
```

If `creationTypeFilter` returns an empty array, the create button will be hidden from the reference field. This produces the same behavior as setting `disableNew: true`.

### Nonexistent reference

Sometimes the reference field may show an error message like `<nonexistent reference>`. This usually happens when creating documents with a client library and can mean one of two things:

- The document with the ID you are referencing does not exist
- The field does not allow references to the document type of the document ID you tried to reference

### Create reference programmatically

If you want to create a reference to another document when using our APIs, you need to know the ID of the document you want to create a reference to. Then you need to add that to an object with the following form: 

```json
{
  _type: 'reference',
  _ref: 'id-of-reference-document'
}
```

 Here's an example using the [Javascript client](https://www.sanity.io/docs/js-client):

```javascript
import {createClient} from '@sanity/client'

export const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  useCdn: true,
  apiVersion: '2023-05-03',
  token: process.env.SANITY_SECRET_TOKEN // Must have write access
})

client.create({
  _type: 'book',
  title: 'Some book title',
  author: {
    _type: 'reference',
    _ref: 'id-of-author-document'
  }
})
.then(result => {
  console.log(`Created book with id: ${result._id}`)
})


```

> [!TIP]
> Always reference the published document _id
> Whenever referencing another document, you should always used the published—or what will become the published—identifier (_id).

### Weak references to unpublished documents

A weak reference can point to a document that has not been published yet. The referring document publishes without waiting for the target, and the reference resolves once the target is published. This comes up most often in scheduled publishing workflows — see [Scheduled drafts](https://www.sanity.io/docs/studio/scheduled-drafts).

The payload differs while the referring document is still a draft. When you select an unpublished document in the reference input, Studio adds a `_strengthenOnPublish` object next to `_weak: true`. Publishing the referring document removes `_strengthenOnPublish` and keeps `_weak: true`, because the field is weak. The published payload is the same whether or not the target was published:

**Draft (target unpublished)**

```json
{
  "_type": "reference",
  "_ref": "cbf5d0e2-1a3b-4f7c-9e21-0d5a6c8b7e14",
  "_weak": true,
  "_strengthenOnPublish": {
    "type": "person",
    "weak": true
  }
}
```

**Published**

```json
{
  "_type": "reference",
  "_ref": "cbf5d0e2-1a3b-4f7c-9e21-0d5a6c8b7e14",
  "_weak": true
}
```

Studio's reference input is what adds `_strengthenOnPublish`. References you create with a client library contain exactly the fields you write.

Validation does not check whether the target is published. On a reference array, `rule.required()` checks that the array is present — Studio unsets the field when you remove the last item, so an empty array fails. A reference to a document that exists only as a draft passes, and the referring document can be published.

> [!WARNING]
> required() checks the array, not the target
> Sanity's built-in reference validation checks that the target exists as a published document, but it skips that check for weak references. If your workflow needs the target published before the referring document goes out, add a custom rule that queries for the published document.

In GROQ, dereferencing a weak reference whose target is not visible returns `null` instead of an error. Visibility follows the perspective you query: on the `published` perspective the value stays `null` until the target is published, and on the `drafts` perspective the same reference resolves to the draft. See [GROQ operators](https://www.sanity.io/docs/specifications/groq-operators).

**GROQ**

```groq
*[_type == "screening" && _id == $id][0]{
  title,
  "movieTitle": movie->title
}
```

**Result**

```json
{
  "title": "Opening night",
  "movieTitle": null
}
```

Once the target is published, `api.sanity.io` returns the resolved value on the next request. `apicdn.sanity.io` serves cached results until the cache is invalidated, so a query made immediately after publishing can still return `null`. See [API CDN](https://www.sanity.io/docs/content-lake/api-cdn).

TypeGen generates the same types for weak and strong references. Both produce a reference type with an optional `_weak` field, and neither includes `_strengthenOnPublish`. Nothing in the generated types tells a weak reference apart from a strong one, so check the schema rather than the types:

**sanity.types.ts (generated)**

```typescript
export type PersonReference = {
  _ref: string
  _type: 'reference'
  _weak?: boolean
  [internalGroqTypeReferenceTo]?: 'person'
}

export type Movie = {
  // ...
  strongDirector?: PersonReference
  weakDirector?: PersonReference
}
```

### Reference unpublished version documents programmatically

> [!NOTE]
> If you're working with drafts or Content Releases, you shouldn't need to handle this. This technique is only for scenarios where you're creating versions of documents not associated with a release.

When you're programmatically creating version documents that need to reference each other, you may run into the problem where you're trying to reference a document that hasn't been published. Weak references should work as expected, but **strong references require more work**. You incorporate the following into any custom logic that creates and publishes version references. 

1. Create the reference with the `_strengthenOnPublish` attribute and `_weak` set to `true`. The contents of the `_strengthenOnPublish` object are primarily used to inform previews in Studio. You can leverage this content further if needed.
2. On publish, find all `_strengthenOnPublish` references and remove it along with the `_weak` property.

**Pre-publish reference**

```json
{
  "_id": "123456",
  "_type": "book",
  "author": {
    "_type": "reference",
    "_ref": "ref-id-of-author",
    "_weak": true,
    "_strengthenOnPublish": {
      "type": "author",
      "template": {
        "id": "author"
      }
    }
  }
}
```

**Post-publish reference**

```json
{
  "_id": "123456",
  "_type": "book",
  "author": {
    "_type": "reference",
    "_ref": "ref-id-of-author",
  }
}
```

### Find documents that reference the current document

To restrict a reference field to documents that already reference the current document, use a function filter with `references($id)` and pass the current document's `_id` as a parameter:

```typescript
defineField({
  name: 'mentions',
  type: 'reference',
  to: [{type: 'article'}],
  options: {
    filter: ({document}) => ({
      filter: 'references($id)',
      params: {id: document._id},
    }),
  },
})
```

This is useful for bidirectional relationships where you only want to link to a document if it already links back.

### The to array is static

The `to` property accepts only a literal array of type entries, such as `[{type: 'person'}, {type: 'company'}]`. It does not accept a function, a wildcard, or a way to express "any document type." To reference multiple types, list each one explicitly. To change which of the listed types can be created inline based on the current document, use [creationTypeFilter](https://www.sanity.io/docs/studio/reference-type).

To dynamically restrict which documents are *selectable* (as opposed to creatable), use `options.filter` with a function that returns a GROQ filter and params.

### Customize the type-select dropdown labels

When a reference can point to multiple types, the type-picker dropdown shows each type's own `title` value. The schema has no way to override these labels per reference field. If you need different labels in different contexts, define separate types with the labels you want, for example `articleAuthor` and `bookAuthor`, each with its own `title`.

The Array type's `insertMenu` configuration can group types, switch to a grid view with preview images, and toggle filtering or icons, but it cannot rename individual entries. See [Array type](https://www.sanity.io/docs/studio/array-type) for the full insert-menu surface.

### Hide already-selected references in an array

When a reference field lives inside an array, `options.filter` receives the array of existing items as its `parent` argument. Collect the `_ref` values from those items and exclude them with `!(_id in $selectedIds)`:

```typescript
import {defineField, type Reference} from 'sanity'

defineField({
  name: 'projects',
  type: 'array',
  of: [
    {
      type: 'reference',
      to: [{type: 'project'}],
      options: {
        filter: ({parent}) => {
          const selectedIds = ((parent as Reference[] | undefined) || [])
            .map((item) => item?._ref)
            .filter((ref): ref is string => Boolean(ref))
          return {
            filter: '!(_id in $selectedIds)',
            params: {selectedIds},
          }
        },
      },
    },
  ],
})
```

The shape of `parent` depends on context. For a reference at the top level of a document, `parent` is the document object. For a reference inside an array, it is the array of items currently in that array, which is what makes this recipe work.

### Disable the Replace action on a reference field

The Replace and Clear actions on a reference field are rendered by the built-in reference input. The schema has no option to disable them individually. The only schema-level toggle on the reference input is `options.disableNew`, which controls the inline **Create new** button. To remove Replace specifically, [register a custom input component](https://www.sanity.io/docs/studio/intro-to-custom-studio-components) for the field and render only the actions you want. Setting `readOnly: true` hides the entire action menu but also disables editing, which is usually too broad.

> [!TIP]
> What you can configure on the reference input
> Schema-level controls on the reference input are limited to `options.disableNew`, `options.filter`, `options.creationTypeFilter`, and `weak`. The Replace action, the Clear action, and the type-select dropdown labels are not configurable through the schema. For anything beyond that surface, use a custom input component.

## Writing GROQ queries for references

References by default are **bi-directional** and can be queried from either side of their relationship. For a movie that has an actors array referencing multiple `person` documents, we can join the person data to the `movie` by dereferencing its data, but we can also query all movies associated with a `person`.

### Join the actor data onto movie data

```groq
*[_type == "movie"] {
  ...,
  "actors": actors[]{
    ...
    person->
  }
}
```

### Get all movies for a person

```groq
*[_type=="person"]{
  name,
  "relatedMovies": *[_type=='movie' && references(^._id)]{ 
  	title,
  	slug,
  	releaseDate
	}
}
```



# Slug

![Screenshot of a slug field from Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/2097cb0ab8784b87b95e4e4f092ebe623af7538d-4608x2800.png)
*A typical slug field with title and description*

A slug is a unique string (typically a normalized version of title or other representative string), often used as part of a URL. The input form will render an error message if the current slug field is not unique (see note on uniqueness below). See the [SlugDefinition](https://reference.sanity.io/sanity/index/SlugDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Value must be set to slug.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal value or a resolver function that returns either a literal value or a promise resolving to the initial value.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**components**

Lets you provide custom components to override the studio defaults in various contexts.

## Options ([SlugOptions](https://reference.sanity.io/sanity/index/SlugOptions/))

#### Properties

**source**

The name of the field which the slug value is derived from. If a string is provided, it should match the name of the source field in your schema. If a function is provided, the source function is called with two parameters: doc (object - the current document) and options (object - with parent and parentPath keys for easy access to sibling fields).

**maxLength**

Maximum number of characters the slug may contain when generating it from a source (like a title field) with the default slugify function. Defaults to 200. If you include your own slugify function, or manually enter your slug this option will be ignored.

**slugify**

Supply a custom override function which handles string normalization. slugify is called with three parameters: input (string), type (object - schema type) and context (object). If slugify is set, the maxLength option is ignored.

**isUnique**

Supply a custom function which checks whether or not the slug is unique. Receives the proposed slug as the first argument and an options object.

**disableArrayWarning**

If set to `true`, disables the warning shown when a slug source field is inside an array.

## Validation ([SlugRule](https://reference.sanity.io/sanity/index/SlugRule/))

#### Properties

**required()**

Ensures that this field exists.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

By *default*, the slug input will check for uniqueness based on the document type and the path to the slug field. For instance, a document of type `article` and a document of type `product` can have the same slug. You can customize this behavior by defining an `isUnique` function in the field options.

The value of the slug field is stored on the `current` property.

Input

```javascript
{
  title: 'Slug',
  name: 'slug',
  type: 'slug',
  options: {
    source: 'title',
    maxLength: 200, // will be ignored if slugify is set
    slugify: input => input
                         .toLowerCase()
                         .replace(/\s+/g, '-')
                         .slice(0, 200)
  }
}
```

Response

```json
{
  "_type": "slug",
  "current": "this-is-the-title"
}
```

### Custom slugify function

```javascript
import slugify from 'some-off-the-shelf-slugifier'

async function myAsyncSlugifier(input, schemaType, context) {
  const slug = slugify(input)
  const {getClient} = context
  const client = getClient({apiVersion: '2022-12-07'})
  const query = 'count(*[_type=="movie" && slug.current == $slug]{_id})'
  const params = {slug: slug}
  return client.fetch(query, params).then((count) => {
    console.log('Movies with identical slug', count)
    return `${slug}-${count + 1}`
  })
  return slug
}

//…
// schema field
{
  title: 'Slug',
  name: 'slug',
  type: 'slug',
  options: {
    source: 'title',
    slugify: myAsyncSlugifier
  }
}
```

#### Custom isUnique function

By default the `isUnique` function checks for uniqueness across **all documents of the same type**. Here's an example of an [isUnique function](https://reference.sanity.io/sanity/index/SlugIsUniqueValidator/) that checks for uniqueness across **all documents in your dataset**:

**isUniqueAcrossAllDocuments.ts**

```typescript
import { getPublishedId } from 'sanity';

export async function isUniqueAcrossAllDocuments(
  slug,
  context
) {
  const { document, getClient } = context;
  const client = getClient({ apiVersion: '2025-02-19' });
  const id = document?._id;

  if (!id || !slug?.current) {
    return true;
  }

  const publishedId = getPublishedId(id);

  const params = {
    published: publishedId,
    slug: slug.current,
  };

  const query = groq`!defined(*[
    !sanity::versionOf($published) &&
    slug.current == $slug
  ][0]._id)`;

  const isUnique = await client.fetch(query, params);
  return isUnique || false;
}
```

**post.ts**

```typescript
import {isUniqueAcrossAllDocuments} from '../lib/isUniqueAcrossAllDocuments'

export default {
  name: 'post',
  type: 'document',
  title: 'Post',
  fields: [
    {
      name: 'title',
      type: 'string',
      title: 'Title'
    },
    {
      name: 'slug',
      type: 'slug',
      title: 'Slug',
      options: {
        isUnique: isUniqueAcrossAllDocuments
      }
    }
  ]
}
```

> [!NOTE]
> Keep in mind that unlike other validator functions that can pass a message, isUnique expects a boolean response.

#### Custom source function

It's also possible to provide the source as a function, that will be called with a first argument containing the whole document, and a second containing a context object.

```javascript
{
  title: 'Slug',
  name: 'slug',
  type: 'slug',
  options: {
    // include category if dataset is production
    source: (doc, context) => context.dataset === 'production' ? `${doc.category}-${doc.title}` : doc.title
  }
}
```

The source function also receives an `options` object containing the parent object/array, if any. It can be useful if you want to derive the slug from a sibling field instead of a property on the document root:

```javascript
{
  title: 'Slug',
  name: 'slug',
  type: 'slug',
  options: {
    source: (doc, context) => context.parent.title
  }
}
```

To query for a document with a given slug, make sure you put the constraint on the `current` key:

```groq
*[_type == "your-document-type" && slugFieldName.current == "your-slug"]
```



# Span

A `span` is a text range within a `block`. It is a child of the `block` type’s children.

## Properties

#### Properties

**type** (required)

The value must be set to span.

## Options

## Validation

The span type is created automatically by the rich text editor as part of the [block type](https://www.sanity.io/docs/block-type). It’s not something you will define yourself. 

When you configure [decorators and annotations](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor) for the rich editor, these will be stored as values in a span’s marks. Decorators as simple text strings, and annotations as keys that reference entries in the block’s mark definitions (`markDefs`). In the example below, there are marks in the third span with the values `strong` and `cbe9d12c6af9`. Most presentation layers will represent the `strong` as in a bold typeface. The other value is a key that corresponds to an object entry under `markDefs` that describes a link.

```json
{
  "_key": "9d2d1ed68d84",
  "_type": "block",
  "children": [
    {
      "_type": "span",
      "marks": [],
      "text": "I am "
    },
    {
      "_type": "span",
      "marks": [
        "strong"
      ],
      "text": "strong and "
    },
    {
      "_type": "span",
      "marks": [
        "strong",
        "cbe9d12c6af9"
      ],
      "text": "annotated"
    },
    {
      "_type": "span",
      "marks": [],
      "text": ""
    }
  ],
  "markDefs": [
    {
      "_key": "cbe9d12c6af9",
      "_type": "link",
      "href": "https://www.google.com/?q=annotation"
    }
  ],
  "style": "normal"
}
```

## Render Spans on the frontend

Spans and Blocks are defined in the Portable Text specification for data storage. They provide insight into how a frontend might use the data provided. If you want to see how to render custom annotations and decorators, see this guide on [Presenting Portable Text](https://www.sanity.io/docs/developer-guides/presenting-block-text).



# String

![Screenshot from Sanity Studio of a string field](https://cdn.sanity.io/images/3do82whm/next/a6c032005fefd5fdfc0f5e177ea5659819dc1971-4608x2800.png)
*A string field with a title and a description*

Short string. Typically used for titles, names, and labels. If you need a basic multi-line string input, use the [text](https://www.sanity.io/docs/text-type). If you need text with markup and structured data, use [block](https://www.sanity.io/docs/block-type). See the [StringDefinition](https://reference.sanity.io/sanity/index/StringDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Required. Value must be set to string.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal string value or a resolver function that returns either a literal string value or a promise resolving to the initial string value.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**components**

Lets you provide custom components to override the studio defaults in various contexts.

**placeholder**

Placeholder text shown in the input when it has no value.

## Options ([StringOptions](https://reference.sanity.io/sanity/index/StringOptions/))

#### Properties

**list**

A list of predefined values that the user can choose from. The array can either include string values ['sci-fi', 'western'] or objects [{title: 'Sci-Fi', value: 'sci-fi'}, ...].

String values will automatically be made uppercase in the Studio. To prevent this, use object values instead.

**layout**

Controls how the items defined in the list option are presented. If set to 'radio' the list will render radio buttons. If set to 'dropdown' you'll get a dropdown menu instead. Default is dropdown.

**direction**

Controls how radio buttons are lined up. Use direction: 'horizontal|vertical' to render radio buttons in a row or a column. Default is vertical. Will only take effect if the layout option is set to radio.

### Dropdown empty option

> [!NOTE]
> When `layout: 'dropdown'` is set on `options.list`, the input renders a blank option at the top of the list representing the unset state. Selecting it clears the field's value. This blank option is built into the dropdown input and cannot be removed, renamed, or restyled through schema configuration.

What schema configuration can and cannot change:

- **Cannot remove the blank option:** `required()` validation, `initialValue`, and none of the available `options` fields remove it from the rendered list.
- **Cannot rename the blank option:** its title is hardcoded to an empty string.
- **Can switch to layout: 'radio':** radio layout does not include a blank option.
- **Can implement a custom input component:** for full control over placeholder text, empty-option behavior, and item rendering.

#### Workarounds

**Use radio layout:** switch from `layout: 'dropdown'` to `layout: 'radio'` to avoid the blank option entirely. This is the simplest schema change:

```typescript
defineField({
  name: 'status',
  type: 'string',
  options: {
    list: ['draft', 'published', 'archived'],
    layout: 'radio',
  },
})
```

**Custom input component for full control:** for full control over rendering, including placeholder text and conditional logic, implement a custom input. See [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input).

### Dynamic option lists

> [!TIP]
> The `options.list` property only accepts a static array of strings or `{title, value}` objects. It does not accept a function or callback, and TypeScript catches this at compile time. If you need a dynamic list (filtered by another field, fetched at runtime, conditionally shown), implement a custom input component.

Register a custom input on the field, then build the option list inside your component. Use `useFormValue` to read other fields on the current document, or fetch data through `@sanity/client`. For an example of the custom input pattern, see [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input).

```typescript
import {defineField} from 'sanity'
import {DynamicStatusInput} from './DynamicStatusInput'

defineField({
  name: 'status',
  type: 'string',
  components: {
    input: DynamicStatusInput,
  },
})
```

Dynamic `options.list` support is tracked in [GitHub issue #4095](https://github.com/sanity-io/sanity/issues/4095).

## Validation ([StringRule](https://reference.sanity.io/sanity/index/StringRule/))

#### Properties

**required()**

Ensures that this field exists.

**min(minLength)**

Minimum length of string.

**max(maxLength)**

Maximum length of string.

**length(exactLength)**

Exact length of string.

**uppercase()**

All characters must be uppercase.

**lowercase()**

All characters must be lowercase.

**email()**

Value must be a valid email-address.

**regex(pattern[, options])**

String must match the given pattern.

options is an optional object, currently you can set options.name and options.invert.

Providing a name will make the message more understandable to the user ("Does not match the <name>-pattern").

Set invert to true in order to allow any value that does NOT match the pattern.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

## Examples

### Field configuration

```javascript
{
  title: 'Title',
  name: 'title',
  type: 'string',
  description: 'Make it catchy',
  validation: Rule => Rule.max(120).warning(`A title shouldn't be more than 120 characters.`)
}
```

### List of predefined strings

Input

```javascript
{
  title: 'Genre',
  name: 'genre',
  type: 'string',
  options: {
    list: [
      {title: 'Sci-Fi', value: 'sci-fi'},
      {title: 'Western', value: 'western'}
    ], // <-- predefined values
    layout: 'radio' // <-- defaults to 'dropdown'
  }
}
```

Response

```json
{
  "_type": "movie",
  "_id": "23407q-qwerqyt12",
  "genre": "sci-fi",
  ...
}
```

> [!TIP]
> Protip
> Want to make a multi-select for strings? Check out [the Array schema type](https://www.sanity.io/docs/array-type) to see how you can build an array of strings, references, objects, and more. 

For details on how to access the `title` value of a list in your document list preview, please see the documentation on [previewing from predefined string lists](https://www.sanity.io/docs/studio/previews-list-views).

### Setting initial value for string fields

You can use [initial values](https://www.sanity.io/guides/getting-started-with-initial-values-for-new-documents) to preset string fields on document creation:

```javascript
export default {
  name: 'post',
  type: 'document',
  title: 'Post',
  initialValue: {
    title: 'The initial title'
  },
  fields: [
    {
      name: 'title',
      type: 'string',
      title: 'Title'
    }
  ]
}
```





# Text

A basic string expected to contain multiple lines. Typically used for a summary, short bio etc. If you need text with markup and structured data, use [block text](https://www.sanity.io/docs/block-type). See the [TextDefinition](https://reference.sanity.io/sanity/index/TextDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Value must be set to text.

**name** (required)

Required. The field name. This will be the key in the data record.

**rows**

Controls the number of rows/lines in the rendered textarea. Default number of rows: 10.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value used when creating new values from this type. Can be either a literal string value or a resolver function that returns either a literal string value or a promise resolving to the string initial value.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**components**

Lets you provide custom components to override the studio defaults in various contexts.

**placeholder**

Placeholder text shown in the input when it has no value.

## Options ([TextOptions](https://reference.sanity.io/sanity/index/TextOptions/))

## Validation ([TextRule](https://reference.sanity.io/sanity/index/TextRule/))

#### Properties

**required()**

Ensures that this field exists.

**min(minLength)**

Minimum length of string.

**max(maxLength)**

Maximum length of string.

**length(exactLength)**

Exact length of string.

**uppercase()**

All characters must be uppercase.

**lowercase()**

All characters must be lowercase.

**email()**

Value must be a valid email-address.

**regex(pattern[, options])**

String must match the given pattern.

options is an optional object, currently you can set options.name and options.invert.

Providing a name will make the message more understandable to the user ("Does not match the <name>-pattern").

Set invert to true in order to allow any value that does NOT match the pattern.

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

Input

```javascript
{
  title: 'Description',
  name: 'description',
  type: 'text'
}
```

Response

```json
{
  "_type": "movie",
  "_id": "23407q-qwerqyt12",
  "description": "...rather long text here....\n  yes.. long",
  ...
}
```



# URL

A string which represents a URL. See the [UrlDefinition](https://reference.sanity.io/sanity/index/UrlDefinition/) reference for the full type definition.

## Properties

#### Properties

**type** (required)

Value must be set to url.

**name** (required)

Required. The field name. This will be the key in the data record.

**title**

Human readable label for the field.

**hidden**

If set to true, this field will be hidden in the studio. You can also return a callback function to use it as a conditional field.

**readOnly**

If set to true, this field will not be editable in the content studio. You can also return a callback function to use it as a conditional field.

**description**

Short description to editors how the field is to be used.

**initialValue**

The initial value that will be used when using this type to create new values. Can be either the literal value or a resolver function that returns either the literal value or a promise that resolves to the initial value.

**deprecated**

Marks a field or document type as deprecated in the studio interface and displays a user-defined message defined by the single required reason property.

If you deploy a GraphQL API schema, this property will translated into the @deprecated directive.

**icon**

Supply a custom icon for this field. See icons documentation for more information.

**components**

Lets you provide custom components to override the studio defaults in various contexts.

**placeholder**

Placeholder text shown in the input when it has no value.

## Options ([UrlOptions](https://reference.sanity.io/sanity/index/UrlOptions/))

## Validation ([UrlRule](https://reference.sanity.io/sanity/index/UrlRule/))

#### Properties

**required()**

Ensures that this field exists.

**uri(options)**

scheme - String, RegExp or Array of schemes to allow (default: ['http', 'https']).

allowRelative - Whether or not to allow relative URLs (default: false).

relativeOnly - Whether to only allow relative URLs (default: false).

**custom(fn)**

Creates a custom validation rule.

**error(message)**

Sets a custom error message for the preceding validation rule.

**warning(message)**

Sets a custom warning message for the preceding validation rule. Warnings do not prevent publishing.

**info(message)**

Sets a custom info message for the preceding validation rule. Info messages are purely informational and do not prevent publishing.

**valueOfField(path)**

Gets the value of a sibling field to use in validation. Useful for creating validation rules that depend on the value of another field.

The URL type is basically just a string input, but the rendered HTML input field will have the `type` attribute set to `url`, like so:

```html
<input type="url">
```

Input

```javascript
{
  title: 'Image URL',
  name: 'imageUrl',
  type: 'url'
}
```

Response

```json
{"imageUrl": "https://example.com/img.jpg"}
```



To allow more protocols than http/https, you can specify validation options:

```javascript
{
  title: 'Link',
  name: 'href',
  type: 'url',
  validation: Rule => Rule.uri({
    scheme: ['http', 'https', 'mailto', 'tel']
  })
}
```



# Asset Source

The form API includes options for working with assets. The `file` and `image` properties will both let you add to or override the list of available asset sources for their respective form inputs, as well as enable or disable direct uploads.

## Properties

#### Properties

**assetSources** (array | AssetSource[])

Accepts either a static array of asset source definitions or a callback function that returns the same. The callback is called with the current list of active asset sources as its first argument and a context object as the second.

**directUploads** (boolean)

Whether or not to allow direct uploading of images/files. Defaults to true.

## Example

```javascript
import {defineConfig} from 'sanity'
import {unsplashAssetSource} from 'sanity-plugin-asset-source-unsplash'
import {customSource} from './src/custom-asset-source'

export default defineConfig({
  // ...rest of config
  form: {
    image: {
      assetSources: (prev) => [...prev, unsplashAssetSource],
    },
    file: {
      assetSources: [customSource],
      directUploads: false,
    },
  },
})
```

## Context properties

These are the properties provided in the context object when defining asset sources using the callback function.

#### Properties

**dataset** (string)

Name of the current dataset.

**projectId** (string)

Unique ID for the project.

**schema** (object | Schema)

The schema registry of your project. Use schema.get("schemaTypeName") to retrieve any schema by name.

**currentUser** (object | CurrentUser)

An object with info about the currently logged-in user.

**getClient** (function | SanityClient)

[Read more about asset source plugins](https://www.sanity.io/docs/studio/custom-asset-sources)

## Asset source properties

Refer to the [AssetSource type reference](https://reference.sanity.io/sanity/index/AssetSource/) for a complete list of properties.

## Asset source selection component props

Refer to the [AssetSourceComponentProps type reference](https://reference.sanity.io/sanity/index/AssetSourceComponentProps/) for a complete list of properties.



# Configuration

[Introduction to Studio configuration](https://www.sanity.io/docs/studio/configuration)
Learn how to configure your Studio

## Workspaces

The root configuration of your Studio is created by supplying either a single workspace configuration object or an array of the same type to the [defineConfig](https://reference.sanity.io/sanity/index/defineConfig/) function, and returning the result as the default export of the configuration file, typically found at the root of your project in a file named `sanity.config.js|ts`.

```javascript
// The absolute minimum viable studio configuration
import { defineConfig } from 'sanity'

export default defineConfig({
  projectId: '<project-id>',
  dataset: 'YOUR_DATASET',
})
```

## Properties

The following table shows the most common top-level properties available for configuring and customizing a single workspace studio.

#### Properties

**projectId** (string, required)

The ID of the Sanity project to use for the studio

**dataset** (string, required)

The name of the dataset to use for the studio

**auth** (object | AuthConfig)

Lets you implement custom authentication by providing a configuration object. Read more about configuring auth providers.

**document** (object | DocumentPluginOptions)

Accepts custom components for document actions and badges, as well as a custom productionUrl resolver and default configuration for new documents. Read more about the document API.

**form** (object | SanityFormConfig)

Extensions / customizations to the Studio forms. Accepts configurations for image and file asset sources as well as custom components to override the default Studio rendering. Read more about the form API.

**plugins** (array | PluginOptions[])

Studio plugins: takes an array of plugin declarations that can be called with or without a configuration object. Read more about plugins.

**tools** (array | Tool[])

Studio tools: takes an array of tool declarations that can be called with or without a configuration object. Read more about the tool API.

**schema** (object | SchemaPluginOptions)

Schema definition: takes an array of types and an optional array of templates (initial value templates). While defining a schema is not required, there are few things inside the Studio that work without one. Read more about the schema API.

**studio** (object | StudioComponentsPluginOptions)

Accepts a components object which will let you override the default rendering of certain bits of the Studio UI. Read more about Studio components.

**theme** (object | StudioTheme)

Accepts a theme configuration object. Read more about theming.

**i18n** (object | LocalePluginOptions)

Accepts a config object for localizing the Studio UI. Read more about Studio localization.

## Additional properties for multiple workspace configurations

#### Properties

**name** (string, required)

Name of the workspace, by convention in lowercase/camelCase

**basePath** (string, required)

URL base path to use, for instance /myWorkspace

**title** (string)

Title of the workspace

**subtitle** (string)

Subtitle to show under the name of the workspace

**icon** (React.ComponentType)

React component to use as icon for this workspace

## Examples

### Minimal example

```javascript
// A more plausible minimalist configuration
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './schemas'

export default defineConfig({
  title: 'My cool project',
  projectId: '<project-id>',
  dataset: 'YOUR_DATASET',
  plugins: [structureTool()],
  schema: {
    types: schemaTypes,
  },
})
```

### Multiple workspace example

```javascript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'
import {LaunchIcon} from '@sanity/icons/Launch'
import {RobotIcon} from '@sanity/icons/Robot'
import {schemaTypes} from './schemas'

export default defineConfig([
  {
    name: 'my-prod-space',
    title: 'My production workspace',
    basePath: '/production',
    icon: LaunchIcon,
    projectId: '<your-project-id>',
    dataset: '<your-dataset>',
    plugins: [structureTool()],
    schema: {
      types: schemaTypes,
    },
  },
  {
    name: 'my-staging-space',
    title: 'My staging workspace',
    basePath: '/staging',
    subtitle: 'The world is a stage',
    icon: RobotIcon,
    projectId: '<your-project-id>',
    dataset: 'staging',
    plugins: [structureTool(), visionTool({defaultApiVersion: '2026-07-01'})],
    schema: {
      types: schemaTypes,
    },
  },
])

```



# Document

## Properties

The following are the most commonly-used properties. For a full list, see the [type reference documentation](https://reference.sanity.io/sanity/index/DocumentPluginOptions/).

#### Properties

**actions** (DocumentActionComponent[] | DocumentActionsResolver)

Accepts an array of document action components, or a callback function that resolves to the same. The callback function receives the existing actions array as its first argument and a context object as its second. Read more about document actions.

**badges** (DocumentBadgeComponent[] | DocumentBadgesResolver)

Accepts an array of document badge components, or a callback function that resolves to the same. The callback function receives the existing badges array as its first argument and a context object as its second. Read more about document badges.

**productionUrl** (function | AsyncComposableOption)

Accepts an async callback function called with the existing value as the first argument and a context object (including the current document) as the second, resolving to the production URL string (or undefined).

If specified, an "Open preview" option appears in the document context menu of your Studio.

**newDocumentOptions** (function | NewDocumentOptionsResolver)

Accepts a callback function that returns an array of new document options templates. The callback is called with the array of existing templates and a context object as arguments. Read more about new document options.

**drafts** (object)

Accepts an object of options. The only available option at this time is enabled. Defaults to true. Setting enabled to false disables draft creation for the Studio. If the dataset already contains drafts, a banner will appear on each draft document in the Studio allowing users to compare, publish, or discard the draft. For example: document: { drafts: { enabled: false } }



# Document Badges

A document badge is a small UI component that indicates the status of a document. It currently appears in the Studio next to the toolbar actions. The default set of document badges currently shows `draft` and `published` status.



[Introduction to using document badges →](https://www.sanity.io/docs/studio/custom-document-badges)

[Learn how to use document badges when building custom workflows →](https://www.sanity.io/docs/studio/document-actions)

## Document badge properties

These are the properties returned to a badge component.

#### Properties

**id** (string)

An id for the current document (e.g. the id of the published document)

**type** (string)

The schema type of the current document.

**draft** (SanityDocument)

Returns the draft document (a document with unpublished changes) if any. Returns null if there is no draft document.

**published** (SanityDocument)

The version of the document that is currently published, if available. Returns null if the document isn't published.

## Document badge description

These are the properties a badge description object must follow.

#### Properties

**title** (string)

Title of the badge. This will be displayed when hovering the badge.

**label** (string)

The label that the badge will display.

**color** (string)

The color for the badge. Can be one of the following values: primary, warning, success, danger

## Example

```javascript
export function HelloWorldBadge(props) {
  return {
    label: 'Hello world',
		title: 'Hello I am a custom document badge',
    color: "success"
  }
} 
```

[See a complete example of implementing custom badges →](https://www.sanity.io/docs/studio/custom-document-badges)



# Document Actions

You can use the Document Actions API for Sanity Studio to customize and control operations that can be done to documents. When you create a custom action, it will be available in the actions menu in the document editor. You create custom actions by adding a [DocumentActionComponent](https://reference.sanity.io/sanity/index/DocumentActionComponent/) to the `document.actions` array of your workspace configuration.

[Learn how to create custom workflows with the Document Actions API](https://www.sanity.io/docs/studio/document-actions).

![The action bar with a badge, an action button, and the action menu](https://cdn.sanity.io/images/3do82whm/next/250a4fc9d947827de6e0e1c02777fdec2c2b6908-2304x1400.png)
*Use Document actions to build custom workflows*

`document.actions` accepts either a static array of document action components or a callback function returning the same. When supplied with a static array, Sanity Studio will append your actions to the list of already existing actions.

> [!TIP]
> Protip
> Sanity Studio comes with a set of predefined document actions enabled that are helpful for manipulating documents. These are:
> - Publish
> - Unpublish
> - Delete
> - Duplicate
> - Discard changes
> - Restore to history state
> You are free to swap any or all of these out with your own custom actions, or conditionally disable or enable them in your studio configuration.

```javascript
import {CustomAction} from './actions'

export default defineConfig({
  // ...rest of config
  document: {
    actions: [CustomAction],
  },
})
```

In contrast, when using the callback method, you will need to make sure you return the exact set of actions you want to register. Helpfully, the callback function receives the current array of registered action components as its first argument and a context object as its second and final argument. 

```javascript
import {HelloWorldAction} from './actions'

export default defineConfig({
  // ... rest of config
  document: {
    actions: (prev, context) => {
      // Only add the action for documents of type "movie"
      // for other types return the current array of actions as is
      return context.schemaType === 'movie' ? [HelloWorldAction, ...prev] : prev;
    },
  },
})
```

## Callback context properties

#### Properties

**currentUser** (object | CurrentUser)

An object containing information about the currently logged in user

**schemaType** (string)

Schema type of the current document

**dataset** (string)

Name of the dataset

**projectId** (string)

Unique ID of the project

**getClient** (function)

Returns a configured SanityClient

**documentId** (string)

ID of the document

**schema** (object | Schema)

The schema registry of your project. Use `schema.get("schemaTypeName") to retrieve any schema by name.

### Example

```javascript
document: {
    actions: function (prev, context) {
      console.log('context: ', context);
      return prev.map((originalAction) => (originalAction.action === 'publish' ? HelloWorldAction : originalAction));
    }
  },
```

## Document Action components

This table describes the values a document action component receives as properties ([DocumentActionProps](https://reference.sanity.io/sanity/index/DocumentActionProps/)):

### Properties

#### Properties

**id** (string)

The current document’s id.

**type** (string)

The schema type of the current document.

**draft** (SanityDocument)

The draft document (e.g. unpublished changes) if any. 

Returns null if there are no unpublished changes.

**published** (SanityDocument)

The version of the document that is currently published (if any).

Returns null if the document isn't published.

**liveEdit** (boolean)

Whether the document is published continuously (live) or not. liveEdit-enabled documents skip the draft workflow. This is not to be confused with the Live Content API, which handles how published changes are handled by queries.

### Identifying built-in actions

Built-in document action components have an optional `action` property that identifies which built-in action they represent. Use this to selectively replace or extend a specific action:



The `action` property uses values like `'publish'`, `'delete'`, `'duplicate'`, `'unpublish'`, `'discardChanges'`, and `'restore'`. Custom actions don't have this property unless you set it.

## Document Action description

Every Document Action component must return either `null` or an action description object ([DocumentActionDescription](https://reference.sanity.io/sanity/index/DocumentActionDescription/)). An action description describes the action state that can be used to render action components in different render contexts (e.g. in a toolbar, as a menu item, etc.). This table describes the different properties of an action description object.

#### Properties

**label** (string, required)

This is the action label. If the action is displayed as a button, this is typically what becomes the button label.

**onHandle** (void, required)

This allows the action component to specify a function that gets called when the user wants the action to happen (e.g. the user clicked the button or pressed the keyboard shortcut combination). The implementation of the onHandle must either make sure to start the dialog flow or to execute the operation immediately.

**icon** (React Element)

In render contexts where it makes sense to display an icon, this will appear as the icon for the action. Default is null

**disabled** (boolean)

This tells the render context whether to disable this action. Default is false.

**shortcut** (string)

A keyboard shortcut that should trigger the action. The keyboard shortcut must be compatible with the format supported by the is-hotkey-package.

**title** (string)

A title for the action. Depending on the render context this will be used as tooltip title (e.g. for buttons it may be passed as the title attribute). Default is null.

**dialog** (ConfirmDialog | PopOverDialog | ModalDialog)

If this is returned, its value will be turned into a dialog by the render context. More about dialog types below. Default is null.

**group** (Array<'default' | 'paneActions'>)

Allow users to specify whether a specific document action should appear in the footer ("default")  or in the document's context menu ("paneActions").

**tone** (ButtonTone)

Allows changing the tone of the action when displayed.

## Document Action dialog types

Dialogs can notify and inform users about the outcome of an action, or they can collect confirmation before executing the action. You can define the following dialog types:

- [confirm](https://www.sanity.io#ef8f04ebc9f1)
- [popover](https://www.sanity.io#6f849687ff57)
- [dialog](https://www.sanity.io#037f877ad3f1)
- [custom](https://www.sanity.io#3d31280433b7)

![Screenshots of dialog types from Sanity Studio](https://cdn.sanity.io/images/3do82whm/next/4f7e7b70f92b7b39586c09cb9dea49301359cff9-2304x1400.png)
*Examples of the different dialog types*

### `confirm`

This tells the render context to display a confirm dialog. See the [DocumentActionConfirmDialogProps](https://reference.sanity.io/sanity/index/DocumentActionConfirmDialogProps/) reference for the full type definition.

#### Properties

#### Properties

**type** (string)

Must be confirm.

**color** (string)

Support the following values warning, success, danger, info.

**message** (string | React.ReactNode )

The message that will be shown in the dialog.

**onConfirm** (function)

A function to execute when the the user confirms the dialog.

**onCancel** (function)

A function to execute when the user cancels the dialog.

#### Example

```javascript
export function ConfirmDialogAction() {
  const [dialogOpen, setDialogOpen] = React.useState(false)
  return {
    label: 'Show confirm',
    onHandle: () => {
      setDialogOpen(true)
    },
    dialog: dialogOpen && {
      type: 'confirm',
      onCancel: () => {
        setDialogOpen(false)
      },
      onConfirm: () => {
        alert('You confirmed!')
        setDialogOpen(false)
      },
      message: 'Please confirm!'
    }
  }
}
```

### `popover`

This will display the value specified by the `content` property in a popover dialog ([DocumentActionPopoverDialogProps](https://reference.sanity.io/sanity/index/DocumentActionPopoverDialogProps/)). The `onClose` property is required, and will normally be triggered by click outside or closing the popover.

#### Properties

**onClose** (function, required)

A function to execute when the dialog is closed.

**type** (string)

Must be popover.

**content** (string | React.ReactNode )

The content to be shown in the popover dialog.

#### Example

```javascript
export function PopoverDialogAction() {
  const [dialogOpen, setDialogOpen] = React.useState(false)
  return {
    label: 'Show popover',
    onHandle: () => {
      setDialogOpen(true)
    },
    dialog: dialogOpen && {
      type: 'popover',
      onClose: () => {
        setDialogOpen(false)
      },
      content: "👋 I'm a popover!"
    }
  }
}
```

### `dialog`

This will display the value specified by the `content` property in a dialog window ([DocumentActionModalDialogProps](https://reference.sanity.io/sanity/index/DocumentActionModalDialogProps/)). The `onClose` property is required.

#### Properties

**onClose** (function, required)

A function to execute when the user closes the dialog.

**type** (string)

Must be dialog.

**header** (string)

Text to show in the header field of the dialog.

**content** (string | React.ReactNode )

The content to show in the dialog.

**footer** (string)

Text to show in the footer field of the dialog.

#### Example

```javascript
export function ConfirmDialogAction() {
  const [dialogOpen, setDialogOpen] = React.useState(false)
  return {
    label: 'Show confirm',
    onHandle: () => {
      setDialogOpen(true)
    },
    dialog: dialogOpen && {
      type: 'dialog',
      onClose:  () => {
        setDialogOpen(false)
      },
      content: <div>
        <h3>👋 ... and I'm a dialog</h3>
        <img src="https://source.unsplash.com/1600x900/?cat" style={{width: '100%'}}/>
        <p>
          I'm suitable for longer and more diverse forms of content.
        </p>
      </div>
    }
  }
}
```

### `custom`

This will display the value specified by the `component` property in a custom dialog window. The `onClose` property is required.

#### Properties

**onClose** (function, required)

A function to execute when the user closes the dialog.

**type** (string)

Must be custom.

**component** (string | React.ReactNode )

The content to show in the dialog. Pass a React component with the custom properties you want to render in the custom modal.

#### Example

```javascript
import {Button, Card, Dialog, Stack, Text} from '@sanity/ui'

export function CustomDialogAction() {
  const [dialogOpen, setDialogOpen] = React.useState(false)
  const toggleOpen = () => setDialogOpen(state => !state)
  return {
    label: 'Custom modal',
    tone: 'primary',
    onHandle: toggleOpen,
    dialog: {
      type: 'custom',
      component: open && (
        <Dialog
          header="Custom action component"
          id="custom-modal"
          onClickOutside={toggleOpen}
          onClose={toggleOpen}
          width={1}
          footer={
            <Stack padding={2}>
              <Button onClick={toggleOpen} text="Close" />
            </Stack>
          }
        >
          <Card padding={5}>
            <Text>This dialog is rendered using a custom dialog component.
            </Text>
          </Card>
        </Dialog>
      ),
    }
  }
}
```

## Action ordering and overflow

Document actions render as one primary button plus all other actions in an overflow menu (the **Actions** dropdown). The split is fixed: there is no way to show multiple primary buttons simultaneously, and the overflow threshold is not configurable.

### Ordering

By default, the built-in **Publish** action is first in the array and renders as the primary button. Actions appear in the order they are returned from your `document.actions` resolver. The first element becomes the primary button; subsequent elements appear in the overflow menu in array order. There is no `priority`, `weight`, or `order` property on `DocumentActionDescription`.

### Promote a custom action to the primary button

Return your action first in the array. The actions that follow appear in the overflow menu in the order they are returned:

```typescript
import {defineConfig} from 'sanity'
import {HelloWorldAction} from './actions/HelloWorldAction'

export default defineConfig({
  // ...
  document: {
    actions: (prev, context) => {
      // Put your custom action first so it becomes the primary button
      return [HelloWorldAction, ...prev.filter((a) => a !== HelloWorldAction)]
    },
  },
})
```

### When the primary button is suppressed

In some contexts, no action is promoted to the primary button and all actions appear in the overflow menu instead:

- When viewing a historical revision of the document.
- When the document type uses `liveEdit` and no version is selected.

In a release context, when the first action in your array is a [built-in Sanity-defined action](https://www.sanity.io#identifying-built-in-actions).

### Schedule publish promotion

When a document has a paused scheduled publish, the **Schedule** action is automatically promoted to the primary button, even if it is not first in your `actions` array. This is intentional, to keep paused schedules visible at the top level.

### `group` vs ordering

The `group` property on a `DocumentActionDescription` (`'default'` or `'paneActions'`) controls which surface the action appears on (toolbar versus document context menu), not its order or whether it is primary. Ordering rules apply within each group.



# Form

#### Properties

**components** (object)

Accepts custom component overrides for the following form components: input, field, preview, item, annotation, block, and inlineBlock (plus portableText.plugins for Portable Text editor plugins). The components can be declared in the root studio configuration, in plugins, or directly in a schema definition.

Form components API ->

**file** (object)

Accepts an object with the following properties: assetSources and directUploads.

assetSources accepts an array of valid asset source configuration objects, or a callback function resolving to the same. The callback function is called with the current list of registered asset sources as its first argument and a context object as the second.

directUploads accepts a boolean true or false.

**image** (object)

Accepts an object with the following properties: assetSources and directUploads.

assetSources accepts an array of valid asset source configuration objects, or a callback function resolving to the same. The callback function is called with the current list of registered asset sources as its first argument and a context object as the second.

directUploads accepts a boolean true or false.

The following are the most commonly used properties. For a full list of available properties, see the [type reference documentation](https://reference.sanity.io/sanity/index/SanityFormConfig/).



# Form components API reference

The following components are available for customization:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // rest of config ...
  form: {
    components: {
      field: MyCustomField,
      input: MyCustomInput,
      item: MyCustomItem,
      preview: MyCustomPreview,
    },
  },
})
```

For a description of how these different components map to the different parts of a form field, visit the [Form components article](https://www.sanity.io/docs/studio/form-components).

Custom form components can be declared either at the configuration level (in `defineConfig` or `definePlugin`) or in a schema. Components added at the configuration level affect all forms in the Studio, while components added to a schema only affect the field or fields specified in that schema.

```javascript
// ./schemas/myDocument.jsx

import {defineType} from 'sanity'

function MyStringInput(props) {
  return (
    <div style={{border: '4px solid magenta'}}>
      {props.renderDefault(props)}
    </div>
  )
}

export const myDocument = defineType({
  name: 'myDocument',
  type: 'document',
  title: 'My document',
  fields: [
    {
      name: 'myTitle',
      type: 'string',
      title: 'My title',
      components: {input: MyStringInput},
    },
  ],
})
```

## Shared properties

#### Properties

**changed** (boolean)

Whether the field value differs from the published version.

**level** (number)

The nesting depth of this field in the document structure.

**path** (Path)

The document path to this field.

**presence** (FormNodePresence[])

Presence indicators showing which users are viewing or editing this field.

**renderDefault** (function)

Renders the default component. Call with the component props to defer to the built-in rendering.

**schemaType** (SchemaType)

The schema definition for this field.

**validation** (FormNodeValidation[])

Validation markers for this field.

**value** (unknown)

The current value of the field. The type depends on the field's schema type.

All form components receive the `renderDefault` method, which defers to the default Studio rendering of the component when called with the component's props.

In addition, each form component receives a set of props that varies in shape depending on the type of field it is assigned to.

## Input components

Input components receive props that vary by field type. [InputProps](https://reference.sanity.io/sanity/index/InputProps/) is a union of type-specific interfaces such as `StringInputProps`, `ObjectInputProps`, and `ArrayOfObjectsInputProps`. In addition to the shared properties (above), all input components have the following:

### Properties

#### Properties

**elementProps** (object)

HTML element attributes to spread onto the input element, including event handlers for focus management.

**focused** (boolean)

Whether the input currently has focus.

**id** (string)

A unique identifier for the input element. Use this as the HTML id attribute.

**onChange** (function)

Callback to update the field value. Accepts a PatchEvent, a FormPatch, or an array of patches.

**readOnly** (boolean)

Whether the field is read-only.

**validationError** (string)

Newline-delimited aggregation of validation error messages. Only present on string, number, and boolean inputs.

## Array item components

In addition to the shared properties (above), array item components ([ItemProps](https://reference.sanity.io/sanity/index/ItemProps/)) have the following:

### Properties

#### Properties

**children** (ReactNode)

The rendered content of the item.

**collapsed** (boolean)

Whether the item is collapsed. Only present on object items.

**collapsible** (boolean)

Whether the item can be collapsed. Only present on object items.

**description** (string)

The item description from the schema, if defined.

**focused** (boolean)

Whether the item currently has focus.

**index** (number)

The position of this item in the array.

**inputId** (string)

The HTML id of the item's input element.

**inputProps** (ObjectInputProps)

The full set of input props for the item's input component. Only present on object items.

**onBlur** (function)

Callback to call when the item loses focus.

**onClose** (function)

Callback to call when the item is closed. Only present on object items.

**onCollapse** (function)

Callback to call when the item is collapsed. Only present on object items.

**onCopy** (function)

Callback to call when the item is copied.

**onExpand** (function)

Callback to call when the item is expanded. Only present on object items.

**onFocus** (function)

Callback to call when the item receives focus.

**onInsert** (function)

Callback to insert new items relative to this item.

**onOpen** (function)

Callback to call when the item is opened. Only present on object items.

**onRemove** (function)

Callback to remove the item from the array.

**open** (boolean)

Whether the item is open. Only present on object items.

**parentSchemaType** (ArraySchemaType)

The schema type of the array containing the item.

**readOnly** (boolean)

Whether the item is read-only.

**title** (string)

The item title from the schema, if defined.

## Field components

In addition to the shared properties (above), field components ([FieldProps](https://reference.sanity.io/sanity/index/FieldProps/)) have the following:

### Properties

#### Properties

**children** (ReactNode)

The rendered input component for this field.

**description** (string)

The field description from the schema, if defined.

**index** (number)

The position of this field among its siblings.

**inputId** (string)

The HTML id of the associated input element. Use for label association.

**inputProps** (InputProps)

The full set of input props for the field's input component.

**name** (string)

The field name as defined in the schema.

**title** (string)

The field title from the schema, if defined.

## List preview components

List preview components ([PreviewProps](https://reference.sanity.io/sanity/index/PreviewProps/)) receive `renderDefault` and an optional `schemaType`, plus the following:

### Properties

#### Properties

**actions** (ReactNode)

Action buttons rendered alongside the preview.

**error** (Error | null)

The error encountered while loading the preview value, if any.

**isPlaceholder** (boolean)

Whether the preview is showing placeholder content while loading.

**layout** (string)

The preview layout variant. Common values include default, media, and detail.

**media** (ReactNode)

The media element (image, icon, or custom component) for the preview.

**schemaType** (SchemaType)

The schema type of the previewed value.

**title** (ReactNode)

The title content to display in the preview.



# Hooks

## useClient

**useClient(clientOptions): SanityClient**

Returns an instance of SanityClient configured with the current project and dataset. Should be provided a configuration object specifying which API version to use for queries. Perspectives can be set by adding .withConfig({perspective: 'raw'}) to the client config.

Parameters:
- **clientOptions** (SourceClientOptions): Configuration object with an appropriate value for apiVersion.

```javascript
import { useClient } from 'sanity'
import { useState, useEffect } from 'react'

export function MyComponent() {
  const [types, setTypes] = useState(undefined)
	const client = useClient({ apiVersion: '2025-02-19' }).withConfig({ perspective: 'raw'})
  
  useEffect(() => {
    async function fetchTypes() {
      const res = await client.fetch(`array::unique(*[]._type)`)
      setTypes(res)
    }
    if (!types) fetchTypes();
  }, [])

	return (
		<div>
			<h1>Types in project</h1>
				<ul>	
					{types && types.map(type => (
						<li key={type}>{type}</li>
					))}
				</ul>
		</div>
	)
}
```

## useDataset

**useDataset(): string**

Returns the name of the current dataset.

```javascript
import { useDataset } from 'sanity'

export function MyComponent() {
	const dataset = useDataset()
	return dataset === 'production' ? <ProductionComponent /> : <StagingComponent />
}
```

## useProjectId

**useProjectId(): string**

Returns the current project ID.

```javascript
import { useProjectId } from 'sanity'

export function MyComponent() {
	const pid = useProjectId()
	return (
			<h1>Project ID: {pid}</h1>
	)
}
```

## useFormValue

**useFormValue(path): unknown**

Returns the value of the field specified by path. Paths are built using array notation with segments that can be either strings representing field names, index integers for arrays with simple values, or objects with a _key for arrays containing objects.

Parameters:
- **path** (Path): Paths are built using array notation with segments that can be either strings representing field names, index integers for arrays with simple values, or objects with a _key for arrays containing objects. Read more about paths here.

```javascript
import { useFormValue } from 'sanity'

export function MyComponent() {
	// ⬇ get value of field 'name' in object 'author'
  const authorName = useFormValue(['author', 'name'])
	// ⬇ get value of the second item in array 'tags' of type 'string'
	const secondTag = useFormValue(['tags', 1])
	// ⬇ get value of the reference with the matching key in an array of references
	const specificBook = useFormValue([ 'bibliography', {_key: '<key>'} ])

  return (
		<div>Author: {authorName}</div>
	)
}
```

## useSchema

**useSchema(): Schema**

Returns the schema registry for the current project.

```javascript
import { useSchema } from 'sanity'
import { useState } from 'react'
import { Container, Card } from '@sanity/ui'

export function MyComponent() {
  const [selectedSchema, setSelectedSchema] = useState(undefined)
	
	// ⬇ the returned value contains the complete catalog of schemas in
	// the project, as well as some neat methods for interacting with them
  const schema = useSchema()

  // ⬇ returns an array of all type names in project
  const types = schema.getTypeNames()

  const handleSelect = (type) => {
		// ⬇ contrived example to show usage of 
		// both schema.has() and schema.get()
		if(schema.has(type)) {
			setSelectedSchema(schema.get(type))
		} else {
			setSelectedSchema(undefined)
		}
  }
  // ⬇ list all types in project and display schema for selected type
  return (
    <Container>
      <Card>
        {types.map((type) => (
          <button key={type} onClick={() => handleSelect(type)}>
            {type}
          </button>
        ))}
      </Card>
      <Card>
				{selectedSchema && (
					<pre>{JSON.stringify(selectedSchema, null, 2)}</pre>
				)}
			</Card>
    </Container>
  )
}
```

## useTemplates

**useTemplates(): Template[]**

Returns an array of initial value templates available in the project. Note that all document types have an initial value template associated that sets the value of _type, even if no templates have been configured by the user.

```javascript
import { useTemplates } from 'sanity'

export function MyComponent() {
  const templates = useTemplates()

  return (
    <ul>
      {templates.map((template) => (
        <li key={template.id}>
          <h1>{template.title}</h1>
          <h2>Type: {template.schemaType}</h2>
        </li>
      ))}
    </ul>
  )
}
```

## useTools

**useTools(): Tool[]**

Returns an array listing all installed tools.

```javascript
import { useTools } from 'sanity'

export function MyComponent() {
  const tools = useTools();
  
	return (
		<div>
			<h1>Studio Tools</h1>
		  <ul>
				{tools.map(tool => <li key={tool.name}>{tool.title}</li>)}
			</ul>
		</div>
		)
}
```

## useWorkspace

**useWorkspace(): Workspace**

Returns the current workspace configuration.

```javascript
import { useWorkspace } from 'sanity'
			
export function MyComponent() {
  const { currentUser: { name }, dataset } = useWorkspace();
	return (
		 <h1>Hello, {name}! You are currently working in {dataset}!</h1>
	)
}
```

## useDocumentStore

**useDocumentStore(): DocumentStore**

Returns a document store with methods that return observables for listening to changes and events on documents in the current project.

```javascript
import {useDocumentStore, useFormValue} from 'sanity'
import {useMemo} from 'react'
import {useObservable} from 'react-rx'

const INITIAL_STATE = []

export function MyComponent() {
  const docId = useFormValue(['_id'])
  const documentStore = useDocumentStore();
  const observable = useMemo(() => 
    documentStore.listenQuery(
      `*[_type == 'article' && references($currentDoc) && !(_id in path("drafts.**"))]`,
      {currentDoc: docId},
      {}
    )
  , [documentStore, docId]);
  const results = useObservable(observable, INITIAL_STATE);

	return null // Render component using `results`
}
```

## Common gotchas

### `useClient` with `.withConfig()`

The `useClient` hook returns a memoized client instance, but calling `.withConfig()` on it creates a new object on every render. If you add the result to a `useEffect` dependency array, the effect runs every render, causing an infinite loop.

> [!NOTE]
> Adding a `.withConfig()` result directly to a dependency array causes an infinite loop because it creates a new object reference on every render.

Wrap the configured client in `useMemo` to maintain a stable reference:

```javascript
import {useClient} from 'sanity'
import {useMemo, useEffect, useState} from 'react'

function MyComponent() {
  const baseClient = useClient({apiVersion: '2025-02-19'})
  const client = useMemo(
    () => baseClient.withConfig({perspective: 'raw'}),
    [baseClient]
  )

  const [data, setData] = useState(null)

  useEffect(() => {
    client.fetch('*[_type == "post"][0..9]').then(setData)
  }, [client])

  return <div>{JSON.stringify(data)}</div>
}
```

## Complete hook reference

This page documents the most commonly used Studio hooks. For the complete list of all available hooks with full type signatures, see the [Studio API reference](https://reference.sanity.io/).

> [!NOTE]
> The hooks documented on this page cover the most common use cases. If you need hooks for comments, presence, history, translations, or other advanced features, check the [full API reference](https://reference.sanity.io/sanity/).



# Structure tool

The Structure Tool is a top-level view within Sanity Studio where editors can drill down to specific documents to edit them. You can configure your studio's Structure tool(s) with the Structure Tool API.

## Properties

#### Properties

**name**

The name you want this structure to have (among other places, this name is used in routing, if name is set to structure, it is shown on /structure). Usually lowercase or camelcase by convention. Defaults to structure.

**title**

The title that will be displayed for the tool. Defaults to Structure.

**icon**

React icon component for the tool, used in navigation bar. Defaults to MasterDetailIcon from @sanity/icons.

**structure**

A structure resolver function. Receives two arguments:

S: an instance of the structure builder that can be used to build the lists/items/panes for the structure tool.

context: an object holding various context that may be used to customize the structure, for instance the current user.

Defaults to (S) => S.defaults().

**defaultDocumentNode**

A resolver function used to return the default document node used when editing documents. Receives two arguments:

S: an instance of the structure builder that can be used to build the document node (S.document()).

context: an object holding various context that may be used to customize the document node.

## Minimal example

The `sanity/structure` package exports a `structureTool`, which is a plugin that installs a structure tool. You can add it to your studio by passing it as part of the `plugins` array.

```typescript
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'

export default defineConfig({
  // ...
  plugins: [
    structureTool() // use defaults
  ]
})
```

To customize your `structure` tool, pass an object in with the settings you want to customize. For instance, if you want a custom structure tool called “cars” that shows in the toolbar as “Cars” and has an icon from `react-icons` and tweaks both the `structure` and `defaultDocumentNode`:

```typescript
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { FaCar } from 'react-icons/fa'
import { Preview } from './Preview' // your custom preview component

export default defineConfig({
  // ...
  plugins: [
    structureTool({
      name: 'cars',
      title: 'Cars',
      icon: FaCar,
      structure: (S) => S.documentTypeList('car'),
      defaultDocumentNode: (S) =>
        S.document().views([
          S.view.form(),
          S.view.component(Preview).title('Preview')
        ])
    })
  ]
})
```



# Studio components reference

The top-level configuration property `studio` enables customization of several parts of the Studio's user interface. Its sole `components` key accepts an object with overrides for the layout, active tool layout, navigation bar, and tool menu:

```javascript
// ./sanity.config.tsx|jsx

import {defineConfig} from 'sanity'
import {MyActiveToolLayout, MyLayout, MyNavbar, MyToolMenu} from './components/studio'

export default defineConfig({
  // rest of config ...
  studio: {
    components: {
      activeToolLayout: MyActiveToolLayout,
      layout: MyLayout,
      navbar: MyNavbar,
      toolMenu: MyToolMenu,
    },
  },
})
```

## Layout

The layout is the root UI component for the Studio ([LayoutProps](https://reference.sanity.io/sanity/index/LayoutProps/)). You probably never want to replace this component entirely with a custom layout component, but you might want to render the default layout component inside, say, a React context provider. This would allow all components inside the Studio to retrieve the values from your provider.

### Properties

#### Properties

**renderDefault** (function)

A callback function that renders the default layout component. The function takes the component's properties as an argument, and these properties can be modified.

### Example

```typescript
// ./sanity.config.tsx|jsx

import {defineConfig, LayoutProps} from 'sanity'
import {MyProvider} from '../path/to/my-provider'

function CustomLayout(props: LayoutProps) {
  return (
    <MyProvider>
      {props.renderDefault(props)}
    </MyProvider>
  )
}

export default defineConfig({
  // rest of config ...

  studio: {
    components: {
      layout: CustomLayout,
    }
  }
})
```

## Active tool layout

Similar to `layout`, but wraps only the currently active tool. You probably never want to replace this component entirely with a custom layout component, but you might want to render the default layout component inside, say, a React context provider. This would allow all components inside the currently active tool to retrieve the values from your provider.

### Properties

#### Properties

**renderDefault** (function)

A callback function that renders the default layout component. The function takes the component's properties as an argument, and these properties can be modified.

**activeTool** (Tool, required)

The currently active Studio tool.

### Example

```typescript
// ./sanity.config.tsx|jsx

import {defineConfig, ActiveToolLayoutProps} from 'sanity'
import {MyProvider} from '../path/to/my-provider'

function CustomActiveToolLayout(props: ActiveToolLayoutProps) {
  return (
    <MyProvider>
      {props.renderDefault(props)}
    </MyProvider>
  )
}

export default defineConfig({
  // rest of config ...

  studio: {
    components: {
      activeToolLayout: CustomActiveToolLayout,
    }
  }
})
```

## Navbar

You can override and insert extra components in the Studio's navbar ([NavbarProps](https://reference.sanity.io/sanity/index/NavbarProps/)). This can be useful if you want to customize the navbar to be visually distinct in certain environments (e.g., development vs. production), or to control what's displayed based on user roles or other contextual factors.

### Properties

#### Properties

**renderDefault** (function)

A callback function that renders the default navbar component. The function takes the component's properties as an argument, and these properties can be modified.

### Example

```typescript
// ./sanity.config.tsx|jsx

import {defineConfig, NavbarProps, useWorkspace} from 'sanity'
import {Card, Stack, Text} from '@sanity/ui'

function CustomNavbar(props: NavbarProps) {
  const {dataset} = useWorkspace()

  return (
    <Stack>
      <Card padding={3} tone="primary">
        <Text size={1}>
          Using the <b>{dataset}</b> dataset
        </Text>
      </Card>

      {props.renderDefault(props)} {/* Render the default navbar */}
    </Stack>
  )
}

export default defineConfig({
  // rest of config ...

  studio: {
    components: {
      navbar: CustomNavbar,
    }
  }
})
```

## Tool menu

The tool menu ([ToolMenuProps](https://reference.sanity.io/sanity/index/ToolMenuProps/)) appears in the navbar and lists all your Studio tools. The tool menu is displayed in two places depending on the width of the screen. On wide screens, it appears in the top bar, while on narrow screens, it appears inside the sidebar.

### Properties

#### Properties

**activeToolName** (string)

The active tool name.

**closeSidebar** (function)

A function that closes the sidebar.

**context** (string)

A string that informs about the "context" in which the tool menu is rendered. This value is useful when you want to make two different customizations depending on whether the tool menu is in the top bar or in the sidebar.

**isSidebarOpen** (boolean, required)

Whether the sidebar is currently open.

**tools** (array)

An array of the tools in the Studio.

**renderDefault** (function)

A callback function that renders the default tool menu component. The function takes the component's properties as an argument, and these properties can be modified.

### Example

```typescript
// ./components/custom-toolmenu.tsx|jsx

import {defineConfig, ToolMenuProps, ToolLink} from 'sanity'
import {Button, Flex} from '@sanity/ui'
import {PlugIcon} from '@sanity/icons/Plug'

function CustomToolMenu(props: ToolMenuProps) {
  const {activeToolName, context, tools} = props
  const isSidebar = context === 'sidebar'

  // Change flex direction depending on context
  const direction = isSidebar ? 'column' : 'row'

  return (
    <Flex gap={1} direction={direction}>
      {tools.map((tool) => (
        <Button
          as={ToolLink}
          icon={tool.icon || PlugIcon}
          key={tool.name}
          name={tool.name}
          padding={3}
          selected={tool.name === activeToolName}
          text={tool.title || tool.name}
          tone="primary"
        />
      ))}
    </Flex>
  )
}
```



# Tools

[Introduction to tools](https://www.sanity.io/docs/studio/studio-tools)

[Tools cheat sheet](https://www.sanity.io/docs/studio/tools-cheat-sheet)

The most commonly familiar tool is the Structure tool (formerly called "Desk tool"), which lets you browse and edit documents. You can install tools with plugins or create your own. Tools also control the top-level Studio routing.

The `tools` config property accepts an array of appropriately shaped objects (Tool) or a callback function returning the same. The callback function receives an array of existing tools and a context object as arguments.

## Tool properties

#### Properties

**name** (string, required)

Unique identifier for the tool.

**title** (string, required)

Title for the tool. This is what will show up in the navbar.

**component** (React.ComponentType, required)

The root component for your tool. This is what shows up in the main work area of your studio.

**icon** (React.ComponentType)

React component for the icon representing the tool. Shown in the navigation menu together with the title.

**router** (object | Router)

Router for the tool. See Router in the API explorer.

**options** (object | any)

Optional configuration object. Passed as arguments to the tool when invoked.

**getIntentState** (function)

Gets the state for the given intent.

**canHandleIntent** (function)

Determines whether the tool can handle the given intent. Receives the intent, its parameters, and a payload; returns a boolean or an object whose keys indicate which parameters can be handled.

### Tool context properties

These are the properties received in the second argument of the callback function.

#### Properties

**dataset** (string)

Name of the current dataset.

**projectId** (string)

Unique ID for the project.

**schema** (object | Schema)

The schema registry of your project. Use schema.get("schemaTypeName") to retrieve any schema by name.

**currentUser** (object | CurrentUser)

An object with info about the currently logged-in user.

**getClient** (function | SanityClient)

Callback function that returns a configured client.

### Example

```typescript
// in dev-tool.tsx
import { Card, Text } from '@sanity/ui'

const MyCoolComponent = (props) => {
  return (
    <Card padding={4} tone="positive">
      <Text>I am a very useful tool.</Text>
    </Card>
  )
}

export const devTool = (config?: any) => ({
  name: 'dev-tool',
  title: 'Dev Tool',
  component: MyCoolComponent,
  ...config,
})

// in sanity.config.ts
import { defineConfig } from 'sanity'
import { devTool } from './dev-tool'
//... more setup

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  tools: [
    devTool(
       // overrides the default tool title
      {title: 'My better title'}
      ),
    ],
  // ... more config
})
```



# Initial Value Templates API reference

> [!NOTE]
> To learn more about templates and how to assign them default values to prepopulate a document, see the [introduction to initial value templates](https://www.sanity.io/docs/studio/initial-value-templates).

defaults(): array

Returns an array of all the default templates - one for each document type defined in the schema. Use this to combine your own templates with the default ones.

template(config): function

Creates a new initial value template with the given configuration. Returns a template builder function which can be used to customize the template.

## Parameters

#### Properties

**id** (string, required)

An id used to identify the template. You will often refer to this ID when configuring which initial value templates are available in a given context. Must be unique.

**title** (string, required)

The title of the template, used for display purposes.

**description** (string)

An optional description, used to clarify the purpose of the template.

**schemaType** (string, required)

The name of the schema type the template applies to.

**value** (object | function, required)

The actual initial value to use, or a function that resolves to one.

The function receives an object of any defined parameters as the first argument and should return either a plain object value or a promise which resolves to one.

**parameters** (array)

An array of parameters the template expects to receive. Follows the same format as fields within a schema type.

Note that only the property name is currently used - validation is not performed, nor is type checking. Parameters should still define the type for future compatiblity.

**icon** (function)

An optional react component to use as the icon for this template



# Help and troubleshooting

#### Most popular

[Studio v3 to v4](https://www.sanity.io/docs/help/v3-to-v4)
Upgrading to v4 adds Node.js 20 as a requirement and requires minimal, if any, changes in your apps.

[React 19 and Sanity](https://www.sanity.io/docs/help/react-19)
Sanity Studio requires React 19.2.2 or later, and Next.js 15 with React 19 is supported.

[React Compiler and Sanity](https://www.sanity.io/docs/help/react-compiler)
Learn how to use the React Compiler with Sanity Studio, React 18 & 19, and for publishing Sanity plugins. Improve performance and reduce manual memoization.

[CLI errors](https://www.sanity.io/docs/help/cli-errors)

[Object type has an invalid field definition](https://www.sanity.io/docs/help/schema-object-fields-invalid)

[API versioning in Javascript Client](https://www.sanity.io/docs/help/js-client-api-version)



# Array items resolve to same JSON type

This warning appears when you have an array type where multiple members resolve to the same underlying JSON type (e.g., both string and text resolve to JSON type "string"). When Sanity stores array data, it uses the JSON type to serialize values. If multiple array members share the same JSON type, Sanity cannot distinguish between them at runtime. For example, if an array allows both string and text items, there's no way to know which type a given string value was originally intended to be when reading the data back.

## Example of problematic schema

**schema.ts**

```
defineField({
  name: 'content',
  type: 'array',
  of: [
    {type: 'string', name: 'heading'},
    {type: 'text', name: 'paragraph'},  // Both resolve to JSON "string"
  ],
})
```

Both string and text (as well as url and email) resolve to the JSON type "string". This means we have no way to tell them apart when the document is read.

### How to fix

You have two options:

- **Option 1: **Use only one primitive type per JSON typeIf you only need one string-based type in your array, remove the duplicate:

**schema.ts**

```
defineField({
  name: 'content',
  type: 'array',
  of: [
    {type: 'string'},
  ],
})
```

- **Option 2:** Wrap primitives in object typesIf you need different string-based inputs with different behaviors, wrap them in named object types:

**schema.ts**

```
defineField({
  name: 'content',
  type: 'array',
  of: [
    {
      type: 'object',
      name: 'heading',
      title: 'Heading',
      fields: [{name: 'value', type: 'string'}],
    },
    {
      type: 'object',
      name: 'paragraph',
      title: 'Paragraph',
      fields: [{name: 'value', type: 'text'}],
    },
  ],
})
```

- - This gives each item a distinct _type property that Sanity can use to distinguish between them.






# Studio Performance Issues Caused by legacy HTTP protocols

### Why Is This Happening?

Sanity Studio is designed to use modern web protocols (`HTTP/2` or `HTTP/3`) to provide the best performance. If your network, VPN, or security software is set up to use an older protocol — such as `HTTP/1.1` or `HTTP/1.0` — the Studio will be much slower and less reliable, and in some cases performance may be extremely degraded or fail to work altogether.

#### Common causes

• Work VPNs that don’t support `HTTP/2` or `HTTP/3`

• Corporate firewalls or proxies that force traffic to use `HTTP/1.1` or `HTTP/1.0` (often ZScaler or similar security software)

• Strict company network policies that restrict web protocols to older versions

### What Should You Do?

1. Try Another Network1. Turn off your VPN (if you’re using one), then reload Sanity Studio.
2. Try from your home network or a mobile hotspot. If Studio suddenly becomes much faster and responsive, the problem is likely with your company’s network.


2. Contact Your IT Department. Share the link to this article which explains the details in the following section.

### For IT and Network Administrators

Sanity Studio requires `HTTP/2 `or `HTTP/3` for normal operation.

Checklist for resolving these issues:

- **Allow HTTP/2 (and/or HTTP/3)**: Ensure your firewall, proxy, or VPN is not forcing outdated protocols (such as HTTP/1.1 or HTTP/1.0) for `*.sanity.io` domains. 
- **Update enterprise security tools**: ZScaler and similar appliances sometimes default to HTTP/1.1 or even HTTP/1.0 — update configurations to support modern protocols.
- **Review VPN settings**: Some VPNs disable HTTP/2/3 by default. Check documentation for enabling them for trusted domains.

If users report Sanity Studio being almost unusable, and it works fine on other networks, this is a strong indicator that outdated protocols are being forced by the network.

### How do I find out which my protocol version?

While newer Studios will warn users about older protocol versions, you can find which one is used in a Studio by checking the network tab in your browser:

Navigate to the Studio in the browser of your choice, [open the developer tools network tab](https://www.google.com/search?q=open+network+developer+tools&sourceid=chrome&ie=UTF-8) and reload the page. Make sure the Protocol column is also displayed to see which version is used. 

If you see `1.1` in the protocol column, the Studio is running in its slower, degraded mode.

![Screenshot with instructions to right click on the network request header bar and make sure protocol is selected](https://cdn.sanity.io/images/3do82whm/next/0f090458e9874c17534c0ed22a9e11a2b63bae0f-974x569.png)
*Right click on the network request header bar and make sure Protocol is selected*

### Still Having Trouble?

If none of the above steps work, please [reach out to the Support team in the help channel in our community](https://snty.link/community) (or to the Support channel if you are an enterprise customer) for further assistance.



# Error: Value of type "object" is not allowed in this array field

Missing Name Error for Inline Object Definitions

**Toast error message**

```text
Error message:
"Invalid clipboard item
Value of type "object" is not allowed in this array field"
```

## What this error means

This error occurs when your inline `object` definition is missing a `name` property. Even though these inline types are locally scoped and may not be reused elsewhere in your schema, Sanity still requires them to have a name.

## Why names are required

Inline object types need names to support essential Sanity features, including:

- Copy and paste functionality
- GraphQL schema generation
- TypeGen and schema extraction
- Content migrations
- Studio functionality

## How to fix this issue

1. **Add a name property** to your inline object definition in your schema
2. **Migrate existing content** to match the updated schema structure

### Example

**Before (causes error)**

```
{
  type: 'object',
  fields: [
    // your fields
  ]
}

```

**After (fixed):**

```
// helper function optional but very useful 
defineArrayMember({
  name: 'myInlineObject',
  type: 'object',
  fields: [
    // your fields
  ]
})
```

## Migration script example

After adding names to your inline objects, you'll need to migrate existing content to include the `_type` property. Here's an example migration script:

**unnamedObjectMigration.ts**

```
import { at, defineMigration, setIfMissing } from 'sanity/migrate'

export default defineMigration({
  title: 'Add missing _type to anonymous inline objects',
  documentTypes: ['yourDocumentType'], // Replace with your actual document type
  migrate: {
    document(doc, context) {
      const arrayField = doc.yourArrayField as {
        title: string
        _key: string
        _type?: string
      }[]
      
      if (
        doc.yourArrayField &&
        arrayField.some((item) => item._type === undefined)
      ) {
        return arrayField
          .filter((item) => item._type === undefined)
          .map((item) => {
            return at(
              ['yourArrayField', { _key: item._key }, '_type'],
              setIfMissing('yourObjectName'), // Use the name you added to your schema
            )
          })
      }
    },
  },
})
```

### Key points for the migration:

- Replace `yourDocumentType` with the document type containing the anonymous objects
- Replace `yourArrayField` with the name of your array field
- Replace `yourObjectName` with the name you assigned to your inline object
- The migration finds items missing the `_type` property and adds it using `setIfMissing()`
- **Keep migration scopes as small as possible** since you never know in the data what an inline object is, because it does not have a `name`



# AVIF

> [!NOTE]
> AVIF is documented in the image transformation reference
> This page was written during the AVIF rollout in 2024. [Image transformations](https://www.sanity.io/docs/apis-and-sdks/image-urls) now covers `auto=format` and AVIF delivery in full, and is the page to rely on; for caching behavior see [Asset CDN](https://www.sanity.io/docs/apis-and-sdks/asset-cdn). Two details below are out of date: images cached before the rollout are no longer a factor, and `avif@sanity.io` is not a monitored support channel.

Images that have the query parameter `auto` set to `format` ([see documentation](https://www.sanity.io/docs/apis-and-sdks/image-urls)) and are requested from a browser that supports the AVIF format will usually get an AVIF returned. There are a few exceptions/quirks:

- The *first few requests* for an AVIF *may* get the "second best option" (WebP if supported, otherwise PNG/JPG depending on the source image). Subsequent requests will *eventually* get an AVIF back. This is done to ensure a speedy response, since encoding AVIFs is a slow process.
- Image requests made prior to the rollout of the AVIF support may already be cached in our CDN and will not return an AVIF response until they expire/fall out of the cache. 

In other words: if you are not seeing AVIF images being returned, don't worry —  they should *eventually* return AVIF. You can use `curl` to verify the behavior:

```sh
# Replace the URL with an actual URL from your project.
# Remember to include `?auto=format`!
curl -sS -I \
  -H 'accept: image/avif,image/webp,image/*' \
  'https://cdn.sanity.io/images/:projectId/:dataset/:filename?auto=format' \
  | grep 'content-type:'
```

On the first request, you will likely see `image/webp` returned. After waiting 30 seconds, run the same command again, and you should see `image/avif`. If you don't, wait a little longer and retry. If you still do not see AVIF, ensure that the accept header includes `image/avif` (before other formats) and that the query parameters includes `auto=format`.

## Reporting issues

If you encounter any issues, send an email to [avif@sanity.io](https://www.sanity.iomailto:avif@sanity.io?subject=AVIF%20issue) with comprehensive details on the issue.



# Client API CDN configuration

Sanity provides a CDN-distributed, cached API that is faster and cheaper to use if you are exposing the API to end-users. If you are building static sites you should use the live API to ensure you always get the freshest version.

A full explanation of the differences between these APIs is outlined in the [API CDN documentation](https://www.sanity.io/docs/content-lake/api-cdn).

The [Sanity JavaScript client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) can be configured to use either the API CDN or the API by setting the `useCdn` option to `true` or `false`, respectively, when configuring the client:

```javascript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'YOUR_DATASET',
  apiVersion: '2026-03-01',
  useCdn: true,
})

```

> [!TIP]
> Protip
> In most cases, we recommend setting your `apiVersion` to [today's date](https://www.sanity.io/docs/content-lake/api-versioning). This ensures you get the most recent bug fixes and improvements, and if it works today it will continue to work tomorrow.

Note that the client will automatically fall back to using the live API in the following scenarios:

- When a mutation is performed (create/edit/delete).
- When listeners are used (subscribing to changes).



# Total attribute count exceeds limit

This page is superseded by [Attribute limit](https://www.sanity.io/docs/content-lake/attribute-limit) in the Content Lake documentation. That is the maintained version, and it includes the worked remediation examples this page is missing.

<p>Everything about the attribute limit: what it is, how to avoid it, and what to do if you hit the limit on one of your projects.</p>## What is the attribute limit?

The attribute limit determines how many unique combinations of path and data type you can have in your dataset. Depending on what plan your project is on, your limit is one of the following:

- Free: 2,000 attributes
- Growth: 10,000 attributes
- Enterprise: custom number of attributes

> [!WARNING]
> Gotcha
> The attribute limit is a hard technical limit right now. For this reason, we do not currently offer a pay-as-you-go option for extra attributes.

## What counts as an attribute?

As shown above, an attribute is officially defined as *a unique combination of attribute and datatype*. An alternative way to think about them is as the different paths through your content.

Let's take a basic data structure:

```json
{
  "foo": [
    {
      "bar":…,
      "baz":…
    },
    {
      "bar":…,
      "baz":…
    },
    {
      "bat": {
        "bar":…
      }
    }
  ]
}
```

This structure contains six unique paths or attributes:

1. foo -> an array
2. foo[] -> an object
3. foo[].bar -> a string
4. foo[].baz -> a string
5. foo[].bat -> an object
6. foo[].bat.bar -> a string

Paths only count towards your attribute limit when they hold actual content. Solely changing your schema definitions will not affect the attribute count. Schema definitions define the structure of your content, a bit like a blueprint defines the structure of a building. Until you add or remove content using the Sanity Studio or the HTTP API, your attribute count will remain unchanged.

Each unique path is counted once, no matter how often it is used. Removing a path from your attribute count requires deleting every piece of content on that path across all documents.

In short, your attribute count:

- goes up when you first add content on a path
- goes down when a path no longer holds any content
- stays the same regardless of whether a path is used once or many times

## Best practices

When structuring your content, there are a few pitfalls to keep in mind to avoid hitting the attribute limit. Although this is not an exhaustive list, following the best practices below should go a long way in keeping your attribute count in check.

### Create reusable data structures

Let's say you have an e-commerce site and want to use Sanity to enrich your product information. You decide  string fields are perfect for this purpose and set up the following structure:

### Use arrays for page building

A common use case for Sanity is using structured content for [page building](https://www.sanity.io/docs/developer-guides/how-to-use-structured-content-for-page-building). In setting up a page builder, it may be tempting to use the block content type as the editor gives a lot of flexibility and allows adding any number of custom objects that can then be used inline.

However, a block content field has quite an extensive data structure by default:
• a `blockContent` array, with inside of it:
• `blocks` objects, with inside of them:
• `markDefs` and `children` arrays, with inside of them:
• `span` types, with inside of them:
• a `marks` array and a `text` field

This nested structure is further extended by any custom types you add to it, all with their own unique paths. A block content field with many custom objects may therefore lead to a hefty amount of attributes.

Another issue with this approach is that people sometimes want to use block content fields *inside* of custom objects. This is likely to lead to even more attributes as a result of now having the above structure embedded in the same structure. Moreover, when the exact same block content component is used, allowing this type of nesting basically gives editors the freedom to nest to an arbitrarily deep level, which can then drag a project over the attribute limit.

To avoid any of these challenges and keep the attribute count as low as possible, we recommend using arrays for page building. In addition to fewer attributes, greater control over the exact content structure, and reduced risk of getting into nesting situations, this approach has the added advantage of not having to deal with serializers for complex custom objects. 

### Avoid excessive nesting and recursive data structures

Things get worse when subsequently the same block content configuration is used for any block content fields inside the custom objects, so editors can endlessly nest the entire page builder inside itself.

### Focus on meaning, not presentation

Before responsive web made its entrance and people started optimizing for different devices, it was customary to mix content with presentation. A headline could be blue, have font size 24px, line-height 30px, and a bottom padding of 10px. Although it may still be tempting today to offer that same level of control to editors, there are several downsides to this approach. For one, whenever you want to change your front-end's design, editors will have to review all relevant content.

Most importantly for this guide, adding all these presentational attributes is likely to boost your attribute count significantly as they would exist for nearly every piece of content.

Instead of mimicking CSS properties in your schema definitions, we recommend a separation of concerns. Leave the presentational aspects to wherever you implement your content and instead stick to semantics in your content structure - in other words, focus on the *meaning* of your content.

### Beware of multipliers in translation/localization

There is a variety of i18n/l10n approaches out there, some of which have a greater impact on your attribute count than others. For example, one approach suggests wrapping all your fields inside a language object, so you get the following structure:

```json
{
 "de": {
  ...
 }
 "en": {
  ...
 }
}
```

This basically multiplies the number of attributes by the number of languages added, as all fields get duplicated on a language path. Adding more than a few languages this way means trouble.

Instead of duplicating the fields inside a document, thereby creating all these extra paths, a more frugal approach is to duplicate the *document* instead. To differentiate between the different languages and more easily query for them, you can consider adding a (hidden) internationalization field to your document type and/or add the language to the document ID. As you will be reusing the same fields across different documents, adding an extra language no longer affects your attribute count at all.

## What to do if you hit the limit?

If you inadvertently hit the attribute limit on one of your datasets, you will see the following error when opening your Sanity Studio: `Total attribute count exceeds limit`.



### Export your data

Before deleting any content or changing your data structure, we highly recommend running a full export of your dataset to prevent any unintended data loss. To do so, you can run the [dataset export](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) command in your terminal. For example:

`sanity dataset export production production.tar.gz`

### Get unblocked

The first step after exporting your data is to get unblocked so you and other users on your project can work in the studio again. In other words, the challenge is to get back below the attribute limit.

Perhaps there is a heavily nested structure with block content *and* translations that could be optimised. Or maybe you have singletons for different pages that could be folded into a single page type instead to further reduce the number of unique paths.

A final note is that it also helps to remove any unused content from schema revisions. For example, if you used to have a particular document type with a bunch of documents, but later removed that type, or even some fields within a type, make sure to clean up the content so there are no leftovers in the datastore that will count towards the attribute limit.

### Restructure your content

How to restructure your content depends on your content model and is therefore different per project. However, there are a bunch of examples to get you started. Please note that in all cases, it is highly recommended to run a full dataset export *before *

### Track your progress

To keep an eye on your attribute limit while restructuring your content, you can use this URL: `https://<projectId>.api.sanity.io/v1/data/stats/<datasetName>`

The attribute count is the value of `fields.count.value` and the limit is inside `fields.count.limit`.

## Closing remarks

Although this guide was specifically about the attribute limit, the principles outlined above are best practices that are likely to lead to a more solid, flexible, and future-proof content model in any situation.



# Desk is now Structure

The version [3.20.0](https://github.com/sanity-io/sanity/releases/tag/v3.20.0) update to Sanity Studio introduced a notable change: the tool previously known as "Desk" has been renamed to "Structure".

You may notice this renaming in the toolbar menu of your studio, as well as in the path segment of your studio URLs.

![Comparison of the studio toolbar and browser address field before and after the change](https://cdn.sanity.io/images/3do82whm/next/ff0863973edd5c2d65c1ed8cf3f106ecdc83cb1f-1024x227.png)
*Before and after*

## Why the rename

The "Desk" name suggested a singular, one-size-fits-all approach to content management. As Sanity Studio has grown, so have its capabilities. With features like [Presentation](https://www.sanity.io/blog/introducing-presentation) broadening your content interaction options, **Structure** is a more appropriate and descriptive name that reflects its status as one of the many diverse ways you can shape and organize your content models.

## For studio users

This update brings two changes to your workspace:

- **Toolbar update**: The studio toolbar label has changed from "Desk" to "Structure", and the tool works as before. If your studio customizations refer to the tool by its old name `desk` — when resolving intents, for example — that identifier changed to `structure` in version 3.20.0 and you need to update those references.
- **URL path update**: The initial path segment of your studio URLs has changed from **/desk** to **/structure**. Existing bookmarks will automatically redirect, so there's no immediate need to update them.

## For studio maintainers

As of version [3.24.1](https://www.sanity.io/changelog/5784e03f-504d-4f74-a6be-443ad1fd96b6), the `deskTool` has been renamed `structureTool` and is found in `sanity/structure`. In other words, where you'd previously do this:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'

export default defineConfig({
  // ...rest of config
  plugins: [
    deskTool(),
  ]
})
```

You should now update your code as follows:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'

export default defineConfig({
  // ...rest of config
  plugins: [
    structureTool(),
  ]
})
```

The previous names are still valid aliases, so existing code keeps working: `deskTool` is the same function as `structureTool`. They are deprecated, though. The 3.24.1 release notes state that the `sanity/desk` imports will be removed in a later major version, so migrate when you can.

> [!TIP]
> Protip
> The Sanity CLI has a codemod that updates your code for you. Run the following command in your studio root directory:
> `npx @sanity/cli@latest codemod deskRename`
> Be sure to check in any local changes to version control *before* running the codemod in case it should fail.

## Current documentation

For the current Structure Tool documentation, see [Structure tool and Structure builder](https://www.sanity.io/docs/studio/structure-introduction) and the [Structure Tool API](https://www.sanity.io/docs/studio/structure-tool-api).



# Invalid configuration for cross dataset reference

Schema validation reports this error when a `crossDatasetReference` field definition is missing a required property or has an invalid value. The most common messages are:

- `The cross dataset reference type is missing or having an invalid value for the required "to" property. It should be an array of accepted types.`
- `A cross dataset reference must specify a `dataset``
- `Missing required preview config for the referenced type`, followed by the type name.

A valid definition needs:

- `dataset`: Required. The dataset that holds the referenced documents. The name must be lowercase and at least two characters.
- `to`: Required. An array of accepted types, with at least one entry.
- `type`: Required on each entry in `to`. The name of the referenced document type.
- `preview`: Required on each entry in `to`. A preview configuration object.
- `studioUrl`: Optional. If set, a function that takes `{id, type}` and returns a URL.

This check looks only at the field definition. It doesn't verify API tokens or CORS origins, so neither is the cause of this error.

A minimal valid definition:

**schemaTypes/article.ts**

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'article',
  type: 'document',
  fields: [
    defineField({
      name: 'author',
      type: 'crossDatasetReference',
      dataset: 'production',
      to: [
        {
          type: 'author',
          preview: {
            select: {title: 'name', media: 'image'},
          },
        },
      ],
    }),
  ],
})
```

#### Learn more about cross-dataset references

[Cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-references)
All you need to know about creating references across datasets.

[Cross Dataset Reference](https://www.sanity.io/docs/studio/cross-dataset-reference-type)
A schema type for referencing documents in another dataset within the same project.



# Missing or duplicate context error

Sanity Studio shows a missing or duplicate context error when more than one version of the `sanity` package is installed. This article explains why that happens and how to resolve it.

Sanity Studio throws `Duplicate instances of context "sanity/_singletons/context/<key>" with incompatible versions detected: Expected <version> but got <version>.` when the duplicated copies are different versions, and warns `Duplicate instances of context "sanity/_singletons/context/<key>" detected. This is likely a mistake and may cause unexpected behavior.` when they are the same version.

## Why the error occurs

This error occurs when there are multiple versions of the `sanity` package installed locally. This causes issues because of how React context works. Having multiple versions of a React library that exports a React context can cause issues due to the singleton nature of React contexts.

React contexts are designed to ensure that there is a single provider for a given context that supplies data to multiple consumers within the component tree. When different versions of the same library are used, each version creates its own isolated context instance. This results in consumers and providers from different versions being incompatible with each other, leading to inconsistent data sharing and state management issues across the application.

## Why multiple versions of the `sanity` package get installed

There are a few reasons why multiple versions of the `sanity` package may be installed in a project:

- **Transitive peer dependencies**: If your project depends on other libraries that also depend on `sanity`, (such as sanity plugins) but they require different versions, package managers may install multiple versions to satisfy all the dependencies. This includes `peerDependencies`. If you're using pnpm, [pnpm may even install the same exact version of sanity twice](https://pnpm.io/how-peers-are-resolved) to satisfy different sets of peer dependencies.
- **Incorrect version specification**: If you specify a version of `sanity` in your project's `package.json` file that doesn't match the version used by other dependencies, it could lead to multiple versions being installed.
- **Lock file state**: If you have updated the `sanity` dependency in your `package.json` file but haven't regenerated the lock file (`pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`), the old version might still be installed based on the lock file. There are scenarios where the state of your lockfile results in more than one version of `sanity` being installed, and deleting the lockfile and reinstalling may fix the issue.

## Fix the error

There are two possible solutions.

### Solution 1: Clean up dependencies

There are many scenarios where transitive dependencies (the dependencies of your dependencies) can cause a mismatched version.

Because `sanity` is React-based, you'll want to make sure that versions of `sanity`, `react`, `react-dom`, and similar React libraries are all compatible and consistent. In particular, this means that you should see just one version of `sanity` installed and the same version of `react` and `react-dom`.

The `sanity` package requires a `^6` version (for example, `6.9.2`), a `react` `^19` version, and a `react-dom` `^19` version (for example, `19.2.2`).

#### Rules of thumb for a clean dependency tree

- Ensure you have no deprecated sanity packages installed (for example, `@sanity/base`, `@sanity/react-hooks`, `@sanity/desk-tool`) and ensure the rest of any `@sanity/` package or plugin is at the latest version. The latest version of any package can be determined by running `npm show PACKAGE_NAME version`. Replace `PACKAGE_NAME` with the package you want to check.
- Ensure you don't see any warnings regarding unmet peer dependencies. Everything should be on the same version of `react` and `react-dom`.
- Ensure you aren't using any outdated React libraries that don't support at least React 19. `sanity` has a peer dependency on at least `react` `^19.2.2`.

The most reliable way to inspect your dependencies is to search your lockfile (`pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock`).

#### Inspect pnpm-lock.yaml

pnpm handles peer dependencies somewhat differently than npm. It's not as strict as npm and it tries its best to satisfy all peer dependencies on its own.

To see potential peer dependency issues, run `pnpm peers check`. Fixing these issues may de-duplicate `sanity`.

> [!TIP]
> Pro tip
> You can also try running [pnpm dedupe](https://pnpm.io/cli/dedupe)!

*Screenshot of the result of running pnpm peers check*

If you've seen warnings with unmet peer dependencies, this may result in more than one `sanity` being installed at once.

When grepping your `pnpm-lock.yaml`, search for `sanity@6`. For every set of peer dependencies, pnpm will include `sanity` and other packages in that set of peer dependencies in a single line.

*Screenshot of pnpm-lock.yaml*

Notice anything odd? There's an incompatible version of `react-dom` (`17.0.2`). The fix here is to search the lockfile for dependencies that are causing `react-dom` 17 to be installed and used.

You heard that right, the presence of `react-dom` 17 can even result in the *same* version of `sanity` to be installed more than once.

After searching for `react-dom:`, the culprit was found: An outdated dependency to `@reach/auto-id` which did not allow a peer dependency of `react-dom` 18.

*Screenshot of pnpm-lock.yaml with an outdated dependency*

Following this trail led to an old version of `@sanity/desk-tool`.

*Screenshot of pnpm-lock.yaml with a previous version of sanity*

This led to the discovery of other legacy packages. Removing `@sanity/desk-tool` and `@sanity/react-hooks` from every `package.json` resolved the context issue.

#### Inspect package-lock.json

npm's package-lock.json `lockfileVersion` 3 file is relatively straightforward lockfile.

*Screenshot of a typical package-lock.json*

Top-level is the `lockfileVersion` followed by a `packages` key that contains a flat list of all the dependencies installed in project. Note for monorepos that this lockfile should contain all dependencies for the root package and all of the subpackages as well.

In this lockfile, search for `node_modules/sanity`, `react`, and `react-dom`.

*Screenshot of sanity in package-lock.json*

Ideally you'd see just one of each. If there are more than one and one of those versions mismatch, look for a package that would depend on the mismatched version.

#### Inspect yarn.lock

The `yarn.lock` file is similar to npm's `package-lock.json`, except that it uses YAML. For more information, see [The ultimate guide to yarn.lock lockfiles](https://www.arahansen.com/the-ultimate-guide-to-yarn-lock-lockfiles/).

### Solution 2: Override the `sanity` dependency

If cleaning up dependencies manually doesn't resolve the issue, you can force your package manager to use a specific version of `sanity` across all dependencies. You may want to do this anyway in order to prevent this issue from occurring in the future.

Doing this ensures that only one version is installed, even if different dependencies specify conflicting versions.

#### npm overrides

npm supports an `overrides` key at the top level of `package.json`. Wherever you see the `sanity` dependency in your `package.json`, add the following:

```json
{
  "name": "your-studio",
  "version": "1.0.0",
  "description": "...",
  "dependencies": {
    "sanity": "^6.9.2"
  },
  "overrides": { "sanity": "$sanity" }
}
```

The `$sanity` value makes it reference the version listed above in your `package.json`.

If working in a monorepo, we recommend installing `sanity` at the root workspace.

#### pnpm overrides

pnpm supports the same overrides, nested under a `pnpm` key:

```json
{
  "name": "your-studio",
  "version": "1.0.0",
  "description": "...",
  "dependencies": {
    "sanity": "^6.9.2"
  },
  "pnpm": {
    "overrides": { "sanity": "$sanity" }
  }
}
```

#### yarn resolutions

yarn lets you force a particular dependency through `resolutions`:

```json
{
  "name": "your-studio",
  "version": "1.0.0",
  "description": "...",
  "dependencies": {
    "sanity": "^6.9.2"
  },
  "resolutions": {
    "sanity": "^6.9.2"
  }
}
```

## Appendix: lockfiles

A lockfile is a file that records the exact versions of all packages installed in a project, including transitive dependencies. It serves as a snapshot of the project's dependency tree at a given point in time.

Lockfiles are generated automatically by package managers like pnpm, npm, and yarn when you run the install command. They ensure reproducible builds by locking the versions of all dependencies, so that everyone working on the project uses the same versions.



# React Compiler and Sanity

Sanity Studio v3.65.0 introduced support for the [React Compiler](https://react.dev/learn/react-compiler/introduction). The compiler improves performance by automatically optimizing component rendering. This reduces the amount of manual memoization developers have to do through APIs such as `useMemo` and `useCallback`.

If you use @sanity/pkg-utils and/or @sanity/plugin-kit to distribute custom plugins and tools on npm then it's also possible to use the compiler there.

## Sanity Studio

Sanity Studio v5.0.0 and later requires React 19.2.2 or later, which has the React Compiler runtime built in. You don't need the `react-compiler-runtime` package in a studio.

Install the Babel plugin for the compiler, the ESLint plugin, and a TypeScript parser for ESLint:

**npm**

```shell
npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

**pnpm**

```shell
pnpm add --save-dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

**yarn**

```shell
yarn add --dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

**bun**

```shell
bun add --dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

Set up your ESLint config. The compiler's checks ship in the plugin's `recommended` preset:

**eslint.config.js**

```javascript
import reactHooks from 'eslint-plugin-react-hooks'
import tsParser from '@typescript-eslint/parser'
import {defineConfig} from 'eslint/config'

export default defineConfig([
  {
    files: ['**/*.{js,jsx,ts,tsx}'],
    languageOptions: {parser: tsParser},
    extends: [reactHooks.configs.flat.recommended],
  },
])
```

The `recommended` preset sets no `files` pattern, and ESLint lints only `.js`, `.mjs`, and `.cjs` files by default. Without the `files` and `languageOptions` above, ESLint skips every `.ts` and `.tsx` file in your studio and still exits `0`, so the compiler checks look like they passed. If you already have an ESLint config, add the preset to a config object that sets `files` and a TypeScript parser rather than at the top level.

You don't need to fix all the warnings before you can start using the compiler, you can incrementally adopt it.

It's also recommended that you have [strictNullChecks](https://react.dev/learn/react-compiler#:~:text=example%2C%20by%20enabling-,strictNullChecks,-if%20using%20TypeScript) enabled.

Add `reactCompiler` to your `sanity.cli.ts` configuration and set `target` to `'19'`:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
   api: {
      projectId: 'YOUR_PROJECT_ID',
      dataset: 'production',
   },
   reactStrictMode: true,
   reactCompiler: {target: '19'},
})
```

### Rust-based transform (experimental)

As of Studio v6.11.0, an opt-in Rust-based transform is available via `oxc-transform-react`. It runs the React Compiler in a single native pass (no Babel in the pipeline), which is significantly faster.

Install `oxc-transform-react` instead of `babel-plugin-react-compiler`:

**npm**

```shell
npm install --save-dev oxc-transform-react
```

**pnpm**

```shell
pnpm add --save-dev oxc-transform-react
```

**yarn**

```shell
yarn add --dev oxc-transform-react
```

**bun**

```shell
bun add --dev oxc-transform-react
```

Add `transform: 'oxc'` to your `reactCompiler` config in `sanity.cli.ts`:

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production',
  },
  reactStrictMode: true,
  reactCompiler: {target: '19', transform: 'oxc'},
})
```

The default `transform: 'babel'` is unchanged, and existing configs continue to work without any changes. The `'oxc'` transform is experimental: review the compiled output before deploying to production. If you use a custom `jsxImportSource`, stay on `'babel'`.

### Embedded Studios

If your studio is hosted inside something like a Next.js, Remix app or otherwise not using `sanity build` and `sanity dev` commands?

If so you'll have to enable the compiler through one of the methods documented [here](https://react.dev/learn/react-compiler/installation).

## Publishing Sanity plugins and tools

[Since the compiler needs to run on the original source code, studios can't compile the libraries they use.](https://react.dev/reference/react-compiler/compiling-libraries) Instead, library authors need to ship compiled code to npm.

`@sanity/pkg-utils` builds libraries for npm. It handles ESM, CJS, and the React Compiler, and it's what `@sanity/plugin-kit` sets up for you.

Install the Babel plugin for the compiler, the ESLint plugin, and a TypeScript parser for ESLint:

**npm**

```shell
npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

**pnpm**

```shell
pnpm add --save-dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

**yarn**

```shell
yarn add --dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

**bun**

```shell
bun add --dev babel-plugin-react-compiler eslint-plugin-react-hooks @typescript-eslint/parser
```

Since v7 of `eslint-plugin-react-hooks` the new React Compiler checks are included in the `recommended` preset.

**eslint.config.js**

```javascript
import reactHooks from 'eslint-plugin-react-hooks'
import tsParser from '@typescript-eslint/parser'
import {defineConfig} from 'eslint/config'

export default defineConfig([
  {
    files: ['**/*.{js,jsx,ts,tsx}'],
    languageOptions: {parser: tsParser},
    extends: [reactHooks.configs.flat.recommended],
  },
])
```

The `recommended` preset sets no `files` pattern, and ESLint lints only `.js`, `.mjs`, and `.cjs` files by default. Without the `files` and `languageOptions` above, ESLint skips every `.ts` and `.tsx` file in your library and still exits `0`, so the compiler checks look like they passed. If you already have an ESLint config, add the preset to a config object that sets `files` and a TypeScript parser rather than at the top level.

You don't need to fix all the warnings before you can start using the compiler, you can incrementally adopt it.

It's also recommended that you have [strictNullChecks](https://react.dev/learn/react-compiler#:~:text=example%2C%20by%20enabling-,strictNullChecks,-if%20using%20TypeScript) enabled.

If your library supports React 18, install `react-compiler-runtime` as a direct dependency:

**npm**

```shell
npm install --save-exact react-compiler-runtime
```

**pnpm**

```shell
pnpm add --save-exact react-compiler-runtime
```

**yarn**

```shell
yarn add --exact react-compiler-runtime
```

**bun**

```shell
bun add --exact react-compiler-runtime
```

Pin it to an exact version. The `--save-exact` flag writes the exact version to `package.json` instead of a caret range.

Next, add the `reactCompiler` option to your `package.config.ts`:

**package.config.ts**

```typescript
import {defineConfig} from '@sanity/pkg-utils'

export default defineConfig({
  // ... other settings
  reactCompiler: {target: '18'}, // matches the minimum `react` major your library supports
})
```

`reactCompiler` is a single top-level option in `@sanity/pkg-utils` v12 and later. Earlier majors used `babel: {reactCompiler: true}` with a separate `reactCompilerOptions`, both of which v12 removed. `@sanity/plugin-kit` v10 requires `@sanity/pkg-utils` v12 or later.

To use the Rust-based transform with `@sanity/pkg-utils`, install `oxc-transform-react` and add `transform: 'oxc'`:

**npm**

```shell
npm install --save-dev oxc-transform-react
```

**pnpm**

```shell
pnpm add --save-dev oxc-transform-react
```

**yarn**

```shell
yarn add --dev oxc-transform-react
```

**bun**

```shell
bun add --dev oxc-transform-react
```

**package.config.ts**

```typescript
import {defineConfig} from '@sanity/pkg-utils'

export default defineConfig({
  // ... other settings
  reactCompiler: {target: '18', transform: 'oxc'},
})
```

### Using `@sanity/tsdown-config` directly

Library authors who use `@sanity/tsdown-config` directly (rather than `@sanity/pkg-utils`) configure the React Compiler in `tsdown.config.ts`. The same `transform: 'oxc'` option is available:

**npm**

```shell
npm install --save-dev oxc-transform-react
```

**pnpm**

```shell
pnpm add --save-dev oxc-transform-react
```

**yarn**

```shell
yarn add --dev oxc-transform-react
```

**bun**

```shell
bun add --dev oxc-transform-react
```

**tsdown.config.ts**

```typescript
import {defineConfig} from '@sanity/tsdown-config'

export default defineConfig({
  tsconfig: 'tsdown.dist.json',
  reactCompiler: {target: '19', transform: 'oxc'},
})
```

The same experimental caveat applies: `transform: 'oxc'` is `@alpha`, so review the generated output before publishing to npm.

## Troubleshooting

[Follow the official troubleshooting docs in case you run into problems.](https://react.dev/learn/react-compiler/debugging) In our experience it's incredibly rare for the compiler to create a regression, it typically choses to skip over optimizing components it deems unsafe, or too complex to safely memoize.

Should a rare problem occur it's often enough to add `'use no memo'` at the top of the affected file, to buy you time and find the fix. And then use ESLint with `eslint-plugin-react-hooks` to find issues that could be the root cause. Running React Strict Mode is also incredibly helpful to uncover root issues.



# Specify API version for studio client

Both examples on this page import `part:@sanity/base/client`, which was removed in Studio v3 and resolves in no supported Studio. To get a client inside the Studio, use `useClient({apiVersion})`, or `getClient({apiVersion})` outside React. See [Studio React hooks](https://www.sanity.io/docs/studio/studio-react-hooks).

In a previous version of the Sanity content studio, you could import a global, preconfigured Sanity client instance by importing `part:@sanity/base/client`.

Having a global client use a single API version is both restrictive and prevents utilizing the latest and greatest features of the Sanity API. This is why we have now deprecated using the global studio client without explicitly defining an API version to use.

## Old usage:

```javascript
import client from 'part:@sanity/base/client'

client.fetch('*[_type == "author"][0...10]')

```

## New usage:

```javascript
import sanityClient from 'part:@sanity/base/client'

const client = sanityClient.withConfig({apiVersion: '2021-06-07'})

client.fetch('*[_type == "author"][0...10]')

```

Details about getting your versioned client set up can be found under [API Versioning](https://www.sanity.io/docs/content-lake/api-versioning) and the [JavaScript Client](https://www.sanity.io/docs/js-client#api).



# Why give schema types a title?

The Studio reports the warning `Type title is not a string.` when the `title` on a schema type or field is set to a value that isn't a string — commonly an object, a number, or a React element. Set `title` to a plain string.

Always give your schema types and fields a descriptive title. The title is used in Studio UI contexts such as buttons and menus.



# Array type has a invalid value for property "of"

Sanity Studio reports `The array type is missing or having an invalid value for the required "of" property` when an array type has no `of`, or when `of` is not an array.

All array types must define what kind of items they may contain. The `of` property must be an array of objects that describes the type of a valid item. Each entry in `of` must have a `type` property which must be the name of a valid schema type.

```typescript
import {defineArrayMember, defineField} from 'sanity'

export const items = defineField({
  type: 'array',
  name: 'items',
  // The "of" property must be set, and it must be an array
  of: [
    defineArrayMember({type: 'author'}), // type is required
    defineArrayMember({type: 'book'}),
  ],
})
```

## Types must be unique, or named

Sanity Studio reports `Found 2 members with same type, but not unique names "author" in array. This makes it impossible to tell their values apart and you should consider naming them` when two members share a type and neither is named.

In order to know which type description an array item belongs to, you can't add multiple entries to `of` with the same `type` unless you give them distinct `name` values to tell them apart. This is therefore not allowed:

```typescript
import {defineArrayMember, defineField} from 'sanity'

export const items = defineField({
  type: 'array',
  name: 'items',
  of: [
    defineArrayMember({type: 'author'}),
    // 💥 ERROR: no way to tell the two members apart
    defineArrayMember({type: 'author'}),
  ],
})
```

Instead, you can give items of the same type another name. This will work:

```typescript
import {defineArrayMember, defineField} from 'sanity'

export const items = defineField({
  type: 'array',
  name: 'items',
  of: [
    defineArrayMember({type: 'author', title: 'Author'}),
    defineArrayMember({type: 'author', name: 'anotherAuthor', title: 'Another author'}),
  ],
})
```

Items in this array will have their `_type` set to either `author` or `anotherAuthor`, depending on which of the types was selected when the item was added. For example:

```json
[
  {"_type": "author", "name": "Camilla Collett"},
  {"_type": "anotherAuthor", "name": "Henrik Ibsen"}
]
```

## Other causes

Two further cases report the same error. An array with a `block` member alongside an object member that has no `name` reports `The array type's 'of' property can't have an object type without a 'name' property as member, when the 'block' type is also a member of that array.` An array that mixes object types and primitive types reports `The array type's 'of' property can't have both object types and primitive types`, followed by the offending type names.



# React 19 and Sanity

The Sanity CLI prints a compatibility warning during `sanity init` when it finds Next.js 15 and React 19 in your project. That combination is supported. React 19 is no longer an opt-in for Sanity Studio; it is the minimum requirement.

## React version requirements

Sanity Studio v5 raised the minimum React version, and v6 keeps it. The `sanity` package requires `react` and `react-dom` at `^19.2.2`. React 18 does not satisfy that range, and neither do React 19.0 and 19.1.

A Studio on the current version has these dependency ranges:

**package.json**

```json
{
  "dependencies": {
    "react": "^19.2.2",
    "react-dom": "^19.2.2",
    "sanity": "^6.12.0",
    "styled-components": "^6.1.15"
  }
}

```

The requirement landed in Sanity v5. Sanity v4 and earlier accept `^18 || ^19`, so a Studio still on v4 runs on either major.

## Upgrade an existing Studio

To move a Studio from React 18 to React 19, follow [Studio v4 to v5](https://www.sanity.io/docs/help/v4-to-v5). It covers clearing React 18 deprecation warnings under strict mode, the install steps for React and `sanity`, and the TypeGen type names that change along the way. From there, [Studio v5 to v6](https://www.sanity.io/docs/help/v5-to-v6) takes you to the current major version.

## Plugins

Official Sanity plugins support React 19. If you maintain a third-party plugin that declares React 18 only, it will not work in a v5 or v6 Studio. Widen its peer dependency range and publish a new version; the notes for plugin authors in [Studio v4 to v5](https://www.sanity.io/docs/help/v4-to-v5) have the range to use.



# Schema: Lift anonymous object types

A common pattern is to embed an object inside your document, which groups related fields together. For instance, a `person` might have an `address` made up of several fields, such as a street name and a zip code.

You can declare that object inline, without giving it a name of its own:

**schemaTypes/person.ts**

```typescript
import {defineType, defineField} from 'sanity'

export const person = defineType({
  name: 'person',
  type: 'object',
  fields: [
    defineField({name: 'name', type: 'string'}),
    defineField({
      // An anonymous inline object: it has no top-level schema type of its own
      name: 'address',
      type: 'object',
      fields: [
        defineField({name: 'street', type: 'string', title: 'Street name'}),
        defineField({name: 'zip', type: 'string', title: 'Zip code'}),
      ],
    }),
  ],
})

```

Sanity Studio accepts this schema, but `sanity graphql deploy` does not. GraphQL cannot represent an object type that has no name, so the deploy stops with a message like `Encountered anonymous inline object "address" for field/type "person". To use this field with GraphQL you will need to create a top-level schema type for it.` For more on the schema constraints GraphQL adds, see [GraphQL](https://www.sanity.io/docs/content-lake/graphql).

An anonymous object inside an array raises the same error, reported by its position in the array rather than by a field name.

Lifting the object into a top-level schema type is required before you can deploy a GraphQL API, and it usually improves the data model regardless.

Defining a type globally often leads to a more thought-out and future-proof data model, since you rethink its fields in a global context — *"how can I define this type so it can be reused for both businesses and person records?"*

A named type is also easier to consume from an application. [Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen) can generate TypeScript types from your schema, so you don't have to mirror it by hand.

To lift a type, create a new type for it in the same way you would a `person` type, then import it into your schema:

**schemaTypes/address.ts**

```typescript
import {defineType, defineField} from 'sanity'

export const address = defineType({
  name: 'address',
  type: 'object',
  fields: [
    defineField({name: 'street', type: 'string', title: 'Street name'}),
    defineField({name: 'zip', type: 'string', title: 'Zip code'}),
  ],
})

```

Then, in your `person` type, set `address` as the `type` for the address field:

**schemaTypes/person.ts**

```typescript
import {defineType, defineField} from 'sanity'

export const person = defineType({
  name: 'person',
  type: 'object',
  fields: [
    defineField({name: 'name', type: 'string'}),
    defineField({name: 'address', type: 'address'}),
  ],
})

```

Register both types in your studio's schema:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

import {address} from './schemaTypes/address'
import {person} from './schemaTypes/person'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  schema: {
    types: [person, address],
  },
})

```



# Reference type has a invalid value for property "to"



All reference types must define what type of documents they may refer *to*. The "`to`" property must be an array of objects that describes the type of a valid reference. Each entry in `to`, must have a `type`-property which must be the name of a valid schema type.

```javascript
{
  type: 'reference',
  name: 'references',
  to: [ // The "to"-property must be set, and it must be an array of at least one type
    {
      type: 'author', // type is required
      title: 'Author'
    },
    {
      type: 'book',
      title: 'Book'
    }
  ]
}
```

### To-types must be unique, or named

In order to know which type description a reference value belongs to, you can not add multiple entries to of with the same type, unless you also give them a *name* to tell them apart. This is therefore not allowed: 

```javascript
{
  type: 'reference',
  name: 'authorReference',
  to: [
    {
      type: 'author',
      title: 'Author'
    },
    {
      type: 'author', // 💥 ERROR will not be able to tell reference values apart
      title: 'Another author'
    }
  ]
}
```

Instead, you can *name* the reference type. This will work:

```javascript
{
  type: 'reference',
  name: 'authorReference',
  to: [
    {
      type: 'author',
      title: 'Author'
    },
    {
      type: 'author',
      name: 'anotherAuthorReference', // all good
      title: 'Another author'
    }
  ]
}
```

The value of this definition will have its `_type` set to either `author` or `anotherAuthor`, depending on which of the type were selected when the value was set  e.g.:

```json
{"_type": "reference", "_ref": "329e893ewi"}
```

Or: 

```json
{"_type": "anotherAuthorReference", "_ref": "293e90iok3elwq213er"}
```



# Incorrect location for reference options

The `reference` field lets you define options for the input component. Define these options under the `options` key.

Sanity Studio reports ``filter` is not allowed on a reference type definition - did you mean `options.filter`?` when you place reference options on the root of the type instead of inside `options`. The same message is emitted for `filterParams`.

## Where reference options belong

**schemaTypes/blogPost.ts**

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'blogPost',
  type: 'document',
  fields: [
    defineField({
      name: 'author',
      type: 'reference',
      to: [{type: 'person'}],
      options: {
        filter: 'age > 30',
      },
    }),
  ],
})
```



# Invalid part syntax

This page documents the Studio v2 parts system, which was removed in Studio v3. Nothing in current Sanity reads a `sanity.json` file or resolves `part:` specifiers. For current plugin authoring, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

How parts are defined also defines how they behave.

An **implementable part** is defined by setting a `name` and a `description`. It should *not* have a path set. If you find yourself wanting to set a path, you probably want to do the following:

```json
[
  {
    "name": "part:foo/bar",
    "description": "Some really good description"
  },
  {
    "implements": "part:foo/bar",
    "path": "./some/part.js"
  }
]

```

A **non-overridable** part can be defined by setting a `name` and a `path` in the same declaration:



```json
{
  "name": "part:@sanity/base/schema",
  "path": "./some/schema.js"
}
```



# Asset metadata field

Schema validation reports `Invalid type for image `metadata` field - must be an array of strings` or `Invalid type for file `metadata` field - must be an array of strings` when the `metadata` option isn't an array of strings.

The `metadata` option on an *image* field lists which metadata Sanity extracts from an uploaded file and writes to the asset's metadata document. Its value must be an array of strings:

**schemaTypes/article.ts**

```typescript
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'article',
  type: 'document',
  fields: [
    defineField({
      name: 'coverPhoto',
      type: 'image',
      options: {
        metadata: ['location', 'palette'],
      },
    }),
  ],
})
```

Valid values are `blurhash`, `thumbhash`, `lqip`, `palette`, `exif`, `image`, and `location`.

`dimensions`, `hasAlpha`, and `isOpaque` are always included and can't be listed. Adding them produces a separate warning: `Image `metadata` field contains superfluous properties (they are always included):` followed by the names.

The same validation runs for *file* fields, but `metadata` is missing from the `FileOptions` type, so TypeScript rejects it on a `file` field even though the Studio accepts it.

This example extracts palette and location data for any image uploaded to the field and stores it on the *asset*. You can then query it:

```groq
*[_type == "article"][0...10]{
  ...,
  "coverPhoto": coverPhoto.asset->{
    url,
    metadata {
      location,
      palette {
        dominant {
          background,
          foreground
        }
      }
    }
  }
}
```

The result looks like this:

```json
[
  {
    "_id": "some-blog-post",
    "_type": "article",
    "title": "Some blog post",
    "coverPhoto": {
      "url": "https://cdn.sanity.io/images/YOUR_PROJECT_ID/production/aa1N73Zv14r7pYsbUdXl-4288x2848.jpg",
      "metadata": {
        "location": {
          "_type": "geopoint",
          "alt": 12.4,
          "lat": 59.924104,
          "lng": 10.758437
        },
        "palette": {
          "dominant": {
            "background": "#99b8cd",
            "foreground": "#000"
          }
        }
      }
    }
  }
]
```

For every metadata value and what it returns, see [Image metadata](https://www.sanity.io/docs/apis-and-sdks/image-metadata).



# Warning: userStore.currentUser is deprecated

The `userStore.currentUser` method has been deprecated in favor of `userStore.me` which is an observable stream of the current logged in user or `null` if the user is logged out.

Where the `userStore.currentUser` observable stream emitted a "snapshot" event object with the user object at the `user` property every time user state changed, the `userStore.me` emits the user object (or null if logged out) as you would see it from the `/users/me` API endpoint.

Example of how to migrate existing code currently using `userStore.currentUser` to instead use `userStore.me`:

## Before

```javascript
userStore.currentUser.subscribe(event => {
  console.log('Current user is:', event.user)
})
```

## After

```javascript
userStore.me.subscribe(user => {
  console.log('Current user is:', user)
})

```



# CLI errors

You may run into errors while using the CLI. Listed below are some explanations and common solutions for these errors. 


> [!WARNING]
> Gotcha
> Some error explanations may be missing. If you cannot find the error you are looking for, please use the feedback form to let us know or make a post in our [Slack Community](https://slack.sanity.io).

## Common errors while installing the CLI

### `Error: EACCES: permission denied, access '/usr/local/lib/node_modules'`

This error often occurs when you do not have the correct permission to install packages with `npm`.

You can fix this by changing the owner of the global `node_modules` folder using the following command:

```sh
sudo chown -R $USER /usr/local/lib/node_modules

```

Another option to fix this issue is managing your node version(s) with a version manager like [nvm](https://github.com/nvm-sh/nvm) or [asdf](https://asdf-vm.com/).

### `Error: spawn cmd ENOENT` on Windows machines

If you're using a Windows computer and running into an error that resembles the one above while attempting to install (or use) the Sanity CLI, it's likely that there is an issue with the `$PATH` environment variable of your operating system.

To fix this, ensure the variable is correctly set before rerunning the CLI. More information on troubleshooting can be found in [this thread on Stack Overflow](https://stackoverflow.com/questions/57054403/problem-with-npm-start-error-spawn-cmd-enoent).



## Common errors while using the CLI

### Port 3333 is already in use

Sanity Studio's dev server defaults to port 3333 with strict port checking. If another process is using that port, the server exits with an error instead of switching to a different port.

#### Find and stop the process

**Mac/Linux:**

```bash
lsof -ti :3333 | xargs kill -9
```

**Windows:**

```bash
for /f "tokens=5" %a in ('netstat -ano ^| findstr :3333') do taskkill /PID %a /F
```



#### Use a different port

Pass the `--port` flag to run the dev server on another port:

```bash
sanity dev --port 3334
```

To make this permanent, set `server.port` in your [CLI configuration](https://www.sanity.io/docs/cli-reference/cli-config):

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: '<YOUR_PROJECT_ID>',
    dataset: '<YOUR_DATASET>',
  },
  server: {
    port: 3334,
  },
})
```

If you change the port, add the new origin to your project's [CORS origins](https://www.sanity.io/docs/content-lake/cors).

### Command `(start|dev|deploy|...)` is not available outside of a Sanity project context

If you're seeing this error, it means that the CLI can not identify your Sanity project context.

To fix this, try the following:

1. Ensure that you're running the command within the correct directory. Your Sanity project directory should have a `package.json` and a `sanity.json` file.
2. Ensure that you have all of the necessary dependencies installed. Do this by running a `npm install` or `yarn install` if you're managing your dependencies with yarn.
3. Delete your `node_modules` folder, reinstall the project's dependencies, and try running the command again.

If these solutions don't solve the issue, please get in contact with us either through the feedback form at the bottom of the page or our [Slack community](http://slack.sanity.io/).

### Command failed with exit code 1 (EPERM): `npm install next-sanity@7`

If you're seeing this error, it's likely that you're on a Windows computer with insufficient permissions for installing dependencies with `npm`.

To fix this, try:

1. Running your command line program as an Administrator
2. Run `npm cache clean --force` and `npm cache verify`
3. Uninstall and reinstall Node.js

If these solutions don't solve the issue, please get in contact with us either through the feedback form at the bottom of the page or our [Slack community](http://slack.sanity.io/).

### `sanity.cli.(js|ts)` does not contain a project identifier

If you're seeing this error, first ensure that your `sanity.cli.(js|ts)` file contains a `projectId` and `dataset` in the `defineConfig` function. If that's not the case, add those details and retry the command you were attempting to execute.

If your `sanity.cli.(js|ts)` file looks correctly setup with those attributes present, try rerunning the CLI command with `npx`. For example, if you were previously trying to run `sanity deploy`, try running `npx sanity deploy`.

If the command executes without error, it's likely that your local CLI version is out of date. Try upgrading with `npm i -g @sanity/cli`.

If these solutions don't solve the issue, please get in contact with us either through the feedback form at the bottom of the page or our [Slack community](http://slack.sanity.io/).

### Unauthorized - You do not have access to the project with ID <projectID>

This error occurs when you run a command without the appropriate permissions. Common causes can be:

- Incorrect or misspelled project ID in your `sanity.json`.
- You don't have the rights to deploy a project. Need to be an Administrator or have a deploy token to do this.
For example: running `sanity graphql deploy` with Write or Read+Write access only will give you this error.

### Unauthorized - User is missing required grant sanity.project/deployStudio to perform this operation

This error occurs on `sanity deploy` when you have access to the studio but without the required permissions to deploy.

To fix this, ensure that you are logged into the CLI with the correct credentials for your project. You can easily do this by logging out of the CLI with the following command:



**npm**

```shell
npx sanity logout
```

**pnpm**

```shell
pnpm dlx sanity logout
```

**yarn**

```shell
yarn dlx sanity logout
```

**bun**

```shell
bunx sanity logout
```

And logging back in again with the following command:

**npm**

```shell
npx sanity login
```

**pnpm**

```shell
pnpm dlx sanity login
```

**yarn**

```shell
yarn dlx sanity login
```

**bun**

```shell
bunx sanity login
```

### Unauthorized - Session not found

This can be one of several issues:

- A temporary issue, please try to run your command again.
- You have specified an invalid token with the `SANITY_AUTH_TOKEN` env variable.
- The session timed out. Try to log out and log in again with the `sanity logout` and `sanity login` CLI commands.
- There was an issue with your logged in user. Try to logout and login again.





### Cannot delete session for robot user - use delete token endpoint

#### Cause

Your CLI is configured with a robot token (a long-lived API token) rather than a user session. Robot tokens do not have a server-side logout, so `sanity logout` cannot invalidate them. They have to be revoked through the tokens API instead.

#### Resolution

List your robot tokens, then revoke the one you want to remove:

```sh
# List robot tokens to find the ID
sanity tokens list

# Revoke the token
sanity tokens delete <token-id>
```

See [CLI authentication](https://www.sanity.io/docs/apis-and-sdks/cli-authentication) for the full reference on robot tokens and the `sanity tokens` command.



# Renamed plugin sanity-plugin-vision

> [!WARNING]
> This page describes a Studio v2 plugin
> The rename warning that brought you here is printed by `sanity-plugin-vision`, which loads only through the Studio v2 parts system. If you're on Studio v3 or later, follow **Studio v3 and later** below, or read [The Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin) for the full current documentation.

The plugin `sanity-plugin-vision` has been renamed to `@sanity/vision`.

## What should I do?

The steps depend on which major version of Sanity Studio you're running.

### Studio v3 and later

1. Install the package with `npm install @sanity/vision`
2. Add `visionTool()` to the `plugins` array in your Studio configuration:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [structureTool(), visionTool()],
})
```

Vision is not bundled with the Studio, but new projects scaffolded by `npm create sanity@latest` already include it. For the configuration options, see [The Vision plugin](https://www.sanity.io/docs/content-lake/the-vision-plugin).

### Studio v2

1. Install the Studio v2 release of the package with `npm install @sanity/vision@2`
2. Remove `"vision"` from the `plugins` array in your `sanity.json`, and add `@sanity/vision` in its place.



# Part name format

This page documents the Studio v2 parts system, which was removed in Studio v3. For current plugin authoring, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

A *part name* must start with `part:` and be followed by a prefix that matches the plugin that defines it, as well as an identifier for this particular part.

Ergo: `part:my-plugin/part-identifier`





# Changes in block schema customization properties

Sanity Studio v3.1 and later accept a `component` property on decorators and styles, which handles custom rendering of those types in the Portable Text input. Annotations are different: they read `components.annotation` instead, and a `component` property set on an annotation is ignored without a validation warning. Both properties replace `blockEditor.render`, which still works in v6 through a back-compatibility shim but is deprecated.

The `icon` property goes on the decorator, style, or annotation itself, as with all other schema types. This replaces the `blockEditor.icon` property.

So if you previously did this in your block type schema:

```javascript
decorators: [
  {
    title: 'Highlight',
    value: 'highlight',
    blockEditor: {
      icon: MarkerIcon,
      render: highlightRender,
    },
  },
],
```

You should now do this:

```javascript
decorators: [
  {
    title: 'Highlight',
    value: 'highlight',
    icon: MarkerIcon,
    component: Highlight,
  },
],
```

Read more about customizing the [Portable Text Editor](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor)



# How to migrate from date to richDate

Do not follow this page. It describes a migration to a `richDate` type that is not registered in current Sanity, so changing a field to `richDate` produces an unknown-type error. Current Sanity has the [date](https://www.sanity.io/docs/studio/date-type) and [datetime](https://www.sanity.io/docs/studio/datetime-type) types, both stored as ISO-8601 strings; the Studio converts a legacy `richDate` value to `datetime`. The migration script this page links to is also archived and requires a Studio v2 `sanity.json`.

We'll soon rename Sanity's internal `date` type to `richDate`. If you're *not* using `date`, don't worry about any of the below.

This is unfortunately a breaking change. These are the three actions required of you:

## 1. Make your front-end(s) tolerate both the old and the new type

In a transition period, front-ends which consume Sanity documents and do conditional checks on `_type === 'date'`, should be updated to handle both the old `date` type and the new `richDate` type. If you don't do any checks on `_type === 'date'`, you can skip this step entirely.

### Before:

```javascript
if (value._type === 'date') {
  // ... doing something with the date value
}
```

### After:

```javascript
if (value._type === 'date' || value._type === 'richDate') {
  // ... doing something with the richDate value
}
```

Note: when all your date values are migrated (see pt. 3 below), you can safely remove the `value._type === 'date'` check.

Also note that only the `_type` attribute will change, so accessing the other attributes of your date object will work as before.

```javascript
moment(person.bornOn.utc).fromNow() // <-- this will still work
```

### 2. Modify your schema

All `date` fields must be changed.

### From this:

```javascript
{
  title: 'Birthday'
  name: 'bornOn',
  type: 'date'
}
```

### To this:

```javascript
{
  title: 'Birthday'
  name: 'bornOn',
  type: 'richDate'
}
```

Any options, as described in the [date documentation](https://www.sanity.io/docs/studio/date-type), stay the same.

### 3. Migrate your data

All documents containing one or more `date` fields must be changed to `richDate` . We have written a [script](https://github.com/sanity-io/migrations/blob/master/date-to-richdate.js) that does this automatically for you. This can be executed with the command line tool `npx` , which has been shipping with `npm` from version `5.2.0`:

**npm**

```shell
cd <your sanity studio project folder>
npx -p sanity-io/migrations date-to-richdate
```

**pnpm**

```shell
cd <your sanity studio project folder>
pnpm dlx -p sanity-io/migrations date-to-richdate
```

**yarn**

```shell
cd <your sanity studio project folder>
yarn dlx -p sanity-io/migrations date-to-richdate
```

**bun**

```shell
cd <your sanity studio project folder>
bunx -p sanity-io/migrations date-to-richdate
```

### Yes, but why?

We're of the opinion that the type named `date` should be represented as a pure string (e.g. `'2017-02-08T01:30:00+01:00'` or `'2017-02-08T00:30:00Z'`) instead of an object. Both because this the least surprising behavior and because it conforms with the `_createdAt` and `_updatedAt` fields which are automatically maintained by the data backend.

Shortly after all `date` --> `richDate` migrations are complete, we'll release a new version of Sanity which offers two distinct date types: `date` (a string representation) and `richDate` (an object representation).

Thanks for the patience!

Want to read the developer discussion? The issue is [over here](https://github.com/sanity-io/sanity/issues/79).



# Invalid shape of predefined choices

This error means an entry in an array's `options.list` isn't a valid value for the array's declared member types.

As a general rule, the list of possible choices for array types must only contain values of valid item types for the array.

The exact message depends on what the array holds. For an array of objects it ends with `Must be an object with "_type" set to …`. For an array of primitives it ends with `Must be either a value of type …, or an object with {title: string, value: …}`.

**schemaTypes/colors.ts**

```typescript
import {defineArrayMember, defineField, defineType} from 'sanity'

export const colors = defineType({
  name: 'colors',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'object',
      name: 'webColor',
      fields: [
        defineField({name: 'name', type: 'string'}),
        defineField({name: 'hex', type: 'string'}),
      ],
    }),
    defineArrayMember({
      type: 'object',
      name: 'rgbaColor',
      fields: [
        defineField({name: 'name', type: 'string'}),
        defineField({name: 'r', type: 'number'}),
        defineField({name: 'g', type: 'number'}),
        defineField({name: 'b', type: 'number'}),
        defineField({name: 'a', type: 'number'}),
      ],
    }),
  ],
  options: {
    list: [
      // Valid
      {_type: 'webColor', hex: '438D80', name: 'Sea Turtle Green'},

      // Valid
      {_type: 'rgbaColor', r: 161, g: 201, b: 53, name: 'Salad Green'},

      // Invalid: an object entry is matched on its _type, and a
      // {title, value} wrapper doesn't have one
      {
        title: 'Sea Turtle Green',
        value: {_type: 'webColor', hex: 'C88141', name: 'Tiger Orange'},
      },

      // Invalid: missing _type
      {hex: '438D80', name: 'Sea Turtle Green'},

      // Invalid: hslaColor is not one of this array's member types
      {_type: 'hslaColor', h: 0.02, s: 0.93, l: 0.71, name: 'Salmon'},
    ],
  },
})
```

A notable exception here is choices for primitive values, which can be given a display title by providing an object with `title` and `value`, where value is of a valid item type:

**schemaTypes/numbersAndAnimals.ts**

```typescript
import {defineArrayMember, defineType} from 'sanity'

export const numbersAndAnimals = defineType({
  name: 'numbersAndAnimals',
  type: 'array',
  of: [
    defineArrayMember({type: 'string'}),
    defineArrayMember({type: 'number'}),
  ],
  options: {
    list: [
      // Valid: this array can contain strings
      'sheep',

      // Valid: this array can contain numbers
      44,

      // Valid: a primitive value can be given a display title
      {title: 'Cat', value: 'cat'},

      // Valid: the same works for numbers
      {title: 'Hundred', value: 100},

      // Invalid: this array can't contain booleans
      true,
    ],
  },
})
```



# Introducing the document type

In version [0.118.0](https://github.com/sanity-io/sanity/releases/tag/v0.118.0) we introduced a new type `document`. This is the type for any object that you would like to store as documents in the datastore. Previously, any object type defined in your schema could be turned into a document, but now you must define these as documents instead. Only document types will appear in the desk tool sidebar.

NOTE: You should still use `type: 'object'` for the schema types that is reused on fields in your schema types (e.g. things like `localeString` and other types that you would never create standalone documents of)

## What should I do?

This is not a breaking change, so everything will continue to work as before. That is, **until** the moment you decide to use the `document` type. If you add a document type to your schema, you should also have to change the type of all the top-level object types in your schema. E.g. if your schema was:

```javascript
export createSchema({
  name: 'mySchema',
  types: [
    {
      name: 'book',
      type: 'object',
      fields: [
        {name: 'title', type: 'string'}
      ]
    }
    //...
  ]
})
```

You should change this to:

```javascript
export createSchema({
  name: 'mySchema',
  types: [
    {
      name: 'book',
      type: 'document',
      fields: [
        {name: 'title', type: 'string'}
      ]
    }
    //...
  ]
})
```

And do this for all types in your schema that you would like to be stored as documents. Note: you may still want re-usable object types at top-level in your schema, but these should should stay with type objects. In that case they will not be listed in the sidebar.

Note: The introduction of the document type makes the `hiddenTypes` config in the  `config/@sanity/data-aspects.json` config file obsolete, and you should remove it entirely.



# Unable to get a ref to an input component

> [!WARNING]
> This page describes Studio v2
> The warning described here was printed by `@sanity/form-builder` in Studio v2, and no Studio version from v3 onward emits it. The fix below is also out of date: custom inputs are no longer wrapped in `React.forwardRef` to receive focus. The current form API passes an `elementProps` object to your input, which you spread onto the element that should take focus. See [Focus and UI state in custom inputs](https://www.sanity.io/docs/studio/focus-and-ui-state-in-custom-inputs).

This happens when the editor is unable to create a [ref](https://reactjs.org/docs/glossary.html#refs) to an input component. This is likely because of one of the following reasons:

- The input component is wrapped in a *higher order component *(HOC), which does not delegate a `focus()` method to the component it wraps. [See this guide on how to forward a ref inside a higher order component](https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-in-higher-order-components).
- The input component is a [function component](https://reactjs.org/docs/components-and-props.html#function-and-class-components). Since function components cannot be given refs, the input component must be wrapped using [React.forwardRef](https://reactjs.org/docs/react-api.html#reactforwardref) in order to specify which element should receive focus. Note: keep in mind that the forwarded ref must be attached to an element that actually exposes a `.focus()` method.



# Outdated modules

This page is superseded by [Upgrade studio packages](https://www.sanity.io/docs/help/upgrade-packages), which carries the current compatibility table for each Studio major version. To upgrade the Studio itself, run `npm install sanity@latest`.

Some of the modules in your Sanity studio are on a version that we no longer support.

Usually, this is related to APIs that have changed and will no longer function when paired with the modules in question. In these cases, things might actually stop working. In other cases, the modules are so old that they might stop working when paired with other plugins and functionality in Sanity.

Either way, you can upgrade from the command line:

1. Open your terminal and go to your Sanity studio folder.
2. Run `npm install sanity@latest`. This installs the latest version of Sanity Studio and its dependencies.
3. Run your studio locally with `npx sanity@latest dev` and check that it works as expected.

If you have trouble upgrading, ask for help [in the Sanity community](https://www.sanity.io/community/join).



# Upgrade studio packages

From time to time, versions of packages Sanity Studio depends on needs to be upgraded. This can be done either by manually entering a new version of the dependency in your studio folder's `package.json`, or by running a command from your command line.

## Upgrading React

**npm**

```shell
npm install "react@latest" "react-dom@latest"
```

**pnpm**

```shell
pnpm add "react@latest" "react-dom@latest"
```

**yarn**

```shell
yarn add "react@latest" "react-dom@latest"
```

**bun**

```shell
bun add "react@latest" "react-dom@latest"
```

Note: if you have customizations in your Sanity Studio and are upgrading between major versions of React (e.g. going from version 18 to 19) you may need to  do some adjustments to your React components as well. Please consult the release announcements on the [React blog](https://react.dev/blog) for details on how to migrate to the latest version.



## Known Sanity Studio version compatibilities

This is a non-exhaustive list of dependencies Sanity Studio is known to work with. Anything outside of these ranges might still work, but we recommend keeping up to date with current versions to get the latest stability and performance improvements.

### v6.x

- `node@>=22.12`
- `react@^19.2.2`
- `react-dom@^19.2.2`
- `styled-components@^6.1.15`
- `@sanity/ui@^4.x`

### v5.x

- `node@>=20.19 <22 || >=22.12`
- `react@^19.2.2`
- `react-dom@^19.2.2`
- `styled-components@^6.1.15`
- `@sanity/ui@^3.x`

### v4.x

- `react@^18.2.x - react@19.x`
- `react-dom@^18.2.x - react@19.x`
- `styled-components@^6.x`
- `@sanity/ui@^3.x`

### v3.69.0 -> v3.99.0

- `react@^18.2.x - react@19.x`
- `react-dom@^18.2.x - react@19.x`
- `styled-components@^6.x`
- `@sanity/ui@^2.x`

### < v3.69.0

- `react@^18.2.x`
- `react-dom@^18.2.x`
- `styled-components@^6.x`
- `@sanity/ui@^2.x`



# Block Content rendering: Image materializing

The image type holds a set of user-defined fields as well as an `asset` field which is a reference to the actual [asset document](https://www.sanity.io/docs/asset-pipeline). Quite often you will need to get ahold of the asset document in order to make decisions based on the size, name, type, or metadata of the image, or to get the full URL to the image.

## Joining the asset document using GROQ

You can join these references when you fetch the document(s) containing a Portable Text field. Let's say you have a document type named `article` which has a `body` field containing an array of blocks. The following query would expand all the `asset` fields within the array:

```json
*[_type == "article"]{
  body[]{
    ..., 
    asset->{
      ...,
      "_key": _id
    }
  }}[0...5]
```

Let's break it down:

- Fetch all documents of type `article`: `*[_type == "article"]`
- For each item in the `body` array: `body[]`
- Return all the properties: `...`
- Make a property called `asset` and let the value be the materialized value of the `asset` property: `"asset" asset->`
- Only return the 5 first documents matched: `[0...5]`

## More information on querying data

Looking to get started working with data from your Sanity data store? Find out [how GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work) or dive in with [Sanity's GraphQL interface](https://www.sanity.io/docs/content-lake/graphql).





# Structure: Document schema type required

Certain nodes within the Structure tool require a document schema type to be defined. The Studio reports `document type (`schemaType`) is required for document nodes`.

Setting a schema type can be done by calling the `schemaType()` method:



```javascript
S.document()
  .id('car-editor')
  .schemaType('car')
  .documentId('am-db9')

```





# Parts: Declare vs implement

This page documents the Studio v2 parts system, which was removed in Studio v3. For current plugin authoring, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

How parts are defined also defines how they behave.

An **implementable part** is defined by setting a `name` and a `description`. It should *not* have a path set. If you find yourself wanting to set a path, you probably want to do the following:

```json
[
  {
    "name": "part:foo/bar",
    "description": "Some really good description"
  },
  {
    "implements": "part:foo/bar",
    "path": "./some/part.js"
  }
]

```

A **non-overridable** part can be defined by setting a `name` and a `path` in the same declaration:



```json
{
  "name": "part:@sanity/base/schema",
  "path": "./some/schema.js"
}
```



# Incorrect options declaration in reference

The reference field allows you to define options for the input component. These options should be defined under the `options` key and be an object.

If you are encountering this error, it usually means that you've defined an options key which is not an object:

```javascript
export default {
  name: 'blogPost',
  type: 'document',
  fields: [
    // ... your other schema fields ...
    {
      name: 'author',
      type: 'reference',
      to: [{type: 'person'}],
      
      // INCORRECT
      options: [{some: 'option'}],
      
      // CORRECT
      options: {
        some: 'option'
      }
    }
  ]
}
```





# Block type cannot be used outside of array

Sanity Studio supports the block type only inside an array, not as a standalone field. Block content is an array by definition, so the Studio reports `Invalid standalone block field(s) "myField". Block content must be defined as an array of blocks`.

This will **not** work:

```javascript
{
  name: 'myField',
  title: 'My field',
  type: 'block'
}
```


But **this** will:

```javascript
{
  name: 'myField',
  title: 'My field',
  type: 'array',
  of: [{type: 'block'}]
}
```



# Structure: Node ID required

All nodes within the Structure tool have an ID assigned to them. Normally, the ID is assigned automatically based on the title of the item, but some items require a manually assigned ID.

One example of this is the document list item - its ID refers to a specific document ID, and as such it needs to be manually assigned.

Setting an ID can be done by calling the `id()` method:



```javascript
S.documentListItem()
  .id('website-featured-articles')
  .schemaType('article-set')
  .title('Site config')
```





# Structure: List items must be an array

The Structure tool list takes an *array* of items. A common mistake is to passing a list of items as arguments instead of an array:



```javascript
// Incorrect:
S.list()
  .title('Content')
  .items(
    S.listItem().title('Foo'),
    S.listItem().title('Bar')
  )

// Correct:
S.list()
  .title('Content')
  .items([
    S.listItem().title('Foo'),
    S.listItem().title('Bar')
  ])

```



# Installing Node.js

Node.js is a tool for developing and running web servers written in JavaScript ([read more](https://nodejs.org/en/about/)). To develop Sanity Studios, you must have Node.js installed on your computer. Sanity Studio v6 requires Node.js 22.12 or later.

Node.js maintains a [download page](https://nodejs.org/en/download) with installers and package-manager instructions for macOS, Windows, and Linux.

**Notes on installing Node.js on macOS**: We recommend installing Node.js using Homebrew. Follow [this guide](https://brew.sh/) to get Homebrew, then run `brew install node`.

All set? Let's [get started](https://www.sanity.io/docs/getting-started)!



# Structure: Action or intent required

Menu items needs to know what to do when they are selected. This can done by specifying one of to parameters:

- `action` - which is a function called with the parameters set for this menu item
- `intent` - an object containing a `name` and an optional bag of `params`

Certain nodes within the Structure tool require a document ID to operate on. 

Setting an action or intent can be done by calling the `action()` or `intent()` methods, respectively:

```javascript
new MenuItemBuilder()
  .title('Open in website')
  .icon(OpenIcon)
  .params({breed: 'schnauzer'})
  .action(params => {
    window.open(`https://mywebsite/breeds/${params.breed}`)
  })

```





# Object type has a invalid value for fields

Sanity Studio reports `The "fields" property must be an array of fields`, `Found 2 fields with name "myField" in object`, or `Invalid field name "_myField". Field names cannot start with underscores "_" as it's reserved for system fields.` depending on which part of the definition is wrong.

Documents or object types must define which fields they have. The fields property must be an array of field definitions, where both `name` and `type` are required, and each field having a unique `name`.

Additionally, field names must start with a letter and can only contain letters, numbers, and underscores. Field names can't start with an underscore, which is reserved for system fields. [We recommend using camel case convention for field names](https://www.sanity.io/docs/apis-and-sdks/naming-things).

Two other definition problems report the same error. An object with an empty `fields` array reports `Object should have at least one field`. A field definition that isn't an object reports `Incorrect type for field definition - should be an object`, followed by what it saw instead.

```typescript
import {defineField, defineType} from 'sanity'

export const myObject = defineType({
  type: 'object',
  name: 'myObject',
  // fields must be defined, and it must be an array
  fields: [
    defineField({
      name: 'myField', // field name is required and must be unique
      type: 'string', // field type is required
    }),
    // ...
    defineField({
      // 💥 ERROR: a field named "myField" is already defined on this object
      name: 'myField',
      type: 'string',
    }),
  ],
})
```



# `studioHost` and `externalStudioHost` properties deprecated

Your projects are no longer tied to a single Sanity Studio application — you can deploy multiple different studios which could all talk to one or more datasets and projects.

In the past, there were two properties attached to project information APIs that recorded information about the studio for that project, `studioHost` and `metadata.externalStudioHost`. These are no longer guaranteed to be present and do not reflect the full truth of deployed studios/applications for those that deploy multiple studios.

To list every studio and application deployed to a project, use the user applications endpoint instead: `GET /projects/<PROJECT_ID>/user-applications`. For the full specification, see the [Applications API reference](https://www.sanity.io/docs/http-reference/applications-api).

The `studioHost` field in `sanity.cli.ts` is a different setting with its own replacement. Use `deployment.appId` there instead.



# Schema type is ES Module but imported through require

Schema validation reports `Type appears to be an ES6 module imported through CommonJS require - use an import statement or access the `.default` property`.

Despite the wording, the check doesn't look for a `require` call. It fires when a value in your list of schema types has no `name` of its own but carries a `default` property that looks like a type definition. That happens when a module object reaches the schema instead of the type the module exports.

Given a schema type defined as a default export:

**schemaTypes/heroImage.ts**

```typescript
import {defineType} from 'sanity'

export default defineType({
  name: 'heroImage',
  type: 'image',
})
```

...the error appears if you register the module itself rather than its default export:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import * as heroImage from './schemaTypes/heroImage'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  schema: {
    // `heroImage` is the module here, not the type it exports
    types: [heroImage],
  },
})
```

Import the default export instead:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import heroImage from './schemaTypes/heroImage'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  schema: {
    types: [heroImage],
  },
})
```

If you can't change the import, reach into the default export where you register the type: `types: [heroImage.default]`.

TypeScript rejects the module form at compile time, so this error usually surfaces in JavaScript studios, or where the list of types is assembled dynamically.

In Studio v2 the same error came from a CommonJS `require` call in a `part:@sanity/base/schema-creator` file. Studio v3 and later load schema modules as ESM, where `require` isn't available.



# Structure: Invalid list item

A Structure tool list takes an array of list items to display. The Studio reports `List items must be of type "listItem", got "<type>"`, and when the value it received was itself an array it appends ` - did you forget to spread (...moreItems)?`. Common causes:

- Passing a promise or an observable instead of an actual list item. If you actually need to resolve a list item asynchronously, resolve the items before you resolve the list definition.
- You passed an array of items within the list. For instance, you might have called the `documentTypeListItems()` method, but did not use the spread operator to flatten the returned items into the array: `...documentTypeListItems()`





# Structure: Query provided where filter is expected

Certain nodes within the Structure tool require a filter. A filter is the part of a [GROQ-query](https://www.sanity.io/docs/content-lake/how-queries-work) which specifies which documents should be matched - the constraints of a query, if you will.

While a full GROQ-query could look like this:


```text
*[_type == "movie" && releaseDate > $afterDate] {
  _id, titlex, releaseDate
} [0...20]
```

The *filter* of the query is simply:



```text
_type == "movie" && releaseDate > $afterDate
```





# Structure: List item IDs must be unique

Within a single list, there can be no duplicate IDs. The IDs are used to resolve which child to render as the next item. The Studio reports `List items with same ID found (<ids>)`, naming up to five of the offending IDs. If a list item has no explicit ID, its ID is the camel-cased title — so titles that differ only in casing or punctuation collide.

If you are not manually assigning IDs, it probably means that the title of two or more of your list items are the same, since the ID is inferred from that if not specified.

When this is the case, you can solve it by calling `id('someOtherId')` on the list items that conflict.



# Given type name is a reserved type

The Studio reports `Invalid type name: "<name>" is a reserved name.` when a schema type uses a name that Sanity reserves. Most reserved names are built-in types; `any`, `date`, and `time` are reserved for future use. The reserved names are:

`any`, `array`, `block`, `boolean`, `crossDatasetReference`, `date`, `datetime`, `document`, `email`, `file`, `geopoint`, `globalDocumentReference`, `image`, `number`, `object`, `reference`, `slug`, `span`, `string`, `telephone`, `text`, `time`, `type`, and `url`.

If you have a type with one of these names in your schema, rename it or remove it.



# Structure: Schema type not found

This error occurs when the Structure tool tries to find a schema type but does not find a match. Usually this is caused by a typo in the type name, or forgetting to import and include the document type in the Studio schema definition.

First, check for any typos (obviously).

Secondly, check your schema definition (usually `<your-studio>/schemas/schema.js`) and ensure that you have both imported and included the document type in the call to `createSchema()`:

```javascript
import createSchema from 'part:@sanity/base/schema-creator'
import schemaTypes from 'all:part:@sanity/base/schema-type'

// Make sure you import the document type
import someDocumentType from './someDocumentType'

export default createSchema({
  name: 'default',
  types: schemaTypes.concat([
    // Make sure you include the type in this array:
    someDocumentType
  ])
})

```





# API versioning

This page is a pointer stub. The maintained article is [API Versioning](https://www.sanity.io/docs/content-lake/api-versioning) in the Content Lake documentation.

Looking for information on API Versioning? View [the official documentation](https://www.sanity.io/docs/content-lake/api-versioning) or visit [the changelog](https://sanity.io/changelog) to see what has changed in various versions.



# Migrating the legacy webhook behavior to GROQ-powered Webhooks

To recreate the payload shape of legacy webhooks, follow these steps. This does not restore dataset-level batching — GROQ-powered webhooks fire once per changed document, so each request carries exactly one ID. Batch on your receiving endpoint if you need dataset-level grouping.

1. In the project's management interface at manage.sanity.io, create a webhook set to trigger on **create**, **update**, and **delete**.
2. Leave the **Filter** field empty.
3. Add the following to the **Projection** field:

**Projection**

```groq
{
  "projectId": sanity::projectId(),
  "dataset": sanity::dataset(),
  "ids": {
    "created": select(before() == null && after() != null => [_id], []),
    "deleted": select(before() != null && after() == null => [_id], []),
    "updated": select(before() != null && after() != null => [_id], []),
    "all": [
      _id
    ]
  }
}
```

The transaction ID is not available to the projection, but every webhook request carries it in the `sanity-transaction-id` header. By default this webhook does not fire for `drafts.` or `versions.` documents — enable the drafts and versions settings if you need those, and note that version support requires webhook API version `v2025-02-19` or later.

You can also [create a webhook from a template that has these settings](https://www.sanity.io/manage/webhooks/share?name=Legacy+webhook&description=Recreation+of+legacy+webhooks&url=&on=create&on=delete&on=update&filter=&projection=%7B%0A%20%20%22projectId%22%3A%20sanity%3A%3AprojectId%28%29%2C%0A%20%20%22dataset%22%3A%20sanity%3A%3Adataset%28%29%2C%0A%20%20%22ids%22%3A%20%7B%0A%20%20%20%20%22created%22%3A%20select%28before%28%29%20%3D%3D%20null%20%26%26%20after%28%29%20%21%3D%20null%20%3D%3E%20%5B_id%5D%2C%20%5B%5D%29%2C%0A%20%20%20%20%22deleted%22%3A%20select%28before%28%29%20%21%3D%20null%20%26%26%20after%28%29%20%3D%3D%20null%20%3D%3E%20%5B_id%5D%2C%20%5B%5D%29%2C%0A%20%20%20%20%22updated%22%3A%20select%28before%28%29%20%21%3D%20null%20%26%26%20after%28%29%20%21%3D%20null%20%3D%3E%20%5B_id%5D%2C%20%5B%5D%29%2C%0A%20%20%20%20%22all%22%3A%20%5B%0A%20%20%20%20%20%20_id%0A%20%20%20%20%5D%0A%20%20%7D%0A%7D&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=).



# Schema type is invalid

Sanity Studio reports `Invalid/undefined type declaration, check declaration or the import/export of the schema type.` when an entry in your schema's `types` array is not a valid schema type declaration. Common causes:

- The type declaration is imported through `import`/`require` from a different file, but the import declaration either references an incorrect name or the imported file has no export declaration. If you imported an ES module through `require`, Studio reports a different problem instead — see [Schema type is ES Module but imported through require](https://www.sanity.io/docs/help/schema-type-is-esm-module).
- Something is returning `undefined`, `null` or `false` instead of the schema type declaration.

Double-check the `types` array of your schema declaration at the specified index to figure out where the error stems from.



# Input component is missing a required prop

This page documents the Studio v2 form-builder API. In current Sanity Studio you do not pass `onFocus` and `onBlur` into an input — the Studio supplies them, and you spread `props.elementProps` onto your element, delegating with `renderDefault`. See [Custom components for Sanity Studio](https://www.sanity.io/docs/studio/intro-to-custom-studio-components).

All input components should be passed a `onFocus` and `onBlur` prop.

Read more about [Custom input widgets](https://www.sanity.io/docs/studio/intro-to-custom-studio-components)



# Structure: Title is required

Certain nodes within the Structure tool require a title. The title is used mainly for presentation concerns, but is also used to generate a node ID if one is not given.

Setting a title can be done by calling the `title()` method:



```javascript
S.documentList()
  .id('cars')
  .title('Cars')
  .filter('_type == $type')
  .params({type: 'car'})
```





# Structure: Filter is required

Certain nodes within the Structure tool require a filter. A filter is the part of a [GROQ query](https://www.sanity.io/docs/content-lake/how-queries-work) that specifies which documents should be matched — the constraints of a query, if you will.

Let's imagine you want to run a query to find all documents that do not currently have a slug set (in a field called `slug`). While the full GROQ query would look like this:


```groq
*[!defined(slug.current)]
```

The *filter* of the query on its own is:



```groq
!defined(slug.current)
```

Set a filter by calling the `filter()` method. Any filter other than a plain `_type == $type` check also needs `apiVersion()` — without it the Studio logs a warning to the console:



```typescript
S.documentList()
  .title('Missing slug')
  .apiVersion('2025-02-19')
  .filter('!defined(slug.current)')

```





# Import: Asset file does not exist

This error usually occurs when you are importing documents using `sanity dataset import ...` and images or files (also known as assets) aren't found.

This typically happens if a file isn't at the given path on your local system or the asset URL returns 404.

The solution is to ensure that each path and URL actually points to a file. Note that local file paths must be absolute, not relative:

**Correct**: `image@file:///local/path/to/rogue-one-poster.jpg`

**Wrong**: `image@file://../../local/path/to/rogue-one-poster.jpg`

Sometimes it's ok if not all assets are imported successfully. E.g. you're fetching tons of cat gifs off the Internet and some of them are bound to not exist. If you can live with that, use the `--allow-failing-assets` flag when running your import command.

You can read more about [importing data here](https://www.sanity.io/docs/content-lake/importing-data).



# Input component is missing a required method

This page documents the Studio v2 form-builder API. Current custom inputs do not implement a `.focus()` method; they spread `props.elementProps` onto the rendered element instead. See [Custom components for Sanity Studio](https://www.sanity.io/docs/studio/intro-to-custom-studio-components).

All input components should implement a .focus() method.

Read more about [Custom input widgets](https://www.sanity.io/docs/studio/from-input-components-to-real-time-safe-patches)



# Implementing non-overridable part

This page documents the Studio v2 parts system, which was removed in Studio v3. For current plugin authoring, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

Some parts are defined as non-overridable. Simply put, they should only be defined once. An example of this is the schema part - `part:@sanity/base/schema`, usually defined as the first thing in your studios `sanity.json`.

It doesn't make sense for other plugins to override this part, but by definining it as a part allows us to access the schema from anywhere without knowing the specific path to where it is located on disk. Another use case would be to provide the actual schema through a plugin instead of through the studio.

If you are encountering this error, it usually means that you have tried to implement a part that should not be overriden. If you think that the part in question is something you should be allowed to override, [reach out to us](https://www.sanity.io/contact).





# Structure: Item returned no child

In most cases, you will want to return a child when a list item is clicked. If you are receiving this warning, your list has probably not defined a child/child resolver, or the child resolver is returning `undefined`.

You usually want to specify a child for an item:

```javascript
S.listItem()
  .title('George R. R. Martin')
  .child(
    S.documentList()
      .title('GRRM books')
      .filter('_type == "book" && author._ref == "grrm"')
  )
```

Omitting `.child(...)` still logs `Pane returned no child`. There is no supported way to mark an item as an intentional leaf.



# Structure: Schema type is required

Certain nodes within the Structure tool require knowledge of which schema type a document or a list of documents operates on.

Setting a schema typecan be done by calling the `schemaType()` method:



```javascript
S.editor()
  .id('car-editor')
  .schemaType('car')
  .documentId('am-db9')

```





# Array type cannot contain array member

Sanity Studio reports `Found array member declaration of type "array" - multidimensional arrays are not currently supported by Sanity` when an entry in an array's `of` list is itself of type `array`.

All array types must define what kind of items they may contain. The `of` property must be an array of objects that describes the type of a valid item. Each entry in `of` must have a `type` property which must be the name of a valid schema type that is *not* an array — Sanity currently does not support arrays inside arrays, also known as multidimensional arrays.

A common use case for multidimensional arrays is when you want to represent rows and columns. One possible solution in this example is to wrap each row in an object type:

**schemaTypes.ts**

```typescript
import {defineArrayMember, defineField, defineType} from 'sanity'

export const row = defineType({
  name: 'row',
  title: 'Row',
  type: 'object',
  fields: [
    defineField({
      name: 'columns',
      title: 'Columns',
      type: 'array',
      of: [defineArrayMember({type: 'string'})],
    }),
  ],
})

export const someDocumentType = defineType({
  name: 'someDocumentType',
  title: 'Some document type',
  type: 'document',
  fields: [
    defineField({
      name: 'rows',
      title: 'Rows',
      type: 'array',
      of: [defineArrayMember({type: 'row'})],
    }),
  ],
})
```



# Using tokens in the browser

Don't put a Sanity token in JavaScript that runs in the browser. Anyone who loads the page can read the token and use it with the permissions the token grants.

If your application needs a token, use an organization-wide robot token scoped to only the permissions the application needs, rather than a personal token tied to your own account.

Before you use a token in browser code, read [how to keep your data safe](https://www.sanity.io/docs/content-lake/keeping-your-data-safe).

If you have taken steps to keep the access token from leaking, you can disable the warning in `@sanity/client` by setting the `ignoreBrowserTokenWarning` option to `true`. Note that `@sanity/client` only prints this warning when it detects a token in a browser on `localhost`, `127.0.0.1`, or `0.0.0.0`. A deployed site never prints it, so the absence of a warning in production is not a sign that exposing the token is safe:

**client.js**

```javascript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-08-17',
  useCdn: true,
  // Only with a token you have confirmed is safe to expose
  token: process.env.SANITY_API_TOKEN,
  ignoreBrowserTokenWarning: true,
})
```

Replace `YOUR_PROJECT_ID` with your project ID, and supply the token through `SANITY_API_TOKEN` in your environment rather than as a literal in source.



# GraphQL

GraphQL is now out of beta. [Go to documentation](https://www.sanity.io/docs/content-lake/graphql).



# Array member type name conflicts with built-in type

This error means an array member has been given the same name as one of the type names Sanity reserves. It's reported as an error, so the Studio shows its schema errors screen until you fix it.

Sanity Studio reports it as `Found array member declaration with the same type name as a built-in type ("…"). Array members can not be given the same name as a built-in type.`

When defining an array type in your schema you have the option to quickly declare several "inline" object types and give each one of them their own name to be able to distinguish between them. For example, the following could be used to define an array that can hold different variations of contact info without having to declare `address` and `phone` as separate schema types.

This lets you define "locally scoped", inline types that you don't want to re-use across other schema types.

**schemaTypes/contactInfo.ts**

```typescript
import {defineArrayMember, defineField, defineType} from 'sanity'

export const contactInfo = defineType({
  name: 'contactInfo',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'object',
      name: 'address',
      fields: [defineField({name: 'street', type: 'string'})],
    }),
    defineArrayMember({
      type: 'object',
      name: 'phone',
      fields: [defineField({name: 'number', type: 'string'})],
    }),
  ],
})
```

Inline object type names must not collide with the type names Sanity already ships with.

In a plain array, the error fires when a member's name is one of the reserved names and differs from that member's own type. Naming an inline object type `string`, `reference`, or `image` errors. Repeating the type as the name — `{type: 'image', name: 'image'}` — is redundant but allowed. Consider this example:

**schemaTypes/contactInfo.ts**

```typescript
import {defineArrayMember, defineField, defineType} from 'sanity'

export const contactInfo = defineType({
  name: 'contactInfo',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'object',
      name: 'address',
      fields: [defineField({name: 'street', type: 'string'})],
    }),
    defineArrayMember({
      type: 'object',
      // Errors: "reference" is a built-in type name
      name: 'reference',
      fields: [defineField({name: 'caption', type: 'string'})],
    }),
  ],
})
```

Here, `reference` is a reserved name, so the second member errors. Pick another name, for example `contactReference`.

The reserved names are Sanity's built-in schema types: `array`, `block`, `boolean`, `crossDatasetReference`, `date`, `datetime`, `document`, `email`, `file`, `geopoint`, `globalDocumentReference`, `image`, `number`, `object`, `reference`, `slug`, `span`, `string`, `telephone`, `text`, and `url`.

The list is defined in [coreTypes.ts](https://github.com/sanity-io/sanity/blob/main/packages/%40sanity/schema/src/sanity/coreTypes.ts) in the Sanity monorepo.

The Studio also reuses this help link for an unrelated Portable Text problem. When a `block` array has a member that isn't a supported object-like type, the message is `Block member types must be a supported object-like type.` That one is not about naming, and renaming won't fix it — change the member's type. The supported built-in members are `file`, `image`, `object`, `reference`, `crossDatasetReference`, and `globalDocumentReference`, or a shorthand for one of your own object types, like `{type: 'myObjectType'}`.

If the name collides with one of your own schema types rather than a reserved one, the Studio raises a warning instead. See [Array member type name is the same as a global type](https://www.sanity.io/docs/help/schema-array-of-type-global-type-conflict).



# Source vs. compiled paths

This page documents the Studio v2 parts system, which was removed in Studio v3. The `paths` key it describes belonged to `sanity.json`, which nothing reads today. For current plugin authoring, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

What just happened? The CLI command `sanity check` is running in production mode, and got this error.

The reason may be that you have defined a `compiled` path in the `sanity.json` file in your Studio. This tells Sanity to look for the files in a different location when running in production mode.

Another reason may be that `sanity check` has found a Studio plugin which is published on npm with files that are not compiled.

The `paths` property in a `sanity.json` file tells Sanity where to look for both compiled and uncompiled code files. Given the following `sanity.json` config:

```json
{
  "paths": {
    "source": "./src",
    "compiled": "./lib"
  },
  "parts": [
    {
      "implements": "part:@sanity/base/tool",
      "path": "my-tool/index.js"
    }
  ]
}
```

Sanity will look for source files in `./src` (relative to the location of the `sanity.json` file) and compiled files in `./lib`. In the particular case above, the tool source should be in `./src/my-tool/index.js` and the compiled version will end up in `./lib/my-tool/index.js`.

If a plugin doesn't require any Babel compilation, the `sanity.json` for that plugin doesn't need a declaration of the `paths` property.

You can read more about sanity.json and parts.



# Import: Asset has different target than source

The `sanity dataset import` command stops with one of these two errors:

- `Asset ASSET_ID references a different project ID than the specified target (asset is in PROJECT_ID, importing to TARGET_PROJECT_ID).`
- `Asset ASSET_ID references a different dataset than the specified target (asset is in DATASET, importing to TARGET_DATASET).`

This usually happens when you exported a dataset with `sanity dataset export --raw`, then imported it to a different project ID or dataset name. The uppercase names are placeholders for the values in your own output.

This fails because the imported documents would refer to assets outside their own dataset, which is usually not what you want. If you delete an asset from the source dataset, it would create a "loose" asset document in the target dataset, which points to a file that no longer exists.

The solution is to not use `--raw` when exporting, which will also export all the assets from the source dataset. This will make sure the assets are also present in the target dataset when importing.

In *very* rare cases, you may want to allow the assets to reference URLs from a different dataset, in which case you can use the `--allow-assets-in-different-dataset` flag when importing.



# Using global studio client without specifying API version

This page documents `part:@sanity/base/client`, which was removed in Studio v3. The backwards-compatibility note below is also out of date: calling `useClient()` with no arguments in current Studio falls back to `v2025-02-07`, not `v1`. See [Studio React hooks](https://www.sanity.io/docs/studio/studio-react-hooks).

In a previous version of the Sanity content studio, you could import a global, preconfigured Sanity client from `part:@sanity/base/client`. From version 2.7.0 and onwards, you should now specify which API version you want to use:

```javascript
import sanityClient from 'part:@sanity/base/client'

const client = sanityClient.withConfig({
  apiVersion: 'v2021-03-25'
})

client.fetch('/* ... */')
```

To explain why this is necessary, consider the following scenario:

- Plugin A is released in 2020, and contains queries and API calls that are written for API version `v1`.
- Plugin B is released in 2021, and contains queries and API calls that are written for API version `v2020-03-25`.
- If both plugins had to use the same API version, you would either have to wait for the plugin authors to align on a single version, or have the risk of the plugins breaking.

By allowing each plugin to declare which API version they want to use, we can use multiple different API versions within the studio, without causing any issues.

## Backwards compatibility

Using the global client without specifying an API version will still work as before (using `v1` for API calls), but will give a warning message in the developer console telling you to specify an API version.



# Structure: Action and intent are mutually exclusive

A menu item cannot have both an intent and an action defined. The Studio reports `cannot set both `action` AND `intent``. Use either `action` (a function or an action name) or `intent` (an intent declaration).





# Upgrade React

This page is superseded by [Upgrade studio packages](https://www.sanity.io/docs/help/upgrade-packages). Note that the commands below install `prop-types`, which is not a Sanity Studio dependency. Studio v6 requires `react@^19.2.2`, `react-dom@^19.2.2`, and `styled-components@^6.1.15`.

The React version used in Sanity Studio can be upgraded from the command line.

## Using yarn

`yarn add react@latest react-dom@latest prop-types@latest`

## Using npm

`npm install react@latest react-dom@latest prop-types@latest`



# Plugin is missing a sanity.json file

This page documents the Studio v2 parts system, which was removed in Studio v3. Current Sanity does not read `sanity.json`, and `sanity start` is now a deprecated alias that previews a production build — the dev server is `sanity dev`. For current plugin authoring, see [Developing plugins](https://www.sanity.io/docs/studio/developing-plugins).

You're probably here because you tried to run `sanity start`, but got:

```markdown
No "sanity.json" file found in plugin "my-plugin-name"
See https://docs.sanity.io/help/missing-plugin-sanity-json
```

This can be fixed by adding a `sanity.json` file to the root level of the plugin in question. Also, you might want to *define* and/or *implement* a `part`, e.g.:

```json
{
  "paths": {
    "source": "./src",
    "compiled": "./lib"
  },
  "parts": [
    {
      "name": "part:@sanity/base/components/unicorn-slider",
      "description": "React component which provides a slider input"
    },
    {
      "implements": "part:@sanity/base/components/unicorn-slider",
      "path": "components/Slider.js"
    }
  ]
}

```



# Structure: Document ID required

Certain nodes within the Structure tool require a document ID to operate on. The Studio reports `document id (`id`) is required for document nodes` when a document node is given an empty or undefined document ID. If you omit the ID entirely, the pane ID is used instead and you get ``id` is required for document nodes` instead.

Setting a document ID can be done by calling the `documentId()` method:



```javascript
S.document()
  .id('car-editor')
  .schemaType('car')
  .documentId('am-db9')

```





# Incompatible combination of params and filter

The reference field lets you define options for the input component: a GROQ filter and, optionally, parameters for that filter. Define the filter statically with `filter` and `filterParams`, or use a function to derive the filter from the surrounding document. You cannot do both. The Studio reports ``filterParams` cannot be used if `filter` is a function. Either statically define `filter` as a string, or return `params` from the `filter`-function.`

If you are encountering this error, it usually means that you've defined a function for deriving the filter, but has also defined a set of static parameters. The solution is to either use static values, or just use the filter function and return an object containing both the filter and the parameters:

```javascript
export default {
  name: 'blogPost',
  type: 'document',
  fields: [
    // ... your other schema fields ...
    {
      name: 'author',
      type: 'reference',
      to: [{type: 'person'}],
      options: {
        filter: () => {
          return {
            filter: 'age > $age',
            params: {age: 30}
          }
        }
      }
    }
  ]
}
```



# Using listener with tokens is not supported in browsers

> [!WARNING]
> This page is out of date
> Token-authenticated listeners work in browsers. `@sanity/client` stopped using the browser's native `EventSource` in version 3 and now sets the `Authorization` header on the listen request, so passing a `token` to `listen()` no longer prevents the connection, and you don't need a server-side proxy to work around it. The security consideration below still stands: a token sent to the browser is readable by anyone who loads your site. See [Use tokens in the browser](https://www.sanity.io/docs/help/js-client-browser-token) and [Listening to content updates with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-realtime).

The browser implementation of EventSource does not allow for sending custom headers. Therefore, authenticating a listener request using a token will not work in browsers.

> [!WARNING]
> Gotcha
> Configuring the sanity client using a token in the browser has security implications, and should only be done after a careful consideration.
> [Read more about how to keep your data safe](https://www.sanity.io/docs/content-lake/keeping-your-data-safe)

Instead consider setting the visibility of your dataset to public or make sure users are logged in using cookies when accessing your frontend.



# Schema type is missing a required property

Every schema type needs both a `type` and a `name` property. Studio reports one of these problems when either is missing or has the wrong shape:

- `Missing type name` — the type has no `name` property.
- `Type is missing a type.` — the type has no `type` property.
- `Type has an invalid "type"-property - should be a string.` — the `type` property is set to something that isn't a string.

The `type` property says which type your schema type builds on — for example a `document`, an `object`, or a `string`. The `name` is what you use to refer to this type elsewhere in your schema. To add a field that references one of your own types, you refer to it by name:

**schemaTypes/index.ts**

```typescript
import {defineType, defineField} from 'sanity'

const author = defineType({
  name: 'author', // the name other types use to refer to this type
  type: 'document',
  fields: [defineField({name: 'name', type: 'string'})],
})

const book = defineType({
  name: 'book',
  type: 'document',
  fields: [
    defineField({name: 'title', type: 'string'}),
    defineField({
      name: 'author',
      type: 'reference',
      to: [{type: 'author'}], // refers to the "author" type by its name
    }),
  ],
})

export const schemaTypes = [author, book]

```

Studio's schema errors screen names the type each problem belongs to, so start there. Declaring your types with `defineType` and `defineField` catches all three problems in your editor, before Studio starts.



# API versioning in Javascript Client

In order to promote incremental changes, [the Sanity API is versioned](https://www.sanity.io/docs/content-lake/api-versioning) based on ISO dates (YYYY-MM-DD) in the UTC timezone.

> [!WARNING]
> Gotcha
> The `apiVersion` property of the JavaScript client is currently optional. If no value is provided, the client will issue a deprecation warning and default to using `v1` of the API.

Unless you know of a specific API version you want to use, you'll want to set it to **today's UTC date**. By doing this, you'll get all the latest bug fixes and features, while preventing any timezone confusion and locking the API to prevent breaking changes.



> [!NOTE]
> What does the apiVersion date mean?
> Essentially, the date you enter for the `apiVersion` will use the API *as it worked on that date*. You can confidently use features that were added on or before that date, and any breaking changes implemented after that date will not affect your use of the API.

**Note**: While it's tempting to use a date that's been set dynamically as an API version, this can be a risky idea. Using a static (i.e., hard coded) date, you pin your project to a specific version of the API, which prevents any sudden changes that can break your implementation. If you hard code your API to `v2021-08-31`, and it works, you can be assured it will continue to work even as new API versions are released.

> [!TIP]
> Protip
> **Recommended:** `apiVersion: '2021-08-31'`
> **Not recommended:** `apiVersion: new Date().toISOString().slice(0, 10)`

In future versions, specifying an API version will be required. For now, to maintain backward compatibility, not specifying a version will trigger a deprecation warning and fall back to using `v1`.

> [!WARNING]
> Gotcha
> When using the HTTP API, the version number is prefixed with the `v` character (`v1`, `v2021-08-31`, etc.). In the JavaScript client, no prefix is needed (`apiVersion: '2021-08-31'`).

## Example usage

```javascript
import sanityClient from '@sanity/client'

const client = sanityClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2021-08-31', // use a UTC date string
  token: 'sanity-auth-token', // or leave blank for unauthenticated usage
  useCdn: true, // `false` if you want to ensure fresh data
})
```



# Upgrade version of studio package

This page is superseded by [Upgrade studio packages](https://www.sanity.io/docs/help/upgrade-packages), which carries the current per-major compatibility table. To upgrade the Studio itself, run `npm install sanity@latest`.

The version of a package used in Sanity Studio can be upgraded from the command line.

## Using yarn

`yarn add <package>@latest`

e.g. to upgrade the version of React: 

`yarn add react@latest`

## Using npm

`npm install <package>@latest`

e.g. to upgrade to the latest version of React:

`npm install react@latest`



# Slug: `slugifyFn` renamed

The Studio reports `Heads up! The "slugifyFn" option has been renamed to "slugify".` when a slug field still uses the old option name. Rename `slugifyFn` to `slugify`.

The signature is `(source, schemaType, context)`, and it can return a promise if you want to generate the slug asynchronously:



```typescript
import {defineField} from 'sanity'

defineField({
  title: 'Slug',
  name: 'mySlugField',
  type: 'slug',
  options: {
    source: 'title',
    slugify: (value) => someAsyncSlugGenerator(value),
  },
})

```

The old option still works in Studio v6, it is aliased to `slugify`, but it emits a schema validation warning and is deprecated.



# Renamed plugin @sanity/date-input

> [!WARNING]
> This page describes a Studio v2 plugin
> The rename warning that brought you here is printed by `@sanity/date-input`, which loads only through the Studio v2 parts system. If you're on Studio v3 or later, follow **Studio v3 and later** below. For most fields, the built-in [date](https://www.sanity.io/docs/studio/date-type) and [datetime](https://www.sanity.io/docs/studio/datetime-type) types are enough. `@sanity/rich-date-input` adds a timezone-aware datetime type and input component.

The plugin `@sanity/date-input` has been renamed to `@sanity/rich-date-input` to better reflect its purpose.

## What should I do?

The steps depend on which major version of Sanity Studio you're running.

### Studio v3 and later

1. Install the package with `npm install @sanity/rich-date-input`
2. Add `richDate()` to the `plugins` array in your Studio configuration:

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {richDate} from '@sanity/rich-date-input'

export default defineConfig({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [richDate()],
})
```

With the plugin registered, you can use `richDate` as a field type in your schema.

### Studio v2

1. Install the Studio v2 release of the package with `npm install @sanity/rich-date-input@2`
2. Remove the `@sanity/date-input` entry from the `plugins` array in your `sanity.json`.
3. Add the `richDate` type definition from the plugin to your schema, for example:

**schemas/schema.js**

```javascript
import richDate from 'part:@sanity/form-builder/input/rich-date/schema'

// ...
export default createSchema({
  name: 'mySchema',
  types: [
    //...
    richDate
  ]
})
```



# Specify API version when using custom document list filters

Sanity Studio logs a warning when a document list uses a custom filter without an `apiVersion`. It will be required in a future version. Until then, omitting it falls back to the Studio's default API version, `2025-02-19`. Set it to a date, for example `v2025-02-19`. For more information, see [API versioning](https://www.sanity.io/docs/content-lake/api-versioning).

The warning reads `No apiVersion specified for document type list with custom filter: <your filter>. This will be required in the future. See <url> for more info.`

## Filter without an API version

This filter logs a warning, because it does not specify an `apiVersion`:

**structure.ts**

```typescript
S.documentList()
  .title('Posts')
  .filter('_type == "post" && $authorId == author._ref')
  .params({ authorId })

```

## Filter with an API version

Add `.apiVersion()` to the document list. This is the recommended form:

**structure.ts**

```typescript
S.documentList()
  .title('Posts')
  .apiVersion('v2025-02-19')
  .filter('_type == "post" && $authorId == author._ref')
  .params({ authorId })
```



# Function Timeout

When testing [Compute Functions](https://www.sanity.io/docs/functions/functions-introduction) locally you may see a timeout error. For example:

**npm**

```shell
npx sanity functions test log-event
› Error: Error: Timeout: The process exceeded your current timeout limit of 10 seconds. Learn to adjust your blueprint's
› timeout settings here: https://www.sanity.io/docs/help/functions-timeout
```

**pnpm**

```shell
pnpm dlx sanity functions test log-event
› Error: Error: Timeout: The process exceeded your current timeout limit of 10 seconds. Learn to adjust your blueprint's
› timeout settings here: https://www.sanity.io/docs/help/functions-timeout
```

**yarn**

```shell
yarn dlx sanity functions test log-event
› Error: Error: Timeout: The process exceeded your current timeout limit of 10 seconds. Learn to adjust your blueprint's
› timeout settings here: https://www.sanity.io/docs/help/functions-timeout
```

**bun**

```shell
bunx sanity functions test log-event
› Error: Error: Timeout: The process exceeded your current timeout limit of 10 seconds. Learn to adjust your blueprint's
› timeout settings here: https://www.sanity.io/docs/help/functions-timeout
```

By default function execution time is set to 10 seconds, but may be configured down as low as 1 second and as high as 900 seconds (15 minutes).

You can experiment with different timeout values directly from the command line via the `--timeout` flag.

**npm**

```shell
npx sanity functions test log-event --timeout 15
```

**pnpm**

```shell
pnpm dlx sanity functions test log-event --timeout 15
```

**yarn**

```shell
yarn dlx sanity functions test log-event --timeout 15
```

**bun**

```shell
bunx sanity functions test log-event --timeout 15
```

This will increase the timeout value to 15 seconds.

**Note:** Your functions will likely run faster locally than in the cloud. Today's modern machines far outpace the capabilities of cloud runners. So expect to add a bit of padding in your timeout value to compensate.

Once you establish an appropriate timeout value, update your blueprint file.

**sanity.blueprints.ts**

```
import { defineBlueprint, defineDocumentFunction } from "@sanity/blueprints"

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      name: "log-event",
      timeout: 15, // Add your new timeout value
    }),
  ],
})

```

Now the next time you test your function locally it will read the new timeout value from the blueprint.



# Functions rate limit

You may have received an error or notification regarding rate limits or concurrency when running your [Functions](https://www.sanity.io/docs/functions/functions-introduction).

If a **function is invoked with the same document** more than 200 times within 30s, we will not execute further function calls until the rate drops below this limit. 

If **functions from a single project are invoked** more than 4000 times within 30s, we will not execute further function calls until the rate drops below this limit. This limit is to prevent a single project with many documents from all running at the same time and using up all of our concurrency.

To prevent these errors, use caution in situations where a function will cause a mutation that triggers itself.

If you're using the `@sanity/client` v7.12.0 or later, it will limit mutations from triggering a function chain recursively up to 16 times.



# Configure TypeGen

Since its introduction the `sanity typegen` command has been configured with a separate config file, typically at `sanity-typegen.json`.

The Sanity CLI tooling now include the configuration properties for type generation by specifying the same parameters under the `typegen` field of the CLI config.

**sanity.cli.ts**

```
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'my-project-id',
    dataset: 'dataset',
  },
  typegen: {
    path: "./src/**/*.{ts,tsx,js,jsx}", // glob pattern to your typescript files. Can also be an array of paths
    schema: "schema.json", // path to your schema file, generated with 'sanity schema extract' command
    generates: "./sanity.types.ts", // path to the output file for generated type definitions
    overloadClientMethods: true, // set to false to disable automatic overloading the sanity client
  },
})
```



# Studio v3 to v4

> [!TIP]
> Available now
> Sanity v4 is available now. See the details below and [follow this guide to upgrade your studio](https://www.sanity.io/docs/studio/upgrade).

Sanity Studio moved to v4 to align more closely with actively maintained versions of the Node.js runtime. We've updated the required Node.js version from v18 to v20+. You can [read more about why we're changing that major version](https://www.sanity.io/blog/a-major-version-bump-for-a-minor-reason).

While a major version change often indicates big changes, that's not the case here. In fact, the vast majority of studios and developers won't see a difference. 

## What this change means for you

In practice, not much. Your Studios are only built with Node.js, and will continue to operate as compiled, single-page web applications. If you've been deploying to Sanity with `sanity deploy`, your Studios are already built with an LTS version of Node.js and have been for quite a while.

Sanity Studio v4 and above requires Node.js v20.19 or later to run commands from the `sanity` CLI. This means if you're building and deploying a studio yourself, you'll need to update to a supported version of Node.js in order to run versions of `sanity` from 4.0.0 onward.

If you're running an earlier version of node during development, or you've set an older `engines` value in your `package.json` file, you may see an error or warning when attempting to upgrade without first updating Node.js.

You can check your installed version of Node.js with the `--version` flag.

**CLI**

```sh
node --version
```

For details on updating your installation of Node.js, [check their documentation](https://nodejs.org/en/download).

## Upgrade Sanity Studio

Upgrade with your package manager of choice by installing the latest v4 release.

**NPM**

```sh
npm install sanity@^4
```

**PNPM**

```sh
pnpm add sanity@^4
```

You can learn more about upgrading Studio and other packages in our [Upgrading Sanity Studio](https://www.sanity.io/docs/studio/upgrade) guide.



# Email addresses show [email protection]

If you’re experiencing issues where email addresses in Portable Text cause your page to display content like `[email protection` and cause additional renders, it may be due to your CDN’s protection settings.

One known cause is [Cloudflare’s Email Address Obfuscation](https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/). This can cause email addresses to briefly, or always, show `[email protection]` instead of an email address string and include a link that contains `cdn-cgi`. Follow their guidance to configure this feature.



# Next.js 16 and SanityLive

> [!TIP]
> This issue is resolved in next-sanity v13
> The request overage issue described in this article is now resolved. If you're on Next.js 16, upgrade to `next-sanity` v13. See the [next-sanity v13 changelog entry](https://www.sanity.io/changelog/1b810aef-7d2e-4422-bece-dc317bbd2995) for details.

If you're using `SanityLive` in a Next.js app on next-sanity v12, upgrading to Next.js 16 can cause a large increase in requests and ISR writes compared to Next.js 15. That increase can drive up Sanity API usage and Vercel ISR costs. next-sanity v13 changes the default cache invalidation behavior to avoid this. This article covers what to expect, what changed in v13, and how to reduce the load if you're still on v12.

**This applies if:** you're running a Next.js App Router app, using `next-sanity`'s `defineLive` and `<SanityLive>`, and you're upgrading from Next.js 15 to 16 on next-sanity v12 (or have already upgraded).

## What to expect

On next-sanity v12 with Next.js 16 and `<SanityLive>`, the default `<Link>` prefetch behavior combined with how `<SanityLive>` calls `revalidateTag` produces a cascade: each prefetch fires more requests than on v15, a live event invalidates the client-side cache, prefetches run again, and routes that match the revalidated tag re-fetch and write to ISR.

In production we've seen an average **4x** increase in request load from the same app after upgrading. Worst cases (marketing and docs sites with many segments) are **7–10x** or more.

### How to tell if this is affecting you

- Your app is on Next.js 16 with `<SanityLive>` in use.
- Sanity API request counts jumped 4x or more after the upgrade, with no comparable change in traffic.
- Vercel ISR writes spiked on routes that consume Sanity content.
- Browser devtools show multiple `?rsc` requests fired for each `<Link>` prefetch.

## Safest way to run SanityLive on Next.js 16 with next-sanity v12

If you can't upgrade to next-sanity v13 yet and you're not using cache components, this is the safest setup on v12:

1. Upgrade to at least Next.js 16.2 and [enable](https://nextjs.org/blog/next-16-2#experimentalprefetchinlining) `experimental.prefetchInlining`.
2. Keep using `sanityFetch` from `defineLive` in production, but don't render `<SanityLive>` unless Presentation Tool or visual editing is active.
3. Use a [sync tag function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) and set up an `/api/expire-tags` handler in your Next.js app that calls `revalidateTag(tag, "max")`. Then call this route from the sync tag function.
4. When you do render `<SanityLive>` (per step 2, for Presentation Tool or visual editing), render it only in draft mode and override `revalidateSyncTags` so it no longer calls `revalidateTag(tag, { expire: 0 })`.

**app/layout.tsx**

```tsx
<>
  {isDraftMode && <SanityLive revalidateSyncTags={refreshAction} />}
</>
```

The `refreshAction` implementation must live in a file marked `'use client'` (not `'use server'`) and return `'refresh'`. That tells `<SanityLive>` to call `router.refresh()` for you, which triggers a single GET that live-updates the page.

**app/actions/refresh.ts**

```typescript
'use client'

export async function refreshAction(): Promise<'refresh'> {
  return 'refresh'
}
```

## Use cache components

next-sanity v13 supports `defineLive` with Next.js Cache Components (`cacheComponents: true`). It isn't a drop-in change: [Sanity Live with Next.js Cache Components](https://www.sanity.io/docs/nextjs/cache-components) covers the setup steps and the migration path from an existing Sanity Live setup.

## Why next-sanity v12 on Next.js 16 causes extra requests

On next-sanity v12, this combination is supported but carries overage risk. The default prefetch behavior on `<Link>` tags changed in v16 (see Next.js's "[Incremental prefetching](https://nextjs.org/blog/next-16#core-features--architecture)"). Combined with `<SanityLive>` calling `revalidateTag(tag, { expire: 0 })`, the result is:

- Each `<Link>` prefetch fires many more requests than in v15. On sanity.io we observed 2 requests with `?rsc` on v15 versus 7 on v16.
- `<SanityLive>` receives a live event and calls `revalidateTag(tag, { expire: 0 })`. That server action nukes the Next.js client-side cache (`router.refresh()` has the same effect).
- Clearing the client cache empties the prefetch cache. Next.js sees the visible Link tags and prefetches them all again.
- If any of those prefetches hit a route tagged with the revalidated tag, Next.js responds `CACHE: REVALIDATED` (its internal signal that a tagged route was re-rendered), which performs a data fetch (counts toward Sanity API usage) and triggers an ISR write (billable on Vercel).

Next.js's core team has confirmed the prefetch request change. [The CACHE: REVALIDATED behavior on prefetch links is still under investigation.](https://github.com/vercel/next.js/issues/93210)

## Resolution in next-sanity v13

next-sanity v13.0.0 shipped on May 21, 2026. It changes the default cache invalidation behavior on `<SanityLive>` and adds support for Next.js Cache Components. Upgrading from v12 is a breaking change: the [v12 to v13 migration guide](https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v12-to-v13.md) lists every change, including the `revalidateSyncTags` prop, which is replaced by `action`.

## Related articles

- [Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)
- [Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide)
- [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api)



# Platform introduction

It provides the structured foundation, automation layer, and agentic context companies need to move faster, work smarter, and power every content experience—from websites to AI agents.

Build a content system that matches how your business operates with three interconnected layers:

- [Content Lake](https://www.sanity.io/docs/content-lake): The content database.
- [AI-first tools](https://www.sanity.io/docs/ai): MCP server, skills, and rules no matter where you build.
- [APIs and SDKs](https://www.sanity.io/docs/apis-and-sdks): Libraries and frameworks to build on top of Sanity.

[The Sanity Dashboard](https://www.sanity.io/docs/dashboard) for running your content operations apps, such as:

- [Studio](https://www.sanity.io/docs/studio): A customizable CMS.
- [Media Library](https://www.sanity.io/docs/media-library): Enterprise asset management.
- [Content Agent](https://www.sanity.io/docs/content-agent): Prompt your content.
- [Canvas](https://www.sanity.io/docs/canvas): AI-powered document editor.
- [Your custom-built apps](https://www.sanity.io/docs/app-sdk): SDK-driven apps for any use case.

Unlike traditional or headless CMSes, Sanity provides a foundation for your entire content lifecycle across all digital channels, with the flexibility to evolve as your needs change.

You can get started with Sanity in minutes. [Go here to explore the different ways](https://www.sanity.io/docs/getting-started).



# AI coding agents

> [!NOTE]
> This guide is written for your AI coding agent
> Point your coding agent (Claude Code, Cursor, or similar) at this page and ask it to set up Sanity. It reads these steps, builds your project, and walks you through the choices. You don't need to install anything first.
> **Agents: **If you don’t have CLI access, go to the [AI app builder quickstart](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart) to guide your user. If the user has no Sanity account and doesn’t want to create one yet, go to [sanity.new](https://sanity.new) and follow the instructions there. It creates a real project they claim later, so you can build now. Else, follow the steps below in order. Everything you need is on this page, and `sanity init` will set up Sanity's skills and MCP server for you as it runs.
> To understand what an unclaimed project is before you start, see [Projects created without an account](https://www.sanity.io/docs/getting-started/projects-without-an-account).

Set up Sanity for the user by following each step. Tell the user what you're doing in a line or two per step, ask the questions called out below, and use the command-line flags shown so the only thing that pauses is the sign-in step, which the user handles.

## 1. Ask what the user is building

Ask one question: what kind of content is this for? A blog, a marketing site, a product catalog, or something else? Use the answer to shape the schema and the project name. If the user is unsure, default to a blog.

## 2. Create the Studio (this signs the user in and sets up Sanity's tools)

If the user has no Sanity account and doesn’t want to create one, stop and use [sanity.new](https://sanity.new) instead, because the rest of this page assumes an account. Otherwise, run `init`. If the user isn't logged in, it prompts them to sign in or create a Sanity account in the browser. Let them finish that, since you can't do browser sign-in for them. Init automatically installs Sanity's agent skills and configures the MCP server, and nothing else prompts. Set the project name from what the user is building, and run it from the repository root, not inside a frontend app folder:

**npm**

```shell
npx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

**pnpm**

```shell
pnpm dlx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

**yarn**

```shell
yarn dlx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

**bun**

```shell
bunx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

This signs the user in if needed, installs the skills and MCP server, creates a project and a public production dataset, and scaffolds the Studio in `studio/`.

## 3. Define the schema and deploy it

Use the sanity best practices skill. Add schema types in `studio/` for the content the user described, then deploy so the editor and content tools can see them:

**Terminal**

```sh
cd studio && npx sanity@latest schema deploy
```

## 4. Add sample content

Create three to five sample documents so the Studio isn't empty. Use the Sanity MCP `create_documents` tool, or write a short script with `@sanity/client`.

## 5. Start the Studio and hand off

Start the Studio and give the user the local URL:

**Terminal**

```sh
cd studio && npx sanity@latest dev
```

Tell the user the Studio is running at `http://localhost:3333` and that they can start editing. Offer next steps: connect a frontend, or add more content types.

See also: [Get started with AI](https://www.sanity.io/docs/ai/get-started) covers using Sanity docs in your editor, `llms.txt`, and Sanity Learn.



# AI app builders

> [!NOTE]
> Using a local agentic code editor/harness?
> If you use an agentic code harness like Cursor, Claude Code, Kiro, OpenCode, etc, then ask it to read [the agentic coding agent quickstart](https://www.sanity.io/docs/getting-started/ai-coding-agents).

## 1. Connect Sanity

Add Sanity from your platform's connector or integrations list. Search for "Sanity" and sign in. Signing in creates your Sanity account if you don't have one yet.

Platforms that lists the Sanity connector: [v0](https://v0.app), [Bolt](https://bolt.new), [Lovable](https://lovable.dev), and [Replit](https://replit.com).

If Sanity isn't listed, add the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server) manually with this configuration:

**MCP configuration**

```json
{
  "url": "https://mcp.sanity.io",
  "type": "http"
}
```

## 2. Describe your content and prompt the builder

Tell the builder what you're building and what content you already have, like Markdown files or hardcoded text. The prompt above asks it to turn that into Sanity content types, give you a place to edit them, and read them back into your app.

Paste this into your app builder to get started:

**Prompt**

```text
Add Sanity to manage content for this app. Use the Sanity connector if it's available. Turn my existing content (Markdown and hardcoded text) into Sanity content types, give me a place to edit it, and read it back into the app.
```

## 3. What the builder sets up

It creates a Sanity project with a public production dataset, deploys a schema for your content, adds CORS origins for client-side data loading, and gives you a hosted Studio (a `your-name.sanity.studio` URL) to edit in. Your existing content moves into Sanity and becomes the source of truth.

## 4. If your app can't load content

Ask the builder to add your platform's preview domain as a CORS origin: `https://*.vusercontent.net` for v0, `https://*.bolt.host` for Bolt, or `https://*.lovable.app` for Lovable.

## 5. Edit your content

Open the hosted Studio to add and edit content. Your changes show up in your app.

Go to [get started with AI](https://www.sanity.io/docs/ai/get-started) to learn how to set up Sanity with your agentic code editors, and to [Sanity Learn](https://www.sanity.io/learn) for courses on how to become a certified Sanity developer. 



# Setting up your studio

## Create a new Studio with Sanity CLI

![Video](https://stream.mux.com/wIMs3CS7T4pP7hRArpQZsBZ01Be02vCjbK)

Run the command in your Terminal to initialize your project on your local computer.

See the documentation if you are [having issues with the CLI](https://www.sanity.io/docs/help/cli-errors).

**npm**

```shell
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**pnpm**

```shell
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**yarn**

```shell
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

**bun**

```shell
bun create sanity@latest --dataset production --template clean --typescript --output-path studio-hello-world
cd studio-hello-world
```

## Run Sanity Studio locally

Inside the directory of the Studio, start the development server by running the following command.

**npm**

```shell
# in studio-hello-world 
npm run dev
```

**pnpm**

```shell
# in studio-hello-world 
pnpm run dev
```

**yarn**

```shell
# in studio-hello-world 
yarn run dev
```

**bun**

```shell
# in studio-hello-world 
bun run dev
```

## Log in to the Studio

**Open** the Studio running locally in your browser from [http://localhost:3333](http://localhost:3333).

You should now see a screen prompting you to log in to the Studio. Use the same service (Google, GitHub, or email) that you used when you logged in to the CLI.



# Defining a schema

## Create a new document type

![Video](https://stream.mux.com/IfVfAwxfwOKN2khdGCQ3cs5IuF1rYte1)

Create a new file in your Studio’s `schemaTypes` folder called `postType.ts` with the code below which contains a set of fields for a new `post` document type.

**/studio-hello-world/schemaTypes/postType.ts**

```
import {defineField, defineType} from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: {source: 'title'},
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
      initialValue: () => new Date().toISOString(),
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'image',
      type: 'image',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{type: 'block'}],
    }),
  ],
})
```

## Register the `post` schema type to the Studio schema

Now you can import this document type into the `schemaTypes` array in the `index.ts` file in the same folder.

**/studio-hello-world/schemaTypes/index.ts**

```
import {postType} from './postType'

export const schemaTypes = [postType]
```

## Publish your first document

When you save these two files, your Studio should automatically reload and show your first document type. Click the `+` symbol at the top left to create and publish a new `post` document.



# Querying content with GROQ

## Write your first GROQ query

![Video](https://stream.mux.com/Mc12Sdeu00ugrGuQyz00Du1G4AQZmT36UV)

Open **Vision** in your Studio's top nav bar and paste this query into the **Query** code block field.

**Vision**

```groq
*[_type == "post"]{
  _id,
  title,
  slug,
  publishedAt
}
```

- `*` represents all documents in a dataset as an array
- `[_type == "post"]` represents a **filter** to only return matching documents
- `{ _id, title, slug, publishedAt }` represents a **projection** which defines the attributes from those documents that you wish to include in the response.

## Run the query

Click **Fetch** to see the JSON output in **Results**. You should see the document you previously published in the results.

Queries run in Vision use your authenticated session, so you will see private documents – which have a `.` in the `_id` key, like `drafts.`. You will not see when queried from your front end in the next step.



# Displaying content in a React Router front end

## Install a new React Router 7 (Remix) application

![Video](https://stream.mux.com/BImVH3jL01viMdWCMfBbfSdrD2Gg01oEB01)

If you have an *existing* application, skip this first step and adapt the rest of the lesson to install Sanity dependencies to fetch and render content.

**Run** the following in a new tab or window in your Terminal (keep the Studio running) to create a new React Router 7 application with Tailwind CSS and TypeScript.

**npm**

```shell
# outside your studio directory
npx create-react-router@latest react-router-hello-world -y
cd react-router-hello-world
```

**pnpm**

```shell
# outside your studio directory
pnpm dlx create-react-router@latest react-router-hello-world -y
cd react-router-hello-world
```

**yarn**

```shell
# outside your studio directory
yarn dlx create-react-router@latest react-router-hello-world -y
cd react-router-hello-world
```

**bun**

```shell
# outside your studio directory
bunx create-react-router@latest react-router-hello-world -y
cd react-router-hello-world
```

You should now have your Studio and React Router 7 application in two separate, adjacent folders:

**your-project-folder**

```text
├─ /react-router-hello-world
└─ /studio-hello-world
```

## Install Sanity dependencies

**Run** the following inside the `react-router-hello-world` directory to install:

- [@sanity/client](https://reference.sanity.dev/_sanity/client/) for fetching content from Sanity
- [@sanity/image-url](https://github.com/sanity-io/image-url) helper functions to take image data from Sanity and create a URL
- [@portabletext/react](https://github.com/portabletext/react-portabletext) to render Portable Text as React components

**npm**

```shell
# in react-router-hello-world
npm install @sanity/client @sanity/image-url @portabletext/react @tailwindcss/typography
```

**pnpm**

```shell
# in react-router-hello-world
pnpm add @sanity/client @sanity/image-url @portabletext/react @tailwindcss/typography
```

**yarn**

```shell
# in react-router-hello-world
yarn add @sanity/client @sanity/image-url @portabletext/react @tailwindcss/typography
```

**bun**

```shell
# in react-router-hello-world
bun add @sanity/client @sanity/image-url @portabletext/react @tailwindcss/typography
```

## Start the development server

**Run** the following command and open [http://localhost:5173](http://localhost:5173) in your browser.

**npm**

```shell
# in react-router-hello-world
npm run dev
```

**pnpm**

```shell
# in react-router-hello-world
pnpm run dev
```

**yarn**

```shell
# in react-router-hello-world
yarn run dev
```

**bun**

```shell
# in react-router-hello-world
bun run dev
```

## Configure the Sanity client

To fetch content from Sanity, you’ll first need to configure a Sanity Client.

**Create** a directory `react-router-hello-world/app/sanity` and within it create a `client.ts` file, with the following code:

**/react-router-hello-world/app/sanity/client.ts**

```
import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "production",
  apiVersion: "2026-05-15",
  useCdn: false,
});
```

## Display content on the home page

React Router uses a `loader` function exported from **routes** for server-side fetching of data. Routes are configured in the `app/routes.ts` file.

The default home page can be found at `app/routes/home.tsx`

**Update** it to render a list of posts fetched from your Sanity dataset using the code below.

**/react-router-hello-world/app/routes/home.tsx**

```tsx
import type { SanityDocument } from "@sanity/client";
import { Link } from "react-router";
import { client } from "~/sanity/client";
import type { Route } from "./+types/home";

const POSTS_QUERY = `*[
  _type == "post"
  && defined(slug.current)
]|order(publishedAt desc)[0...12]{_id, title, slug, publishedAt}`;

export async function loader() {
  return { posts: await client.fetch<SanityDocument[]>(POSTS_QUERY) };
}

export default function IndexPage({ loaderData }: Route.ComponentProps) {
  const { posts } = loaderData;

  return (
    <main className="container mx-auto min-h-screen max-w-3xl p-8">
      <h1 className="text-4xl font-bold mb-8">Posts</h1>
      <ul className="flex flex-col gap-y-4">
        {posts.map((post) => (
          <li className="hover:underline" key={post._id}>
            <Link to={`/${post.slug.current}`}>
              <h2 className="text-xl font-semibold">{post.title}</h2>
              <p>{new Date(post.publishedAt).toLocaleDateString()}</p>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}
```

## Display individual posts

**Create** a new route for individual post pages.

The dynamic value of a slug when visiting `/:post` in the URL is used as a parameter in the GROQ query used by Sanity Client.

Notice that we’re using [Tailwind CSS Typography](https://github.com/tailwindlabs/tailwindcss-typography)’s `prose` class to style the post’s `body` content. We installed `@tailwindcss/typography` in the dependencies step. Enable it by adding `@plugin "@tailwindcss/typography";` to `app/app.css` below the existing `@import "tailwindcss";` line.

**Update** the `routes.ts` configuration file to load this route when individual post links are clicked.

**/react-router-hello-world/app/routes/post.tsx**

```tsx
import { Link } from "react-router";
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";
import type { SanityDocument } from "@sanity/client";
import { PortableText } from "@portabletext/react";
import type { Route } from "./+types/post";
import { client } from "~/sanity/client";

const { projectId, dataset } = client.config();
const urlFor = (source: SanityImageSource) =>
  projectId && dataset
    ? createImageUrlBuilder({ projectId, dataset }).image(source)
    : null;

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`;

export async function loader({ params }: Route.LoaderArgs) {
  return { post: await client.fetch<SanityDocument>(POST_QUERY, params) };
}

export default function Component({ loaderData }: Route.ComponentProps) {
  const { post } = loaderData;
  const postImageUrl = post.image
    ? urlFor(post.image)?.width(550).height(310).url()
    : null;

  return (
    <main className="container mx-auto min-h-screen max-w-3xl p-8 flex flex-col gap-4">
      <Link to="/" className="hover:underline">
        ← Back to posts
      </Link>
      {postImageUrl && (
        <img
          src={postImageUrl}
          alt={post.title}
          className="aspect-video rounded-xl"
          width="550"
          height="310"
        />
      )}
      <h1 className="text-4xl font-bold mb-8">{post.title}</h1>
      <div className="prose">
        <p>Published: {new Date(post.publishedAt).toLocaleDateString()}</p>
        {Array.isArray(post.body) && <PortableText value={post.body} />}
      </div>
    </main>
  );
}
```

**/react-router-hello-world/app/routes.ts**

```
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("/:slug", "routes/post.tsx"),
] satisfies RouteConfig;

```



# Deploying Studio and inviting editors

## Deploy your Studio with Sanity

![Video](https://stream.mux.com/CvYhCQr8e1oZt98NW202BZLLNv376VVKc)

In your Studio directory (`studio-hello-world`) run the following command to deploy your Sanity Studio.

The first time you run this command, the CLI will prompt you to enter a **hostname**. This is the unique name for your Studio's URL (entering *my-app* will make your Studio available at *my-app*.sanity.studio).

**npm**

```shell
npm run deploy
```

**pnpm**

```shell
pnpm run deploy
```

**yarn**

```shell
yarn run deploy
```

**bun**

```shell
bun run deploy
```

## Invite a collaborator

Now that you’ve deployed your Studio, you can optionally invite a collaborator to your project. Navigate to your project in [Sanity Manage](https://www.sanity.io/manage), then select "Members". 

They will be able to access the deployed Studio, where you can collaborate together on creating content.





# Media Library

#### Get started

[Media Library introduction](https://www.sanity.io/docs/media-library/introduction)
Learn about Media Library, how to incorporate it into your workflow, and how to get started.

[Configure your studios and CLI to use Media Library](https://www.sanity.io/docs/media-library/configure-library)
Once Media Library is enabled for your organization, you can connect it to your Studio and the CLI. 

[Upload assets programmatically](https://www.sanity.io/docs/media-library/upload-assets)
Programmatically upload assets to your Media Library.

#### Next steps

[Working with video](https://www.sanity.io/docs/media-library/working-with-video)
Learn how to handle video content in Sanity's Media Library.

[Import assets to Media Library](https://www.sanity.io/docs/media-library/importing-assets)
Import assets in bulk and automatically categorize with aspect data.

[Create an aspect](https://www.sanity.io/docs/media-library/create-aspect)
Create and deploy aspects for Media Library.

#### Dive deeper

[Media Library API reference](https://www.sanity.io/docs/http-reference/media-library)
HTTP endpoints reference for the Media Library API

[Link assets to documents](https://www.sanity.io/docs/media-library/link-media-assets)
Linking an asset in the Media Library to a document in your project requires a few extra steps.



# Introduction

Media Library is a Sanity app for managing your organization's assets.

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

Media Library allows you to:

- Centrally store assets for use across multiple applications and datasets.
- Create custom groupings, called aspects, to make managing assets easier.

[Configure your studios and CLI to use Media Library](https://www.sanity.io/docs/media-library/configure-library)
Once Media Library is enabled for your organization, you can connect it to your Studio and the CLI. 

## Requirements

- Dashboard
- Studio v3.82.0 or later is required to incorporate Media Library assets in the Studio.
- API v2024-06-24 or later is required for any Media Library API requests.

## Core concepts

The Media Library introduces a few new concepts in addition to the image and asset workflows in the rest of the Sanity ecosystem.

### Assets

An asset is a digital file that your apps and Studio can use, like an image, video, or document.

Common examples include product photos, marketing videos, and downloadable PDFs. Beyond standard image previews, Media Library supports specialized previews for multimedia and document formats. 

Video files display in a preview player, PDF documents open in a full-screen viewer where you can browse pages, audio files include playback controls, and animation formats like Lottie and Rive render their animations. 

Files without specialized preview support display as standard file types.

Outside of Media Library, these assets live alongside your dataset. In Media Library, they live in a special dataset your organization shares.

You can set assets as public or private. Assets set to "Private" are not served through their normal public URL. Logged-in Media Library users can still see them, and an app or website can be granted time-limited access with a signed URL. Without a signed URL or a Media Library session, the asset isn't accessible. Learn more about changing asset visibility in [the interface guide](https://www.sanity.io/docs/media-library/interface).

> [!TIP]
> Your Sanity project still supplies the assets to your applications
> With Media Library, you can treat it as the source of truth for your assets, but your project is still the access point for rendering images and creating download links. All requests for Media Library assets should go through your project datasets.
> [Enable library access](https://www.sanity.io/docs/media-library/configure-library) in your studios, then continue [presenting images](https://www.sanity.io/docs/apis-and-sdks/presenting-images) as if they were coming straight from the same dataset as the rest of your content. This could be by passing `asset` into a URL builder, or expanding the asset reference with `asset -> {...}` and building the URL yourself.

### The library

The library is the interface that your content teams use to manage assets. Users can upload, search, manage, and assign aspects to assets.

[Meet the library](https://www.sanity.io/docs/media-library/interface)
Get to know Media Library's user interface.

### Aspects

Aspects are schema-style fields that apply to assets. They include additional, identifying information that helps asset managers search and organize assets. Some examples are usage licenses, references to products in your organization, and copyright details. This extra level of information is specific to the Media Library. For local metadata, you should create schemas in your Studio projects.

Developers define aspects that users can then apply to an asset. Depending on your plan, there are limits to the number of aspects each asset can have.

[Create an aspect](https://www.sanity.io/docs/media-library/create-aspect)
Create and deploy aspects for Media Library.

[Aspect patterns](https://www.sanity.io/docs/media-library/aspect-patterns)
Common patterns for defining aspects

### Collections

Collections allow teams to group assets for better organization and sharing.

### Global document references

Media Library assets exist outside your projects and datasets, so you need a way to connect them. Global document references are a new reference type that allows you to target a reference in a different resource. Resources are currently limited to datasets and media libraries, and at this time you can only reference dataset documents from Media Library aspects. See the [common aspect patterns guide](https://www.sanity.io/docs/media-library/aspect-patterns) for details on referencing documents from within aspects.

### Folders

Folders provide a nested hierarchy for organizing your assets, similar to a file system. Use folders to model your team’s structure, a project, or any taxonomy that matches how your organization works. Each asset can live in one folder; **shortcuts** make a single asset appear in additional folders without duplicating the underlying file.

Folder operations are available throughout the [Media Library app](https://www.sanity.io/docs/media-library/interface) as well as [programmatically via the API](https://www.sanity.io/docs/media-library/folders).

## Limitations

- Media Library is only available within [Dashboard](https://www.sanity.io/docs/dashboard).
- For additional usage limits, see the [limits and usage document](https://www.sanity.io/docs/media-library/limits-and-usage).



# Meet the library

## Media Library at a glance

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

Media Library is home to your organization's shared assets. It stores assets for use across your projects and datasets, and allows content teams to have a central source of truth for their media.

![a screenshot of a media library showing various images](https://cdn.sanity.io/images/3do82whm/next/cae386064a9678b739ff46b3370a3773a92c6c10-3136x1596.png)

Media Library is an organization-wide application. [You can access it from the dashboard](https://www.sanity.io/docs/dashboard) by selecting the "Media" icon in the left navigation bar. Media Library requires the dashboard.

> [!NOTE]
> Where are my existing assets?
> If you've been using Sanity already, you may have images and other files that you're using in your studios. These files are saved within your datasets, and they are not automatically copied into the media library.
> Soon, we will add the capability to migrate existing assets into the media library and preserve connections to those assets within your studios.

## The library interface

The library adapts based on the assets you have selected.

![a view of the three main panels in the media library](https://cdn.sanity.io/images/3do82whm/next/0771a14ab13fa57617b60a79fda2c3f416825d5b-3128x1596.png)

The core of the interface is split into three sections:

1. The asset list: View existing assets, filter the results, and upload new assets.
2. The library menu: Narrow your view of the asset list, explore collections, navigate folders, and see recently uploaded assets.
3. The asset sidebar: Edit asset metadata, apply aspects, and view additional details about the asset. 

## Assets

### Uploading assets

There are two ways to upload assets in the library interface:

1. Select the **Upload** button in the top right of the asset list to upload an asset.
2. Drag-and-drop one or more assets directly into the asset list to start an upload.

As your assets upload, you'll see a status screen showing the progress of each asset.

### Select multiple assets

Click **Select** in the top-right of the asset list, then click each asset to add to your selection.

![A dark-themed media library interface displaying a grid of pink, purple, and blue image thumbnails, with the "Select" button highlighted and "4 assets selected" visible.](https://cdn.sanity.io/images/3do82whm/next/85243b40d8e9e34c244fad9fab00ecd77c49d0ce-1585x966.png)

### Delete assets

To delete one or more assets, first select them in the asset list.

Next, select the vertical **"..."** icon from the popover at the bottom of the asset list.

![a screenshot of the popover that says delete 1 asset](https://cdn.sanity.io/images/3do82whm/next/1582b2b41d1a0b92c88cb6742a396be2af2da257-1306x826.png)

Select **"Delete 1 asset"** to delete the asset.

> [!TIP]
> Deleting an asset also removes any [shortcuts](https://www.sanity.io/docs/media-library/interface) that point to it. If the asset is currently referenced by a document in one of your studios, deletion is blocked until those references are removed.

## Folders

Folders organize your assets into a navigable hierarchy, similar to a file system. Use folders to reflect your team's structure, projects, or any taxonomy that matches how you work. Each asset can live in one folder, and shortcuts let a single asset appear in additional folders without duplicating it.

### The folder tree

The folder tree lives in the library menu on the left of the asset list. Click a folder to view its contents, assets and any subfolders inside it. Breadcrumbs above the asset list show your current location and let you click back to any ancestor folder.

### Create a folder

To create a folder, open the **Add** menu in the header and select **New folder**. The new folder appears in the tree at the location you're currently viewing. To create a subfolder, navigate into the parent folder first.

### Move assets into a folder

To move an asset, select it in the asset list and use the **Location** action in the asset sidebar to pick a destination folder. To move multiple assets at once, select them, then use the same Location action in the bulk-edit sidebar.

You can also drag files from your operating system onto the Media Library window while viewing a folder. The upload starts immediately and the assets land in that folder.

### Shortcuts

Sometimes the same asset belongs in more than one place. Create a **shortcut** from an asset's actions menu. The asset appears in your chosen destination folder with a small badge to mark it as a shortcut.

If you delete an asset or remove it from its folder, every shortcut that points to it is cleaned up automatically. Moving an asset between folders preserves its shortcuts.

### Delete a folder

Open the folder, then use the **Delete folder** action. A confirmation dialog shows a summary of the folder's contents so you know what will be removed. Folder deletion is permanent and removes everything inside.

If any asset inside the folder is currently referenced by a document in one of your studios, the deletion is blocked until those references are removed. The dialog shows you which assets are blocking.

For the developer guide to folders, including programmatic operations, query patterns, and the API surface, see [Organize assets with folders](https://www.sanity.io/docs/media-library/folders).

## Aspects

![a screenshot of a media library with an asset detail panel open](https://cdn.sanity.io/images/3do82whm/next/6bb17c72377a527f1b361a8ede94a1877f2360b1-3388x1910.png)

Aspects let you organize your assets with custom fields. Aspects are defined programmatically with a schema-like syntax.

#### Developing aspects

[Create an aspect](https://www.sanity.io/docs/media-library/create-aspect)
Create and deploy aspects for Media Library.

[Aspect patterns](https://www.sanity.io/docs/media-library/aspect-patterns)
Common patterns for defining aspects

You can use aspects to sort and filter results in the asset list, or to store internal metadata.

### Add aspects to an asset or edit an aspect

To add aspects to an asset, first select one or more assets in the asset list.

The sidebar will list all available aspects. You can click the title of any aspect to expand it and change its values.

![A digital asset manager interface showing a grid of image thumbnails, with an image selected  and its metadata details in a side panel](https://cdn.sanity.io/images/3do82whm/next/f9d9bacf93e99fe5ec37866b8f69b1b59305a36a-720x556.png)

Once you've made changes to an aspect, select the** "Publish"** button to publish the changes to the asset.

> [!TIP]
> Publishing changes
> Don't forget to publish changes whenever you add or remove aspects, or when you make updates to the asset title.

### Filters and unpublished aspect changes

Filters in the asset list compare against the values you're currently editing. If an asset has unpublished changes, its draft aspect values decide whether it matches a filter, not its published ones. Clear an aspect field and the asset disappears from a filtered view right away, before you select **Publish**.

An asset that disappears this way is still in the library. Remove the filter to see it again, then restore the aspect value or select **Publish** to confirm the change. To see which assets have unpublished changes, add the **Status** filter and select **Has draft**.

## Collections

![a screenshot of the media library showing a collection of landscapes](https://cdn.sanity.io/images/3do82whm/next/de4563243f810e5c856444146aa6c22544debc80-3070x1596.png)

Collections allow further grouping of assets and are not limited to available aspects. You can create new collections while selecting an asset, or from the collection's screen.

### Add an asset to a collection

You can add an asset to a collection in two ways:

1. Navigate to the collection, then select **"Add"** in the top right, where the upload button normally is.
2. In any view, select the asset then, then select the vertical **"..."** icon, then select **"Add to existing collection"** from the popover menu.

> [!WARNING]
> Collection deletion is permanent
> Media Library has no trash can or restore mechanism for deleted collections. Once a collection is deleted, it cannot be recovered. The assets within the collection are not deleted, but the collection grouping is gone permanently.
> Before deleting a collection, note its contents or export a record of the assets it contains.

## Public and private assets

By default, assets are public to any person or app with the URL or identifier. You can set an asset to private to limit its visibility to logged-in users of the Media Library.

To change an asset's visibility:

1. Select the asset in Media Library.
2. In the [asset sidebar](https://www.sanity.io/docs/media-library/interface), select the visibility indicator. If the asset is public, it will display **Public** with a globe icon. If the asset is private, it will display **Private** with a lock icon.
3. Select the desired visibility from the popover list.

![User interface with the visibility selector open and "Private" selected.](https://cdn.sanity.io/images/3do82whm/next/c73070a8ecc248111f9324f07fb7d430bd73b6d2-770x672.png)

### Private asset restrictions

When setting an asset's visibility to private, keep the following in mind:

- Assets set to "Private" are not served through their normal public URL. Logged-in Media Library users can still see them, and an app or website can be granted time-limited access with a signed URL. Without a signed URL or a Media Library session, the asset isn't accessible.
- Switching visibility does not require a "Publish" for changes to take effect. 
- When changing from public to private, the asset's URL may remain active for up to 30 days if it was previously cached. To limit this, set assets to private during upload.



# Asset Versions

Asset versioning allows you to maintain different versions of the same media library asset while controlling which version is used across your content. This feature is ideal for managing subtle variations of an asset, such as retouched photos or updated files, without creating entirely new assets.

![Sanity Media Library with the versions inspector open](https://cdn.sanity.io/images/3do82whm/next/d7fe7acd92b21c417ae080b44a11b5a335af9f2b-1443x974.png)



This guide explains how to use asset versioning to manage different iterations of your media assets while maintaining content integrity.

### Prerequisites:

- Access to Sanity [Media Library](https://www.sanity.io/docs/media-library)
- Assets uploaded to your Media Library
- Content that references your assets (optional)

## View asset versions

1. Open Media Library from your Dashboard.
2. Select an asset to view its details in the right panel.
3. Click the dropdown menu button labeled **Aspects** in the right panel.
4. Select **Versions** to view all versions of the selected asset.

![Shows Sanity Media Library with the versions inspector open and the menu option highlighted](https://cdn.sanity.io/images/3do82whm/next/993d7d54a3ed502fa0ddf887c5b4a71f4cbce620-1443x974.png)

The versions panel displays all available versions of your asset. The current version is marked with a blue indicator, while any outdated versions in use are marked with an orange indicator.

> [!TIP]
> Access and usage
> Note that you will only see version usage from documents you have access to. Depending on your organization's setup a version could potentially be in use even if no usage is listed.

## Upload a new version

1. Navigate to the versions panel for your asset.
2. Click **Upload new version** at the bottom of the panel.
3. Select a file from your device to upload as a new version.
4. Once uploaded, the new version will appear in the list.

The uploaded file should be a variation of the original asset, such as a color correction or minor edit, rather than an entirely different asset.

![Shows Sanity Media Library with the versions inspector open, and an option to upload new versions highlighted](https://cdn.sanity.io/images/3do82whm/next/fd724bc8f8bb9bd4961ad9aed1e8266b76d20db6-1443x974.png)

## Set a version as current

1. In the versions panel, locate and select the version you want to set as current.
2. Select **Set as current and sync all usage** to make it the current version and update all existing usage.

![Shows Sanity Media Library with the versions inspector open, and options to sync usage highlighted](https://cdn.sanity.io/images/3do82whm/next/b196087850e16fd1377ad64aea0e91a1a1a3a1d1-1443x974.png)

Alternatively, you can set a version as current without updating existing usage by selecting **Set as current** from the context menu available by clicking the three dots next to the aforementioned button. This is useful for previewing changes before applying them everywhere.

## Sync usage with current version

When you have outdated usage (orange indicators), you can update all instances to use the current version:

1. In the versions panel, look for the warning about outdated usage at the bottom.
2. Click **Sync all usage with current** to update all instances to use the current version.

![Shows Sanity Media Library with the versions inspector open, and options for selective sync highlighted](https://cdn.sanity.io/images/3do82whm/next/03876f085fd04aa5c9b7c2d034b559034630dfbc-1443x974.png)

To selectively update specific usage:

1. Click the usage indicator (dot) next to a version to expand the usage list.
2. For each document listed, click **Sync version usage with Current** to update only that specific instance.

![Shows Sanity Media Library with the versions inspector open, and options for selective sync highlighted](https://cdn.sanity.io/images/3do82whm/next/3bd7a57bd7a1929fd8fdce92848738c327f107cd-1443x974.png)

## Querying for versions

Asset versions can be retrieved using GROQ [the same way as aspects](https://www.sanity.io/docs/media-library/query-aspects). The same restrictions and access requirements apply.

```
*[_type == 'myDocumentType']{
  "versions": documents::get(imageField.media).versions
} 
```

## Best practices

- Use versions for subtle variations of the same asset (retouches, minor edits), not for completely different assets.
- Keep version usage in sync with the current version when possible to maintain consistency.
- Use descriptive version names to easily identify different versions (note: version renaming will be available in a future update).

## Further reading

- [Media Library overview](https://www.sanity.io/docs/media-library)
- [Meet the library](https://www.sanity.io/docs/media-library/interface)
- [Link assets to documents](https://www.sanity.io/docs/media-library/link-media-assets)



# Configure your studios and CLI

This articles covers enabling Media Library in your Studio, managing asset sources, and configuring the `sanity` CLI to create and deploy aspects.

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

Prerequisites:

- `npm`, `pnpm`, or a similar package manager capable of installing and running the `sanity` CLI.
- Read/write access to your organization's Media Library.
- Studio v3.82.0 or later for Studio integration. [Learn how to upgrade your Studios](https://www.sanity.io/docs/studio/upgrade).

## Obtain your `mediaLibraryId`

When you interact with the `sanity media` commands, you're prompted to select your library. If you only have a single organization, you should see a single library. If you're a member of multiple organizations, you can parse your organization and library ID using the URL.

Navigate to [sanity.io/welcome](https://sanity.io/welcome), select your organization, then select the Media Library app. The URL for your library includes the mediaLibraryId.

```text
https://www.sanity.io/@<organizationId>/media/<mediaLibraryId>/assets
```

## Configure Studio

Media Library integration with Studio lets editors select assets from the library for use in their documents, and upload new files and images to the library.

![The Studio image selector interface with Media Library included.](https://cdn.sanity.io/images/3do82whm/next/a4583e5572cb2473fcdac34aadce64df0c56358c-1369x486.png)

Enable Media Library support by setting `mediaLibrary.enabled` to `true` in your `sanity.config.ts` file.

Token-based authentication should also be enabled by setting `auth.loginMethod` to `'token'` to ensure full functionality.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'

export default defineConfig({
  projectId: '<your-project-Id>',
  dataset: '<your-dataset>',
  mediaLibrary: {
    enabled: true,
    // Optional: pin the Studio to a specific library
    libraryId: '<mediaLibraryId>',
  },
  auth: {
    loginMethod: 'token',
  },
  plugins: [structureTool()],
  schema: {
    // ...
  },
})
```

The optional `libraryId` setting connects the Studio to a specific library. If you don't provide it, the Media Library is detected automatically. Set it to the `mediaLibraryId` you obtained earlier if your studio does not detect the library.

### Authentication

Some Media Library features are unsupported when using cookie-based authentication. To ensure full functionality, we strongly recommend using token-based authentication.

> [!NOTE]
> When cookie-based authentication is detected, a warning banner will be displayed in the Media Library.

Currently unsupported features:

- Previewing private assets, both in Studio and when selecting assets from the Media Library
- Managing signing keys
- Downloading assets
- Syncing asset version usage

## Optional: disable the default source

If you want to move completely to Media Library, you can disable the default media source. Use the `form.image.assetSources` configuration in your Studio config file (`sanity.config.ts`) to filter the sources.

This example keeps Media Library and any custom sources, but removes the default media select option.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'

export default defineConfig({
  // ... rest of config

  mediaLibrary: {
    enabled: true,
  },
  form: {
    // Disable the default for image assets
    image: {
      assetSources: (sources) => sources.filter((source) => source.name !== 'sanity-default')
    },
    // Disable the default for file assets
    file: {
      assetSources: (sources) => sources.filter((source) => source.name !== 'sanity-default')
    }
  },

  // ... rest of config
})
```

If you'd like to be more explicit and target the Media Library source, you can do so by comparing the source name with `sanity-media-library`.

## Add filters to image type selector

You can add selectable filters to the Studio image type by adding the `mediaLibrary` option to the image type's `options` object.

**imageType.ts**

```typescript
export default defineType({
  name: 'imageWithMediaLibraryFilters',
  title: 'Image with Media Library filter',
  type: 'image',
  options: {
    mediaLibrary: {
      filters: [
        {
          name: 'Has colorDetails aspect',
          query: 'defined(aspects.colorDetails)',
        },
        {
          name: 'Greater than 4000px wide',
          query: 'currentVersion->metadata.dimensions.width > 4000',
        },
      ],
    },
  },
})
```

Each filter has a `name` and `query`. The query is a GROQ query filter that runs against the Media Library dataset. If it returns true, the matching image is displayed.

## Configure the CLI

Tell `sanity` CLI where to define your aspects by updating `sanity.cli.ts`.

**sanity.cli.ts**

```typescript
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: '<your-project-Id>',
    dataset: '<dataset-name>'
  },
  mediaLibrary: {
    // set the path relative to the location of sanity.cli.ts.
    aspectsPath: 'aspects',
  },
  /**
   * Enable auto-updates for studios.
   * Learn more at https://www.sanity.io/docs/cli#auto-updates
   */
  autoUpdates: true,
})
```

With the aspects path defined, you can now use the `media` commands to [create and deploy aspects](https://www.sanity.io/docs/media-library/create-aspect).



# Create an aspect

Aspects are sets of properties that describe an asset, and are defined like Studio schemas. Asset managers can apply aspects to assets in the library, with mutations, or programmatically during upload. The information stored in aspects is specific to the Media Library. For local metadata, use fields in your studio projects.

In this guide, you'll create a new aspect and deploy it to your Media Library.

Prerequisites:

- `sanity` v3.85.1 or later

## Configure your aspect directory

In a project with a `sanity.cli.ts` file, edit the configuration to include a `mediaLibrary.aspectsPath`:

**sanity.cli.ts**

```ts
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'YOUR_PROJECT_ID',
    dataset: 'production'
  },
  mediaLibrary: {
    aspectsPath: 'aspects',
  },
  autoUpdates: true,
})
```

The `aspectsPath` value is relative to the location of the `sanity.cli.ts` file.

> [!NOTE]
> Aspects require a CLI configuration file
> To deploy aspects, you need a `sanity.cli.ts` configuration connected to a project. We recommend setting up a configuration file manually, or working directly in an existing Sanity Studio project.

## Define a new aspect

In the directory you set as `aspectsPath`, generate a new aspect with the Sanity CLI:

**npm**

```shell
npx sanity@latest media create-aspect
```

**pnpm**

```shell
pnpm dlx sanity@latest media create-aspect
```

**yarn**

```shell
yarn dlx sanity@latest media create-aspect
```

**bun**

```shell
bunx sanity@latest media create-aspect
```

This command prompts you for a title and a name, then creates a new aspect definition file in your aspects directory. Aspect names must be unique. Whatever you enter is normalized to camel case, so `copyright-info` becomes `copyrightInfo`, and the file is written as `copyrightInfo.ts`.

Aspects can be a single field or an object containing multiple fields. They can contain strings, objects, arrays, or nearly any [Studio schema type](https://www.sanity.io/docs/studio/schema-types).

> [!NOTE]
> Aspect schema limitations
> Aspects support most schema types including strings, numbers, booleans, dates, objects, and arrays. However, you can't use executable code in aspect definitions. This includes:
> - Custom validation functions
> - Custom input or preview components
> - Callback functions such as `hidden`, `readOnly`, and `initialValue`
> - The preview `prepare` function
> - Functions in `options` or other configuration properties
> Aspects also don't support the `image`, `file`, `reference`, `crossDatasetReference`, or `document` types.

The CLI creates an object-type aspect with a single string field, like this example, where the name is `copyright`:

**copyright.ts**

```ts
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'string',
      title: 'Plain String',
      type: 'string',
    }),
  ],
})

```

Modify the aspect with more fields. This example updates the existing string field and adds a `date` type field:

**copyright.ts**

```ts
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
})

```

Once deployed, the aspect appears in your Media Library like this:

![The Aspects panel in Media Library, showing the Copyright aspect with its Copyright Holder and Date fields.](https://cdn.sanity.io/images/3do82whm/next/49ac62823d932a41274fce626ea3b92e1e2e02eb-882x806.png)

You can see more aspect examples in the [aspect patterns cheat sheet](https://www.sanity.io/docs/media-library/aspect-patterns).

### Make an aspect public

To query the aspect value from your dataset without authentication, mark the aspect as public:

**copyright.ts**

```ts
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
  public: true
})
```

When you mark an aspect definition as public, you can resolve its value from your dataset with `media::aspect(MEDIA_REF, "ASPECT")`, where `MEDIA_REF` is the asset reference and `ASPECT` is the aspect name:

**aspect.groq**

```groq
*[_type == "post"][0...10] {
  _id,
  title,
  mainImage {
    asset,
    "copyright": media::aspect(media, "copyright")
  }
}
```

## Deploy an aspect

With your aspect defined, it's time to deploy it to your Media Library.

Run the following to deploy a single aspect. Replace `copyright` with your aspect name:

**npm**

```shell
npx sanity@latest media deploy-aspect copyright
```

**pnpm**

```shell
pnpm dlx sanity@latest media deploy-aspect copyright
```

**yarn**

```shell
yarn dlx sanity@latest media deploy-aspect copyright
```

**bun**

```shell
bunx sanity@latest media deploy-aspect copyright
```

If you make additional changes to the aspect, you can update it by running the `deploy-aspect` command again.

To deploy every aspect in your aspects directory, run `npx sanity@latest media deploy-aspect --all`.

## Delete an aspect

To delete an aspect from your library, run the following command, replacing `copyright` with the name of your aspect:

**npm**

```shell
npx sanity@latest media delete-aspect copyright
```

**pnpm**

```shell
pnpm dlx sanity@latest media delete-aspect copyright
```

**yarn**

```shell
yarn dlx sanity@latest media delete-aspect copyright
```

**bun**

```shell
bunx sanity@latest media delete-aspect copyright
```

This deletes the aspect from your library, but doesn't remove the local definition file.



# Assign an aspect to an asset

This guide explores options for programmatically assigning aspects to Media Library assets.

Prerequisites:

- `mediaLibraryId`: The ID for your organization's Media Library.
- Read/write access to documents in Media Library.
- A [personal authentication token](https://www.sanity.io/docs/content-lake/http-auth), or an organization-wide robot token with read/write access to Media Library.

## Mutate the asset

To add an aspect to an asset, you need to mutate the asset in Media Library. Rather than capture and rewrite the whole asset, [use a patch](https://www.sanity.io/docs/content-lake/http-patches) to apply only the aspect change to the asset document.

### Set aspects with the Sanity CLI

Use the `media import` CLI command to set aspects on a single asset or a set of assets. For more information, see [importing assets](https://www.sanity.io/docs/media-library/importing-assets). If the asset already has a value for that aspect, `media import` skips it. Pass `--replace-aspects` to overwrite existing aspect data.

> [!TIP]
> Pro tip
> This option requires that you have a local copy of the file you're adding aspect information for. Use `media export` to generate an archive of the assets in your library alongside their existing aspect data.

### Set aspects with the HTTP API

Use the `media-libraries/<media-library-id>/mutate` endpoint to apply the mutation.

This example patches the value of a single-field aspect named `comment`.

**mutate.ts**

```typescript
const mediaLibraryId = 'MEDIA_LIBRARY_ID'
const ASSET_ID = 'ASSET_ID'
const url = `https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/mutate`

const mutation = JSON.stringify({
  mutations: [
    {
      patch: {
        id: ASSET_ID,
        setIfMissing: { aspects: {} }, // confirm the asset has an aspects property.
        set: {
          "aspects.comment": "Updated aspect details"
        }
      }
    }
  ]
})

await fetch(url, {
  method: 'POST',
  headers: {
    "Content-type": "application/json",
    "Authorization": `Bearer ${process.env.SANITY_API_TOKEN}`
  },
  body: mutation
})
```

This modifies the asset document to look something like this:

**Asset document**

```json
{
  "title": "myImage.jpg",
  "assetType": "sanity.imageAsset",
  "_rev": "8397ffea-abf2-4eed-b6b7-d5e383171061",
  "_type": "sanity.asset",
  "aspects": {
    "comment": "Updated aspect details"
  },
  "_createdAt": "2025-04-02T15:46:26Z"
}
```

For nested fields or more complex aspects, start with the outermost name and work down to the individual field level.

### Set aspects with the @sanity/client library

Configure your client with a Media Library resource, then use `client.patch()` to assign aspect values to an asset:

**index.ts**

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  apiVersion: '2026-03-01',
  useCdn: false,
  token: process.env.SANITY_API_TOKEN,
  resource: {
    type: 'media-library',
    id: 'MEDIA_LIBRARY_ID',
  },
})

// Assign an aspect value to an asset
await client
  .patch('ASSET_ID')
  .setIfMissing({aspects: {}})
  .set({'aspects.comment': 'Updated aspect details'})
  .commit()
```

#### Additional resources

[Media Library API reference](https://www.sanity.io/docs/http-reference/media-library)
HTTP endpoints reference for the Media Library API



# Query aspects in Media Library

When you create and deploy an aspect, it's stored in the Media Library dataset alongside your asset documents. In this guide, you'll query your Media Library based on your aspects.

Prerequisites:

- API version v2025-02-19 or later

Some examples use JavaScript's `fetch` to perform API calls, but the same principles apply regardless of the language or request library.

## Retrieve aspect data in a dataset query

> [!WARNING]
> Visibility
> **Option 1: Public aspects (recommended)**
> To resolve the value of a Media Library aspect in your dataset without an authenticated query, mark the aspect definition as public. Marking individual aspect definitions as public keeps the rest of your library data private.
> **Option 2: Authorized tokens**
> For environments where your token remains secure, such as server-side rendered apps and pages (SSR), you can make queries with an authenticated robot or user token that has access to both the project dataset and Media Library. This lets `documents::get` resolve the Global Document Reference to the library's document. Do not use this approach if your token is sent to a client bundle.

To retrieve aspect data in your dataset queries, supply the `media` reference along with the aspect name to the `media::aspect` GROQ function.

The `media` field is a [Global Document Reference](https://www.sanity.io/docs/studio/global-document-reference-type) that links to the corresponding asset in the Media Library. Its `_ref` value follows the format `media-library:{LIBRARY_ID}:{ASSET_DOCUMENT_ID}`, where `ASSET_DOCUMENT_ID` is the `_id` of the `sanity.asset` document in the library (for example, `2ygmRi950252vmDzYfw3WPqhye3`).

**GROQ**

```groq
*[_type == "post"][0]{
  _id,
  mainImage{
    "url": asset->url,
    "copyright": media::aspect(media, "copyright")
  }
}
```

**Example result**

```json
{
  "_id": "09c3264a-7ed8-4f1d-8264-8dd172d92103",
  "mainImage": {
    "copyright": {
      "license": "...",
      "...": "...."
    },
    "url": "https://cdn.sanity.io/images/YOUR_PROJECT_ID/production/6005c6a1da9e27b033589ef439f8bb8f38420933-5152x7728.jpg"
  }
}
```

To resolve aspects in a non-authenticated query, the aspect definition has to be [marked as public](https://www.sanity.io/docs/media-library/create-aspect).

### Use the Media Library reference

An alternative method to retrieve aspect data in your dataset queries is to supply the `media` reference to the `documents::get` GROQ function. This function lets you dereference [Global Document References](https://www.sanity.io/docs/studio/global-document-reference-type).

The following example dereferences the linked `asset` to retrieve the asset's `url`, then dereferences the `media` reference to obtain the `aspects` object:

**GROQ**

```groq
*[_type == "post"][0]{
  _id,
  mainImage{
    "url": asset->url,
    "aspects": documents::get(media).aspects
  }
}
```

**Example result**

```json
{
  "_id": "09c3264a-7ed8-4f1d-8264-8dd172d92103",
  "mainImage": {
    "aspects": {
      "metadata": {
        "description": "Migrated description",
        "tags": [
          "photo"
        ],
        "title": "Migrated title"
      }
    },
    "url": "https://cdn.sanity.io/images/YOUR_PROJECT_ID/production/6005c6a1da9e27b033589ef439f8bb8f38420933-5152x7728.jpg"
  }
}
```

The shape of the aspect data is dependent on your [aspect schema](https://www.sanity.io/docs/media-library/create-aspect).

## List all aspects

Aspects are Sanity documents with a `_type` of `sanity.asset.aspect`. You can query them with GROQ using the Media Library's query endpoint:

**list-aspects.ts**

```typescript
const mediaLibraryId = 'MEDIA_LIBRARY_ID'
const token = process.env.SANITY_API_TOKEN
const query = encodeURIComponent(`*[_type == 'sanity.asset.aspect'][0...100]{_id, definition}`)

await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/query?query=${query}`, {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${token}`
  }
})
```

Like other Sanity queries, you'll receive a response containing the original query, a result, sync tags, and the response time. Here's an example response for a single-field boolean aspect:

**response.json**

```json
{
  "query": "*[_type == 'sanity.asset.aspect'][0...100]{_id, definition}",
  "result": [
    {
      "_type": "sanity.asset.aspect",
      "definition": {
        "type": "boolean",
        "initialValue": false,
        "name": "placeholder",
        "description": "Set to true for temporary placeholder assets.",
        "title": "Placeholder"
      },
      "_id": "placeholder",
      "_updatedAt": "2025-04-15T23:21:59Z",
      "_system": {
        "createdBy": "gvRshKueQ"
      },
      "_createdAt": "2025-04-15T23:16:59Z",
      "_rev": "liBwLfU12KkZimf6bVlggr"
    }
  ],
  "syncTags": [
    "s1:W7DfKQ"
  ],
  "ms": 3
}
```

## Query assets by aspect details

Any asset document in your library that has an assigned aspect includes those aspect details in an `aspects` property. You can query for specific aspect information using GROQ and the Media Library's query endpoint.

As an example, if you want to query all assets that have the `placeholder` aspect set to `true`, you can perform the following query:

**query-by-aspect.ts**

```typescript
const mediaLibraryId = 'MEDIA_LIBRARY_ID'
const token = process.env.SANITY_API_TOKEN

// You may need to encode your query to pass it as a query string.
const query = encodeURIComponent(`*[_type == 'sanity.asset' && (defined(aspects.placeholder) && true == aspects.placeholder)][0...100]{_id, title, aspects}`)

await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/query?query=${query}`, {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${token}`
  }
})
```

This request returns any `sanity.asset` document with the `placeholder` aspect set to `true`.

### Query the Media Library endpoint with POST instead of GET

You can also query with a `POST` request. Instead of using the `?query=` parameter, set the body to your stringified query, the method to `POST`, and the `Content-type` to `application/json`. Here's the same query as a `POST` request:

**query-post.ts**

```typescript
const mediaLibraryId = 'MEDIA_LIBRARY_ID'
const token = process.env.SANITY_API_TOKEN
const query = `*[_type == 'sanity.asset' && (defined(aspects.placeholder) && true == aspects.placeholder)][0...100]{_id, title, aspects}`

await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/query`, {
  method: 'POST',
  body: JSON.stringify({query}),
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  }
})
```



# Aspect patterns

The [Create an aspect guide](https://www.sanity.io/docs/media-library/create-aspect) covers how to define and deploy an aspect. This guide explores common patterns for defining aspects.

Prerequisites: `sanity` CLI v3.88.0 or newer.

> [!NOTE]
> Code-based features not supported
> Aspects cannot include executable code such as custom validation functions, custom components, or callbacks (for example, `hidden` or `readOnly`). Aspects also don't support the `image`, `file`, `reference`, `crossDatasetReference`, or `document` types. Use static configuration only.

## Aspect with a single string field

**copyright.ts**

```typescript
import {defineAssetAspect} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'Copyright',
  type: 'string',
  description: 'Enter the copyright value for this asset.',
})
```

## Aspect with a single boolean field

**placeholder.ts**

```typescript
import {defineAssetAspect} from 'sanity'

export default defineAssetAspect({
  name: 'placeholder',
  title: 'Placeholder',
  type: 'boolean',
  initialValue: false,
  description: 'Set to true for temporary placeholder assets.',
})

```

## Aspect with multiple fields

**copyright.ts**

```typescript
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'copyright',
  title: 'Copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
})

```

## Aspect with a global document reference

You can combine aspects with global document references. Use the `globalDocumentReference` type to target documents in another project and dataset. This example targets a `photographer` type in the `example` dataset of the project identified by `YOUR_PROJECT_ID`.

**photographer.ts**

```typescript
import { defineAssetAspect } from 'sanity'

export default defineAssetAspect({
  name: 'photographer',
  title: 'Photographer',
  type: 'globalDocumentReference',
  description: 'Select the photographer.',
  resourceType: 'dataset',
  resourceId: 'YOUR_PROJECT_ID.example',
  weak: true,
  to: [
    {
      type: 'photographer',
      preview: {
        select: {
          title: 'name'
        }
      }
    }
  ]
})
```

The `resourceId` value is the `YOUR_PROJECT_ID.DATASET_NAME`. As with normal references in Sanity Studio, you can use the `preview` property to select fields to display. In this case, it sets the preview `title` to the `photographer.name` field.

Learn more about [global document references](https://www.sanity.io/docs/studio/global-document-reference-type).

## Public aspects

To query an aspect value from a dataset, mark the aspect definition as public.

**copyright.ts**

```typescript
import {defineAssetAspect, defineField} from 'sanity'
export default defineAssetAspect({
  name: 'copyright',
  title: 'Copyright',
  type: 'object',
  fields: [
    defineField({
      name: 'copyrightHolder',
      title: 'Copyright Holder',
      type: 'string',
    }),
    defineField({
      name: 'copyrightDate',
      title: 'Date',
      type: 'date',
    }),
  ],
  public: true
})


```

Marking an aspect as public lets a dataset resolve the aspect value with `media::aspect(MEDIA_REF, "NAME")`, where `MEDIA_REF` is a reference to the media asset and `NAME` is the aspect name.



# Importing assets (media + aspects)

When importing assets to Media Library, there are two primary goals:

1. Upload the media so it can be used in a studio or another application.
2. Assign [aspect data](https://www.sanity.io/docs/media-library/aspect-patterns) so that the asset is categorized and tagged correctly.

There are multiple ways to import assets into your library to accomplish these goals together. The recommended way is to use the [Sanity CLI](https://www.sanity.io/docs/apis-and-sdks/cli). You can run `npx sanity@latest media import --help` for a quick summary of syntax and options.

## Import using the CLI

The `media import` command operates on a directory or archive that can have three components:

- An `images` directory for files that should be uploaded as an image.
- A `files` directory for non-image files.
- A `data.ndjson` file that contains aspect data for any of the files contained in the `images` or `files` directories.

```text
product-photos
├── data.ndjson
├── images
│   └── ...all image files
└── files
    └── ...all other files
```

The `import` command uploads every file directly inside the `images` and `files` directories. Files in subdirectories of those directories are not imported — flatten them first.

> [!NOTE]
> `data.ndjson` is required. The import fails with `No data.ndjson file found in import source <path>` if it is missing. The `images` and `files` directories are individually optional, but at least one of them must contain a file — otherwise the import fails with `No assets to import`.

### `data.ndjson` format

`data.ndjson` is a [newline-delimited JSON file](https://github.com/ndjson/ndjson-spec) (NDJSON) that can contain aspect information for any of the processed files. Each line must be a valid JSON object containing the information that should be associated with a single file.

These example JSON objects each define a few aspects for a file:

**Asset 1**

```json
{
  "filename": "images/hero-shot.jpg",
  "aspects": {
    "description":"A dog chasing a stick"
  }
}
```

**Asset 2**

```json
{
  "filename": "images/studio-portrait.jpg",
  "aspects": {
    "licensedPhotograph": {
      "expiration": "2026-05-02T17:34:00.000Z",
      "photographer": {
        "_ref": "dataset:YOUR_PROJECT_ID.example:photographer-7926527",
        "_type": "globalDocumentReference",
        "_weak": true
      }
    }
  }
}
```

However, NDJSON uses the newline character as a delimiter to combine multiple JSON objects into a single file. Here are the two example objects combined into a single NDJSON file:

**data.ndjson**

```json
{"filename": "images/hero-shot.jpg","aspects":{"description":"A dog chasing a stick"}}
{"filename": "images/studio-portrait.jpg","aspects":{"licensedPhotograph":{"expiration": "2026-05-02T17:34:00.000Z","photographer": {"_ref":"dataset:YOUR_PROJECT_ID.example:photographer-7926527","_type":"globalDocumentReference","_weak": true}}}}
```

Each JSON object for an asset has two components:

- `filename`: The relative path to the file you want to apply the aspect information to.
- `aspects`: The aspect data that should be saved attached to the asset.

After you've prepared your files, run the import with the Sanity CLI:

> [!NOTE]
> What should I import?
> In some cases you want to import your directory, such as when you've exported your library, made changes to the ndjson file, and are importing it back into the same library.
> In other cases you want to compress your assets into a tarball / tar file (`.tar`, `.tar.gz`, or `.tgz`), which includes the ndjson file and your assets.

**npm**

```shell
npx sanity@latest media import product-photos

# or

npx sanity@latest media import product-photos.tar.gz
```

**pnpm**

```shell
pnpm dlx sanity@latest media import product-photos

# or

pnpm dlx sanity@latest media import product-photos.tar.gz
```

**yarn**

```shell
yarn dlx sanity@latest media import product-photos

# or

yarn dlx sanity@latest media import product-photos.tar.gz
```

**bun**

```shell
bunx sanity@latest media import product-photos

# or

bunx sanity@latest media import product-photos.tar.gz
```

> [!TIP]
> Pro tip
> If a file matches an existing asset in the library, that asset is not uploaded a second time, and its aspect data in `data.ndjson` is not applied. To set aspect data on assets that are already in the library — including assets that have no aspect data yet — run the import with `--replace-aspects`. That option replaces all versions of the aspect data, published and draft.

## Import using a client library

If you prefer not to use the Sanity CLI import tool, you can run the import yourself with the HTTP API:

1. [Upload an asset](https://www.sanity.io/docs/media-library/upload-assets)
2. [Add an aspect to an asset](https://www.sanity.io/docs/media-library/assign-aspects)

There are some common pitfalls to keep in mind:

- Concurrency: While you may have thousands of assets to import, don't trigger thousands of requests in parallel. Parallel requests exceed API rate limits and can fail. Use a queue with a low concurrency to keep your import below the [API rate limit](https://www.sanity.io/docs/content-lake/technical-limits).
- API usage limits: Importing large libraries can quickly cause a lot of requests, especially if you import a single asset per request. Send [multiple mutations within a single transaction](https://www.sanity.io/docs/js-client#multiple-mutations-in-a-transaction).
- Mutation size limits: While it's a good idea to do multiple mutations per transaction, make sure the size of the request is [within our limits](https://www.sanity.io/docs/content-lake/technical-limits), in terms of byte size.



# Upload an asset

Users can upload assets to Media Library directly from the app or from a configured Studio. Developers can upload assets with the CLI and the Media Library HTTP API. In this guide, you'll learn to upload an asset to your library programmatically.

Looking for details on uploading to Media Library from the app? See the [Media Library user guide](https://www.sanity.io/docs/media-library/interface).

> [!NOTE]
> Looking to upload to a single project?
> This guide discusses uploading assets to Media Library. For information on uploading assets to your project see the [content lake guide on uploading assets](https://www.sanity.io/docs/content-lake/manage-assets).

## Upload an asset

### Option 1: use the CLI

The Sanity CLI includes the `media import` command that standardizes the process for uploading batches of assets and simultaneously assigning aspect information. More detail is available in our guide for [importing assets](https://www.sanity.io/docs/media-library/importing-assets).

To use the CLI with the `media` commands, you must run the command in a directory containing a `sanity.cli.ts` configuration file. Learn more about [configuring the CLI to recognize your library](https://www.sanity.io/docs/media-library/configure-library).

### Option 2: use the HTTP API

Prerequisites:

- Node.js v21.0 or later to run the built-in `fetch` API, or a compatible request library.
- A [personal authentication token](https://www.sanity.io/docs/content-lake/http-auth) with read/write access to your organization's Media Library.
- The `mediaLibraryId`.

Make a POST request to the `/media-libraries/<mediaLibraryId>/upload` endpoint. In the code below, replace `mediaLibraryId` with your library's ID, and `token` with your personal authentication token.

```
import fs from 'node:fs'
// Define your library ID.
const mediaLibraryId = '<your-library-id>'

// Define your personal auth token.
const token = '<personal-auth-token>'

// Read the contents of a file.
const asset = fs.readFileSync('assets/spring-launch-promo.jpg')

// POST a request to the HTTP API with the file as the body
await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/upload`, {
  method: 'POST',
  body: asset,
  headers: {
    'Authorization': `Bearer ${token}`,
  }
})
```

Uploading an asset without additional parameters will let Media Library generate the asset title and infer the filename. 

See the Media Library HTTP API reference for all available upload options and parameters.

[Media Library API reference](https://www.sanity.io/docs/http-reference/media-library)
HTTP endpoints reference for the Media Library API

### Upload directly into a folder

Pass a `parent` query parameter to upload an asset directly into a folder (a `sanity.directory` document) in a single request.

```typescript
import fs from 'node:fs'
const mediaLibraryId = '<your-library-id>'
const token = '<personal-auth-token>'
const directoryId = '<destination-directory-id>'

const asset = fs.readFileSync('assets/spring-launch-promo.jpg')

await fetch(
  `https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/upload?parent=${directoryId}`,
  {
    method: 'POST',
    body: asset,
    headers: {
      Authorization: `Bearer ${token}`,
    },
  },
)
```

> [!TIP]
> `parent` must reference a `sanity.directory`. `sanity.tree` is not a valid value. Assets in Media Library cannot live directly under the tree, only inside a folder.



# Folders

> [!NOTE]
> Beta primitive
> Folders are built on the [hierarchy primitive in Content Lake](https://www.sanity.io/docs/content-lake/hierarchy), which is currently in public beta. The underlying API surface may change.

## Concepts

A folder hierarchy in Media Library is made up of three document types from the [Content Lake hierarchy primitive](https://www.sanity.io/docs/content-lake/hierarchy): `sanity.tree`, `sanity.directory`, and `sanity.symlink`.

Assets (`sanity.asset`) sit inside folders by carrying a `parent` reference to a `sanity.directory`.

## Getting started with the API

These examples use [@sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) configured against your Media Library. They also work via the standard Media Library [mutate](https://www.sanity.io/docs/http-reference/media-library) and [query](https://www.sanity.io/docs/http-reference/media-library) endpoints.

### Step 1: Create the tree

Every folder hierarchy starts with one `sanity.tree` document. 

**@sanity/client**

```typescript
import {createClient} from '@sanity/client'

const libraryId = '<your-library-id>'

const client = createClient({
  apiVersion: '2025-02-19',
  resource: {type: 'media-library', id: libraryId},
  token: process.env.SANITY_TOKEN,
})

await client.createIfNotExists({
  _id: `tree.${libraryId}`,
  _type: 'sanity.tree',
  name: 'folders',
})
```

**HTTP**

```ts
const libraryId = '<your-library-id>'
const token = '<personal-auth-token>'

await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${libraryId}/mutate`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    mutations: [
      {
        createIfNotExists: {
          _id: `tree.${libraryId}`,
          _type: 'sanity.tree',
          name: 'folders',
        },
      },
    ],
  }),
})
```

> [!WARNING]
> Media Library requires the tree's `_id` to be `tree.{libraryId}`.

### Step 2: Create folders

Create a `sanity.directory` with a `name` and a `parent` reference. Top-level folders point at the tree; nested folders point at their parent directory.

```typescript
// Use the same `tree.${libraryId}` ID created in Step 1
const treeId = `tree.${libraryId}`

// Top-level folder
const marketing = await client.create({
  _type: 'sanity.directory',
  name: 'Marketing',
  parent: {_ref: treeId},
})

// Nested folder
const campaigns = await client.create({
  _type: 'sanity.directory',
  name: 'Campaigns',
  parent: {_ref: marketing._id},
})
```

For directory creation patterns, validation rules, and the error reference, see [Creating directories in the hierarchy primitive](https://www.sanity.io/docs/content-lake/hierarchy).

### Step 3: Place assets in a folder

For assets that are already uploaded, set `parent` on the `sanity.asset` document to move it into a folder.

```typescript
await client
  .patch(asset._id)
  .set({parent: {_ref: folderId}})
  .commit()
```

> [!TIP]
> Place assets inside a `sanity.directory` rather than directly under the tree. The schema permits both, but the Media Library app browses, lists, and operates on assets through directories. 
> Leave `parent` unset to keep an asset outside of the folder hierarchy.

## Upload directly into a folder

When uploading a new asset, set the `parent` query parameter on the upload endpoint to place it in a folder in a single request, no follow-up patch needed.

> [!TIP]
> `parent` must reference a `sanity.directory`. 
> `sanity.tree` is not a valid value, assets in Media Library cannot live directly under the tree, only inside a folder.

For the full upload pattern, see [Upload assets programmatically](https://www.sanity.io/docs/media-library/upload-assets).

## Querying assets and folders

Media Library hierarchy is queried with standard GROQ. The same patterns documented in [Querying the hierarchy](https://www.sanity.io/docs/content-lake/hierarchy) apply

You can query assets inside a specific folder with:

```groq
*[_type == "sanity.asset" && parent._ref == $folderId]
```

## Moving folders and assets

Patch `parent` to a new destination. The server validates the destination before any change is applied, see the [error reference](https://www.sanity.io/docs/content-lake/hierarchy).

```typescript
await client
  .patch(folderToMove._id)
  .set({parent: {_ref: newParentId}})
  .commit()
```

The same pattern applies to assets. Set `parent` to move an asset into a different folder, or `unset` it to remove the asset from any folder.

## Shortcuts

A **shortcut** makes a single asset visible inside an additional folder without duplicating the underlying file. Shortcuts are `sanity.symlink` documents that reference a destination folder via `parent` and an asset via `target`.

```typescript
await client.create({
  _type: 'sanity.symlink',
  parent: {_ref: destinationFolderId},
  target: {_ref: assetId},
})
```

> [!WARNING]
> Media Library constraint
> In Media Library, `target` must reference a `sanity.asset`. Targeting a `sanity.directory` (a “folder shortcut”) is not supported and will not render correctly in the app.

## Deleting folders

```typescript
await client.delete(folderId)
```

> [!WARNING]
> No cascade delete
> Deleting a folder does not cascade. The server rejects any delete where children still reference the folder via `parent`. See [deleting a hierarchy primitive](https://www.sanity.io/docs/content-lake/hierarchy).

In the Media Library app, the delete folder action handles this for you. It deletes children recursively and displays a confirmation dialog that surfaces information about contents and potential warnings. See [Deleting folders in the interface](https://www.sanity.io/docs/media-library/interface).



# Link assets to documents

When editors use Studio to add assets to documents, Studio and Media Library work together to link the project, document, and library together. To do this programatically, like when migrating a large number of assets to your library, you'll need to perform the steps manually.

> [!TIP]
> Rendering assets
> This guide is specifically about programatically linking assets from Media Library to documents in your datasets. For details on rendering assets, check out the [Presenting Images guide](https://www.sanity.io/docs/apis-and-sdks/presenting-images).

In this guide, you'll learn how to link Media Library assets to documents in a project dataset. There are three key steps:

1. Upload the asset to Media Library, or query an existing asset in Media Library to obtain the necessary IDs.
2. Link the Media Library asset to your project dataset.
3. Patch any documents that use the asset with the correct references.

Prerequisites:

- An environment where you can make HTTP requests. The code examples use `fetch` in Node.js, which was made stable in v21.0. You can substitute any request library and any language.
- Project and library identifiers:- `mediaLibraryId` from your Media Library.
- `projectId` for the project you want to link the assets to.
- `dataset` for the project you want to link the assets to.


- A [personal authorization token](https://www.sanity.io/docs/content-lake/http-auth) with permission to read/write from both the Media Library and your project.

## Gather the asset IDs

Linking a Media Library asset requires two identifiers:

- Asset ID: The primary identifier (`_id`) for an asset document in your Media Library. 
- Asset instance ID: The identifier for a versioned instance of the asset. Your assets likely have a single version, but in some future cases there may be multiple instances.

There are two ways to obtain these values.

### Get IDs during upload

After uploading an asset with the `media-libraries/upload` endpoint, the response contains `asset._id`* and *`assetInstance._id`. Refer to the guide below for details on using the API to upload assets.

[Upload assets programmatically](https://www.sanity.io/docs/media-library/upload-assets)
Programmatically upload assets to your Media Library.

### Get IDs by querying assets

If your assets are already in Media Library, you can query the Media Library API for asset documents. If you already know the Asset ID, you can query it directly with the `_id == '<asset-id>'` GROQ filter. If you don't know the ID(s), you can query all assets with `_type == 'sanity.asset'`.

For example, this query returns all asset documents in the library. 

**query-media.ts**

```
// Define your library ID.
const mediaLibraryId = '<your-library-id>'

// Define your personal auth token.
const token = '<personal-auth-token>'

// Define your query
const query = `*[_type == 'sanity.asset']`

// POST a request to the HTTP API with the file as the body
await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/query`, {
  method: 'POST',
  body: JSON.stringify({query}),
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  }
})
```

**Response**

```json
{
  "query": "*[_type == 'sanity.asset']",
  "result": [
    {
      "versions": [],
      "_type": "sanity.asset",
      "_id": "2w91UKgsNKEhWD6au6OzeNaOe7f",
      "_updatedAt": "2025-04-23T20:09:54Z",
      "_createdAt": "2025-04-23T20:09:49Z",
      "_rev": "LR2OfQiXk5TMHKZpxdBNWd",
      "aspects": {},
      "title": "Silhouette of a Woman in a Doorway Overlooking the Ocean",
      "currentVersion": {
        "_type": "reference",
        "_key": "",
        "_weak": false,
        "_ref": "image-11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422-jpg"
      },
      "assetType": "sanity.imageAsset",
      "cdnAccessPolicy": "public",
      "_system": {
        "createdBy": "gvRshKueQ"
      }
    }
  ],
  "syncTags": [],
  "ms": 5
}
```

In the example response JSON, the highlighted lines show the IDs you need.

- Asset ID: The `id` of the document.
- Asset Instance ID: The `currentVersion._ref`.

## Link the asset to your project dataset

The next step is to link the asset to your project dataset with the [assets/media-library-link API](https://www.sanity.io/docs/http-reference/assets). This creates a local reference point that you can use throughout the dataset. Note the `id` and the `media._ref` in the response for the next step.

**media-link.ts**

```
// Define your library ID, project ID, and dataset
const mediaLibraryId = '<your-library-id>'
const projectId = 'YOUR_PROJECT_ID'
const dataset = 'YOUR_DATASET'

// Define your personal auth token.
const token = '<personal-auth-token>'

// Define the request body based on the Asset IDs from the previous step
// We've included the IDs from the earlier output as an example.
const requestBody = {
  mediaLibraryId: mediaLibraryId,
  assetInstanceId: "image-11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422-jpg",
  assetId: "2w91UKgsNKEhWD6au6OzeNaOe7f"
}

// POST a request to the assets API
fetch(`https://${projectId}.api.sanity.io/v2025-02-19/assets/media-library-link/${dataset}`, {
  method: 'POST',
  body: JSON.stringify(requestBody),
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  }
})
```

**Response**

```json
{
  "document": {
    "_createdAt": "2025-04-23T22:01:54Z",
    "_id": "image-11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422-jpg",
    "_rev": "DkN0DBPn76SUp7SIvfkLE9",
    "_type": "sanity.imageAsset",
    "_updatedAt": "2025-04-23T22:01:54Z",
    "assetId": "11736fa2881515ae4fb5ba3db2fc247778ce8fab",
    "extension": "jpg",
    "media": {
      "_ref": "media-library:mlNBkjZ8wqSZ:2w91UKgsNKEhWD6au6OzeNaOe7f",
      "_type": "reference",
      "_weak": true
    },
    "metadata": {},
    "mimeType": "image/jpeg",
    "originalFilename": "11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422.jpg",
    "path": "images/y856rro4/production/11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422.jpg",
    "sha1hash": "11736fa2881515ae4fb5ba3db2fc247778ce8fab",
    "size": 4763977,
    "uploadId": "ml-link-Sg56SH3Sncz1on2HW9jvecXRdNC4mSWJ",
    "url": "https://cdn.sanity.io/images/y856rro4/production/11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422.jpg"
  }
}
```

## Patch the documents

Now that the asset is linked to your project and dataset, you can attach it to documents with a [document mutation](https://www.sanity.io/docs/http-reference/mutation).

Use the document ID and the media reference from the previous step to attach the asset to documents in your dataset. As with local assets, you can patch linked Media Library assets to a document. The key difference is that you need to update the `asset` and `media` objects.

In this example, we're adding the asset to a `poster` field on our target document. Replace the path to the asset and media to match the shape of your content.

```
// Define your project ID, and dataset
const projectId = 'YOUR_PROJECT_ID'
const dataset = 'YOUR_DATASET'

// Define your personal auth token.
const token = '<personal-auth-token>'

// Define the ID of the target document
const documentId = '<target-document-id>'

// Define the asset document ID and the media reference
// from the previous step
const assetDocumentId = 'image-11736fa2881515ae4fb5ba3db2fc247778ce8fab-4948x7422-jpg'
const mediaRef = 'media-library:mlNBkjZ8wqSZ:2w91UKgsNKEhWD6au6OzeNaOe7f'

// Define the mutation
const mutations = [{
  patch: {
    id: documentId,
    set: {
      poster: {
        asset: {
          _type: 'reference',
          _ref: assetDocumentId,
        },
        media: {
          _type: 'globalDocumentReference',
          _ref: mediaRef,
          _weak: true,
        }
      }
    }
  }
}]

// POST a request to the mutate API
fetch(`https://${projectId}.api.sanity.io/v2025-02-19/data/mutate/${dataset}`, {
  method: 'POST',
  body: JSON.stringify({mutations}),
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  }
})
```

We captured the `media._ref` in the previous step, but you can also build it by combining the resource type (`media-library`) with your library ID and the asset ID.

These same steps apply for linking Media Library assets to any documents. 

#### Additional resources

[Media Library API reference](https://www.sanity.io/docs/http-reference/media-library)
HTTP endpoints reference for the Media Library API

[Assets API reference](https://www.sanity.io/docs/http-reference/assets)
Upload images and files to Content Lake, and link Media Library assets to your dataset.





# Asset visibility

Asset visibility controls how individual assets like images and files can be accessed from Sanity’s Content Delivery Network (CDN). This feature ensures Media Library can securely manage confidential assets, embargoed launches, and other sensitive or licensed materials.

- **Public**: Anyone with the asset's URL or identifier can request and view it.
- **Private**: Access is restricted to authenticated Media Library and Studio users. External controlled access can be granted using signed URLs.

> [!NOTE]
> Video assets
> Private visibility is not yet available for video assets. Uploaded videos are public, so plan your use of video content with this in mind.

## Updating asset visibility

![Screenshot of the Media Library showing the visibility switcher](https://cdn.sanity.io/images/3do82whm/next/2f91af899fd1556702a756d3dc7f0b708c48fc6c-1600x1035.webp)

1. Select the asset in Media Library.
2. In the asset sidebar, select the visibility indicator. If the asset is public, it will display **Public** with a globe icon. If the asset is private, it will display **Private** with a lock icon.
3. Select the desired visibility from the list in the popover.

> [!TIP]
> Note that switching visibility does not require a **Publish** action for changes to take affect.

## Setting asset visibility

By default, assets are uploaded with public visibility. To change this for your session, select **Upload** at the top of the asset grid and use the visibility switcher in the upload modal before uploading.

![Media library interface with a Upload modal open, showing the visibility switcher affordance for setting the visibility of assets as they are uploaded.](https://cdn.sanity.io/images/3do82whm/next/189e9a91d87ff820822db70021bdc16d96ae6214-2822x1908.webp)

### Caching and propagation

When switching an asset's visibility from public to private, the CDN may continue serving cached responses for up to 30 days. To minimize exposure, set sensitive assets to private before upload.

## Signed URLs

Signed URLs provide a secure way to deliver private assets through Sanity's CDN. Each URL includes a signature that both validates access and ensures the asset is served only with the exact transformations specified in the URL. This prevents unauthorized use, hotlinking, and unapproved image manipulation.

To display images with private visibility using signed URLs, the `@sanity/image-url` [package](https://github.com/sanity-io/image-url?tab=readme-ov-file#signed-urls) exports an extended image URL builder with signing methods via the `@sanity/image-url/signed` export path.

For non-image assets such as PDFs and audio files, use the lower level `@sanity/signed-urls` [package](https://github.com/sanity-io/signed-urls) to create a signed version of a given asset URL.

Check the READMEs of both packages for more details on signing URLs.

### Signing keys

Signing URLs using the above packages requires providing a private key and an associated key ID to whichever helper functions you are using. Signing keys are managed in the Media Library itself:

1. At the top of the left panel, click the Media Library dropdown.
2. Select **Signing keys**

![Media library interface with a "Signing keys" modal open, listing keys for marketing, mobile, and web applications, and a button to add a new key.](https://cdn.sanity.io/images/3do82whm/next/c3ede98db5b642f8eafee90a03d9b473ec6a0e19-2823x2007.webp)





# Container URLs 

Container URLs provide a stable, shareable link to Media Library assets.* *They automatically reflect changes to an asset’s version or visibility (public or private), so you never need to update links manually. Container URLs are optimised for delivery by Sanity’s CDN and are available through both the Media Library UI and GROQ.

## Copy a Container URL from the UI

1. Open Media Library from your Dashboard.
2. Select an asset to view its details in the right panel.
3. Click the ellipsis menu at the bottom of the right panel.
4. Select **Copy URL > Copy asset CDN URL** to copy the Container URL to your clipboard.

![Screenshot of an asset management interface with abstract colorful thumbnails and a menu showing options to copy asset URLs.](https://cdn.sanity.io/images/3do82whm/next/64009dbcd795aa4adfe383620ace3b738613f8a5-1300x786.png)

## Querying Container URLs

**GROQ**

```groq
*[_type=="event"][0] {
  poster {
    "containerURL": documents::get(media).url
  }
}
```

## Update durations

Because Container URLs are served by Sanity’s CDN, it can take some time for visibility or version changes to propagate. **Updates typically appear within minutes.**





# Working with video

Media Library's video streaming capabilities let you present high-quality, adaptive video content directly from your centralized asset storage. Videos uploaded to your Media Library are automatically processed and optimized for streaming through Mux, providing reliable video delivery with adaptive quality and global CDN distribution. You can also download the original file you uploaded and static renditions of it from the Media Library interface, separate from the playback stream.

This guide explains how to effectively present videos from your Media Library in your front-end applications. For information about uploading videos or configuring Media Library, see the [Media Library documentation](https://www.sanity.io/docs/media-library).

## Prerequisites

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

- A Sanity project with **Media Library enabled** and **Studio v4.0.1 or later.**
- **Video addon enabled for your project.** Video is an addon feature of Media Library and is not enabled by default. Contact [sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.
- **Video assets** uploaded to your Media Library.

## Understanding video representation in Media Library

Before implementing videos in your front-end, it's important to understand how Media Library handles videos:

- Video fields are created using the `defineVideoField` helper from `sanity/media-library`
- Videos are processed to generate **playback IDs** for streaming
- Each video asset contains **metadata,** including aspect ratio, duration, and framerate

**Note:** Both public and private videos are supported. Private videos play through short-lived signed URLs rather than a public URL.

## Supported formats and limitations

Media Library accepts most common video formats for upload:

**Supported formats:**

- MP4 (recommended)
- MOV
- AVI
- MKV
- WebM
- And most other standard video formats

**Considerations:**

- Processing time varies based on video length and resolution.
- Higher resolution videos (4K+) may take longer to process.

For best performance and compatibility, we recommend uploading videos in **MP4 format** with H.264 encoding. To minimize processing time, follow [Mux's standard input specifications](https://www.mux.com/docs/guides/minimize-processing-time#standard-input-specs).

### Video asset structure

Video assets in your Media Library have a nested structure:

```json
{
  "_id": "2yg9Un9RMsQjuf3WDqEo70ggi8D",
  "_type": "sanity.asset",
  "assetType": "sanity.videoAsset",
  "title": "video-filename.mp4",
  "versions": [
    {
      "instance": {
        "_id": "2yg9UGEpG8H4xjgMj8YSstwpfVh",
        "_type": "sanity.videoAsset",
        "originalFilename": "video-filename.mp4",
        "metadata": {
          "_type": "sanity.videoMetadata",
          "aspectRatio": 0.5625,
          "duration": 8.86,
          "playbacks": [ // one entry per playback policy
            {
              "_id": "V5uFaHghtnzgV6lYlBkrbehGkvd5KNGHYU7w2Eo7HoQ", // Mux playback ID
              "_type": "sanity.videoMetadata.playback",
              "policy": "public" // "public" or "secured"
            }
          ]
        },
        "mimeType": "video/mp4",
        "originalFilename": "video-filename.mp4"
      }
    }
  ]
}

```

## Setting up video fields in Studio

Use the `defineVideoField` helper to create video fields in your studio schemas:

**video-document.ts**

```
import { defineVideoField } from 'sanity/media-library'

export default {
  name: 'videoDocument',
  title: 'Video Document',
  type: 'document',
  fields: [
    defineVideoField({
      title: 'Featured Video',
      name: 'video',
    }),
    // ... other fields
  ]
}

```

## Get the video data

You can fetch video data in two ways: with the Sanity client's `getPlaybackInfo()` method, or with a GROQ query against the video asset's metadata.

`getPlaybackInfo()` is the safer default: it covers both public and secured videos, and returns a signed URL and token when a video needs one. A GROQ query is convenient when you already fetch the surrounding document. The playback ID it returns plays only for videos with a `public` playback policy.

### Fetch playback info with the Sanity client

The Sanity client provides a `getPlaybackInfo()` method that retrieves all necessary video information in a single call. This method requires API version `v2025-03-25` or later.

First, configure your client to use Media Library:

```
import {createClient} from '@sanity/client'

const client = createClient({
  apiVersion: '2025-03-25',
  useCdn: false,
  token: 'your-token',
  '~experimental_resource': {
    type: 'media-library',
    id: 'your-media-library-id',
  },
})
```

Query for the document with your `video` field:

**GROQ**

```groq
*[_type == 'videoDocument'] {
  title,
  video
}
```



```json
{
  "title": "My Video Document",
  "video": {
    "_type": "sanity.video",
    "asset": {
      "_ref": "media-library:mlZxz9rvqf76:video-30rh9U3GDEK3ToiId1Zje4uvalC-mp4",
      "_type": "reference"
    }
  }
}
```

Fetch playback information using the video asset reference:

```
const document = await client.fetch(
  `*[_type == 'videoDocument']{ title, video }`
)

const playbackInfo = await client.mediaLibrary.video.getPlaybackInfo(
  document.video.asset
)
```

The response contains URLs for the stream, images like the thumbnail, static MP4 renditions, and subtitle tracks, along with metadata like duration and aspect ratio.

```javascript
{
  id: "30rh9U3GDEK3ToiId1Zje4uvalC", // Playback ID
  stream: { url: "https://stream.m.sanity-cdn.com/..." },
  thumbnail: { url: "https://image.m.sanity-cdn.com/..." },
  animated: { url: "https://image.m.sanity-cdn.com/..." },
  storyboard: { url: "https://image.m.sanity-cdn.com/..." },
  duration: 120.5,
  aspectRatio: 1.77,
  renditions: [
    { url: "https://apicdn.sanity.io/.../renditions/1080p.mp4", resolution: "1080p" },
    { url: "https://apicdn.sanity.io/.../renditions/480p.mp4", resolution: "480p" },
    { url: "https://apicdn.sanity.io/.../renditions/270p.mp4", resolution: "270p" }
  ],
  subtitles: [
    {
      trackId: "AbC123",
      languageCode: "en",
      url: "https://stream.m.sanity-cdn.com/.../text/AbC123.vtt",
      closedCaptions: false
    }
  ]
}
```

### Fetch the playback ID with GROQ

Each entry in `metadata.playbacks` pairs a Mux playback ID (`_id`) with a playback `policy` of either `public` or `secured`. An asset can hold more than one entry, so filter by policy instead of taking the first one. A video with only a `secured` playback has no public playback ID — use `getPlaybackInfo()` to get a signed URL for it.

You can also fetch video data using GROQ queries. Use the `documents::get()` function to follow the Global Dataset Reference and access the video asset:

**GROQ**

```groq
*[_type == 'videoDocument'] {
  title,
  "video": documents::get(video.asset){
    _id,
    "aspectRatio": metadata.aspectRatio,
    "playbackId": metadata.playbacks[policy == "public"][0]._id
  }
}
```

**RESULT**

```json
{
  "title": "My Video Document",
  "video": {
    "_id": "video-30rh9U3GDEK3ToiId1Zje4uvalC-mp4",
    "aspectRatio": 0.5625,
    "playbackId": "V5uFaHghtnzgV6lYlBkrbehGkvd5KNGHYU7w2Eo7HoQ"
  }
}
```

For more comprehensive video information, you can query additional metadata:

**GROQ**

```groq
*[_type == 'videoDocument'] {
  title,
  "video": documents::get(video.asset){
    _id,
    "aspectRatio": metadata.aspectRatio,
    "duration": metadata.duration,
    "originalFilename": originalFilename,
    "playbackId": metadata.playbacks[policy == "public"][0]._id
  }
}
```



## Display videos with Mux Player

To present videos from Media Library in your frontend, we'll use the Mux video player. 

Install the Mux React player component:

**npm**

```shell
npm install @mux/mux-player-react
```

**pnpm**

```shell
pnpm add @mux/mux-player-react
```

**yarn**

```shell
yarn add @mux/mux-player-react
```

**bun**

```shell
bun add @mux/mux-player-react
```

### Basic implementation

Here's the simplest way to display a video from your Media Library:

**video-player.tsx**

```tsx
import MuxPlayer from '@mux/mux-player-react'

type VideoPlayerProps = {
  playbackId: string
  aspectRatio: number
}

export default function VideoPlayer({ playbackId, aspectRatio }: VideoPlayerProps) {
  return (
    <MuxPlayer
      customDomain="m.sanity-cdn.com"
      playbackId={playbackId}
      style={{
        width: '100%',
        height: '100%',
        aspectRatio: aspectRatio
      }}
    />
  )
}

```

### Using with queried data

Integrate with your GROQ query results:

**video-player.tsx**

```tsx
import MuxPlayer from '@mux/mux-player-react'

type VideoData = {
  playbackId: string
  aspectRatio: number
  originalFilename: string
}

type DocumentWithVideoProps = {
  video: VideoData
}

export default function DocumentWithVideo({ video }: DocumentWithVideoProps) {
  if (!video?.playbackId) {
    return <div>No video available</div>
  }

  return (
    <div className="video-container">
      <MuxPlayer
        customDomain="m.sanity-cdn.com"
        playbackId={video.playbackId}
        style={{
          width: '100%',
          height: '100%',
          aspectRatio: video.aspectRatio
        }}
      />
    </div>
  )
}

```



## Displaying thumbnails

Thumbnail images are automatically generated for your videos. You can use these as poster images:

**video-player-poster.tsx**

```tsx
import MuxPlayer from '@mux/mux-player-react'

export default function VideoWithPoster({ video }: { video: VideoData }) {
  const posterUrl = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.jpg`

  return (
    <MuxPlayer
      customDomain="m.sanity-cdn.com"
      playbackId={video.playbackId}
      poster={posterUrl}
      style={{
        width: '100%',
        height: '100%',
        aspectRatio: video.aspectRatio
      }}
    />
  )
}

```


You can also customize the poster image size and format:

**video-poster.ts**

```
// Custom poster with specific dimensions
const posterUrl = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.jpg?width=800&height=${Math.round(800 / video.aspectRatio)}&fit_mode=crop`

// Use WebP format for better compression
const optimizedPoster = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.webp?width=800&fit_mode=preserve`

```

## Subtitles and captions

Subtitles are separate text tracks on the video asset, not part of the video file itself. You add them in Media Library by generating them with AI or by uploading a VTT or SRT file. Because the tracks are separate, a static MP4 rendition plays without them — the player loads each track alongside the video.

`getPlaybackInfo()` returns every track that has finished processing in a `subtitles` array. Each entry has these fields:

- `trackId`: Identifier for the text track.
- `languageCode`: ISO 639-1 code for the track's language, such as `en`.
- `url`: WebVTT file for the track, in the form `https://stream.m.sanity-cdn.com/<playbackId>/text/<trackId>.vtt`.
- `closedCaptions`: `true` when the track is a closed-captions track rather than a plain subtitle track.

A track that is still generating is left out of the array, so fetch playback info again after adding a subtitle.

Pass each track to a `track` element inside the player:

**video-with-subtitles.tsx**

```tsx
import MuxPlayer from '@mux/mux-player-react'
import {createClient} from '@sanity/client'

const client = createClient({
  apiVersion: '2025-03-25',
  useCdn: false,
  token: process.env.SANITY_API_READ_TOKEN,
  '~experimental_resource': {
    type: 'media-library',
    id: 'your-media-library-id',
  },
})

export default async function VideoWithSubtitles({assetRef}: {assetRef: string}) {
  const playbackInfo = await client.mediaLibrary.video.getPlaybackInfo(assetRef)

  return (
    <MuxPlayer
      customDomain="m.sanity-cdn.com"
      playbackId={playbackInfo.id}
      style={{width: '100%', aspectRatio: playbackInfo.aspectRatio}}
    >
      {playbackInfo.subtitles?.map((subtitle) => (
        <track
          key={subtitle.trackId}
          kind={subtitle.closedCaptions ? 'captions' : 'subtitles'}
          label={subtitle.languageCode}
          src={subtitle.url}
          srcLang={subtitle.languageCode}
        />
      ))}
    </MuxPlayer>
  )
}
```

For a video with a `secured` playback policy, the subtitle URL comes back signed and each entry also carries `token` and `expiresAt`. Fetch playback info again before the token expires.

## Understanding streaming and thumbnail URLs

Media Library provides video streaming and thumbnail generation through Mux. Understanding the URL structure helps you optimize video delivery:

### Streaming URLs

Videos are delivered via HLS (HTTP Live Streaming) using this URL pattern:

```text
https://stream.m.sanity-cdn.com/{playbackId}.m3u8
```

The Mux Player handles this automatically, but you can access the raw streaming URL if needed:

**video-url.ts**

```
const hlsUrl = `https://stream.m.sanity-cdn.com/${video.playbackId}.m3u8`

```

### Thumbnail URLs

Thumbnails are automatically generated using this URL pattern:

```text
https://image.m.sanity-cdn.com/{playbackId}/thumbnail.{format}
```

You can customize thumbnails with query parameters, similar to Sanity's image pipeline:

**video-urls-params.ts**

```
// Basic thumbnail
const thumbnail = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.jpg`

// Specific size and format
const customThumb = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.webp?width=400&height=300`

// Crop and fit options
const croppedThumb = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.jpg?width=200&height=200&fit_mode=crop`

// Thumbnail from specific time (in seconds)
const timeThumb = `https://image.m.sanity-cdn.com/${video.playbackId}/thumbnail.jpg?time=30`

```



**Available thumbnail parameters:**

- `width` and `height` - Resize the thumbnail
- `fit_mode` - How to fit within dimensions (`crop`, `preserve`, `stretch`, `pad`)
- `time` - Extract thumbnail from specific video timestamp (in seconds)
- Format options: `.jpg`, `.png`, `.webp`

For the complete list of thumbnail transformation options, see the [Mux thumbnail documentation](https://www.mux.com/docs/guides/get-images-from-a-video#get-an-image-from-a-video).

## Advanced player configuration

Configure additional player features:

**video-player-config.tsx**

```tsx
import MuxPlayer from '@mux/mux-player-react'

type AdvancedVideoProps = {
  video: {
    playbackId: string
    aspectRatio: number
    originalFilename: string
  }
  autoPlay?: boolean
  muted?: boolean
  onPlay?: () => void
  onEnded?: () => void
}

export default function AdvancedVideo({
  video,
  autoPlay = false,
  muted = false,
  onPlay,
  onEnded
}: AdvancedVideoProps) {
  return (
    <MuxPlayer
      customDomain="m.sanity-cdn.com"
      playbackId={video.playbackId}
      autoPlay={autoPlay}
      muted={muted}
      loop={false}
      preload="metadata"
      style={{
        width: '100%',
        height: '100%',
        aspectRatio: video.aspectRatio
      }}
      onPlay={onPlay}
      onEnded={onEnded}
    />
  )
}

```

## Customizing player appearance

For styling and theming options, see the [Mux Player customization guide](https://www.mux.com/docs/guides/player-customize-look-and-feel). The player supports:

- Custom accent colors
- CSS custom properties for extensive styling
- Multiple built-in themes
- Custom CSS for complete control over appearance

## Background video

For looping, muted, autoplay videos use the dedicated `@mux/mux-background-video` React component rather than Mux Player. It is lightweight, uses HLS adaptive streaming, and is optimized for this use case.

### Background video vs. Mux Player

Choose the right component based on how the video is used:

- **Background video**: short, looping, muted clips that autoplay silently. No playback controls. Use `MuxBackgroundVideo`.
- **Interactive video**: product demos, explainers, or any video the user actively watches with play/pause/seek controls. Use `MuxPlayer`.

### Installation

**npm**

```shell
npm install @mux/mux-background-video
```

**pnpm**

```shell
pnpm add @mux/mux-background-video
```

**yarn**

```shell
yarn add @mux/mux-background-video
```

**bun**

```shell
bun add @mux/mux-background-video
```

### Basic implementation

Pass the playback ID from your Media Library asset to the component. Include an `<img>` element as a poster — it displays while the video loads and is important for LCP performance.

**hero-background.tsx**

```tsx
import MuxBackgroundVideo from '@mux/mux-background-video'

type HeroBackgroundProps = {
  playbackId: string
}

export default function HeroBackground({ playbackId }: HeroBackgroundProps) {
  return (
    <MuxBackgroundVideo
      customDomain="m.sanity-cdn.com"
      playbackId={playbackId}
      style={{ width: '100%', height: '100%' }}
    >
      <img
        src={`https://image.m.sanity-cdn.com/${playbackId}/thumbnail.webp?width=1920&fit_mode=preserve`}
        alt=""
        aria-hidden="true"
      />
    </MuxBackgroundVideo>
  )
}
```

### Controlling resolution

Use the `maxResolution` prop to cap the quality served. This reduces bandwidth usage when the video is displayed at a smaller size — for example, a full-bleed hero rarely needs more than 1080p.

**hero-background.tsx**

```tsx
<MuxBackgroundVideo
  customDomain="m.sanity-cdn.com"
  playbackId={playbackId}
  maxResolution="1080p"
  style={{ width: '100%', height: '100%' }}
>
  <img
    src={`https://image.m.sanity-cdn.com/${playbackId}/thumbnail.webp?width=1920&fit_mode=preserve`}
    alt=""
    aria-hidden="true"
  />
</MuxBackgroundVideo>
```

Available values: `270p`, `360p`, `480p`, `540p`, `720p`, `1080p`, `1440p`, `2160p`.

### Pause when the tab is hidden

Background videos continue playing when users switch tabs, consuming CPU and battery. Pause playback when the page is hidden using the Page Visibility API:

**hero-background.tsx**

```tsx
import MuxBackgroundVideo from '@mux/mux-background-video'
import { useEffect, useRef } from 'react'

export default function HeroBackground({ playbackId }: { playbackId: string }) {
  const videoRef = useRef<HTMLVideoElement>(null)

  useEffect(() => {
    const handleVisibilityChange = () => {
      if (!videoRef.current) return
      if (document.hidden) {
        videoRef.current.pause()
      } else {
        videoRef.current.play()
      }
    }

    document.addEventListener('visibilitychange', handleVisibilityChange)
    return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
  }, [])

  return (
    <MuxBackgroundVideo
      ref={videoRef}
      customDomain="m.sanity-cdn.com"
      playbackId={playbackId}
      maxResolution="1080p"
      style={{ width: '100%', height: '100%' }}
    >
      <img
        src={`https://image.m.sanity-cdn.com/${playbackId}/thumbnail.webp?width=1920&fit_mode=preserve`}
        alt=""
        aria-hidden="true"
      />
    </MuxBackgroundVideo>
  )
}
```

## Performance considerations

To maximize performance when displaying videos:

**Preload settings**

Use appropriate preload settings based on your use case:

- `preload="none"` - Don't preload anything. Best for pages with many videos.
- `preload="metadata"` - Preload video metadata only. Default setting.
- `preload="auto"` - Preload the entire video. Use sparingly, and avoid when loading multiple videos.

**Loading strategies**

Lazy load videos below the fold as users scroll to reduce initial page load time.

**Resolution control**

Set `maxResolution` on the player to cap the maximum video quality and reduce bandwidth usage. Available options are `"720p"`, `"1080p"`, `"1440p"`, and `"2160p"`.

**Player.tsx**

```tsx
<MuxPlayer 
  playbackId={playbackId}  
  maxResolution="1080p"  
  style={{  
    width: '100%',  
    height: '100%',  
    aspectRatio: aspectRatio,
  }} 
/>
```



**Image optimization**

Use appropriately sized thumbnails instead of full-resolution images by utilizing the [transformation options](https://github.com/sanity-io/client#getting-video-playback-information).

**User interaction**

Consider implementing click-to-play for videos that aren't essential to the user experience to save bandwidth.

**Poster images and LCP**

For above-the-fold videos, always provide a poster image. The `<img>` element inside `MuxBackgroundVideo` (or the `poster` prop on `MuxPlayer`) is treated as a Largest Contentful Paint candidate. Use an appropriately sized `.webp` thumbnail from the Mux image CDN to keep LCP fast.

**min-resolution**

Use `minResolution` alongside `maxResolution` to define a quality range. This is useful when your video contains text or fine detail that must remain legible — for example, a screen recording or product demo where 480p would be unacceptable.

**Player.tsx**

```tsx
<MuxPlayer
  customDomain="m.sanity-cdn.com"
  playbackId={playbackId}
  minResolution="720p"
  maxResolution="1080p"
  style={{ width: '100%', height: '100%', aspectRatio: aspectRatio }}
/>
```

## Common issues

**Video not loading**

- Check that the playback ID exists in your query: `video.asset->metadata.playbacks[policy == "public"][0]._id`
- For a secured video, `metadata.playbacks[policy == "public"]` is empty — use `getPlaybackInfo()` to get a signed stream URL
- Ensure your Media Library integration is properly configured
- Verify the GROQ query is returning the expected data structure

**Player not displaying**

- Confirm `@mux/mux-player-react` is properly installed
- Check browser console for JavaScript errors
- Verify the container element has appropriate dimensions

**Aspect ratio issues**

- Ensure you're using the correct aspect ratio from `video.asset->metadata.aspectRatio`
- Note that aspect ratio is width/height (e.g., 0.5625 for 9:16 vertical video)
- Set proper container styles to maintain aspect ratio

**Subtitles not appearing**

- Subtitles are separate text tracks, so a static MP4 rendition URL never carries them. Read the `subtitles` array from `getPlaybackInfo()` and attach each URL to a `track` element on the player.
- A track that is still generating is left out of the `subtitles` array. Wait for processing to finish, then fetch playback info again.

## Additional resources

- [Mux Player documentation](https://www.mux.com/docs/guides/mux-player-web): Complete guide to Mux Player features and customization options.
- [Media Library documentation](https://www.sanity.io/docs/media-library): Learn more about configuring and using Media Library.
- [Creating custom aspects](https://www.sanity.io/docs/media-library/create-aspect): Add custom metadata fields to your video assets.



# Migrate assets from Media Plugin

**This is a paid feature**
This feature is available as an addon for certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

In this guide, we'll look at one approach to migrate assets from the Media Plugin to Media Library. At the end of this guide, we've included an example migration script and instructions on how to run it. You can go directly to the code if you aren't interested in the core parts of the script.

In the future, we plan to offer a more direct dataset to Media Library migration tool. For now, we recommend adapting this code to your needs.

## Limitations and tips

- This approach is intended for assets stored in a Sanity dataset that you want migrated to Media Library. If you have assets stored elsewhere, you'll need to adjust the download/upload approach. For example, if you use an external asset source, you'll need to download those assets and adjust how you identify their connection to documents.
- Keep rate limits and API quota in mind. Downloading all assets, and making high volumes of document mutations, can drastically affect your usage.

## Migration overview

Migrating from dataset-stored assets to Media Library involves the following process.

1. Export your dataset and assets.
2. Upload assets to Media Library.
3. Link assets to your dataset.
4. Update (mutate) documents in the dataset with new reference to each new ML asset.
5. Optional: Migrate legacy metadata to a Media Library aspect.

### Export your dataset and gather assets

While you can use the APIs to download each asset, search for it across documents, and write changes, we find it is easiest to download your entire dataset and assets to work with the files locally.

From your Sanity project, export the dataset with the `sanity` CLI:

**npm**

```shell
npx sanity dataset export dataset-name
```

**pnpm**

```shell
pnpm dlx sanity dataset export dataset-name
```

**yarn**

```shell
yarn dlx sanity dataset export dataset-name
```

**bun**

```shell
bunx sanity dataset export dataset-name
```

Replace `dataset-name` with the name of your dataset. Follow the prompts to select a download location and filename.

Next, uncompress the file (`production.tar.tz`, if your dataset was production) and you're left with a directory containing your data (`data.ndjson`, asset document data (`assets.json`), and an `images` directory. 

### Upload assets to Media Library

We'll use Media Library's HTTP API to upload each image. The process looks like this:

1. Iterate over each image in the `images` directory.
2. Upload the image to Media Library using the `/upload` endpoint.
3. Store the response, which contains the `assetId` and the `assetInstanceId`. You'll need these to link the asset to your dataset.

Learn more about the [upload process in this guide](https://www.sanity.io/docs/media-library/upload-assets).

### Link assets to your dataset

Media Library connects assets to your datasets with a Global Document Reference (GDR). The process is as follows:

1. Iterate over each asset, and use the `assetId`, `assetInstanceId`, and `mediaLibraryId` to make a request to the `/assets/media-library-link` endpoint. 

### Update documents to the new reference

Next, you need to iterate over the documents in the `data.ndjson` file, identify the location of each asset reference, then patch all instances with the correct reference to the asset in Media Library. This process is:

1. Iterate over the lines of `data.ndjson`
2. Check if a the line (document) contains an image reference, and if so store the document's `_id` and the image path.
3. Iterate over the matching documents and **patch** them with a `media` object containing the GDR to the asset in Media Library.

Learn more about the [linking and patching assets](https://www.sanity.io/docs/media-library/link-media-assets) process.

### Optional: Migrate metadata to aspect

Media Library offers aspects for managing internal metadata. This differs from the Media Plugin's tags and additional fields (like title, alt text, description), but can be a useful place to store these during migration.

To migrate the metadata to an aspect, the process is:

1. Create a new aspect that matches the shape of the Media Plugin's metadata.
2. After uploading assets to Media Library, collect metadata from the old assets using the `assets.json` export.
3. Map the metadata onto the new Media Library assets, and patch it into the aspects field.

Learn more about [adding aspects to uploaded assets](https://www.sanity.io/docs/media-library/assign-aspects).



## Migration example script

In a new directory, initialize an NPM/PNPM project. If you prefer, you can use an existing Sanity project, but we'll be adding some development dependencies.

> [!WARNING]
> Experimental
> This script is experimental and intended as an example solution. While it has worked for many test cases, it is not an official drop-in solution. We highly suggest performing your own tests and modifying the code to fit your needs.
> If you're unsure, we recommend waiting until a direct migration tool is released.

**NPM**

```sh
npm init
```

**PNPM**

```sh
pnpm init
```

Install types and dependencies:

**NPM**

```sh
npm i dotenv ndjson
```

**PNPM**

```sh
npm add dotenv ndjson
```

And finally types:

**NPM**

```sh
npm i -D @types/node @types/ndjson
```

**PNPM**

```sh
pnpm add -D @types/node @types/ndjson
```

Next, create a `.env` file with the following values:

**.env**

```text
SANITY_TOKEN=
SANITY_PROJECT_ID=
SANITY_SOURCE_DATASET=
SANITY_MEDIA_LIBRARY_ID=
IMAGES_DIR=
FILES_DIR=
DATA_FILE_PATH=
ASSETS_FILE_PATH=
```

- `SANITY_TOKEN`: Run `npx sanity debug --secrets` to obtain your auth token. You'll need read/write access to the source dataset and Media Library.
- `SANITY_PROJECT_ID`: Copy your projectId from [sanity.io/manage](https://www.sanity.io/manage).
- `SANITY_SOURCE_DATASET`: Set to the dataset containing the documents and images you wish to update.
- `SANITY_MEDIA_LIBRARY_ID`: Set to your Media Library ID. You can obtain this from the Media Library URL. [See this guide for details](https://www.sanity.io/docs/media-library/configure-library).
- `IMAGES_DIR`: Set to the path to the `images` folder in the exported dataset archive. This path is relative to the current directory.
- `FILES_DIR`: Set to the path to the `files` folder in the exported dataset archive. This path is relative to the current directory.
- `DATA_FILE_PATH`: The path to the `data.ndjson` file relative to the current directory.
- `ASSETS_FILE_PATH`: The path to the `assets.json` file relative to the current directory.

Finally, create a TypeScript file and add code below. We'll name ours `migrate-media.ts`.

You can run the code with `tsx`. 

**npm**

```shell
npx tsx migrate-media.ts
```

**pnpm**

```shell
pnpm dlx tsx migrate-media.ts
```

**yarn**

```shell
yarn dlx tsx migrate-media.ts
```

**bun**

```shell
bunx tsx migrate-media.ts
```

It comes with a few options:

- `--dry-run`: Explains what the script *would do* based on your options, but won't perform any writes.
- `--verbose`: Outputs all logging details as each image is uploaded, linked, etc.
- `--test-image <image-name>`: You can pass an individual image name to see how the script will act on an individual image.
- `--include-aspects`: Use this flag to also map Media Plugin metadata onto aspects when available. Make sure to [create a new aspect](https://www.sanity.io/docs/media-library/create-aspect) named `metadata`. See the example aspect file.

**migrate-media.ts**

```
import * as fs from 'node:fs'
import * as path from 'node:path'
import {config} from 'dotenv'
import ndjson from 'ndjson'

// Define interface for migration options
export interface MigrateMediaOptions {
  projectId: string
  dataset: string
  mediaLibraryId: string
  sanityToken: string
  imagesDir: string
  filesDir: string
  dataFilePath: string
  assetsFilePath: string
  isDryRun?: boolean
  isVerbose?: boolean
  testImageName?: string
  includeAspects?: boolean
}

// Load environment variables from .env file when running as CLI
if (fs.existsSync('./.env')) {
  config({path: './.env'})
}

interface Document {
  _id: string
  _type: string
  [key: string]: any
}

interface UploadResult {
  assetId: string
  assetInstanceId: string
}

interface ImageProcessingResult {
  filename: string
  hash: string
  success: boolean
  error?: string
}

interface FileProcessingResult {
  filename: string
  hash: string
  success: boolean
  error?: string
}

// Add command line argument parsing
function parseCliArgs() {
  const args = process.argv.slice(2)
  const isDryRun = args.includes('--dry-run')
  const isVerbose = args.includes('--verbose')
  const includeAspects = args.includes('--include-aspects') // migrate legacy metadata to aspects
  const testImageIndex = args.indexOf('--test-image')
  const testImageName = testImageIndex !== -1 ? args[testImageIndex + 1] : undefined
  const BATCH_SIZE = 20 // Number of images to process in parallel

  return {
    isDryRun,
    isVerbose,
    testImageName,
    includeAspects,
    BATCH_SIZE,
  }
}

// Only logs if verbose flag is present
function logVerbose(isVerbose: boolean, ...args: any[]) {
  if (isVerbose) {
    console.log(...args)
  }
}

// Always prints a message, clearing the previous line if not in verbose mode
function logStatus(isVerbose: boolean, message: string) {
  if (!isVerbose) {
    process.stdout.write('\r'.padEnd(process.stdout.columns || 80) + '\r') // Clear line first
  }
  console.log(message) // Always log the status
}

// Function to validate path segments
function validatePathSegment(segment: string, index: number): void {
  if (segment === undefined || segment === null || segment === '') {
    throw new Error(`Invalid path segment at index ${index}: segment is empty or undefined`)
  }

  // Check if this is an array index
  if (!isNaN(Number(segment))) {
    return // Valid array index
  }

  // Check for valid property name format
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(segment)) {
    throw new Error(
      `Invalid property name at index ${index}: "${segment}" - must start with a letter or underscore and contain only alphanumeric characters and underscores`,
    )
  }
}

// Function to build field path. The path is used to patch documents that contain the image.
function buildFieldPath(fieldPath: string[]): string {
  if (!fieldPath || fieldPath.length === 0) {
    throw new Error('Field path cannot be empty')
  }

  // Validate all segments first
  fieldPath.forEach((segment, index) => validatePathSegment(segment, index))

  let path = fieldPath[0]

  for (let i = 1; i < fieldPath.length; i++) {
    const segment = fieldPath[i]
    // If the segment is a number or looks like an array index, wrap it in brackets
    if (/^\d+$/.test(segment) || segment.startsWith('[')) {
      path += `[${segment}]`
    } else {
      path += `.${segment}`
    }
  }

  return path
}

// Function to find image references in a document.
async function findImageReferences(
  doc: Document,
): Promise<{docId: string; path: string; fieldPath: string[]}[]> {
  const results: {docId: string; path: string; fieldPath: string[]}[] = []

  function traverse(obj: any, currentPath: string[] = []) {
    if (!obj || typeof obj !== 'object') return

    if (obj._sanityAsset && obj._sanityAsset.startsWith('image@file://./images/')) {
      const imagePath = obj._sanityAsset.replace('image@file://./images/', '')
      results.push({
        docId: doc._id,
        path: imagePath,
        fieldPath: [...currentPath],
      })
    }

    Object.entries(obj).forEach(([key, value]) => {
      if (typeof value === 'object' && value !== null) {
        traverse(value, [...currentPath, key])
      }
    })
  }

  traverse(doc)
  return results
}

// Function to find file references in a document.
async function findFileReferences(
  doc: Document,
): Promise<{docId: string; path: string; fieldPath: string[]}[]> {
  const results: {docId: string; path: string; fieldPath: string[]}[] = []

  function traverse(obj: any, currentPath: string[] = []) {
    if (!obj || typeof obj !== 'object') return

    if (obj._sanityAsset && obj._sanityAsset.startsWith('file@file://./files/')) {
      const filePath = obj._sanityAsset.replace('file@file://./files/', '')
      results.push({
        docId: doc._id,
        path: filePath,
        fieldPath: [...currentPath],
      })
    }

    Object.entries(obj).forEach(([key, value]) => {
      if (typeof value === 'object' && value !== null) {
        traverse(value, [...currentPath, key])
      }
    })
  }

  traverse(doc)
  return results
}

// Function to verify image file before upload
function verifyImageFile(filePath: string): {exists: boolean; size: number} {
  try {
    if (!fs.existsSync(filePath)) {
      return {exists: false, size: 0}
    }
    const stats = fs.statSync(filePath)
    return {exists: true, size: stats.size}
  } catch (error) {
    console.error(`Error verifying file ${filePath}: ${error.message || error}`)
    return {exists: false, size: 0}
  }
}

// Function to verify file before upload
function verifyFile(filePath: string): {exists: boolean; size: number} {
  try {
    if (!fs.existsSync(filePath)) {
      return {exists: false, size: 0}
    }
    const stats = fs.statSync(filePath)
    return {exists: true, size: stats.size}
  } catch (error) {
    console.error(`Error verifying file ${filePath}: ${error.message || error}`)
    return {exists: false, size: 0}
  }
}

// Function to upload asset to media library
// Returns an object with assetId and assetInstanceId or throws error
async function uploadAsset(
  mediaLibraryId: string,
  token: string,
  assetPath: string,
  isVerbose: boolean,
): Promise<UploadResult> {
  const baseUrl = `https://api.sanity.io/v2024-06-24/media-libraries/${mediaLibraryId}/upload`
  const parts = assetPath.split('/')
  const filename = parts[parts.length - 1]

  const fileInfo = verifyFile(assetPath)
  if (!fileInfo.exists) {
    throw new Error(`File not found: ${assetPath}`)
  }
  if (fileInfo.size === 0) {
    throw new Error(`File is empty: ${assetPath}`)
  }

  const params: Record<string, string> = {
    filename,
    autoGenerateTitle: 'true',
  }
  const queryParams = new URLSearchParams(params).toString()
  const url = `${baseUrl}?${queryParams}`

  try {
    logVerbose(isVerbose, 'Uploading asset with:', {
      mediaLibraryId,
      tokenLength: token.length,
      assetPath,
      url,
    })

    const body = fs.readFileSync(assetPath)
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded',
        Authorization: `Bearer ${token}`,
      },
      body,
    })

    const responseText = await response.text()
    if (!response.ok) {
      throw new Error(
        `Upload HTTP Error: ${response.status} ${response.statusText} - ${responseText}`,
      )
    }

    try {
      const parsedResponse = JSON.parse(responseText)

      const assetId = parsedResponse?.asset?._id
      const assetInstanceId = parsedResponse?.assetInstance?._id

      if (!assetId || !assetInstanceId) {
        throw new Error(
          `Required ID(s) not found in parsed response (assetId: ${assetId}, assetInstanceId: ${assetInstanceId}). Structure logged above. Response: ${responseText}`,
        )
      }
      logVerbose(
        isVerbose,
        `Upload successful for ${filename}, received assetId: ${assetId}, assetInstanceId: ${assetInstanceId}`,
      )
      return {assetId, assetInstanceId}
    } catch (parseError) {
      throw new Error(
        `Failed to parse upload server response: ${parseError.message} - Response: ${responseText}`,
      )
    }
  } catch (error) {
    throw new Error(`Upload failed for ${filename}: ${error.message}`)
  }
}

// Helper function for delaying execution
async function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

// Function to link media library asset with exponential backoff retry
async function linkMediaLibraryAsset(
  token: string,
  projectId: string,
  dataset: string,
  mediaLibraryId: string,
  assetInstanceId: string,
  assetId: string,
  isVerbose: boolean,
) {
  const baseUrl = `https://${projectId}.api.sanity.io/v2025-01-04/assets/media-library-link/${dataset}`
  const maxRetries = 6 // Will give us roughly 1 minute total (1+2+4+8+16+32 = 63 seconds)
  const baseDelay = 1000 // Start with 1 second
  const maxTimeout = 60000 // 1 minute total timeout

  const startTime = Date.now()

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    // Check if we've exceeded the total timeout
    if (Date.now() - startTime > maxTimeout) {
      throw new Error(`Failed to link Media Library asset (ID: ${assetId}): Timeout after ${maxTimeout}ms`)
    }

    try {
      logVerbose(isVerbose, `Linking media library asset (attempt ${attempt + 1}/${maxRetries + 1}):`, {
        url: baseUrl,
        mediaLibraryId,
        assetId,
        assetInstanceId,
      })

      const response = await fetch(baseUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          mediaLibraryId,
          assetInstanceId,
          assetId,
        }),
      })

      if (!response.ok) {
        let errorData = {}
        let errorText = ''
        try {
          errorData = await response.json()
          errorText = JSON.stringify(errorData)
        } catch (e) {
          errorText = await response.text()
          errorData = {
            error: 'Failed to parse error response',
            responseText: errorText,
          }
        }

        // Check if this is a "Media library asset is not ready" error
        if (errorText.includes('Media library asset is not ready')) {
          if (attempt < maxRetries) {
            const delay = Math.min(baseDelay * Math.pow(2, attempt), maxTimeout - (Date.now() - startTime))
            logVerbose(isVerbose, `Media library asset not ready, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries + 1})`)
            await sleep(delay)
            continue
          }
        }

        throw new Error(
          `Link HTTP Error: ${response.status} ${response.statusText} - ${errorText}`,
        )
      }

      const result = await response.json()
      logVerbose(isVerbose, `Media Library asset linked successfully on attempt ${attempt + 1}:`, result)
      return result
    } catch (error) {
      // If it's a network error or other non-HTTP error, retry as well
      if (attempt < maxRetries && (error.message.includes('fetch') || error.message.includes('network'))) {
        const delay = Math.min(baseDelay * Math.pow(2, attempt), maxTimeout - (Date.now() - startTime))
        logVerbose(isVerbose, `Network error, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries + 1}): ${error.message}`)
        await sleep(delay)
        continue
      }
      
      // If it's the final attempt or a non-retryable error, throw
      throw new Error(`Failed to link Media Library asset (ID: ${assetId}): ${error.message}`)
    }
  }

  // This should never be reached, but just in case
  throw new Error(`Failed to link Media Library asset (ID: ${assetId}): Maximum retry attempts exceeded`)
}

// Process a single image through all steps
async function processImage(
  filename: string,
  index: number,
  totalImagesInThisRun: number,
  imagesDir: string,
  dataFilePath: string,
  mediaLibraryId: string,
  sanityToken: string,
  projectId: string,
  dataset: string,
  documentUpdateLimiter: RateLimiter,
  mediaLibraryLimiter: RateLimiter,
  aspectUpdateLimiter: RateLimiter,
  parsedDocuments: Document[],
  parsedAssets: any,
  tags: Map<string, string>,
  uploadedAssetIds: Map<string, UploadResult>,
  isDryRun: boolean,
  isVerbose: boolean,
  includeAspects: boolean,
): Promise<ImageProcessingResult> {
  const hash = filename.split('-')[0]
  const result: ImageProcessingResult = {
    filename,
    hash,
    success: false,
  }

  try {
    // Only log detailed progress in verbose mode
    const imageProgress = `(${index + 1}/${totalImagesInThisRun})`
    logVerbose(isVerbose, `\nProcessing ${filename} ${imageProgress}`)

    // Upload step
    // Uploads the image to the media library,
    // adds the actualAssetId to a map of asset IDs.
    const filepath = path.resolve(imagesDir, filename)
    if (isDryRun) {
      logVerbose(
        isVerbose,
        `DRY RUN ${imageProgress}: Would upload ${filename} with auto-generated title`,
      )
    } else {
      logVerbose(isVerbose, `Uploading ${imageProgress} ${filename}...`)

      const actualAssetId = await mediaLibraryLimiter.enqueue(() =>
        uploadAsset(mediaLibraryId, sanityToken, filepath, isVerbose),
      )
      uploadedAssetIds.set(hash, actualAssetId)
      logVerbose(isVerbose, `Upload ${imageProgress}: ${filename} uploaded successfully.`)
    }

    // Link step: Take the assetId and asset instance ID from the upload step,
    // and link them to the source dataset.

    // Pull the asset details from the upload map.
    const uploadResult = uploadedAssetIds.get(hash)
    if (!isDryRun && !uploadResult) {
      throw new Error(
        `Cannot link, upload result for hash ${hash} not found (upload likely failed).`,
      )
    }

    if (isDryRun) {
      const dryAssetId = uploadResult?.assetId || `mock-asset-id-${hash}`
      logVerbose(
        isVerbose,
        `DRY RUN ${imageProgress}: Would link ${filename} (using Asset ID: ${dryAssetId})`,
      )
    } else {
      const {assetId, assetInstanceId} = uploadResult!
      logVerbose(isVerbose, `Linking ${imageProgress} ${filename}...`)

      await mediaLibraryLimiter.enqueue(() =>
        linkMediaLibraryAsset(
          sanityToken,
          projectId,
          dataset,
          mediaLibraryId,
          assetInstanceId,
          assetId,
          isVerbose,
        ),
      )
      logVerbose(isVerbose, `Link ${imageProgress}: ${filename} linked.`)
    }

    // Documents step: Find all documents that contain the image.
    // This is done by searching through the parsed documents from the data file.
    // The field path is used to patch the document.
    const matchingDocs: {docId: string; fieldPath: string[]}[] = []
    logVerbose(isVerbose, `Docs ${imageProgress}: Reading references for ${filename}...`)
    try {
      for (const doc of parsedDocuments) {
        const refs = await findImageReferences(doc)
        const matches = refs.filter((ref) => ref.path.startsWith(hash))
        if (matches.length > 0) {
          for (const match of matches) {
            matchingDocs.push({docId: doc._id, fieldPath: match.fieldPath})
          }
        }
      }
    } catch (readError) {
      throw new Error(`Failed reading/parsing ${dataFilePath}: ${readError.message}`)
    }

    logVerbose(isVerbose, `Found ${matchingDocs.length} document references for ${filename}.`)

    if (isDryRun) {
      logVerbose(
        isVerbose,
        `DRY RUN ${imageProgress}: Would update ${matchingDocs.length} documents for ${filename}`,
      )
    } else {
      const uploadResult = uploadedAssetIds.get(hash)
      if (!uploadResult) {
        throw new Error(`Cannot update docs, asset ID for hash ${hash} not found.`)
      }

      const {assetId} = uploadResult
      if (matchingDocs.length > 0) {
        logVerbose(
          isVerbose,
          `Docs ${imageProgress}: Updating ${matchingDocs.length} refs for ${filename}...`,
        )

        const docBatchSize = 10
        let failedBatchCount = 0

        // Patch documents in batches with the GDR.
        for (let i = 0; i < matchingDocs.length; i += docBatchSize) {
          const batch = matchingDocs.slice(i, i + docBatchSize)
          const mutations = batch.map((doc) => ({
            patch: {
              id: doc.docId,
              set: {
                [`${buildFieldPath(doc.fieldPath)}.media`]: {
                  _type: 'globalDocumentReference',
                  _ref: `media-library:${mediaLibraryId}:${assetId}`,
                  _weak: true,
                },
              },
            },
          }))

          const url = `https://${projectId}.api.sanity.io/vX/data/mutate/${dataset}`

          await documentUpdateLimiter.enqueue(async () => {
            try {
              const response = await fetch(url, {
                method: 'POST',
                headers: {
                  'Content-Type': 'application/json',
                  Authorization: `Bearer ${sanityToken}`,
                },
                body: JSON.stringify({mutations}),
              })

              if (!response.ok) {
                const error = await response.json()
                logVerbose(isVerbose, 'Failed to update document batch:', error)
                failedBatchCount++
                return
              }

              logVerbose(
                isVerbose,
                `Successfully updated batch of ${batch.length} documents for ${filename}`,
              )
            } catch (fetchError) {
              logVerbose(isVerbose, 'Error during document update fetch:', fetchError)
              failedBatchCount++
            }
          })
        }

        if (failedBatchCount > 0) {
          throw new Error(`${failedBatchCount} batch(es) failed to update for ${filename}.`)
        }

        logVerbose(isVerbose, `Docs ${imageProgress}: ${filename} refs updated.`)
      } else {
        logVerbose(
          isVerbose,
          `Docs ${imageProgress}: No documents needed updating for ${filename}.`,
        )
      }
    }

    // Copy metadata to aspect.
    // use --include-aspects to enable.
    // Requires a deployed aspect named 'metadata' that matches the aspect shape.
    // Edit the shape in the patch below to match the desired shape in Media Library.

    if (isDryRun && includeAspects) {
      logVerbose(isVerbose, 'DRY RUN: Would update aspect with metadata.')
    } else if (includeAspects) {
      logVerbose(isVerbose, `Updating aspect data for ${imageProgress} ${filename}...`)
      // Assert non-null as we checked above
      const {assetId, assetInstanceId} = uploadResult!
      // We have to build the ID to match the key format found in assets.json
      const idParts = assetInstanceId.split('-')
      const assetDataId = `${idParts[0]}-${idParts[1]}`
      const asset = parsedAssets[assetDataId]
      // Pull the tags from the media plugin.
      const assetTags = asset?.opt?.media?.tags?.map((tag: any) => tags.get(tag._ref))
      const mutations = JSON.stringify({
        mutations: [
          {
            patch: {
              id: assetId,
              // Create an empty aspects object if it doesn't exist.
              setIfMissing: {aspects: {}},
              set: {
                'aspects.metadata': {
                  title: asset?.title || '',
                  description: asset?.description || '',
                  tags: assetTags || [],
                  creditLine: asset?.creditLine || '',
                  altText: asset?.altText || '',
                  originalFilename: asset?.originalFilename || '',
                },
              },
            },
          },
        ],
      })

      // Update the aspect by patching the asset in Media Library.
      await aspectUpdateLimiter.enqueue(async () => {
        const url = `https://api.sanity.io/v2024-06-24/media-libraries/${mediaLibraryId}/mutate`
        try {
          const response = await fetch(url, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Authorization: `Bearer ${sanityToken}`,
            },
            body: mutations,
          })
          if (!response.ok) {
            const error = await response.json()
            logVerbose(isVerbose, 'Failed to update aspect:', error)
            return
          }
          logVerbose(isVerbose, `Successfully updated aspect for ${filename}`)
        } catch (error) {
          logVerbose(isVerbose, 'Error updating aspect with metadata:', error)
        }
      })
    }

    // Mark as successful
    result.success = true
    return result
  } catch (error) {
    result.error = error.message || String(error)
    logVerbose(isVerbose, `Error processing ${filename}: ${result.error}`)
    return result
  }
}

// Process a single file through all steps
async function processFile(
  filename: string,
  index: number,
  totalFilesInThisRun: number,
  filesDir: string,
  dataFilePath: string,
  mediaLibraryId: string,
  sanityToken: string,
  projectId: string,
  dataset: string,
  documentUpdateLimiter: RateLimiter,
  mediaLibraryLimiter: RateLimiter,
  aspectUpdateLimiter: RateLimiter,
  parsedDocuments: Document[],
  parsedAssets: any,
  tags: Map<string, string>,
  uploadedAssetIds: Map<string, UploadResult>,
  isDryRun: boolean,
  isVerbose: boolean,
  includeAspects: boolean,
): Promise<FileProcessingResult> {
  const hash = filename.split('-')[0]
  const result: FileProcessingResult = {
    filename,
    hash,
    success: false,
  }

  try {
    // Only log detailed progress in verbose mode
    const fileProgress = `(${index + 1}/${totalFilesInThisRun})`
    logVerbose(isVerbose, `\nProcessing ${filename} ${fileProgress}`)

    // Upload step
    // Uploads the file to the media library,
    // adds the actualAssetId to a map of asset IDs.
    const filepath = path.resolve(filesDir, filename)
    if (isDryRun) {
      logVerbose(
        isVerbose,
        `DRY RUN ${fileProgress}: Would upload ${filename} with auto-generated title`,
      )
    } else {
      logVerbose(isVerbose, `Uploading ${fileProgress} ${filename}...`)

      const actualAssetId = await mediaLibraryLimiter.enqueue(() =>
        uploadAsset(mediaLibraryId, sanityToken, filepath, isVerbose),
      )
      uploadedAssetIds.set(hash, actualAssetId)
      logVerbose(isVerbose, `Upload ${fileProgress}: ${filename} uploaded successfully.`)
    }

    // Link step: Take the assetId and asset instance ID from the upload step,
    // and link them to the source dataset.

    // Pull the asset details from the upload map.
    const uploadResult = uploadedAssetIds.get(hash)
    if (!isDryRun && !uploadResult) {
      throw new Error(
        `Cannot link, upload result for hash ${hash} not found (upload likely failed).`,
      )
    }

    if (isDryRun) {
      const dryAssetId = uploadResult?.assetId || `mock-asset-id-${hash}`
      logVerbose(
        isVerbose,
        `DRY RUN ${fileProgress}: Would link ${filename} (using Asset ID: ${dryAssetId})`,
      )
    } else {
      const {assetId, assetInstanceId} = uploadResult!
      logVerbose(isVerbose, `Linking ${fileProgress} ${filename}...`)

      await mediaLibraryLimiter.enqueue(() =>
        linkMediaLibraryAsset(
          sanityToken,
          projectId,
          dataset,
          mediaLibraryId,
          assetInstanceId,
          assetId,
          isVerbose,
        ),
      )
      logVerbose(isVerbose, `Link ${fileProgress}: ${filename} linked.`)
    }

    // Documents step: Find all documents that contain the file.
    // This is done by searching through the parsed documents from the data file.
    // The field path is used to patch the document.
    const matchingDocs: {docId: string; fieldPath: string[]}[] = []
    logVerbose(isVerbose, `Docs ${fileProgress}: Reading references for ${filename}...`)
    try {
      for (const doc of parsedDocuments) {
        const refs = await findFileReferences(doc)
        const matches = refs.filter((ref) => ref.path.startsWith(hash))
        if (matches.length > 0) {
          for (const match of matches) {
            matchingDocs.push({docId: doc._id, fieldPath: match.fieldPath})
          }
        }
      }
    } catch (readError) {
      throw new Error(`Failed reading/parsing ${dataFilePath}: ${readError.message}`)
    }

    logVerbose(isVerbose, `Found ${matchingDocs.length} document references for ${filename}.`)

    if (isDryRun) {
      logVerbose(
        isVerbose,
        `DRY RUN ${fileProgress}: Would update ${matchingDocs.length} documents for ${filename}`,
      )
    } else {
      const uploadResult = uploadedAssetIds.get(hash)
      if (!uploadResult) {
        throw new Error(`Cannot update docs, asset ID for hash ${hash} not found.`)
      }

      const {assetId} = uploadResult
      if (matchingDocs.length > 0) {
        logVerbose(
          isVerbose,
          `Docs ${fileProgress}: Updating ${matchingDocs.length} refs for ${filename}...`,
        )

        const docBatchSize = 10
        let failedBatchCount = 0

        // Patch documents in batches with the GDR.
        for (let i = 0; i < matchingDocs.length; i += docBatchSize) {
          const batch = matchingDocs.slice(i, i + docBatchSize)
          const mutations = batch.map((doc) => ({
            patch: {
              id: doc.docId,
              set: {
                [`${buildFieldPath(doc.fieldPath)}.media`]: {
                  _type: 'globalDocumentReference',
                  _ref: `media-library:${mediaLibraryId}:${assetId}`,
                  _weak: true,
                },
              },
            },
          }))

          const url = `https://${projectId}.api.sanity.io/vX/data/mutate/${dataset}`

          await documentUpdateLimiter.enqueue(async () => {
            try {
              const response = await fetch(url, {
                method: 'POST',
                headers: {
                  'Content-Type': 'application/json',
                  Authorization: `Bearer ${sanityToken}`,
                },
                body: JSON.stringify({mutations}),
              })

              if (!response.ok) {
                const error = await response.json()
                logVerbose(isVerbose, 'Failed to update document batch:', error)
                failedBatchCount++
                return
              }

              logVerbose(
                isVerbose,
                `Successfully updated batch of ${batch.length} documents for ${filename}`,
              )
            } catch (fetchError) {
              logVerbose(isVerbose, 'Error during document update fetch:', fetchError)
              failedBatchCount++
            }
          })
        }

        if (failedBatchCount > 0) {
          throw new Error(`${failedBatchCount} batch(es) failed to update for ${filename}.`)
        }

        logVerbose(isVerbose, `Docs ${fileProgress}: ${filename} refs updated.`)
      } else {
        logVerbose(
          isVerbose,
          `Docs ${fileProgress}: No documents needed updating for ${filename}.`,
        )
      }
    }

    // Copy metadata to aspect.
    // use --include-aspects to enable.
    // Requires a deployed aspect named 'metadata' that matches the aspect shape.
    // Edit the shape in the patch below to match the desired shape in Media Library.

    if (isDryRun && includeAspects) {
      logVerbose(isVerbose, 'DRY RUN: Would update aspect with metadata.')
    } else if (includeAspects) {
      logVerbose(isVerbose, `Updating aspect data for ${fileProgress} ${filename}...`)
      // Assert non-null as we checked above
      const {assetId, assetInstanceId} = uploadResult!
      // We have to build the ID to match the key format found in assets.json
      const idParts = assetInstanceId.split('-')
      const assetDataId = `${idParts[0]}-${idParts[1]}`
      const asset = parsedAssets[assetDataId]
      // Pull the tags from the media plugin.
      const assetTags = asset?.opt?.media?.tags?.map((tag: any) => tags.get(tag._ref))
      const mutations = JSON.stringify({
        mutations: [
          {
            patch: {
              id: assetId,
              // Create an empty aspects object if it doesn't exist.
              setIfMissing: {aspects: {}},
              set: {
                'aspects.metadata': {
                  title: asset?.title || '',
                  description: asset?.description || '',
                  tags: assetTags || [],
                  creditLine: asset?.creditLine || '',
                  altText: asset?.altText || '',
                  originalFilename: asset?.originalFilename || '',
                },
              },
            },
          },
        ],
      })

      // Update the aspect by patching the asset in Media Library.
      await aspectUpdateLimiter.enqueue(async () => {
        const url = `https://api.sanity.io/v2024-06-24/media-libraries/${mediaLibraryId}/mutate`
        try {
          const response = await fetch(url, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Authorization: `Bearer ${sanityToken}`,
            },
            body: mutations,
          })
          if (!response.ok) {
            const error = await response.json()
            logVerbose(isVerbose, 'Failed to update aspect:', error)
            return
          }
          logVerbose(isVerbose, `Successfully updated aspect for ${filename}`)
        } catch (error) {
          logVerbose(isVerbose, 'Error updating aspect with metadata:', error)
        }
      })
    }

    // Mark as successful
    result.success = true
    return result
  } catch (error) {
    result.error = error.message || String(error)
    logVerbose(isVerbose, `Error processing ${filename}: ${result.error}`)
    return result
  }
}

// Rate limiter class to help avoid rate limiting errors.
class RateLimiter {
  private queue: (() => Promise<any>)[] = []
  private processing = false
  private lastRequestTime = 0
  private activeRequests = 0

  constructor(
    private requestsPerSecond: number,
    private maxConcurrent: number = 25,
  ) {}

  async enqueue<T>(fn: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        try {
          this.activeRequests++
          const result = await fn()
          this.activeRequests--
          resolve(result)
        } catch (error) {
          this.activeRequests--
          reject(error)
        }
      })
      this.processQueue()
    })
  }

  private async processQueue() {
    if (this.processing || this.queue.length === 0) return
    this.processing = true

    while (this.queue.length > 0) {
      const now = Date.now()
      const timeSinceLastRequest = now - this.lastRequestTime
      const minTimeBetweenRequests = 1000 / this.requestsPerSecond

      // Wait if we've hit the concurrent request limit
      if (this.activeRequests >= this.maxConcurrent) {
        await new Promise((resolve) => setTimeout(resolve, 100))
        continue
      }

      // Wait if we need to respect the rate limit
      if (timeSinceLastRequest < minTimeBetweenRequests) {
        await new Promise((resolve) =>
          setTimeout(resolve, minTimeBetweenRequests - timeSinceLastRequest),
        )
      }

      const batch = this.queue.splice(0, this.maxConcurrent - this.activeRequests)
      await Promise.all(batch.map((fn) => fn()))
      this.lastRequestTime = Date.now()
    }

    this.processing = false
  }
}

// Main function that can be imported or run from CLI
export async function migrateMedia(options: MigrateMediaOptions): Promise<void> {
  const {
    projectId,
    dataset,
    mediaLibraryId,
    sanityToken,
    imagesDir,
    filesDir,
    dataFilePath,
    assetsFilePath,
    isDryRun = false,
    isVerbose = false,
    testImageName = null,
    includeAspects = false,
  } = options

  if (!sanityToken || sanityToken.trim() === '') {
    throw new Error(
      "Sanity auth token not provided. Please run 'sanity debug --secrets' to find it, then provide it using --token argument.",
    )
  }

  if (!mediaLibraryId || mediaLibraryId.trim() === '') {
    throw new Error(
      'MEDIA_LIBRARY_ID is not set. Ensure it is defined in the environment variables.',
    )
  }

  if (!projectId || projectId.trim() === '') {
    throw new Error('Project ID not provided.')
  }

  if (!dataset || dataset.trim() === '') {
    throw new Error('Dataset name not provided.')
  }

  if (!imagesDir || !fs.existsSync(imagesDir)) {
    throw new Error(`Images directory not found: ${imagesDir}`)
  }
  logStatus(isVerbose, `Using images directory: ${imagesDir}`)
  
  if (!filesDir || !fs.existsSync(filesDir)) {
    throw new Error(`Files directory not found: ${filesDir}`)
  }
  logStatus(isVerbose, `Using files directory: ${filesDir}`)
  // --- End Determine Images Directory ---

  const mediaLibraryLimiter = new RateLimiter(25, 25)
  const documentUpdateLimiter = new RateLimiter(25, 25)
  const aspectUpdateLimiter = new RateLimiter(25, 25)

  const uploadedAssetIds = new Map<string, UploadResult>()
  let allImageFiles: string[]
  let allFileFiles: string[]
  const parsedDocuments: Document[] = []
  let parsedAssets: any

  const tags = new Map<string, string>() // store tags from media plugin

  // load ndjson data file
  try {
    const dataFilePath = process.env.DATA_FILE_PATH || 'data.ndjson'
    const fileStream = fs.createReadStream(dataFilePath)
    const parsedData = fileStream.pipe(ndjson.parse())
    for await (const doc of parsedData) {
      parsedDocuments.push(doc)
    }
  } catch (error) {
    console.error(`Error reading data file: ${error}`)
    throw error
  }

  // load assets data file
  try {
    parsedAssets = JSON.parse(fs.readFileSync(assetsFilePath, 'utf8'))
  } catch (error) {
    console.error(`Error reading assets file: ${error}`)
    throw error
  }

  // parse tags from media plugin
  try {
    for await (const doc of parsedDocuments) {
      if (doc._type === 'media.tag') {
        tags.set(doc._id, doc.name.current)
      }
    }
  } catch (error) {
    console.error(`Error reading tags from data file: ${error}`)
    throw error
  }

  // read images directory
  try {
    allImageFiles = fs.readdirSync(imagesDir).filter((filename) => !filename.startsWith('.'))
    if (allImageFiles.length === 0) {
      throw new Error(`No non-hidden images found in directory: ${imagesDir}`)
    }
    // Use logStatus
    logStatus(
      isVerbose,
      `Found ${allImageFiles.length} total images in directory (excluding hidden).`,
    )
  } catch (error) {
    console.error(`Error reading images directory: ${error}`) // Keep console.error for errors
    throw error
  }

  // read files directory
  try {
    allFileFiles = fs.readdirSync(filesDir).filter((filename) => !filename.startsWith('.'))
    if (allFileFiles.length === 0) {
      throw new Error(`No non-hidden files found in directory: ${filesDir}`)
    }
    // Use logStatus
    logStatus(
      isVerbose,
      `Found ${allFileFiles.length} total files in directory (excluding hidden).`,
    )
  } catch (error) {
    console.error(`Error reading files directory: ${error}`) // Keep console.error for errors
    throw error
  }

  let imageFilesToProcess: string[]
  let fileFilesToProcess: string[]

  if (testImageName) {
    const testImage = allImageFiles.find((file) => file === testImageName)
    if (!testImage) {
      console.error(`Error: Image "${testImageName}" not found in images directory`)
      throw new Error(`Test image "${testImageName}" not found`)
    }
    imageFilesToProcess = [testImage]
    fileFilesToProcess = [] // Skip files when testing specific image
    logStatus(isVerbose, `--test-image specified. Processing only: ${testImageName}`)
  } else {
    imageFilesToProcess = allImageFiles
    fileFilesToProcess = allFileFiles

    if (isDryRun && !isVerbose) {
      const limit = 5
      if (imageFilesToProcess.length > limit) {
        logStatus(
          isVerbose,
          `\nLimiting dry run to the first ${limit} *unprocessed* image files found...`,
        )
        imageFilesToProcess = imageFilesToProcess.slice(0, limit)
      }
      if (fileFilesToProcess.length > limit) {
        logStatus(
          isVerbose,
          `\nLimiting dry run to the first ${limit} *unprocessed* files found...`,
        )
        fileFilesToProcess = fileFilesToProcess.slice(0, limit)
      }
    }
  }

  const totalImagesInThisRun = imageFilesToProcess.length
  const totalFilesInThisRun = fileFilesToProcess.length
  let overallProcessedCount = 0
  let overallErrorCount = 0

  logStatus(isVerbose, `Starting migration process for ${totalImagesInThisRun} image(s) and ${totalFilesInThisRun} file(s)...`)

  const BATCH_SIZE = 20

  // Process images in batches
  const totalBatches = Math.ceil(imageFilesToProcess.length / BATCH_SIZE)

  for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
    const batchStart = batchIndex * BATCH_SIZE
    const batchEnd = Math.min(batchStart + BATCH_SIZE, imageFilesToProcess.length)
    const currentBatch = imageFilesToProcess.slice(batchStart, batchEnd)
    const batchNumber = batchIndex + 1

    try {
      // Process all images in this batch in parallel
      const batchPromises = currentBatch.map((filename, index) =>
        processImage(
          filename,
          batchStart + index,
          totalImagesInThisRun,
          imagesDir,
          dataFilePath,
          mediaLibraryId,
          sanityToken,
          projectId,
          dataset,
          documentUpdateLimiter,
          mediaLibraryLimiter,
          aspectUpdateLimiter,
          parsedDocuments,
          parsedAssets,
          tags,
          uploadedAssetIds,
          isDryRun,
          isVerbose,
          includeAspects,
        ),
      )

      // Wait for all images in the batch to complete
      const results = await Promise.all(batchPromises)

      const successfulResults = results.filter((r) => r.success)
      overallProcessedCount += successfulResults.length
      overallErrorCount += results.length - successfulResults.length

      // Report batch results
      const batchMessage = isDryRun
        ? `Batch ${batchNumber}/${totalBatches} simulated: ${successfulResults.length}/${currentBatch.length} processed`
        : `Batch ${batchNumber}/${totalBatches} completed: ${successfulResults.length}/${currentBatch.length} processed`

      // Always show a message about the batch completion, even in verbose mode
      if (isVerbose) {
        logStatus(isVerbose, batchMessage)
      }

      if (!isVerbose) {
        // Show any errors in non-verbose mode (they're already shown in verbose mode)
        const failedResults = results.filter((r) => !r.success)
        if (failedResults.length > 0) {
          logStatus(isVerbose, `  ${failedResults.length} images failed in this batch:`)
          failedResults.forEach((result) => {
            logStatus(isVerbose, `  - ${result.filename}: ${result.error}`)
          })
        }
      }

      // Small delay between batches to allow rate limiting to catch up
      if (batchIndex < totalBatches - 1) {
        await sleep(500) // Half second between batches
      }
    } catch (batchError) {
      console.error(`Error processing batch ${batchNumber}:`, batchError)
    }
  }

  // Process files in batches
  const totalFileBatches = Math.ceil(fileFilesToProcess.length / BATCH_SIZE)

  for (let batchIndex = 0; batchIndex < totalFileBatches; batchIndex++) {
    const batchStart = batchIndex * BATCH_SIZE
    const batchEnd = Math.min(batchStart + BATCH_SIZE, fileFilesToProcess.length)
    const currentBatch = fileFilesToProcess.slice(batchStart, batchEnd)
    const batchNumber = batchIndex + 1

    try {
      // Process all files in this batch in parallel
      const batchPromises = currentBatch.map((filename, index) =>
        processFile(
          filename,
          batchStart + index,
          totalFilesInThisRun,
          filesDir,
          dataFilePath,
          mediaLibraryId,
          sanityToken,
          projectId,
          dataset,
          documentUpdateLimiter,
          mediaLibraryLimiter,
          aspectUpdateLimiter,
          parsedDocuments,
          parsedAssets,
          tags,
          uploadedAssetIds,
          isDryRun,
          isVerbose,
          includeAspects,
        ),
      )

      // Wait for all files in the batch to complete
      const results = await Promise.all(batchPromises)

      const successfulResults = results.filter((r) => r.success)
      overallProcessedCount += successfulResults.length
      overallErrorCount += results.length - successfulResults.length

      // Report batch results
      const batchMessage = isDryRun
        ? `File Batch ${batchNumber}/${totalFileBatches} simulated: ${successfulResults.length}/${currentBatch.length} processed`
        : `File Batch ${batchNumber}/${totalFileBatches} completed: ${successfulResults.length}/${currentBatch.length} processed`

      // Always show a message about the batch completion, even in verbose mode
      if (isVerbose) {
        logStatus(isVerbose, batchMessage)
      }

      if (!isVerbose) {
        // Show any errors in non-verbose mode (they're already shown in verbose mode)
        const failedResults = results.filter((r) => !r.success)
        if (failedResults.length > 0) {
          logStatus(isVerbose, `  ${failedResults.length} files failed in this batch:`)
          failedResults.forEach((result) => {
            logStatus(isVerbose, `  - ${result.filename}: ${result.error}`)
          })
        }
      }

      // Small delay between batches to allow rate limiting to catch up
      if (batchIndex < totalFileBatches - 1) {
        await sleep(500) // Half second between batches
      }
    } catch (batchError) {
      console.error(`Error processing file batch ${batchNumber}:`, batchError)
    }
  }

  // --- Final Summary ---
  logStatus(isVerbose, '\n--- Migration Summary ---')
  logStatus(
    isVerbose,
    `Total images found in directory (excluding hidden): ${allImageFiles.length}`,
  )
  logStatus(
    isVerbose,
    `Total files found in directory (excluding hidden): ${allFileFiles.length}`,
  )
  logStatus(isVerbose, `Images attempted this run: ${totalImagesInThisRun}`)
  logStatus(isVerbose, `Files attempted this run: ${totalFilesInThisRun}`)
  logStatus(isVerbose, `Successfully processed assets this run: ${overallProcessedCount}`)
  if (overallErrorCount > 0) {
    logStatus(isVerbose, `Assets with errors during this run: ${overallErrorCount}`)
  }
  if (!isDryRun && overallProcessedCount > 0) {
    logStatus(isVerbose, `Successfully processed ${overallProcessedCount} asset(s).`)
  }
  logStatus(isVerbose, 'Processing complete!')
}

// CLI entry point when script is run directly
if (require.main === module) {
  const args = parseCliArgs()

  if (args.isDryRun) {
    logStatus(args.isVerbose, '\n=== DRY RUN MODE ===')
    logStatus(args.isVerbose, 'No actual changes will be made to the media library or documents')
    logStatus(args.isVerbose, 'This will simulate the entire process and show what would be done\n')
  }

  if (args.testImageName) {
    logStatus(args.isVerbose, `\n=== TESTING SINGLE IMAGE: ${args.testImageName} ===`)
    logStatus(args.isVerbose, 'This will process only the specified image through all steps\n')
  }

  // Read from environment variables when run as CLI
  const sanityToken = process.env.SANITY_TOKEN || ''
  const projectId = process.env.SANITY_PROJECT_ID || ''
  const dataset = process.env.SANITY_SOURCE_DATASET || ''
  const mediaLibraryId = process.env.SANITY_MEDIA_LIBRARY_ID || ''
  let imagesDir = process.env.IMAGES_DIR ? path.resolve(process.env.IMAGES_DIR) : ''
  let filesDir = process.env.FILES_DIR ? path.resolve(process.env.FILES_DIR) : ''

  // Fallback for imagesDir
  if (!imagesDir) {
    logStatus(
      args.isVerbose,
      'Warning: IMAGES_DIR environment variable not set. Falling back to relative path.',
    )
    imagesDir = path.resolve(__dirname, 'export', 'images')
  }

  // Fallback for filesDir
  if (!filesDir) {
    logStatus(
      args.isVerbose,
      'Warning: FILES_DIR environment variable not set. Falling back to relative path.',
    )
    filesDir = path.resolve(__dirname, 'export', 'files')
  }

  // Get the data file path
  let dataFilePath = ''
  if (process.env.DATA_FILE_PATH) {
    dataFilePath = path.resolve(process.env.DATA_FILE_PATH)
  } else {
    dataFilePath = path.resolve(__dirname, 'export', 'data.ndjson')
  }
  let assetsFilePath = ''
  if (process.env.ASSETS_FILE_PATH) {
    assetsFilePath = path.resolve(process.env.ASSETS_FILE_PATH)
  } else {
    assetsFilePath = path.resolve(__dirname, 'export', 'assets.ndjson')
  }

  migrateMedia({
    projectId,
    dataset,
    mediaLibraryId,
    sanityToken,
    imagesDir,
    filesDir,
    dataFilePath,
    assetsFilePath,
    isDryRun: args.isDryRun,
    isVerbose: args.isVerbose,
    testImageName: args.testImageName,
    includeAspects: args.includeAspects,
  }).catch((error) => {
    logStatus(args.isVerbose, '\n--- UNHANDLED SCRIPT ERROR ---')
    console.error('Error:', error.message || error)
    process.exit(1)
  })
}
```

**metadata-aspect.ts**

```
import {defineAssetAspect, defineField} from 'sanity'

export default defineAssetAspect({
  name: 'metadata',
  title: 'Metadata',
  type: 'object',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
    }),
    defineField({
      name: 'description',
      title: 'Description',
      type: 'text',
    }),
    defineField({
      name: 'creditLine',
      title: 'Credit line',
      type: 'string',
    }),
    defineField({
      name: 'altText',
      title: 'Alt text',
      type: 'string',
    }),
    defineField({
      name: 'originalFilename',
      title: 'Original filename',
      type: 'string',
    }),
    defineField({
      name: 'tags',
      title: 'Tags',
      type: 'array',
      of: [{type: 'string'}],
    }),
    
  ],
})

```



# Media Library API reference

The Media Library API lets you programmatically interact with assets in your organization’s Media Library.

#### Want to get started?

[Media Library introduction](https://www.sanity.io/docs/media-library/introduction)
Learn about Media Library, how to incorporate it into your workflow, and how to get started.

[Upload assets programmatically](https://www.sanity.io/docs/media-library/upload-assets)
Programmatically upload assets to your Media Library.

[Folders](https://www.sanity.io/docs/media-library/folders)
Organize Media Library assets into a navigable hierarchy with folders.

## Authentication

- All requests to private data must be [authenticated](https://www.sanity.io/docs/content-lake/http-auth). Requests to public information, like public assets, are available without an authentication token.
- Manipulating documents requires read+write access permission for Media Library.



# Media Library CLI commands

Interact with Media Library with the `npx sanity media` command.

**npm**

```shell
npx sanity media --help
```

**pnpm**

```shell
pnpm dlx sanity media --help
```

**yarn**

```shell
yarn dlx sanity media --help
```

**bun**

```shell
bunx sanity media --help
```

The `media` command must be run from within a directory that contains a valid `santy.cli.ts` configuration file. We recommend running it from within an existing Sanity project. [Learn more about configuring Media Library](https://www.sanity.io/docs/media-library/configure-library).

## Commands

### `create-aspect`

**CLI output**

```sh
USAGE
  $ sanity media create-aspect [--name <value>] [--title <value>]

FLAGS
      --name=<value>   Aspect name. Defaults to the title in camel case
      --title=<value>  Aspect title

DESCRIPTION
  Create a new aspect definition file

EXAMPLES
  Create a new aspect definition file

    $ sanity media create-aspect
```

### `delete-aspect`

**CLI output**

```sh
USAGE
  $ sanity media delete-aspect ASPECTNAME [-p <id>] [--yes] [--media-library-id <value>]

ARGUMENTS
  ASPECTNAME  Name of the aspect to delete

FLAGS
  -y, --yes                       Run without prompts and confirm deletion
      --media-library-id=<value>  The id of the target media library

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to delete media aspect from (overrides CLI configuration)

DESCRIPTION
  Delete an aspect definition

EXAMPLES
  Delete the aspect named "someAspect"

    $ sanity media delete-aspect someAspect
```

### `deploy-aspect`

**CLI output**

```sh
USAGE
  $ sanity media deploy-aspect [ASPECTNAME] [-p <id>] [--all] [--media-library-id <value>]

ARGUMENTS
  [ASPECTNAME]  Name of the aspect to deploy

FLAGS
      --all                       Deploy all aspects
      --media-library-id=<value>  The id of the target media library

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to deploy media aspect to (overrides CLI configuration)

DESCRIPTION
  Deploy an aspect

EXAMPLES
  Deploy the aspect named "someAspect"

    $ sanity media deploy-aspect someAspect

  Deploy all aspects

    $ sanity media deploy-aspect --all
```

### `export`

**CLI output**

```sh
USAGE
  $ sanity media export [DESTINATION] [-p <id>] [--asset-concurrency <value>] [--media-library-id <value>] [--no-compress] [--overwrite]

ARGUMENTS
  [DESTINATION]  Output destination file path

FLAGS
      --asset-concurrency=<value>  Concurrent number of asset downloads
      --media-library-id=<value>   The id of the target media library
      --no-compress                Skips compressing tarball entries (still generates a gzip file)
      --overwrite                  Overwrite any file with the same name

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to export media from (overrides CLI configuration)

DESCRIPTION
  Export file and image assets from a media library (excludes video)

EXAMPLES
  Export media library interactively

    $ sanity media export

  Export media library to output.tar.gz

    $ sanity media export output.tar.gz

  Export specific media library

    $ sanity media export --media-library-id my-library-id
```

### `import`

**CLI output**

```sh
USAGE
  $ sanity media import SOURCE [-p <id>] [--media-library-id <value>] [--replace-aspects]

ARGUMENTS
  SOURCE  Image file or folder to import from

FLAGS
      --media-library-id=<value>  The id of the target media library
      --replace-aspects           Replace existing aspect data. All versions will be replaced (e.g. published and draft aspect data)

OVERRIDE FLAGS
  -p, --project-id=<id>  Project ID to import media to (overrides CLI configuration)

DESCRIPTION
  Import a set of assets to the target media library.

EXAMPLES
  Import all assets from the "products" directory

    $ sanity media import products

  Import all assets from "gallery" archive

    $ sanity media import gallery.tar.gz

  Import all assets from the "products" directory and replace aspects

    $ sanity media import products --replace-aspects
```



# Limits and usage

This article describes limits in the Media Library and discusses techniques for leveraging your project bandwidth when rendering Media Library assets.

## Media Library limits

The Media Library APIs share the same [technical limits defined here](https://www.sanity.io/docs/content-lake/technical-limits) for rate limiting, HTTP requests, and asset details. Key limits for Media Library uploads are:

- Maximum file size: up to 5 TB
- Maximum upload duration: 1 hour
- Maximum image size: 256 megapixels (images only)
- Maximum request body size: 100 MB

## Library and project usage

When using the Media Library, you'll interact with a few different types of APIs. Some APIs are specific to the library, others apply globally across your organization, and others are specific to a project where you're running your studio.

Storing assets in the library will count against the asset limits defined by your plan. However, using the Media Library does not count against any document limits you have in your projects.

When you use an image or file asset within a studio, the asset becomes available through the standard project APIs for [presenting the media](https://www.sanity.io/docs/apis-and-sdks/presenting-images). When you present the media this way, your bandwidth usage is accounted for through your project. This means that rendering image and file assets that you've attached to a dataset/studio apply to that project's bandwidth, not the bandwidth of the Media Library.

> [!TIP]
> When is library bandwidth used?
> The library has a "Copy Media URL" option to generate a URL to render the selected asset. This URL isn't linked to any particular dataset or studio. If you render an image or file through this URL, then the usage will count against your Media Library bandwidth. You might want to do this if you're using the asset in a social media post or an email—locations outside your studio-driven applications.

### Dataset exports include linked assets

Linking a library asset to a dataset creates an asset document in that dataset, with a URL on the project's CDN. `sanity dataset export` downloads the binary for every asset document it finds. Linked assets are therefore bundled into the export archive at full size, the same as assets uploaded directly to the dataset. A dataset that gets most of its media from a library can still produce a multi-gigabyte export.

To leave asset binaries out, run the export with `--no-assets`. That flag also drops the asset documents and strips asset references from the exported documents, so the result isn't a complete copy of the dataset. Add `--raw` alongside it to keep the references in place. For every export flag, see the [Datasets CLI command reference](https://www.sanity.io/docs/cli-reference/cli-datasets).

Deleting an asset from the library doesn't remove the asset document that linking created in your dataset. The library blocks deletion while a document still references the asset, so this affects assets that were linked and later went unused. The dataset keeps the asset document, its binary is gone, and the next export reports a 404 for it.

A 404 during export is a warning, not a failure. The export prints `⚠ Asset failed with HTTP 404 (ignoring)` with the asset document ID, skips that asset, and continues.

To confirm whether a library asset still exists, take the asset ID from the last segment of the document's `media._ref` and query the library for it:

**check-asset-exists.ts**

```typescript
const mediaLibraryId = 'MEDIA_LIBRARY_ID'
const assetId = 'ASSET_ID' // The last segment of media._ref
const token = process.env.SANITY_API_TOKEN

const query = encodeURIComponent(`*[_type == 'sanity.asset' && _id == '${assetId}']{_id}`)

await fetch(`https://api.sanity.io/v2025-02-19/media-libraries/${mediaLibraryId}/query?query=${query}`, {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${token}`
  }
})
```

An empty `result` array means the asset is no longer in the library. The asset document left in your dataset has no binary behind it, which is what produces the 404.

## Video usage

Video assets count against your Media Library quota, but are handled differently from images and files.

You can download the original file you uploaded and its static renditions from the library. For playback, video is streamed through an optimized video CDN rather than the standard Sanity CDN. See [Working with video](https://www.sanity.io/docs/media-library/working-with-video) for how to present video and download files.

## Folder limits

Folders inherit limits from the [Content Lake hierarchy primitive](https://www.sanity.io/docs/content-lake/technical-limits).

For Media Library specifically:

- Place assets inside a `sanity.directory` rather than directly under the tree. The primitive permits both, but the Media Library only supports assets in directories.
- Shortcuts (`sanity.symlink`) only support `sanity.asset` targets.



# Developer guides

#### Getting Started

[An opinionated guide to Sanity Studio](https://www.sanity.io/docs/developer-guides/an-opinionated-guide-to-sanity-studio)
Sanity Studio is an incredibly flexible tool with near limitless customisation. Here's how I use it.

[Deciding on fields and relationships](https://www.sanity.io/docs/developer-guides/deciding-fields-and-relationships)
How to work through tricky content questions and build structures that will stand the test of time. 

[Beginners guide to Portable Text](https://www.sanity.io/docs/developer-guides/beginners-guide-to-portable-text)
Discover the power of Portable Text with this essential guide. From data structure, serialisation to validation strategies, you'll learn everything you need to harness its potential.

[Get started](https://www.sanity.io/docs/ai/get-started)
Set up the Sanity Agent Toolkit and MCP server to help AI assistants generate high-quality Sanity code that follows established best practices.

#### Portable Text Customization

[Adding things to Portable Text - From block content schema to React component](https://www.sanity.io/docs/developer-guides/ultimate-guide-for-customising-portable-text-from-schema-to-react-component)
This Guide will lead you through the all the steps you need to level-up your use of Portable Text: from setting up block content, adding custom blocks and renderers for the Portable Text Editor in your studio. But also help you query for everything and render your awesome content in React!

[Add Inline blocks for the Portable Text Editor](https://www.sanity.io/docs/developer-guides/add-inline-blocks-to-portable-text-editor)
Enrich your content and add inline blocks to your Portable Text Editor. This guide takes you from schema to query output

[How to add custom YouTube blocks to Portable Text](https://www.sanity.io/docs/developer-guides/portable-text-how-to-add-a-custom-youtube-embed-block)
How to add a YouTube embed in the Studio, and render it on front ends

[Presenting Portable Text](https://www.sanity.io/docs/developer-guides/presenting-block-text)
Transform Portable Text to whatever you want

[Change the height of a Portable Text Editor (PTE) using a custom input component](https://www.sanity.io/docs/developer-guides/change-the-height-of-a-portable-text-editor-pte-using-a-custom-input-component)
Ever used a PTE and thought you would like it to take up less space and be focusable without activating it? Now you can!

#### Studio Customization

[Browsing Content How You Want with Structure Builder](https://www.sanity.io/docs/developer-guides/getting-started-with-structure-builder)
How to make content more browseable using the Structure Builder API for Sanity Studio‘s Desk Tool

[Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
Delight your content creators with intelligent inputs for more complex data structures

[Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
Go beyond a plain radio list of inputs by giving authors more contextually useful buttons to select values from.

[Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
Make repetitive content creation tasks a breeze by supplying content creators with buttons to populate complex fields.

[Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
Object types use a preview property to display contextual information about an item when they are inside of an array; customizing the preview component can make them even more useful for content creators.

[Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)
Summarise form progression by decorating the entire editing form for a document with a component loaded at the root level.

#### Advanced Concepts

[High-performance GROQ](https://www.sanity.io/docs/developer-guides/high-performance-groq)
GROQ gives you a fast, expressive way to query data from Sanity.

[GROQ-Powered Webhooks – Intro to Filters](https://www.sanity.io/docs/developer-guides/filters-in-groq-powered-webhooks)
A thorough intro to using GROQ-filters in a webhook-context

[GROQ-Powered Webhooks – Intro to Projections](https://www.sanity.io/docs/developer-guides/projections-in-groq-powered-webhooks)
A thorough intro to using GROQ-projections in a webhook contest

[How to implement Multi-tenancy with Sanity](https://www.sanity.io/docs/developer-guides/multi-tenancy-implementation)
In this guide, you’ll see how Sanity separates organizations, projects, datasets, and members by working through a hypothetical example of a growing company that can expand its content model as they grow – without needing a complete overhaul.

[Paginating with GROQ](https://www.sanity.io/docs/developer-guides/paginating-with-groq)
Learn efficient pagination in GROQ using cursor-based filtering instead of array slicing. Covers tiebreakers for non-unique fields and batch processing.

[How to use structured content for page building](https://www.sanity.io/docs/developer-guides/how-to-use-structured-content-for-page-building)
Learn how to create a page builder from structured content that can withstand the test of time and redesigns.

#### Content OS & Organizations

[Agencies: Navigating the Spring 2025 Organization Changes](https://www.sanity.io/docs/developer-guides/agencies-navigating-the-spring-2025-organization-changes)
How to handle the changes to Organizations and Projects in Sanity

[Setting up single sign-on with SAML](https://www.sanity.io/docs/developer-guides/sso-saml)
This article will take you through the process of setting up SAML (Security Assertion Markup Language) SSO (single sign-on) for your organization.

[Set up SSO authentication with SAML and Azure/Entra ID](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-azure)
Implement single sign-on authentication with the SAML protocol and Microsoft Azure AD/ Entra ID as the identity provider.

[Reconcile users against internal systems](https://www.sanity.io/docs/developer-guides/remove-project-users-in-bulk)
Use Sanity API's to compare current project members against an internal list to remove those that no longer require access

#### Integration Patterns

[Integrating external data sources with Sanity](https://www.sanity.io/docs/developer-guides/integrating-external-data)
Learn the 2 most common patterns for integrating external data sources with Sanity

[How to implement front-end search with Sanity](https://www.sanity.io/docs/developer-guides/how-to-implement-front-end-search-with-sanity)
By integrating Sanity's structured content with Algolia, you can provide your users with fast, relevant search results

[Forms with Sanity](https://www.sanity.io/docs/developer-guides/forms-with-sanity)
Common approaches for using forms with Sanity

[Managing redirects with Sanity](https://www.sanity.io/docs/developer-guides/managing-redirects-with-sanity)
How to use Sanity to control redirects in your JavaScript framework of choice.

[Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide)
Learn to use the Live Content API with Next.js or your own integration for real-time content updates in your app.



# Best practices

> [!NOTE]
> Just want to get started?
> If you want an agent to set up a Sanity project for you, start with [Quickstart: AI coding agents](https://www.sanity.io/docs/getting-started/ai-coding-agents) or [Quickstart: AI app builders](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart). This page goes deeper on the AI tooling itself.

AI tools can dramatically accelerate Sanity development, but without proper guidance, they often produce generic code that fails to leverage Sanity's full capabilities.

This guide will help you:

- Set up AI tools to generate high-quality Sanity code.
- Avoid common pitfalls of AI-generated configurations.
- Implement best practices from Sanity into your AI workflow.

## Configure the MCP server

The fastest way to connect your AI tools to Sanity is with the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server). Run the following command to automatically detect and configure the MCP server for Cursor, Claude Code, and VS Code:

**npm**

```shell
npx sanity@latest mcp configure
```

**pnpm**

```shell
pnpm dlx sanity@latest mcp configure
```

**yarn**

```shell
yarn dlx sanity@latest mcp configure
```

**bun**

```shell
bunx sanity@latest mcp configure
```

This detects and configures the MCP server automatically. See the [MCP server documentation](https://www.sanity.io/docs/ai/mcp-server) for manual configuration options and troubleshooting. If you’re starting a new project with `sanity init`, the CLI will help you set up the MCP server as part of the setup steps. 

## Add Sanity skills and plugins

The [Sanity Agent Toolkit](https://github.com/sanity-io/agent-toolkit) is a collection of resources to help AI agents build better with Sanity.

It includes:

- **Agent skills** covering Sanity best pracitces, AEO/SEO, content modelling and personalisation.
- **Claude Code plugin** with slash commands and interactive skills for common workflows.
- **Cursor plugin** with automatic MCP setup, slash commands and agent skills all included

You can also install skills directly by running the following command from your project directory:

**npm**

```shell
npx skills add sanity-io/agent-toolkit
```

**pnpm**

```shell
pnpm dlx skills add sanity-io/agent-toolkit
```

**yarn**

```shell
yarn dlx skills add sanity-io/agent-toolkit
```

**bun**

```shell
bunx skills add sanity-io/agent-toolkit
```

## Leveraging documentation content

The Sanity documentation has several ways you can use AI to get the job done:

- **For quick, specific reference**: Use the **Copy article** button on all articles that puts the markdown version of the content on your clipboard. You can also add `.md` at the end of any article URL to get the markdown version.
- **For comprehensive context**: You can point LLMs to `/docs/llms.txt` and `/docs/llms-full.txt` to access all the links and the full corpus as markdown formatted content.
- **For interactive queries**: Use the [MCP server](https://www.sanity.io/docs/ai/mcp-server)'s `search_docs` and `read_docs` tools.
- **For CLI-based work**: You can even tell LLMs to use `sanity docs search` and `sanity docs read` to find docs articles.

In tools like Cursor that support local docs, you can add the Sanity Docs and Learn materials directly by typing `@Docs` in their agent chats.

## Leveraging Sanity Learn content

All course and lesson material on [Sanity Learn](https://www.sanity.io/learn) is also available in the LLM-friendly `llms.txt` standard. You can read [how we made this](https://www.sanity.io/blog/improving-the-agent-experience-for-sanity-learn) on our blog.

There are two different sizes you can import into your IDE:

- `/llms.txt` is an abbreviated index of all the content with links.
- `/llms-full.txt` is the complete content (sometimes optimized to fit within the context window limits).



# Paginating with GROQ

## What is pagination?

It's often necessary to display a large amount of content. For example, a shopping site may want to show thousands of products and let the user navigate them, page by page.

GROQ lets you sort and slice your data, and it's tempting to use array slicing to select the data to display on a page. For example, if we think of a traditional web page with a **Next Page** link, the first page might show results 1–100, while the next page shows results 101–200, and so on. We can also use page numbers, and calculating the offset range from a page number is simple.

However, the most obvious way to do pagination isn't actually very performant. In this article, we'll explore different ways to do it.

## Prerequisites

- Familiarity with GROQ basics such as filters, ordering, and projections. See [How GROQ queries work](https://www.sanity.io/docs/content-lake/how-queries-work) for an introduction.
- A configured Sanity client for running queries from your application code. See [Querying content with @sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-querying).

## The less efficient approach: array slicing

One approach to pagination would be to use array slicing. For this example, we want to fetch articles sorted by their ID:

```groq
*[_type == "article"] | order(_id) [100...200] {
  _id, title, body
}
```

While GROQ keeps this query short, this is actually surprisingly inefficient. To understand why that is, we need to look at how the GROQ execution engine operates on your dataset's content.

In order to slice your dataset like this, GROQ needs to fetch all the content and then sort it (this is true even if you don't use `order()`, because results are always ordered in some way). It then needs to skip all the documents that aren't included in the slice range.

The sorting can usually happen while fetching, and we can apply some magic to limit the total number of results we need to look at. But the engine still has to skip all the unwanted results; it can't "teleport" to a specific position.

In this case, it will need to fetch 200 documents and then skip the first 100.

If you only have a few hundred documents in your dataset, the performance drop might not be that noticeable. The problem only becomes measurable once you reach thousands of results. For example, it's not unthinkable that a query such as `*[10000...10100]` could take several seconds to execute.

Generally, you can expect slicing performance to be roughly linear relative to the slice range. For example, if the range `100...200` takes 5ms to run, then you can expect `200...300` to take about 10ms, `300...400` to take 15ms, and so on.

## A better approach: filtering

We can use GROQ's filtering capabilities in combination with sorting to quickly skip elements. This is much more efficient than slicing, because it allows the GROQ query engine to throw away lots of results very efficiently.

> [!NOTE]
> Make sure all parts of your pagination query are optimized
> At this moment, not all functions etc. are optimised to be used in filters for pagination.
> If you want to make sure that your pagination queries run smoothly, try to follow the article on [High-performance GROQ](https://www.sanity.io/docs/developer-guides/high-performance-groq) in your queries!

The very first page is exactly the same as before; we fetch the first 100 results:

```groq
*[_type == "article"] | order(_id) [0...100] {
  _id, title, body
}
```

The difference in the approach becomes clear when we want to fetch the second page. To find the second page, we first need to know what the last document we looked at was, which we can find from the last document ID in the array of results. Once we have that information, we can plug it back into our next query as a query parameter:

```groq
*[_type == "article" && _id > $lastId] | order(_id) [0...100] {
  _id, title, body
}
```

The key part here is `_id > $lastId`. By adding such a filter, we are skipping past all the results that were present in the first batch of results. 

Getting the third page is exactly the same: After getting the second page, we need to look at the last ID and then continue from there.

To tie everything together, our code will end up looking something like this:

```javascript
let lastId = ''

async function fetchNextPage() {
  if (lastId === null) {
    return []
  }
  const result = await client.fetch(
    groq`*[_type == "article" && _id > $lastId] | order(_id) [0...100] {
      _id, title, body
    }`, {lastId})
  
  if (result.length > 0) {
    lastId = result[result.length - 1]._id
  } else {
    lastId = null // Reached the end
  }
  return result
}

```

> [!NOTE]
> Why _id comparison is safe
> Document IDs in Sanity are lexicographically sortable strings. When you compare `_id > $lastId`, GROQ performs string comparison, which produces a stable, deterministic order across all documents, even though IDs appear random.
> This means:
> - The same query will always return documents in the same order.
> - No documents are skipped between pages.
> - The pagination cursor is reliable and consistent.
> Using `_id` as a tiebreaker ensures a stable order when multiple documents share the same value for the field you sort by (see the tiebreakers section below).

## Sorting on other fields: tiebreakers to the rescue

One thing we didn't tell you is that the above solution is a partial one: **It only works on fields whose values are unique in your dataset**. If you want to sort on something non-unique like a "published at" field, you need to do a little bit more work.

The filter technique doesn't work properly for non-unique fields because the filter will skip duplicate values, even if the documents are different. For example, imagine the filter, once again, but for a `publishedAt` field:

```groq
publishedAt > $lastPublishedAt
```

If you have more than one document with the same `publishedAt` timestamp at the pagination boundary, this filter will actually skip documents by accident: the filter only matches values strictly greater than the last one, so documents sharing that boundary value are skipped.

The solution is to use a tiebreaker, which is another field that takes priority if more than one document with the same `publishedAt` is encountered. As before, the first page needs no special handling:

```groq
*[_type == "article"] | order(publishedAt) [0...100] {
  _id, title, body, publishedAt
}
```

Notice how we are also asking for the `publishedAt` attribute. We'll need this for the next page.

Here's how we would fetch the next page:

```groq
*[_type == "article" && (
  publishedAt > $lastPublishedAt
  || (publishedAt == $lastPublishedAt && _id > $lastId)
)] | order(publishedAt) [0...100] {
  _id, title, body, publishedAt
}
```

As before, we are filtering. Our main filter is `publishedAt > $lastPublishedAt`, which allows us to continue from the last page. But we also include a tiebreaker: If we have a document whose `publishedAt` is the same as the last one, we instead ask that it be higher than the last *document ID*.

As before, our client code would look something like this:

```javascript
let lastPublishedAt = '' 
let lastId = ''

async function fetchNextPage() {
  if (lastId === null) {
    return []
  }

  const result = await client.fetch(
    groq`*[_type == "article" && (
      publishedAt > $lastPublishedAt
      || (publishedAt == $lastPublishedAt && _id > $lastId)
    )] | order(publishedAt) [0...100] {
      _id, title, body, publishedAt
    }`, {lastPublishedAt, lastId})
  
  if (result.length > 0) {
    lastPublishedAt = result[result.length - 1].publishedAt
    lastId = result[result.length - 1]._id
  } else {
    lastId = null  // Reached the end
  }
  return result
}

```

## What if the data changes during pagination?

One key difference between filter-based and slice-based pagination is how they behave when the data changes.

Slicing will always operate on the entire dataset as a sequence. This means that the following can happen:

1. The user views page 1 of the results, which are sorted by date.
2. You publish five new documents.
3. The user navigates to page 2.
4. The user will now see a repetition of the last five documents they saw on page 1.

This cannot happen if you are using the filter approach.

## Finding the total number of documents

Something you may want is to display the total number of results somewhere. This can be accomplished by wrapping your query in a `count()`:

```groq
count(*[_type == "article"])
```

This query should be relatively fast, but watch out for large datasets, as the speed of counting is relative to the number of results.

## Using this technique in a real-world application

### Tracking navigation state

In order to provide forward and backward navigation in a real-world application, we'll need to store the navigation state about the last page we saw:

- In a React application, you can use `useState()` to store the `lastId` information as state. If you want to support backward navigation, you will also have to store the previous one.
- In a traditional backend app (Rails, Django, etc.), you may want to encode this as a query parameter instead. For example, a URL might look like this: `https://example.com/products?lastId=4a3b2e84`.

### Page numbers

The main downside to filtering is that there's no way to easily jump to any page number (there's no *random access*). In order to render the right page, we need to have visited its preceding pages. But it's still possible to implement number-based navigation.

To show the **previous** page numbers (relative to the current page), you can track the current page number, which increases by one for each forward navigation. To enable quick navigation to a past page, you can also store some state about each `lastId` encountered during forward navigation (e.g. stored as an array of IDs).

To show the **next** page numbers, you need to do a `count()` pass that counts the total number of results that follow the last `lastId`. (See the “Finding the total number of documents” section above.) To determine the ID that each next page corresponds to, your application then needs to be able to randomly skip results. For example, if the user is on page 2 and wants to jump to page 10, you can find the next `lastId` with this query:

```groq
*[_type == "article" && _id > $lastId][$index]._id
```

The value for `$index` should be set to `($pageNumber - $currentPage) * $pageSize - 1`.

Note that this query can be slow, and you probably don't want to offer this type of navigation past a certain page number.

> [!TIP]
> Protip
> **Do you need page numbers?** It may be tempting to render a traditional navigation bar with links to each numbered page. But you may want to think about whether this UX pattern even makes sense for your application. What a user *mostly* wants, we would argue, is to go forward and backward.

## Batch processing

Everything above has been written with a frontend application in mind. But pagination is also relevant in a different scenario: when we want to execute a query that returns many results, perhaps even the entire dataset, and we want to process those results.

Some examples:

- A batch process that runs through the entire dataset and updates a field.
- A static site builder job that builds a website from a Sanity dataset, using something like Next.js or Gatsby.
- A job that exports a big subset of the data to a file.

For such scenarios, we strongly recommend paginating by filtering, and by sorting the dataset by `_id`. Since all datasets are already physically sorted by ID, this is the most performant way to sort the dataset, and avoids the need for a tiebreaker.

You may also want to experiment with different batch sizes. Larger batch sizes are usually faster than small ones, but only up to a point.

> [!TIP]
> Protip
> In the general case, we recommend a batch size of no more than 5,000. If your documents are very large, a smaller batch size is better.

## Next steps

Continue with these guides to keep your queries fast and get more out of GROQ.

[High-performance GROQ](https://www.sanity.io/docs/developer-guides/high-performance-groq)
Learn techniques for writing GROQ queries that stay fast as your dataset grows.

[GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet)
Browse example queries for common content scenarios.

[GROQ functions reference](https://www.sanity.io/docs/specifications/groq-functions)
Look up the built-in functions you can use in your queries.



# High-performance GROQ

Occasionally you may discover that queries that were initially fast when the dataset was small have become slower as the dataset has grown and the queries have become more complex and resource-heavy during development.

Unlocking optimization in the way you write queries can be helped by understanding the way in which a query is executed. In this document we'll explain how the query engine works, and how to avoid certain pitfalls that can make your queries slow.

## Understanding fetching, filtering, and sorting

The GROQ query engine is designed to deconstruct a query into a set of *pipelines* according to a query plan. A pipeline has the following broad structure:

![Query pipeline diagram](https://cdn.sanity.io/images/3do82whm/next/502007bb22c147e11f8a6803977f25bc5da4aa42-1780x670.jpg)
*The query pipeline*

Most GROQ queries start by **fetching** data. The shortest query you can write looks like this, and will return all documents:

```groq
// Fetch all documents
*[]
```

Anything we write inside of a pair of square brackets will **filter** the results. Let’s update our query to filter for just `product` type documents:

```groq
// Only fetch documents where the '_type' field equals 'product'
*[_type == "product"]
```

This filter is made of three parts:

- `_type` is an **identifier**, referring to the attribute of a document
- `==` is an **operator**, “equals”
- `"product"` is a **literal**, in this instance a string

This query is fast because we know ahead of time what we want `_type` to equal. So the query can perform a **filtered fetch** against the dataset’s indexes for exact matches. Internally, the query engine uses special index structures to optimize such queries, which may be familiar to you from traditional SQL databases.

Queries that can do filtered fetches are the fast type of query. But it’s also possible to write a query that is unable to do filtering efficiently, which will often make the query execute slowly.

> [!WARNING]
> Gotcha
> **Inefficient filtering may not become apparent until your dataset reaches a certain size**. Small datasets are inherently fast to query. A query that executes against a dataset of five documents may take a millisecond. But once your dataset grows to thousands of documents, performance issues may start to crop up.

Similarly to filtering, using **sorting** in a query can be unexpectedly slow. The `order()` function is designed to also make use of index structures, which means it generally only accepts simple attributes or attribute paths. For example, the following query cannot be optimized and must load the entire result set into memory to sort it:

```groq
// This cannot be optimized, because the order expression uses string concatenation
*[_type == "person"] {
  firstName, lastName
} | order(firstName + " " + lastName)
```

### Unfiltered over-fetches

It’s possible to write filters that cannot be optimized to make use of our internal index structures. One example is when they use a non-literal expression on both sides of the operator.

Imagine our product documents have both `salePrice` and `displayPrice` number fields. Since the Content Lake is schemaless and we know these fields only exist on product documents, let’s fetch all products that are currently discounted with the shortest possible query.

```groq
// Fetch all documents
// ...then filter down to those where salePrice is less than displayPrice
*[salePrice < displayPrice]
```

- `salePrice` is an **identifier**, assumed to be a numeric attribute that exists
- `<` is an **operator**, “less than”
- `displayPrice` is an **identifier**, assumed to be a numeric attribute that exists

This is not something that the query engine can optimize, because neither `salePrice` nor `displayPrice` are known ahead of time. In order to satisfy the filter expression, we explicitly need to look at every single document.

So in this case, the GROQ query engine must **over-fetch** for all documents, loading every single document into memory and then filtering the results by the expression.

> [!TIP]
> Protip
> **See reference below**. For a list of optimizable filter expressions, see the reference section at the end of this document.

### The parent operator

The parent operator `^` is a notable exception to the rule above. Consider a document that contains a reference to a parent document. An additional subquery will fetch all documents that also have the same parent reference:

```groq
// Direct-use of the parent operator is optimized
*[_type == "person"] {
  _id, parent,
  "siblings": *[_type == ^._type && parent._ref == ^._id] 
}
```

Note: This is currently only optimized where the expression satisfies the expressions listed in the Reference: Filter expressions section at the end of this document. Using an expression on `^` together with a function, string concatenation, etc. may not be optimized. For example:

```groq
// String concatenation of the operator is not optimized 
* { _id, "draft": *[_id == "drafts." + ^._id] }
```

### Counting aggregation

Similarly to filtering, a `count()` is only optimizable if its interior expression is completely optimizable. For example:

```groq
count(*[_type == "person" && isPublished])
```

This, on the other hand, is not optimizable:

```groq
// Not optimizable, because it uses == with a computed expression
count(*[_type == "person" && (firstName + " " + lastName) == "Ronald McDonald"])
```

### Sorting

As with filtering, sorting is only optimizable on certain types of expressions such as single attributes:

```groq
* | order(name)
```

### Deep array indexing and slicing

It's worth talking about what happens when you slice a query result:

```groq
// Deep index
*[_type == "article"][10000]

// Deep slice
*[_type == "article"][10000..10100]
```

All results returned by `*` always have a sort order; even if you don't specify an `order()`, the results will be ordered by `_id`. This means that to slice a subset of the results, the entire dataset needs to be sorted and the first 10,000 results "skipped" over in order to reach the slice range.

While this sorting and skipping is quite fast, the performance is directly related to the number of results that are skipped. For example, if getting `[1000]` takes 100ms, you can expect `[2000]` to take about 200ms.

### Query performance and dataset size

Dataset growth should have **no impact** on optimized, filtered queries that return the same amount of data. For example, this query should return at the same speed whether there are 100 or 100,000 documents in the dataset.

```groq
*[slug.current == "discounted"]._id
```

## Tips and tricks

### Reduce search space by “stacking” filters

If your query cannot be written to avoid this comparison of non-literals, you can stack additional filters into your query to reduce the number of documents loaded into memory before filtering.

For example, we can be explicit about the `_type` and in this hypothetical we know that not all products even have a `salePrice` field.

```groq
// Only fetch documents where:
// the '_type' field equals 'product' and it has a 'salePrice' field
// ...then filter down to those where salePrice is less than displayPrice
*[_type == "product" && salePrice != null && salePrice < displayPrice]
```

This query can now run faster because it only needs to look at products where the `salePrice` exists as an attribute. But it's best to avoid comparisons of non-literals where possible.

### Avoid repeated resolving of references

GROQ resolves references with the reference access operator `->`. The simplicity of this syntax hides its functionality, however. This `->` operator is actually a subquery in disguise:

```groq
// Fetch all categories titles and parents:
*[_type == "category"] {
  title,
  parent->
}

// ...is the same as:
*[_type == "category"] {
  title,
  "parent": *[_id == ^.parent._ref][0] 
}
```

This is fine in the above instance where we are resolving the reference once. However, needlessly repeating the `->` operator will perform that subquery over and over.

```groq
// Slow, repeated subquery
*[_type == "category"] {
  title,
  "slug": slug.current,
  "parentTitle": parent->title, 
  "parentSlug": parent->slug.current
}
```

We can instead use the expansion operator, `...`:

```groq
// Merge a single subquery into the root level of the result
*[_type == "category"] {
  title,
  "slug": slug.current,
  ...(parent-> {
    "parentTitle": title,
    "parentSlug": slug.current
  }) 
}
```

### Reduce the amount of data returned

The expansion operator `...` is a convenient way to return all data. However, this may be returning significantly more data than required. This can impact the speed at which your data is returned.

For example, our query for discounted products is currently returning every field in each document. But we could scope this down to required parts once our frontend knows exactly what it needs.

```groq
// Return all fields and resolve all fields in all 'categories' references
*[_type == "product" && defined(salePrice) && salePrice < displayPrice]{
  ...,
  categories[]->
}

// Return just these required fields and the title of each 'categories' reference
*[_type == "product" && defined(salePrice) && salePrice < displayPrice]{
  title,
  salePrice,
  displayPrice,
  "categories": categories[]->title
}
```

### Avoid joins in filters

The `->` operator can be used to pull in related data. However, it is a comparatively expensive operation, and should be used with care.

It is particularly expensive to use `->` inside a filter expression:

```groq
*[_type == "post" && author->name == "Bob Woodward"]
```

Avoiding this can be difficult, but spending a little more time on the data modeling can be worth the effort. While “denormalizing” a data model is often considered a negative, a little denormalizing for frequently used “core” fields can significantly improve query performance.

Another common pitfall is using a related document’s field as an “identity” field, rather than relying on that document’s ID. This is a sign of poor data modeling:

```groq
*[_type == "post" && vertical->slug.current == "football"]
```

Instead, consider using the ID itself:

```groq
*[_type == "post" && vertical._ref == "football-doc-id"]
```

### Avoid resolving assets

It may be tempting to resolve an asset reference in order to get its URL. This URL is to the full-size image and therefore not optimized. The rest of the metadata on the asset record may not be used by your frontend and adds bloat to the amount of data returned.

```groq
*[_type == "product"]{
  // Resolves much more metadata than you probably need
  image->,

  // The URL of a full-size, unoptimized image
  "imageUrl": image->asset.url,

  // Just get the image _ref and dynamically create the URL
  image
}
```

However, the `_id` assigned to an image once uploaded is deterministic, unique to that specific file, and contains a lot of data about the image itself like its filetype and size.

So you can [dynamically create a URL](https://www.sanity.io/docs/apis-and-sdks/image-urls) using just the `projectId`, `dataset`, and `_id` of the image.

### Avoid reusing projected values

A common mistake is to use a projection expression, followed by additional query expressions that filter or sort. For example:

```groq
// Sorting on a projected attribute
*[_type == "person"] {
  "name": firstName + " " + lastName,
  "isBoss": role->name == "boss",
} | order(isBoss, name)

// Filtering on a projected attribute
*[_type == "person"] {
  "isBoss": role->name == "boss",
}[isBoss == true]
```

In these two queries, the expressions we use (`order(isBoss, name)` and `isBoss == true`) look like they satisfy the constraints we explained earlier; after all, they are using simple attributes. But they are in fact not optimizable, since the attributes in question are computed at query time.

> [!TIP]
> Protip
> The query engine is smart enough to know if an attribute is merely being renamed or moved around. For example, `*{ "foo": bar.baz } | order(foo)`. Here, even though `foo` is a “made up” attribute, the query engine understands that it can sort on `bar.baz`.

### Avoid large queries

The bigger a GROQ query, the longer the engine takes to parse and plan it. It can also be slow for a web browser to send several hundred kilobytes of GROQ to the backend.

### Avoid slicing when "paginating"

It's tempting to use slicing to fetch results in pages:

```groq
*[_type == "article"] | order(_id)[1000..1020]
```

As described above, this is inefficient. You can make this faster by paginating by a field instead:

```groq
*[_type == "article" && _id > $lastId] | order(_id)[0..20]
```

For each page, you can note down the highest `_id` and use that as the next `$lastId`.

### Parallelize independent queries

A common technique for “page builder”-driven applications is to build a “superquery” that collects data for multiple components at once:

```groq
{
  "topPosts": *[_type == "post" && category == $category]
    | order(popularity desc)[0..30],
  "news": *[_type == "news"] | order(_createdAt desc)[0..10],
  "user": *[_type == "user" && _id == $id],
}
```

This could be broken up into three queries, which allows them to be parallelized. The `topPosts`, `news`, and `user` queries would each be fetched separately, as in the following example using the [JavaScript client](https://www.sanity.io/docs/js-client): 

```javascript
const topPostsParams = {
  category: // ...
}
const userParams = {
  id: // ...
}

const topPosts = client.fetch(`*[_type == "post" && category == $category] | order(popularity desc)[0..30]`, topPostsParams)
const news = client.fetch(`*[_type == "news"] | order(_createdAt desc)[0..10]`)
const user = client.fetch(`*[_type == "user" && _id == $id]`, userParams)
```

## Explain mode

If you want to look at how your query is executed by the query engine, you can ask the API for a query plan. You do this by providing the `explain` parameter, which you can read more about in the [API reference](https://www.sanity.io/docs/http-api).

> [!NOTE]
> The explain format
> The format used for the explain output is currently undocumented and can be difficult to understand if you are not familiar with the query engine.

## Reference: Filter expressions

The following filter expressions can always be optimized. **This is not an exhaustive list**.

> [!NOTE]
> Attributes
> In the examples below, `attribute` can also be a dotted path such as `foo.bar.baz`, but not more complex attribute expressions such as `foo[0].bar` or `foo[1..2]`.

### Binary

#### Expression

Where `op` is one of `==`, `!=`, `<`, `<=`, `>`, `>=`:

- `<attribute> <op> <literal>`
-  `<literal> <op> <attribute>`

#### Examples

- `category == "sport"`
- `age > 20`

### Boolean attribute

#### Expression

- `attribute`

#### Examples

- `isPublished`

### Logical expressions

#### Expression

- `!optimizableExpression`
- `<optimizableExpression> && <optimizableExpression>`
- `<optimizableExpression> || <optimizableExpression>`

#### Examples

- `published && !visible`
- `!hidden || category == "post"`

### Arrays

#### Expression

- `<literal> in <attribute>`
- `<literal> in <arrayAttribute>`

#### Examples

- `"sport" in categories`
- `"user-123" in users[]._ref`

### `dateTime()` function

#### Expression

Where `op` is one of `==`, `!=`, `<`, `<=`, `>`, `>=`:

- `dateTime(<attribute>) <op> dateTime(<string>)`
- `dateTime(<string>) <op> dateTime(<attribute>)`

Note that `now()` is a special case; it evaluates to a static string at query time, and can be used here.

#### Examples

- `dateTime(publishedAt) <= dateTime(now())`

### `defined()` function

#### Expression

**Note**: This is shorthand for `<attribute> != null`.

- `defined(<attribute>)`

#### Examples

- `defined(publishedAt)`

### `string::lower()` function

**Note**: This also applies to `lower()` (with no namespace).

#### Expression

- `string::lower(<attribute>) == <string>`
- `<string> == string::lower(<attribute>)`

#### Examples

- `lower(tag) == "football"`

### `match` operator

#### Expression

- `<attribute> match <string>`
- `<attribute> match <array>`

#### Examples

- `title match "content*"`
- `title match ["structured", "content"]`

### `pt::text()` function

#### Expression

- `pt::text(<attribute>) match <string>`
- `pt::text(<attribute>) match <array>`

#### Examples

- `pt::text(content) match "structured content"`

### `path()` function

#### Expression

> [!WARNING]
> Gotcha
> This is **only** optimized for the `_id` attribute.

- `_id in path(<string>)`

#### Examples

- `_id in path("a.b.**")`

### `references()` function

#### Expression

- `references(<string>[, ...])`

#### Examples

- `references(^._id)`

### Geographic functions

#### Expression

- `geo::intersects(<attribute>, <geo>)`
- `geo::contains(<attribute>, <geo>)`
- `geo::disjoint(<attribute>, <geo>)`
- `geo::contained(<attribute>, <geo>)`

## Reference: Sort expressions

All of these sort expressions compare strings by Unicode codepoint, not by a locale-aware collation: uppercase letters sort before lowercase letters, and accented characters sort after the entire ASCII range. `string::lower()` normalizes case only, and leaves accents in place. To sort by locale rules, store a precomputed sort key on the document and order on that field.

The following expressions are optimized for sorting.

### Plain attribute

#### Expression

- `order(<attribute>)`

#### Examples

- `* | order(lastName, firstName)`

### `string::lower()` function

**Note**: This also applies to `lower()` (with no namespace).

#### Expression

- `order(string::lower(<attribute>))`

#### Examples

- `* | order(string::lower(name))`

### `dateTime()` function

#### Expression

- `order(dateTime(<attribute>))`

#### Examples

- `* | order(dateTime(publishedAt))`



# Setting up single sign-on with SAML

[SAML](https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language) (Security Assertion Markup Language) [SSO](https://en.wikipedia.org/wiki/Single_sign-on) (single sign-on) enables your organization to control access to Sanity projects by using a third-party identity provider, such as [Okta](https://www.okta.com/), [Google](https://support.google.com/a/answer/6087519?hl=en), or [Microsoft Entra ID (formerly Azure AD)](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id). When you enable SAML SSO, users who log in to a project through Sanity Studio or Sanity Manage are authenticated through the organization’s identity provider. Sanity then assigns them roles based on their group membership in the identity provider.

This guide walks through creating a SAML SSO configuration for your organization, mapping identity provider groups to Sanity roles, and configuring your studio to use SAML login.

## Prerequisites

- An [organization](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing) with a project on the [Enterprise plan](https://www.sanity.io/pricing).
- An external identity provider that supports SAML authentication (for example, [Okta](https://www.okta.com/), [Google](https://support.google.com/a/answer/6087519?hl=en), or [Microsoft Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id)).
- Organization administrator permissions.

## Configuration steps

### 1. Create a new SAML SSO configuration for your organization

To open your organization's settings, go to [sanity.io/manage](https://sanity.io/manage) and select your organization from the organization menu. Then select **SAML SSO** in the settings sidebar and click **Create SAML SSO provider**.

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

A dialog appears, telling you that **SAML SSO is not configured**. Click **Configure** to start setting up your provider. Sanity then generates the details you need to connect your identity provider to your organization.

![A dialog informing the user that SAML SSO has not yet been configured.](https://cdn.sanity.io/images/3do82whm/next/af796cdb46bca2202072958c9e08f1d7c2cd7000-4020x2259.png)

### 2. Use the details presented to configure the external identity provider

Sanity provides the details you need to set up your external identity provider. In the **Sanity's service provider details** section, use the **Copy** buttons to copy each string, or click **Download as XML** to download the settings as a SAML XML file.

![The Sanity provider details screen with four fields highlighted and numbered one through four: “Sanity callback URL”, “Sanity entity ID”, “NameID Format”, and “Attributes”.](https://cdn.sanity.io/images/3do82whm/next/2f76e590de33b08a16622d5da0ce8a5e854623a2-4020x3090.png)
*Sanity's service provider details: callback URL, entity ID, NameID Format, and Attributes.*

The following image shows where to enter these details in [Okta](https://www.okta.com/). Provider-specific guides are available for [Azure/Entra ID](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-azure), [JumpCloud](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-jumpcloud), and [PingIdentity](https://www.sanity.io/docs/developer-guides/set-up-sso-authentication-with-saml-and-pingidentity).

![The Okta SAML settings screen with four fields highlighted and numbered one through four: “Single sign-on URL”, “Audience URI (SP Entity ID)”, “Name ID Format”, and “Attribute statements”.](https://cdn.sanity.io/images/3do82whm/next/3121993fea41db265f77342257c3c47e213c0bf6-4020x3090.png)
*The matching Okta SAML settings: Single sign-on URL, Audience URI, Name ID Format, and Attribute statements.*

Sanity's fields correspond to Okta's as follows:

- **Sanity callback URL**: Okta's **Single sign-on URL**.
- **Sanity entity ID**: Okta's **Audience URI (SP Entity ID)**.
- **NameID Format**: Okta's **Name ID Format**. Sanity uses **Persistent**.
- **Attributes**: Okta's **Attribute statements**.

Map the attributes for user accounts carefully. Sanity requires `email`, `firstName`, and `lastName` to be mapped to corresponding values from the identity provider. The `id` and `displayName` attributes are optional. Each of the three required attributes accepts either camelCase or PascalCase (`email` or `Email`), but don't send both casings of the same attribute. The assertion must also include either a `nameID` or an `id` attribute. If user attribute management is enabled for your organization, attributes from the assertion are also stored as [user attributes](https://www.sanity.io/docs/http-reference/user-attributes).

> [!WARNING]
> Gotcha
> Set the groups in the external identity provider that should have access to the integration. Sanity reads group membership from the `groups` attribute in the assertion. This attribute name must be lowercase: `Groups` isn't recognized.
> Sanity validates `InResponseTo` by default, which requires your identity provider to return it. Microsoft Entra ID doesn't, and identity-provider-initiated logins never do. In either case, clear **Enable InResponseTo** in **Your Identity Provider details**.

### 3. Configure the SAML service provider with the settings of the external identity provider

With the external identity provider configured, do the reverse. In the **Your Identity Provider details** section, fill in the values from your identity provider and click **Save**.

> [!TIP]
> Protip
> Many providers let you download the required settings as an XML file. If you have that file, click **Upload metadata** instead of copying each value by hand.

![The “Your Identity Provider details” form in Sanity Manage, with fields for the identity provider’s SAML settings and an “Upload new metadata” button.](https://cdn.sanity.io/images/3do82whm/next/9819293029949cf9ea730c668c5e55881b8d2070-4021x3090.png)

### 4. Name your configuration and set options for role mapping

In the **General** section, name your configuration in **Configuration name** and choose whether to **Enable auto update roles on login**. **Session TTL** defines in hours how long a login session stays valid. It defaults to 12 hours, and you can choose 12 hours, 16 hours, 1 day, 2 days, 3 days, or 1 week. Save the configuration when you're done.

> [!NOTE]
> Auto update roles on login
> When auto update roles on login is enabled, Sanity re-evaluates your role mapping rules every time a user logs in with SAML SSO, through either the project-specific login URL or an organization-level login. Manual role changes are still allowed, but Sanity overwrites them at the user's next login.

![The “General settings for SAML SSO across all projects” section in Sanity Manage, with options for “Configuration name”, “Auto update roles on login”, and “Session TTL”.](https://cdn.sanity.io/images/3do82whm/next/6d028886f556fd521c639de8ee893601ac7a79f1-4020x2259.png)

### 5. Set a slug for your organization

On the SAML SSO configuration page, the **Organization slug** section defines a unique slug that identifies your organization in certain SSO workflows, such as logging in with the Sanity command-line interface (CLI) or logging in to Sanity Manage. The same setting appears under **General settings** in your organization settings, so it might already have a value.

> [!NOTE]
> Your organization slug must:
> - Be globally unique.
> - Be between 2 and 20 characters long.
> - Start with a lowercase letter or a number.
> - Contain no characters other than `a-z`, `0-9`, `-`, and `_`.

![The Sanity Manage form for specifying a slug for an organization.](https://cdn.sanity.io/images/3do82whm/next/7f5b45a6f5bf426fec9b2ce7747ed2fe20b8f3d1-4020x2259.png)

Once the slug is set, you can use it to log in with the Sanity CLI:

**npm**

```shell
# Log in with the organization slug 'saml-docs'
npx sanity login --sso saml-docs

# Name a provider when the organization has several, or when running unattended
npx sanity login --sso saml-docs --sso-provider "Okta SSO"
```

**pnpm**

```shell
# Log in with the organization slug 'saml-docs'
pnpm dlx sanity login --sso saml-docs

# Name a provider when the organization has several, or when running unattended
pnpm dlx sanity login --sso saml-docs --sso-provider "Okta SSO"
```

**yarn**

```shell
# Log in with the organization slug 'saml-docs'
yarn dlx sanity login --sso saml-docs

# Name a provider when the organization has several, or when running unattended
yarn dlx sanity login --sso saml-docs --sso-provider "Okta SSO"
```

**bun**

```shell
# Log in with the organization slug 'saml-docs'
bunx sanity login --sso saml-docs

# Name a provider when the organization has several, or when running unattended
bunx sanity login --sso saml-docs --sso-provider "Okta SSO"
```

### 6. Enable SSO and configure role mapping for the desired projects

After saving your settings, enable SSO for one or more of your projects. In the same flow, you configure role mapping for each project.

![A list of projects belonging to an organization, each labeled as not having SAML SSO configured yet.](https://cdn.sanity.io/images/3do82whm/next/d06f4bd99e65405a58c70dc1981e322a10d5043c-4020x2259.png)

In the role mapping dialog, set a default fallback role for users who don't belong to any group matching your rules. Then add rules that map groups from your identity provider to roles in this project. Rules are evaluated against the `groups` attribute of the user identity and support [RE2 regular expression](https://github.com/google/re2/wiki/Syntax) syntax. Each rule is anchored to the full group name, so `editors` doesn't match `editors-eu`. The anchors wrap the whole pattern, so group any alternation: write `(editors|admins)` rather than `editors|admins`. Backreferences, lookahead assertions, and lookbehind assertions are not supported. Microsoft Entra ID sends group IDs rather than group names, so write those rules against the group ID. These examples show how rules match:

- `editors` matches *exactly* `editors`
- `.*-admin` matches `news-admin`, `sales-admin`, and `-admin`
- `[aA]dmin` matches `admin` and `Admin`

![A role mapping dialog with a default fallback role and three group names mapped to Sanity roles.](https://cdn.sanity.io/images/3do82whm/next/eeea636533b5c2bd061c7a0a8434e6ea5cad38e5-4020x3090.png)

### 7. Test your configuration by attempting to log in

Before configuring your studio to use the new SSO setting, test the project-specific login URL. Copy that URL from Sanity Manage and paste it into your browser's address bar. If the configuration is correct, Sanity logs you out of your current account and into the user account from your SSO identity provider. Testing in a separate browser or a private window keeps your existing session intact.

![The project-specific SAML SSO login URL shown in Sanity Manage.](https://cdn.sanity.io/images/3do82whm/next/6904066477e2b016f5feac0c8e39c9ff1bd19cf4-4020x2259.png)

### 8. Configure your studio to use the new SSO provider

Next, update your studio to show the login screen from your SSO identity provider by using the [custom authentication configuration](https://www.sanity.io/docs/studio/custom-auth). Expand **SAML SSO login for Sanity Studio** to get a code snippet for your `sanity.config.ts` file. Choose **Add to existing options** to offer SAML alongside the default login providers, or **Replace existing options** to make SAML the only login option in that studio. Token-based authentication is recommended for full Media Library functionality: private asset previews, downloads, and signing key management are unsupported under cookie-based authentication. Either set `auth.loginMethod: 'token'` in your studio config, or log in to the Sanity Dashboard before opening Media Library. See [Custom authentication](https://www.sanity.io/docs/studio/custom-auth) for details.

Studio login options apply only to that studio. The login screen at sanity.io, which covers Sanity Manage, the Sanity Dashboard, and Media Library, still offers email and password, Google, and GitHub, and no setting hides those options there. To limit who can reach those surfaces, manage your organization's members.

![The Sanity Studio SAML SSO configuration panel showing a code example that enables SAML login.](https://cdn.sanity.io/images/3do82whm/next/8f386a08c1c01199b4b3a32c8ec626e866bd0f60-1326x1204.png)

### 9. Verify by logging in with SSO

Finally, verify the configuration by logging in as a user from your identity provider. Your login screen lists only the options you configured.

![The Sanity Studio login screen with four options: “Google”, “GitHub”, “E-mail / password”, and “SAML”.](https://cdn.sanity.io/images/3do82whm/next/a6bde8176914f60b15bb1c0838371a7f196096d8-4020x2259.png)

Enabling SAML SSO doesn't remove or block accounts that already exist: members who signed in with an email and password, Google, or GitHub keep their access. After logging in at least once with SAML SSO, check your organization's members in Sanity Manage. An indicator on each member's avatar shows which sign-in method they use, so you can delete or demote accounts outside your identity provider's domain.

![A list of users with different roles, each with an icon reflecting their chosen method of authentication.](https://cdn.sanity.io/images/3do82whm/next/7860adfc3c9a109729f9188efc23f7c0e3eb395c-4020x2259.png)

> [!WARNING]
> Gotcha
> A SAML identity is a separate account from a user’s previous Google, GitHub, or email login. Anyone who switches to SAML SSO occupies two seats until you remove their old account. Check your member list for duplicates after enabling SSO or changing identity providers.

## Change your identity provider

Sanity matches a returning user to an existing account by the identifier in the SAML assertion — the `nameID` or the `id` attribute — combined with the SAML SSO configuration that sent it. If either changes, the next login creates a new user instead of matching the existing one.

To keep existing users when you move to a new identity provider:

- Edit your existing SAML SSO configuration instead of deleting it and creating a new one. A new configuration produces new identities even when the identifier stays the same.
- Confirm that the new identity provider sends the same identifier value for each user. Providers name their NameID formats differently, so compare the values rather than the format names.
- Check that group claims still reach Sanity and that your role mapping rules still match them. Microsoft Entra ID sends group IDs rather than group names.
- Test the project-specific login URL from Sanity Manage in a private window before you move everyone to the new provider.

## Troubleshooting

If SSO login doesn’t behave as expected, check these causes first.

- **SAML SSO isn't listed in your organization's settings**: your account doesn't have permission to manage SSO. Configuring SAML SSO requires the Administrator role on the organization. Ask an organization administrator to grant it, then reload Sanity Manage.
- **Login fails right after the identity provider redirects back**: the assertion is missing a required attribute, and Sanity rejects it with a `422` response. Confirm that `email`, `firstName`, and `lastName` are all mapped, that only one casing of each is sent, and that the assertion includes either a `nameID` or an `id` attribute.
- **Every user lands on the fallback role**: group membership isn't reaching Sanity, or no rule matches. Confirm that the identity provider sends a lowercase `groups` attribute, and that each rule matches the full group name rather than a substring. Microsoft Entra ID sends group IDs, so map against the ID.
- **Login fails with an InResponseTo error**: your identity provider doesn't return `InResponseTo`, or the login was identity-provider-initiated. Sanity reports `InResponseTo is missing from response` or `InResponseTo is not valid`. Clear **Enable InResponseTo** in **Your Identity Provider details**.
- **A role mapping rule is rejected as invalid**: the pattern uses a feature RE2 doesn't support, or it's complex enough to be rejected as unsafe. Sanity reports `Unsupported regex "...". Verify the regex is valid and does not contain any backreferences or lookahead assertions.` Remove backreferences, lookahead assertions, and lookbehind assertions, and simplify nested repetition.
- **The organization slug is rejected**: another organization already uses it. Sanity reports `An organization with the slug "..." already exists`. Slugs are globally unique, so choose a different one.
- **Users appear twice in your member list**: they signed in with SAML after previously using another login method. Remove the old account to free the seat.
- **SSO isn't offered as a login option in the studio**: the studio configuration is missing the provider, CORS blocks the request, or the studio version is affected by a login loop. Confirm that `sanity.config.ts` includes the SAML provider from Sanity Manage, following [Custom authentication](https://www.sanity.io/docs/studio/custom-auth), and that your studio URL is an [allowed CORS origin with credentials enabled](https://www.sanity.io/docs/content-lake/cors). Studio v5.30.0 fixes an authentication loop that could trap users on the login screen. On earlier versions, clear the studio's site data in your browser and reload.
- **Login succeeds but lands on an API response instead of Sanity Manage or your studio**: the identity provider has no default **Relay State**, so an identity-provider-initiated login stops at `api.sanity.io/v1/users/USER_ID` and returns the user object as JSON. Set the default **Relay State** in your identity provider to the login URL from Sanity Manage. To send users straight to a studio, replace the `origin` parameter with your encoded studio URL. For the full walkthrough, see [Setting up a default Relay State for IdP-initiated SAML logins](https://www.sanity.io/docs/developer-guides/setting-up-a-default-relay-state-for-idp-initiated-saml-logins).

## Next steps

Now that SAML SSO is set up for your organization, learn more about managing access and authentication in Sanity.

[Roles and permissions](https://www.sanity.io/docs/content-lake/roles-concepts)
Learn how Sanity enforces access control with roles for projects and datasets.

[Custom authentication](https://www.sanity.io/docs/studio/custom-auth)
Configure the Studio to use your own login solution.

[User attributes API reference](https://www.sanity.io/docs/http-reference/user-attributes)
Manage user attributes and attribute definitions for role-based access control.



# Third-Party Login (Deprecated)

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

> [!NOTE]
> Looking for SAML SSO?
> This article discusses the **deprecated, legacy method** of implementing third-party login. Please use our [new and improved implementation of SAML SSO](https://www.sanity.io/docs/developer-guides/sso-saml). 

Users that have activated custom access control on their plan may replace a project's user database with their own custom login solution, e.g. to integrate with a single sign-on (SSO) system such as Active Directory or Kerberos. 

Sanity provides APIs to register users and permissions for datasets and create Sanity sessions, but it is up to the customer to actually implement the integration with their authentication system.

Implementing third-party login involves:

- Registering Sanity groups with appropriate permissions via the API, either manually or with code.
- Writing code to generate a Sanity session when a user logs in.
- Optionally modifying the Sanity Studio to use a separate login form.

We'll go through each of these steps in detail below. 

We also have a [sample Node.js application](https://github.com/sanity-io/3rd-party-auth-example) that shows how to go about this using Passport.js. It currently has support for authenticating with Google's OAuth and Okta's SAML API.

## Managing groups and users

Sanity uses groups to grant permissions to various users, as described in the access control section. You will first need to set up the groups and permissions that you need, as well as group memberships for users. Depending on your use case it may be sufficient to simply set up the groups manually, but we expect most users will require code to automatically keep groups in sync with their local database.

> [!WARNING]
> Gotcha
> Custom user IDs must adhere to the following rules:
> - Must begin with a lower case `e`
> - Followed by a string of any combination of upper or lowercase characters from the English alphabet, numbers, hyphens, and underscores. E.g. `e-A3f-Lm_N6-eQrS-Xw9y`
> This can also be expressed with the following regular expression: `/^e[a-zA-Z0-9-_]+$/`

> [!TIP]
> Protip
> There is no need to create Sanity users corresponding to your local users, it is sufficient to simply list the user IDs as group members.

> [!WARNING]
> Gotcha
> You cannot assign a third-party login user to a role created with the [roles API](https://www.sanity.io/docs/user-guides/roles). We recommend using [SAML integration](https://www.sanity.io/docs/developer-guides/sso-saml) instead where possible. You can create roles for third-party login users with groups, described below.

Groups are stored as regular Sanity documents of type `system.group` under the `_.groups.` path, as outlined in the access control section. Use regular [mutations](https://www.sanity.io/docs/http-reference/mutation) via the API to create and modify them. 

For example, let's say we would like to give the journalists `e-henrik` and `e-emma` in our Norway office full access to all articles in the `norway` edition, but only read access to other editions - we could create the following group for this:

```json
{
  _id: '_.groups.office-norway',
  _type: 'system.group',
  grants: [
    {
      filter: "_type == 'article' && edition._ref == 'norway'",
      permissions: ["create", "update", "read"]
    },
    {
      filter: "_type == 'article'",
      permissions: ["read"]
    }
  ],
  members: ["e-henrik", "e-emma"]
}
```

> [!TIP]
> Protip
> Sanity user IDs may be exposed in publicly available data (e.g. as the author of a document), so take care not to use any personally identifiable information when generating IDs. An arbitrary number or a hash is usually a good choice.

Once all of our groups and memberships are properly set up, we'll need to give users a Sanity token when they log in.

## Generating Sanity tokens

You will need some sort of login solution on your end, which authenticates users with your user database and then makes an API call to Sanity to generate a session claim. The details depend entirely on the specific authentication system you use, but we have a GitHub repo with a [complete example](https://github.com/sanity-io/3rd-party-auth-example).

Once you have authenticated the user you should make an HTTP `POST` request using a [robot token](https://www.sanity.io/docs/content-lake/http-auth) that has the `create-session` permission to the following endpoint:

`POST https://<projectId>.api.sanity.io/v2021-06-07/auth/thirdParty/session`

The `POST` body should be JSON- or URL-encoded and contain the following fields:

- `userId`: the user's ID as listed in the group (see above).
- `userFullName`: the user's full name.
- `userEmail`: the user's email address.
- `userImage`: optional HTTPS URL to the user's profile image.
- `userRole`: If the user should be able to log into the Sanity Studio, role must be either `administrator` or `editor` 
- `sessionExpires`: ISO timestamp for when the session should expire.
- `sessionLabel`: optional label for the session.

The API call will return JSON with two fields; `token` and `endUserClaimUrl`. If the session is to be used for managing content in the Sanity Studio, the `endUserClaimUrl` contains a URL which the end user's browser can visit to obtain a Sanity session (set as a cookie). This URL is valid for a single use only. You can add a query parameter `origin` with a URL to redirect the user to after the session has been created - this URL must be listed as a valid [CORS origin](https://www.sanity.io/docs/content-lake/cors) for the project. For other use cases, such as creating native applications, you'll want to store the `token` returned in a secure location and use it to authenticate requests against the Sanity API. 

### User profiles

Every time you create a SSO session, the user info you post with it will be saved to a user profile model. It's attatched to the user id and project id. This information is only available for logged in users to your project. The model is needed to display user info even though the session is destroyed (user logging out or session expires). You are responsible for deleting these profiles when they should be deleted (according to your terms).

```text
DELETE https://api.sanity.io/v2021-06-07/projects/<project-id>/users/<e-user-id>/profile

Authorization: Bearer <your-create-session-token>

```

If you want to explicitly create or update a user profile, you can do so by sending a PUT request with the details:

```text
PUT https://api.sanity.io/v2021-06-07/projects/<project-id>/users/<e-user-id>/profile

Content-Type: application/json
Authorization: Bearer <your-create-session-token>

{
  "name": "Some username",
  "profileImage": "https://optional.user.img/url.jpg"
}

```

## Using external logins in the Sanity Studio

The Studio can be configured to use your own login solution rather than the standard ones by modifying the config file `config/@sanity/default-login.json` in the studio code.

```json
{
  "providers": {
    "mode": "replace",
    "redirectOnSingle": true,
    "entries": [
      {
        "name": "custom-login",
        "title": "Custom Login",
        "url": "https://mydomain.com/login",
        "logo": "static/custom-login.png"
      }
    ]
  }
}
```

The Studio redirects the user to the specified `url` to initiate the authentication. The `title` and `logo` are displayed to the user on the Studio login screen. You may add multiple entries if you need to support several authentication solutions.



# Set up SSO authentication with SAML and Azure/Entra ID

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer), Benjamin Weinberger (Support Engineer at Sanity.io), Tim Naughton (Sanity Support Engineer), Dain Cilke (Software Engineer @ Sanity.io), and Marco Spinello (Technical writer at Sanity.io. I curate my own typos.).

Configure and enable SSO authentication in your Sanity instance using the SAML protocol and Microsoft Azure AD as an identity provider (IdP.)

During the setup and configuration process, it's a good idea to keep two windows side by side:

- One with [Sanity Manage](https://www.sanity.io/docs/developer-guides/sso-saml).
- The other with the configuration settings of the IdP; in this case, Azure (Microsoft Entra ID).

## Getting ready

### Go to the service configuration (Sanity)

Go to [Sanity Manage](https://www.sanity.io/manage) and select the organization you want to enable SSO for your organization.

To navigate to the service provider configuration inside Sanity Manage:

1. In the organization you intend to add SSO to, go to **Settings → SAML SSO**.
2. If no SAML SSO provider exists, click **Open SAML SSO configuration** and proceed to create and configure a SAML SSO provider.

### Go to the service configuration (Azure/Entra ID)

To navigate to the identity provider configuration in Azure: 

1. Log into Azure.
2. Go to **Azure Active Directory**.
3. On the sidebar, go to **Enterprise applications**.

![In Azure, go to Services, and then select Azure Active Directory.](https://cdn.sanity.io/images/3do82whm/next/5ab6659c9083bbc18e62deda29e6138ef81ab136-646x422.png)
*In Azure, go to Services, and then select Azure Active Directory.*

![On the sidebar, select Enterprise applications.](https://cdn.sanity.io/images/3do82whm/next/faf8dcdbec3a52ebb467d6b4bc674f7267168c64-516x764.png)
*On the sidebar, select Enterprise applications.*

In **Enterprise applications**:

1. Select an existing SAML application or create a new enterprise application. 
If you create a new application, you can also integrate any other applications not available in the gallery.
2. Go to Set **up single sign on**, and then choose the SAML sign-on method to use.

![Set up single sign-on.](https://cdn.sanity.io/images/3do82whm/next/582f16aa4f2e96702fbf4015b609ce16efa73ed9-704x300.png)
*Set up single sign-on.*

![Choose the SAML single sign-on method to use.](https://cdn.sanity.io/images/3do82whm/next/a3c77bfd1288363513fbcb8c2eba105c4d5aca3a-700x364.png)
*Choose the SAML single sign-on method to use.*

If you're keeping two browser tabs or windows open side by side, now you should have one on the configuration screen inside Sanity Manage, and the other on the configuration screen in Azure.

## Configuring the Azure IdP

### Basic SAML Configuration

1. In Azure, edit the **Basic SAML Configuration** form.
2. Add an **Identifier (Entity ID)** to the basic SAML configuration.1. * Identifier (Entity ID) -> Sanity entity ID* in Sanity Manage. 


3. Add a **Reply URL (Assertion Consumer Service URL)**.1. *Reply URL (Assertion Consumer Service URL) ->  Sanity callback URL* in Sanity Manage. 


4. Click **Save**.

![Edit the Basic SAML Configuration form.](https://cdn.sanity.io/images/3do82whm/next/d35116e2665bd60c8d831ba4d9aa4076c876e4ed-1440x364.png)
*Edit the Basic SAML Configuration form.*

![The Entity ID in Azure corresponds to the Sanity entity ID inside Sanity Manage.](https://cdn.sanity.io/images/3do82whm/next/c2708b282b7b68d938cb5f1bc336940fcaca14ef-384x216.png)
*The Entity ID in Azure corresponds to the Sanity entity ID inside Sanity Manage.*

![The Reply URL (Assertion Consumer Service URL) in Azure corresponds to the Sanity callback URL inside Sanity Manage.](https://cdn.sanity.io/images/3do82whm/next/3451d846dbf6ea46424433cbac85654f08029567-720x206.png)
*The Reply URL (Assertion Consumer Service URL) in Azure corresponds to the Sanity callback URL inside Sanity Manage.*

## Attributes & Claims

### Required Claim

1. In Azure, edit the **Attributes & Claims** form.
2. Edit the **Unique User Identifier (Name ID)** claim, and change the **Name identifier format** to **Persistent**.
3. Click **Save**. 

![Edit the Unique User Identifier (Name ID) claim.](https://cdn.sanity.io/images/3do82whm/next/089281130cd065e95672fa2ab4241cdb6440fb49-792x234.png)
*Edit the Unique User Identifier (Name ID) claim.*

![Change the Name identifier format to Persistent.](https://cdn.sanity.io/images/3do82whm/next/4d0a84456cd0d0d1547af866afc635ff1defb202-1900x808.png)
*Change the Name identifier format to Persistent.*

### Additional Claims

Now, configure Azure to send the claims that Sanity requires in the expected form.
The claims (attributes) that Sanity expects are listed inside Sanity Manage:

![Inside Sanity Manage you can view the claims (attributes) that Sanity requires from Azure.](https://cdn.sanity.io/images/3do82whm/next/289a5d6065b7dfa5805a9cd12a461e87aa48f34e-1584x476.png)
*Inside Sanity Manage you can view the claims (attributes) that Sanity requires from Azure.*

For each claim:

1. Ensure the claim **Name** matches the attribute name in the table above.
2. Ensure the **Namespace** is deleted.
3. Ensure the **Name format** is set to **Unspecified**.
4. Ensure the **Source attribute** is mapped correctly. This varies, and it depends on the specific Azure Active Directory configuration.

![In the form, set the appropriate values for each claim.](https://cdn.sanity.io/images/3do82whm/next/34bbc8cae909e6c55fa2d8498312d3d61644ca5e-1934x830.png)
*In the form, set the appropriate values for each claim.*

Once all claims have been added:

![The mapping of Azure claims and the corresponding Sanity attributes.](https://cdn.sanity.io/images/3do82whm/next/ed5da2bd6c5ab19ac4a5757dc0445b71d89a6c66-1442x360.png)
*The mapping of Azure claims and the corresponding Sanity attributes.*

Sanity requires `user.firstName` and `user.surname`. The mapping in the example replaces both fields with `user.displayname`.

### Group Claims

Enterprise customers can map user identity provider roles to service provider roles. For example, users with an Azure `example-azure-user-role` role are mapped to the Sanity `viewer` role when they log in.

- To support the mapping functionality, you must configure the identity provider to send the groups of the user.
- To do so, Sanity Manage expects a `groups` claim with the format set to `unspecified`. 

> [!WARNING]
> *Note: with Azure/Entra ID, you will be sending the Group ID and not the name of the group in your IdP. If you send the name, you may not see your role mappings correctly when logging into Sanity*

![Inside Sanity Manage, set Name to groups and Format to unspecified. ](https://cdn.sanity.io/images/3do82whm/next/0c3a9ae47ad308db111a16c3fac735151b03f877-1586x242.png)
*Inside Sanity Manage, set Name to groups and Format to unspecified. *

In Azure, add a new group claim:

![In Azure, select + Add a group claim.](https://cdn.sanity.io/images/3do82whm/next/953a5eef6b8ba38663e52590f96e5ce178dd0447-1662x892.png)
*In Azure, select + Add a group claim.*

Select the groups that you want Azure to send to Sanity, and assign the group claim a descriptive name:

![In Azure, select the groups used to populate SAML tokens issued to Sanity. ](https://cdn.sanity.io/images/3do82whm/next/801b74d77d4b131b52139642e3cc885caa4f2ba7-1116x1794.png)
*In Azure, select the groups used to populate SAML tokens issued to Sanity. *

Once you're done, save the changes.

## Configuring the Sanity Service Provider

### Sign-On URL and Issuer

In Azure, browse to the `Set up {application name}` block:

![In Azure, go to the application setup to get the Azure URLs for login and authentication..](https://cdn.sanity.io/images/3do82whm/next/affefac7043ba67bab63f59c21463fe071033937-1538x418.png)
*In Azure, go to the application setup to get the Azure URLs for login and authentication.*

Get the Azure URLs for login and authentication, and add them to the **Your Identity Provider details** configuration section inside Sanity Manage:

![In Your Identity Provider details, set the Azure URLs for login, auth, and logout.](https://cdn.sanity.io/images/3do82whm/next/d4fd95f60ca3e8851ebcb6d41e7ea63114051d57-1700x676.png)
*In Your Identity Provider details, set the Azure URLs for login, auth, and logout.*

In this scenario:

1. Azure **Login URL** maps to Sanity **Identity Provider Single Sign-On URL**.
2. Azure **Azure AD Identifier** maps to Sanity **Identity Provider issuer**.

### InResponseTo

In the SAML specification, `InResponseTo` is defined as

> *The ID of a SAML protocol message in response to which an attesting entity can present the assertion.*

This setting is identity provider-specific. Azure doesn’t support it. Therefore, ensure that **Enable InResponseTo** is deselected/disabled.

![Enable InresponseTo must be disabled/deselected.](https://cdn.sanity.io/images/3do82whm/next/9aa6f690939977d61a90f08844af5d57e774ecf5-1546x158.png)
*Enable InresponseTo must be disabled/deselected.*

### Signed SAML Assertion

The **Signed SAML Assertion** option notifies the Sanity instance that the identity provider is configured to use the signing certificate found in the Sanity service provider details section.

![Example certificate in the Signing certificate section.](https://cdn.sanity.io/images/3do82whm/next/6e3b733b9ce4bbd11718196658fcb5693081440a-1580x406.png)
*Example certificate in the Signing certificate section.*

This is an optional step configured in **Verification certificates**:

![Verification certificate is an optional step to configure signing certificates with a signed assertion.](https://cdn.sanity.io/images/3do82whm/next/0e2718c861204ca530273abba9d867413e35688b-1366x196.png)
*Verification certificate is an optional step to configure signing certificates with a signed assertion.*

Unless you have already uploaded the certificate, leave the **Want assertion signed** deselected under **Signed SAML Assertion**.

![If no certificate has been uploaded, leave Want assertion signed deselected under Signed SAML Assertion.](https://cdn.sanity.io/images/3do82whm/next/4d1eddac90a89a91e98614b1f098712500ab730f-1578x158.png)
*If no certificate has been uploaded, leave Want assertion signed deselected under Signed SAML Assertion.*

### X.509 Certificate

To get an [X.509](https://en.wikipedia.org/wiki/X.509) certificate:

1. Go to **SAML Certificates** and click **Edit**.
2. Download the certificate as **PEM certificate download**.
3. Open the downloaded certificate file with any text editor, and copy-paste the certificate content into Sanity Manage.

![  Click Edit in SAML Certificates.](https://cdn.sanity.io/images/3do82whm/next/092c62ea49f0bffe485a58d6cf0e615d4791f93d-1532x766.png)
*Click Edit in SAML Certificates.*

![Select PEM certificate download.](https://cdn.sanity.io/images/3do82whm/next/bf9d54643bb3e2a1bd9aa18d5bffaa9ad7ca2597-1672x702.png)
*Select PEM certificate download.*

![Paste the certificate body into Sanity Manage.](https://cdn.sanity.io/images/3do82whm/next/70d9464b81e02c8c079e6a584a0e1e79afefb635-1612x292.png)
*Paste the certificate body into Sanity Manage.*

## Save

Ensure you save all changes inside Sanity Manage and in Azure.



## Common errors

These errors are specific to Azure/Entra ID. For SSO problems that aren't provider-specific, such as SSO not appearing as a login option in the studio, see the troubleshooting section of [Setting up single sign-on with SAML](https://www.sanity.io/docs/developer-guides/sso-saml).

- Receiving a 422 error: `{"statusCode":422,"error":"Unprocessable Entity","message":"child \"attributes\" fails because [\"value\" must contain at least one of ...`- There is an issue with your claims. All claims are case sensitive and are required. Make sure the type is set to unspecified and that the namespace URI is empty and the name format is 'unspecified'


- My users are being assigned the default role and not their group mapped role- Ensure your mappings in Sanity are going off the Group ID within Azure/Entra ID as the ID is sent, not the name. 
- You may need to enable "Auto update roles on login". When a SAML SSO user logs in to Sanity their roles will be updated to reflect those defined by the project's role mappings. If you change the role mappings the user's roles will not be reset if this is not enabled.
- Do you have a [default relay state](https://www.sanity.io/docs/developer-guides/setting-up-a-default-relay-state-for-idp-initiated-saml-logins) setup? 


- When I access Sanity from my IdP dashboard, I receive:
`{ "id": "3431pXO", "displayName": "Sanity Support", "email": "sanity@sanity.io", "familyName": "Sanity Support", "givenName": "Sanity", "middleName": null, "imageUrl": null, "provider": "saml-f6a94", "tosAcceptedAt": "2024-11-20T18:51:57.264Z", "createdAt": "2024-11-20T18:51:57.264Z", "updatedAt": "2024-11-20T18:51:57.535Z", "isCurrentUser": true, "providerId": "49jc94jf949930304jkojfciojlj934003490943" }`- It does not appear you have set up your default relay state within your IdP, you will need to also configure within your Idp settings. You can follow our [guide on setting the default relay state](https://www.sanity.io/docs/developer-guides/setting-up-a-default-relay-state-for-idp-initiated-saml-logins). 





## Further reading

- [Setting up Single Sign-On with SAML](https://www.sanity.io/docs/developer-guides/sso-saml)
- [Third-Party Login (SSO)](https://www.sanity.io/docs/developer-guides/third-party-login)



# Set up SSO authentication with SAML and PingIdentity

> [!NOTE]
> This developer guide was contributed by Tim Naughton (Sanity Support Engineer).

Expands upon our general [SAML setup guide](https://www.sanity.io/docs/developer-guides/sso-saml) to configure and enable SSO authentication in your Sanity instance using the SAML protocol and PingIdentity (Cloud) as an identity provider (IdP)

During the setup and configuration process, it's a good idea to keep two windows side by side:

- One with [Sanity Manage](https://www.sanity.io/docs/developer-guides/sso-saml).
- The other with the configuration settings of the IdP; PingIdentity (Cloud).

## Getting ready

### Go to the service configuration (Sanity)

Go to [Sanity Manage](https://www.sanity.io/manage) and select the organization you want to enable SSO for your organization.

To navigate to the service provider configuration inside Sanity Manage:

1. In the organization you intend to add SSO to, go to **Settings → SAML SSO**.
2. If no SAML SSO provider exists, click **Open SAML SSO configuration** and proceed to create and configure a SAML SSO provider.
3. Optional: Download Sanity's SSO details as XML (This will make configuring Ping easier)
4. Disable InResponseTo setting in Sanity
5. Optional: Enable auto update roles on login - This will update the users role when they sign in with SAML

*Download XML*

### Go to the service configuration in PingIdentity

1. In PingIdentity add an app. 
2. Select SAML and "Configure"
3. Select import metadata and attach the XML from earlier or manually enter in Sanity's configuration details from the Sanity Manage page. Here are the mappings:1. *ACS URLS -> Sanity callback URL*
2. *Entity ID -> Sanity entity ID*



## Configuring Ping Identity

### Configure Attribute Mapping

You will need to configure the attributes sent to Sanity from Ping Identity, several are required including: email, firstName, and lastName. these can be found within the SSO setting from the Getting Ready step.

> [!WARNING]
> Attributes are case sensitive and if not inputted correctly may service as a 422 error.

### Configure Groups

Enterprise customers can map user identity provider roles to service provider roles. For example, users with a Ping Identity `example-admin-user-role` role are mapped to the Sanity `viewer` role when they log in.

- To support the mapping functionality, you must configure the identity provider to send the groups of the user.
- [Edit/enable role mapping](https://www.sanity.io/docs/developer-guides/sso-saml) in Sanity
- In Ping Identity, go to the 'Access' Tab

*Go to the access tab and edit*

- Select the groups you wish to send to Sanity and click save 

*Select your groups*

- You will need to ensure these are also added to your Attributes within Ping. Ensure that the name is set to `groups`

*Ensure your groups are being sent*

## Update Configuration within Sanity 

Now that you have set up everything in Ping, you can now upload your certification and update the configuration on the Sanity side. 

1. You can download the configuration and cert from Ping and Upload directly or you can manually enter in the configuration below are the mappings.1. *Identity Provider Single Sign-On URL* -> *Single Signon Service*
2. *Identity Provider issuer *-> *Issuer ID*



## Save

Ensure you save all changes inside Sanity Manage and Ping Identity



### Common Errors

These errors are specific to PingIdentity. For SSO problems that aren't provider-specific, such as SSO not appearing as a login option in the studio, see the troubleshooting section of [Setting up single sign-on with SAML](https://www.sanity.io/docs/developer-guides/sso-saml).

- Receiving a 422 error: `{"statusCode":422,"error":"Unprocessable Entity","message":"child \"attributes\" fails because [\"value\" must contain at least one of ...`- There is an issue with your claims. All claims are case sensitive and are required. 


- Groups are not being role mapped properly- Ensure you are sending the groups attribute and it is mapped to Group Names in your Ping configuration. It will need to be lower case exactly like 'groups'


- When I access Sanity from my IdP dashboard, I receive:
`{ "id": "3431pXO", "displayName": "Sanity Support", "email": "sanity@sanity.io", "familyName": "Sanity Support", "givenName": "Sanity", "middleName": null, "imageUrl": null, "provider": "saml-f6a94", "tosAcceptedAt": "2024-11-20T18:51:57.264Z", "createdAt": "2024-11-20T18:51:57.264Z", "updatedAt": "2024-11-20T18:51:57.535Z", "isCurrentUser": true, "providerId": "49jc94jf949930304jkojfciojlj934003490943" }`- It does not appear you have set up your default relay state within your IdP, you will need to also configure within your Idp settings. You can follow our [guide on setting the default relay state](https://www.sanity.io/docs/developer-guides/setting-up-a-default-relay-state-for-idp-initiated-saml-logins). 







# Set up SSO authentication with SAML and JumpCloud

> [!NOTE]
> This developer guide was contributed by Tim Naughton (Sanity Support Engineer).

Expand upon our general [SAML setup guide](https://www.sanity.io/docs/developer-guides/sso-saml) to configure and enable SSO authentication in your Sanity instance using the SAML protocol and JumpCloud  as an identity provider (IdP).

During the setup and configuration process, it's a good idea to keep two windows or tabs open side by side:

- One with [Sanity Manage](https://www.sanity.io/docs/developer-guides/sso-saml).
- The other with the configuration settings of the IdP; JumpCloud.

## Set up SSO

### Go to the service configuration (Sanity)

Go to [Sanity Manage](https://www.sanity.io/manage) and select the organization where you want to enable SSO.

To navigate to the service provider configuration inside Sanity Manage:

1. In the organization you intend to add SSO to, go to **Settings → SAML SSO**.
2. If no SAML SSO provider exists, click **Open SAML SSO configuration** and proceed to create and configure a SAML SSO provider.
3. Optional: Download Sanity's SSO details as XML (This will make configuring JumpCloud easier).
4. Disable InResponseTo setting in Sanity
5. Optional: Enable auto update roles on login - This will update the users role when they sign in with SAML. Note, if you update a user's role within Sanity, this role will be removed and updated with the role from the Idp groups array when the user logs back in.


![Interface for setting SAML SSO configuration](https://cdn.sanity.io/images/3do82whm/next/64251e3fe68a058daf6bc3536c7be44ccee77ef7-867x272.png)

### Create an app in JumpCloud

1. Visit [https://console.jumpcloud.com/#/applications](https://console.jumpcloud.com/#/applications).
2. Select "Add New Application".
3. Select "Custom Application" and "Next".



#### Configure the app

Select the following options:

- Manage Single Sign-On (SSO): This will need to be checked along with "Configure SSO with SAML".
- Export users to this app (Identity Management): You will want this checked if you plan on [mapping roles](https://www.sanity.io/docs/developer-guides/sso-saml) from Sanity. 

![a screenshot of the create new application integration page](https://cdn.sanity.io/images/3do82whm/next/508b594313f1eb5c107e611ed40c3f822839478f-3392x1546.png)

You will now see this app within JumpCloud and continue configuring.

#### Configure the app's SSO settings

Select the app while in the admin portal and navigating to the SSO tab. You will be able to upload the Sanity XML metadata the pervious step.

#### Add Attributes to JumpCloud



The email, firstName, lastName attributes are required while id and displayName are optional. These are case sensitive and can be mapped to the corresponding names in JumpCloud as shown above.

#### Set Default Relay State

You will need to set the Default RelayState option within JumpCloud to correctly navigate to your studio or project within Sanity. If this is left blank, you may see unexpected behavior with role mapping and where you are routed to after logging in. We have a further guide [here](https://www.sanity.io/docs/developer-guides/setting-up-a-default-relay-state-for-idp-initiated-saml-logins) that goes over steps to complete this.

### Configure SSO settings within Sanity

Now that you have JumpCloud set up you can setup within Sanity.

#### Add Identity Provider details

Sanity required the Identity Provider Single Sign-On URL, and the Identity Provider issuer which maps to the IdP URL from JumpCloud.

Copy these values from JumpCloud to the fields in Sanity Manage.





## Enable role mapping

If you want to manage your roles through JumpCloud's Users Groups, you can set up role mapping within your Sanity settings. More on this [here](https://www.sanity.io/docs/developer-guides/sso-saml).

## Add JumpCloud to your studio code

You will need to add a login button for SSO when users land on your studio url. Otherwise they will not be able to login with JumpCloud and may cause confusion for the users. Within your Sanity SAML SSO settings, copy the code snippet and add to your Studio config (`sanity.config.ts`) as shown in the example. 





Once this is added and your studio is deployed you should be all set to test the login. 

## Common Errors

These errors are specific to JumpCloud. For SSO problems that aren't provider-specific, such as SSO not appearing as a login option in the studio, see the troubleshooting section of [Setting up single sign-on with SAML](https://www.sanity.io/docs/developer-guides/sso-saml).

- Receiving a 422 error: `{"statusCode":422,"error":"Unprocessable Entity","message":"child \"attributes\" fails because [\"value\" must contain at least one of ...`- There is an issue with your claims. All claims are case sensitive and are required. 


- Groups are not being role mapped properly- Ensure you are sending the groups attribute and it is mapped to Group Names in your Ping configuration. It will need to be lower case exactly like 'groups'


- When I access Sanity from my IdP dashboard, I receive:
`{ "id": "3431pXO", "displayName": "Sanity Support", "email": "sanity@sanity.io", "familyName": "Sanity Support", "givenName": "Sanity", "middleName": null, "imageUrl": null, "provider": "saml-f6a94", "tosAcceptedAt": "2024-11-20T18:51:57.264Z", "createdAt": "2024-11-20T18:51:57.264Z", "updatedAt": "2024-11-20T18:51:57.535Z", "isCurrentUser": true, "providerId": "49jc94jf949930304jkojfciojlj934003490943" }`- It does not appear you have set up your default relay state within your IdP, you will need to also configure within your Idp settings. You can follow our [guide on setting the default relay state](https://www.sanity.io/docs/developer-guides/setting-up-a-default-relay-state-for-idp-initiated-saml-logins). 







# Reconcile users against internal systems

> [!NOTE]
> This developer guide was contributed by Daniel Favand (Solution Engineer at Sanity.io, helping clients build great content experiences.).

Use Sanity API's to compare current project members against an internal list to remove those that no longer require access

You may need to automate some project hygiene activities when managing many members across multiple and long-lived Sanity projects, datasets, and roles.

This guide is for team administrators who need to perform bulk actions on projects that have many users, whether you use [SAML authentication](https://www.sanity.io/docs/developer-guides/sso-saml) or the default login options.

For example:

- You may be using Sanity, Google, or GitHub logins and want to validate the list of members against an internal list of employees and remove those who should not have access anymore.
- You may be using SAML logins and need to remove members no longer with your organization.- SAML validates users when they log in. Users without permission to log in via your identity provider (IdP) will not have access. However, their user account will not be removed automatically because the IdP does not send permissions updates other than when a user logs in.


- You may be migrating to SAML logins and want to remove the Sanity, Google, or GitHub logins for users who have moved to SAML.

These are all actions that can be done through scripts accessing the API.

## Definitions

See the [Platform Terminology page in the documentation](https://www.sanity.io/docs/platform-management/platform-terminology) for more details.

### Organization

An organization is a unit of billing and the central point of configuration for SAML integrations.

### Project

A project contains datasets and is the main unit a user is associated with.

### Member

A member is the person’s account, which may have a role in zero or more projects. Within the context of a project, they will have a “Project User ID.”

The terms “member” and “user” are used below, but these terms have no functional difference. For the purpose of this guide, we might consider “users” to be those who still actively require project access. While “members” refers to all accounts that have at some point been active in the project but may no longer be using it.

### Role

A role describes what a user can access within a project.

## Steps

You will create a script that uses the Sanity Client to:

1. Fetch a Sanity project’s members and their roles
2. Filter out users from a predefined list of allowed users
3. Remove the roles of the remaining users so they will no longer have access to your project

### Create a CLI script

The `sanity` package lets you run scripts in the shell that use Studio configuration and perform operations using your authentication. You can write these scripts in JavaScript or TypeScript, and they will be run and evaluated with the same tooling that the Studio uses. These scripts use the configuration defined in `sanity.cli.ts` in your project folder.

This makes it convenient to create scripts for your Sanity project for tasks like migration and administration.

Within your Sanity Studio folder, create a directory named `scripts` and within that, a file named `reconcileUsers.ts` (the location of this script is up to you, but the examples below will show this filename and path).

### Import the Sanity client

The Sanity client will enable you to access the API. When imported from `sanity/cli` it will use the Studio’s configuration and import its environment variables. These configurations are set in the `sanity.cli.ts` file in your Sanity Studio folder. You can then override any of these settings – such as the dataset name – from within your script if required.

You will also need the Project ID for some API endpoints, which will be taken from the Sanity client’s configuration.

```typescript
// ./scripts/reconcileUsers.ts

import { getCliClient } from "sanity/cli"

// Configure Sanity Client
// See docs on API Versioning: https://www.sanity.io/docs/api-versioning
const client = getCliClient({ apiVersion: '2022-03-20' })
const { projectId } = client.config()

console.log(`Reconciling users for ${projectId}`)
```

### Try running your script

To test the script is working from your Studio directory, run the following command:

**npm**

```shell
npx sanity@latest exec ./scripts/reconcileUsers.ts
```

**pnpm**

```shell
pnpm dlx sanity@latest exec ./scripts/reconcileUsers.ts
```

**yarn**

```shell
yarn dlx sanity@latest exec ./scripts/reconcileUsers.ts
```

**bun**

```shell
bunx sanity@latest exec ./scripts/reconcileUsers.ts
```

If you see the *“Reconciling users for…”* in your console, then your script ran successfully!

### Obtain your internal list of users

Next, obtain your internal list of users with their email addresses. This example assumes you have a list of email addresses.

```typescript
// ./scripts/reconcileUsers.ts

// ...Sanity Client, etc

// A list of users you want to keep
const internalUsers = [
  'myUser@example.com',
  'anotherUser@example.com'
].map(email => email.toLocaleLowerCase())
```

Please make sure the email addresses are lowercase to make it easier to match them against your project’s current users later.

### Generate a full list of project users

Now in a function called `run()`, using the Sanity Client, get all the human members of your Sanity project, and retrieve their email addresses.

```typescript
// ./scripts/reconcileUsers.ts

// ...Sanity Client, internalUsers, etc

interface UserDetail {
  id: string
  email: string
}

interface ProjectUser {
  projectUserId: string
  isRobot: boolean
  roles: {
    name: string
    title: string
  }[]
}

async function run() {
  // 1: Perform a query for the list of Sanity project members
  const projectUsers = await client.request<ProjectUser[]>({
    // See: https://www.sanity.io/docs/roles-reference#309c2896a315
    url: `/projects/${projectId}/acl/`,
  })

  // 2: Filter out the robot tokens
  const humanUsers = projectUsers.filter((user) => !user.isRobot)

  // 3: Query each member's details and map them to the user ID
  const unprocessedUsers = [...humanUsers]
  const userDetails: UserDetail[] = []
  while (unprocessedUsers.length > 0) {
    const batchUsers = unprocessedUsers.splice(0, 100)
    const batchIds = batchUsers.map((user) => user.projectUserId).join(',')

    // Each member's details contain the ID and email address
    const batchUserDetails = await client.request({
      url: `/projects/${projectId}/users/${batchIds}`,
    }).catch((error) => {
      throw new Error(error)
    })
    userDetails.push(...batchUserDetails)
  }

  // 4: Filter the results to only those users on the internal list
  const usersNotInList = userDetails.filter(
    (detail) => !internalUsers.includes(detail.email.toLowerCase())
  )

  console.log('Users not in list:', usersNotInList.map(user => user.email))
}

run()
```

> [!WARNING]
> You may notice in your IDE that `client.request` gives a deprecation warning. You can overlook this for now when using it to make Sanity API requests. Future versions of the client may have built-in methods for Sanity API requests.

> [!TIP]
> The `while` statement in the script above calls the API in sequence, not concurrently, and only once for every 100 members in your project.
> However, when writing scripts that call Sanity APIs repeatedly, you might also need to avoid hitting rate limits. Or benefit from the option to pause and continue the script. By calling the API from a queue. 
> [p-queue](https://www.npmjs.com/package/p-queue) is a popular library for this and could be used in this script, or any other that repeatedly calls a Sanity API.

### Running the script with permissions

Run the script again now with the `--with-user-token` flag to use your personal token and permissions for the Sanity API client.

**npm**

```shell
npx sanity@latest exec ./scripts/reconcileUsers.ts --with-user-token
```

**pnpm**

```shell
pnpm dlx sanity@latest exec ./scripts/reconcileUsers.ts --with-user-token
```

**yarn**

```shell
yarn dlx sanity@latest exec ./scripts/reconcileUsers.ts --with-user-token
```

**bun**

```shell
bunx sanity@latest exec ./scripts/reconcileUsers.ts --with-user-token
```

You should see a list of project members that are not in the `internalUsers` array. These are the members that you’ll be removing from the project.

### Remove roles from Sanity users who are not on the internal list

For each user in the final list, remove each of their roles. When all roles are removed from a user, they will no longer be a project member.

> [!WARNING]
> Be careful not to remove permissions from your account! 
> Make sure that the email address associated with your user account is in the list of `internalUsers`.

```typescript
// ./scripts/reconcileUsers.ts

// ...Sanity Client, internalUsers, Types, usersNotInThisList etc

async function run() {
  // ...steps 1-4

  // 5: Find the roles of each member in this project
  for await (const user of usersNotInList) {
    const projectRoles = projectUsers.find(
      (projectUser) => projectUser.projectUserId === user.id
    )!.roles

    // Delete all roles from the member
    // A project member with no roles is removed from the project
    for await (const role of projectRoles) {
      await client.request({
        method: 'DELETE',
        url: `/projects/${projectId}/acl/${user.id}`,
        body: {
          roleName: role.name,
        },
      })
    }
  }
}

run()
```

## Full script

The full script is below, with Types and comments. You can run this with the same script as above, ensuring to include the `--with-user-token` flag.

```typescript
// ./scripts/reconcileUsers.ts

// This script will remove the roles from all project members
// that are not in the list of "internalUsers"

import {getCliClient} from 'sanity/cli'

interface UserDetail {
  id: string
  email: string
}

interface ProjectUser {
  projectUserId: string
  isRobot: boolean
  roles: {
    name: string
    title: string
  }[]
}

// Configure a Sanity client to make authenticated API calls
const client = getCliClient({apiVersion: '2022-03-20'})
const {projectId} = client.config()

// A list of users you want to keep
const internalUsers = [
  'myUser@example.com',
  'anotherUser@example.com'
].map(email => email.toLocaleLowerCase())

async function run() {
  // 1: Perform a query for the list of Sanity project members
  const projectUsers = await client.request<ProjectUser[]>({
    url: `/projects/${projectId}/acl/`,
  })

  // 2: Filter out the robot tokens
  const humanUsers = projectUsers.filter((user) => !user.isRobot)

  // 3: Query each user's details and map them to the user ID
  const unprocessedUsers = [...humanUsers]
  const userDetails: UserDetail[] = []
  while (unprocessedUsers.length > 0) {
    const batchUsers = unprocessedUsers.splice(0, 100)
    const batchIds = batchUsers.map((user) => user.projectUserId).join(',')

    // Each member's details contain the ID and email address
    const batchUserDetails = await client.request({
      url: `/projects/${projectId}/users/${batchIds}`,
    })
    userDetails.push(...batchUserDetails)
  }

  // 4: Filter the results to only those users on the internal list
  const usersNotInList = userDetails.filter(
    (detail) => !internalUsers.includes(detail.email.toLowerCase())
  )

  console.log('Users not in list:', usersNotInList)

  // 5: Find the roles of each member in this project
  for await (const user of usersNotInList) {
    const projectRoles = projectUsers.find(
      (projectUser) => projectUser.projectUserId === user.id
    )!.roles

    // Delete all roles from the member
    // A project member with no roles is removed from the project
    for await (const role of projectRoles) {
      await client.request({
        method: 'DELETE',
        url: `/projects/${projectId}/acl/${user.id}`,
        body: {
          roleName: role.name,
        },
      })
      console.log(`Removed ${role.name} from ${user.id}`)
    }
  }

  console.log('Complete')
}

run()
```

## Next steps

Now that you can automate the maintenance of project members that no longer require access, you may wish to take it further by importing the `internalUsers` list from a CSV file or API request.

Consider also what other bulk or maintenance operations might be streamlined with a CLI script, such as migrating content.



# Restrict Access to Specific Documents

> [!NOTE]
> This developer guide was contributed by Adam Gray (Solution Architect at Sanity).

Ensure your editors can only publish content they have permission to by implementing document level access control.

> [!WARNING]
> This guide includes **custom roles** features available exclusively on Sanity’s Enterprise plans.

There are some use cases that require granular, down to the document, access control. Thanks to the flexibility of Sanity, this is entirely possible! We will achieve this by adding metadata to documents that we can then filter on.

### Adding the Metadata

We’ll start by adding a new field to our document called `allowedEditors`. We  want each document to track which users have the ability to edit, so we need to store an array of the user IDs.

One approach is to create an array of strings:

```typescript
// schemaTypes/postType.ts

import {defineField, defineType} from 'sanity'

export const postType = defineType({
  type: 'document',
  name: 'post',
  title: 'Post',
  fields: [
    // ...all other fields
    defineField({
      name: 'allowedEditors',
      type: 'array',
      of: [{type: 'string'}],
    }),
  ],
})
```

This gives us the correct data structure for our field, but isn’t a great user experience (who wants to type in user IDs by hand!)

*Allowed Editors UI with "user_id1" in the input field*

We can improve the UX dramatically by installing the [User Select Input](https://www.sanity.io/plugins/sanity-plugin-user-select-input) plugin. Following the guide to install the plugin gives us access to the `userSelect` field type.

Let’s update our field type to use it.

```typescript
defineField({
  name: "allowedEditors",
  type: "array",
  of: [{ type: "userSelect" }]
})
```

*Allowed Editors Selector using the User Select Input Sanity Plugin*

Much better. Now we can search for users with an intuitive interface.

Currently, all users can modify the “Allowed Editors” for our document. Let’s update the field type so that this field is hidden to all users, except for administrators.

```typescript
defineField({
  name: "allowedEditors",
  type: "array",
  of: [{ type: "userSelect" }],
  hidden: ({ currentUser }) => currentUser.role !== "administrator",
})
```

Great! Our metadata field is set up. Next, we need to create a new content resource that will allow us to assign users to a role that can only modify documents they’ve been allowed to edit.

### Creating a Content Resource

Define a new [Content Resource](https://www.sanity.io/docs/user-guides/roles) by visiting [sanity.io/manage](https://sanity.io/manage), or using the CLI with:

**npm**

```shell
npx sanity@latest manage
```

**pnpm**

```shell
pnpm dlx sanity@latest manage
```

**yarn**

```shell
yarn dlx sanity@latest manage
```

**bun**

```shell
bunx sanity@latest manage
```

Then, selecting the project, navigating to “Access”, then “Resources” in the sidebar.

*The content resources manage page*

From here, create a new Content Resource. Feel free to name it whatever you like, I went with “Allowed to Edit”. In the “GROQ filter” section, define the filter that will return documents that the user has the ability to edit. We can use the [identity GROQ function](https://www.sanity.io/docs/specifications/groq-functions) to achieve this. The identity function returns the ID of the current user. As our document stores an array of user IDs, this filter will check if the current user is one of those IDs.

```text
identity() in allowedEditors
```

### Attaching the Content Resource to a Custom Role

Now that we’ve created our Content Resource, apply it to a role by navigating to “Roles” by selecting it in the sidebar. 

*Access Roles*

From here, if you already have a custom role you wish to apply this resource too, select it. If not, create a new role.

In the “Content permissions” section, select Edit, then set the permissions for your content resource to “Publish”.

*Content permissions setting with "Allowed to Edit" content resource set to "Publish"*

Users that are assigned this role will now only be able to publish documents that an administrator has allowed them to!

### A Better Structure

Now that we’ve implemented both the data structure to store the allowed editors, and created the permissions, the only thing that’s left is to customize the structure tool so that editors only see documents they have the ability to edit.

This section will require some experience with Structure Builder, [we have great documentation](https://www.sanity.io/docs/studio/structure-builder-introduction) if you’ve not customized your structure before!

The following function defines a new list item that shows all posts if the user is an administrator, and only editable posts if the user is not.

```typescript
function posts(S: StructureBuilder) {
  let documentFilter = "";

  if (S.context.currentUser?.role === "administrator") {
    documentFilter = '_type == "post"';
  } else {
    documentFilter = '_type == "post" && identity() in allowedEditors';
  }

  return S.listItem()
    .title("Posts")
    .id("posts")
    .child(S.documentList().title("Posts").filter(documentFilter));
}
```

With that, you now have a system to define editors for specific documents, along with a great user experience!



# Setting up a Default Relay State for IdP Initiated - SAML Logins

> [!NOTE]
> This developer guide was contributed by Tim Naughton (Sanity Support Engineer).

Expands upon our general [SAML setup guide](https://www.sanity.io/docs/developer-guides/sso-saml) to configure a default relay state.

During the setup and configuration process, it's a good idea to keep two windows side by side:

- One with [Sanity Manage](https://www.sanity.io/docs/developer-guides/sso-saml).
- The other with the configuration settings of the IdP. For this example we will show screenshots from Okta.

## Setup

### Go to the service configuration (Sanity)

Go to [Sanity Manage](https://www.sanity.io/manage) and select the organization you want to enable SSO for your organization.

To navigate to the service provider configuration inside Sanity Manage:

1. In the organization you intend to add a relay state to, go to **Settings → SAML SSO**.
2. Find the relevant project, click the vertical “…” and select **Copy Login URL (screenshot)**.


*Copy Login URL*

### Customizing the URL

This login url will take you to the Sanity Manage page once logged in. **If you'd instead prefer to, you can edit the URL for Studio Access rather than Manage.** In this URL, replace the origin parameter value with your encoded Sanity Studio URL, which will route users directly to the Studio instead of the management page.**Ex**: If the copied login URL is:

`https://api.sanity.io/v2021-10-01/auth/saml/login/{UNIQUE_ID_AVAILABLE_IN_MANAGE}?origin=https%3A%2F%2Fwww.sanity.io%2Fmanage&projectId={MYPROJECT_ID}`

update it to:

`https://api.sanity.io/v2021-10-01/auth/saml/login/{UNIQUE_ID_AVAILABLE_IN_MANAGE}?origin={MY_ENCODED_STUDIO_URL}&projectId={MYPROJECT_ID}`

> [!WARNING]
> This url will need to be encoded and you can use an online encoder like [urlencoder.org](https://www.urlencoder.org/)

### Updating the IdP

You can now update your IdP's default relay state. 

*Update the default relay state to the url copied or customized earlier.*

> [!WARNING]
> Ensure that the URL you are encoding is added to your [CORS origin list](https://www.sanity.io/docs/content-lake/cors) in Sanity. 

### Common errors

These errors relate to the Relay State configuration. For other SSO login problems, such as SSO not appearing as a login option in the studio, see the troubleshooting section of [Setting up single sign-on with SAML](https://www.sanity.io/docs/developer-guides/sso-saml).

- When I access Sanity from my IdP dashboard, I receive:
`{ "id": "3431pXO", "displayName": "Sanity Support", "email": "sanity@sanity.io", "familyName": "Sanity Support", "givenName": "Sanity", "middleName": null, "imageUrl": null, "provider": "saml-f6a94", "tosAcceptedAt": "2024-11-20T18:51:57.264Z", "createdAt": "2024-11-20T18:51:57.264Z", "updatedAt": "2024-11-20T18:51:57.535Z", "isCurrentUser": true, "providerId": "49jc94jf949930304jkojfciojlj934003490943" }`- It does not appear you have set up your default relay state within your IdP, you will need to also configure within your Idp settings.


- Receiving a blocked or permissions error- Ensure that you have the newly encoded url added to your [CORS origin list](https://www.sanity.io/docs/content-lake/cors) in Sanity





# Scalable navigation patterns

Designing a navigation in a structured content system like Sanity is different from what you might be used to in a monolithic content management system (CMS). Instead of dragging menu items around in a theme editor, you're building a flexible, scalable content model that supports both the site today and what it might grow into. This guide walks you through one approach to structuring navigation in Sanity, whether you're working with straightforward links or building out a more complex menu.

## Prerequisites

- A studio with document types for the navigation to link to. The examples reference `page`, `product`, and `blogPost`; see [Schema](https://www.sanity.io/docs/studio/schema-types) to define your own.

## Why model navigation in Sanity?

When you use Sanity to manage navigation, you get a few key benefits:

- **Structured content:** Your links are data. You can reference them, reuse them, and change how they're rendered without changing the data.
- **Editor-friendly:** Once set up, editors can manage the navigation without asking developers for help.
- **Keeps things in sync:** If you reference a page in the nav, and the slug changes, it updates everywhere—automatically. No more broken links.

## Model a basic navigation menu

Here's a lightweight schema for a basic navigation menu. This example assumes your studio already has `page`, `product`, and `blogPost` document types; adjust the `to` array to match the types in your schema. It supports:

- Internal links (references to pages)
- External links (plain URLs)

Start with the navigation item. Defining it as a standalone type, rather than inline in the navbar's array, lets you register it once and reference it by name from the navbar, a footer, or anywhere else the site needs links:

**nav-item.ts**

```typescript
import { defineType, defineField, defineArrayMember } from 'sanity'

export const navItem = defineType({
  name: 'navItem',
  title: 'Navigation Item',
  type: 'object',
  fields: [
    defineField({
      name: 'label',
      type: 'string',
      title: 'Label',
    }),
    defineField({
      name: 'link',
      type: 'array',
      validation: (rule) => rule.max(1).required(),
      of: [
        defineArrayMember({
          name: 'internalLink',
          type: 'object',
          title: 'Internal Link',
          fields: [
            defineField({
              name: 'internalReference',
              type: 'reference',
              to: [
                { type: 'page' },
                { type: 'product' },
                { type: 'blogPost' }
              ],
            }),
          ],
        }),
        defineArrayMember({
          name: 'externalLink',
          type: 'object',
          title: 'External Link',
          fields: [
            defineField({ name: 'url', type: 'url' }),
          ],
        }),
      ],
    }),
    defineField({
      name: 'openInNewTab',
      type: 'boolean',
      initialValue: false,
    }),
  ],
})
```

Then define the `navbar` document that holds an array of those items:

**navbar.ts**

```typescript
import { defineType, defineField, defineArrayMember } from 'sanity'

export const navbar = defineType({
  name: 'navbar',
  title: 'Site Navigation',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      title: 'Navigation Title',
    }),
    defineField({
      name: 'items',
      type: 'array',
      title: 'Links',
      of: [defineArrayMember({ type: 'navItem' })],
    }),
  ],
})
```

This model lets editors manage links, and gives you the data shape you need on the frontend.

The result looks like this:

![Sanity Studio editing a Simple Navigation document, with a Navigation Title field and a Links array holding three items: Page 1, Blog, and Product.](https://cdn.sanity.io/images/3do82whm/next/71dd6fc3fc56d96c1bd3b9d55b17b9e151f8bce3-954x587.png)

## Group links with a mega menu

A mega menu is a large dropdown that displays multiple columns of organized links instead of a flat list — the wide navigation dropdowns you see on e-commerce and software-as-a-service (SaaS) sites, with sections such as Resources, Products, and Company.

Here's an add-on model that enables grouped links:

**nav-column.ts**

```typescript
import { defineType, defineField, defineArrayMember } from 'sanity'

export const megaMenuColumn = defineType({
  name: 'megaMenuColumn',
  title: 'Mega Menu Column',
  type: 'object',
  fields: [
    defineField({
      name: 'heading',
      type: 'string',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'items',
      type: 'array',
      title: 'Links',
      of: [
        defineArrayMember({
          type: 'object',
          name: 'columnLink',
          fields: [
            defineField({
              name: 'linkLabel',
              type: 'string',
              title: 'Link Label',
            }),
            defineField({
              name: 'link',
              type: 'array',
              validation: (rule) => rule.max(1).required(),
              of: [
                defineArrayMember({
                  name: 'internalLink',
                  type: 'object',
                  title: 'Internal Link',
                  fields: [
                    defineField({
                      name: 'internalReference',
                      type: 'reference',
                      to: [
                        { type: 'page' },
                        { type: 'product' },
                        { type: 'blogPost' }
                      ],
                    }),
                  ],
                }),
                defineArrayMember({
                  name: 'externalLink',
                  type: 'object',
                  title: 'External Link',
                  fields: [
                    defineField({ name: 'url', type: 'url' }),
                  ],
                }),
              ],
            }),
            defineField({
              name: 'openInNewTab',
              type: 'boolean',
              initialValue: false,
            }),
          ],
        }),
      ],
    }),
  ],
  preview: {
    select: {title: 'heading'},
  },
})
```

The Sanity Studio interface looks like this:

![Sanity Studio editing a Navbar document, with the Edit Navigation Column dialog open on a column titled Company and its Column Links list of About us, Careers, and Investors.](https://cdn.sanity.io/images/3do82whm/next/064732a2e3ae336f35f2ad7c366174473759431c-928x749.png)

Embed `megaMenuColumn` in a top-level `navbar` document like this:

**navbar.ts**

```typescript
import { defineType, defineField, defineArrayMember } from 'sanity'

export const navbar = defineType({
  name: 'navbar',
  title: 'Site Navigation',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      title: 'Navigation Title',
    }),
    defineField({
      name: 'items',
      type: 'array',
      title: 'Links',
      of: [
        defineArrayMember({ type: 'navItem' }),
        defineArrayMember({ type: 'megaMenuColumn' }),
      ],
    }),
  ],
})
```

A navigation can now mix single links and grouped mega menu columns in one flexible array.

## Register the schema types

Sanity resolves `defineArrayMember({ type: 'navItem' })` by name, and that name exists only if the type is registered in your Studio config. Add `navItem` and `megaMenuColumn` alongside the `navbar` document:

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { navItem } from './schemaTypes/nav-item'
import { megaMenuColumn } from './schemaTypes/nav-column'
import { navbar } from './schemaTypes/navbar'

export default defineConfig({
  name: 'default',
  title: 'My Studio',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [structureTool()],
  schema: {
    types: [navItem, megaMenuColumn, navbar],
  },
})
```

Leave one out and the Studio fails to load with `Unknown type: navItem`.

## Query the navigation

Navigation items store a reference, not a URL. Dereferencing it with `->` at query time is what makes the model pay off: change a page's slug and every navigation item pointing at it resolves to the new path, with no edit to the navbar document.

This query fetches one navbar by title and flattens both item types into the shape a frontend renders:

**GROQ**

```groq
*[_type == "navbar" && title == $navTitle][0]{
  title,
  items[]{
    _type,
    _type == "navItem" => {
      label,
      openInNewTab,
      "href": select(
        link[0]._type == "internalLink" => "/" + link[0].internalReference->slug.current,
        link[0]._type == "externalLink" => link[0].url
      )
    },
    _type == "megaMenuColumn" => {
      heading,
      items[]{
        linkLabel,
        openInNewTab,
        "href": select(
          link[0]._type == "internalLink" => "/" + link[0].internalReference->slug.current,
          link[0]._type == "externalLink" => link[0].url
        )
      }
    }
  }
}
```

**Result**

```json
{
  "title": "Main navigation",
  "items": [
    {
      "_type": "navItem",
      "label": "About",
      "openInNewTab": false,
      "href": "/about"
    },
    {
      "_type": "navItem",
      "label": "Community",
      "openInNewTab": true,
      "href": "https://slack.sanity.io"
    },
    {
      "_type": "megaMenuColumn",
      "heading": "Products",
      "items": [
        {
          "linkLabel": "Studio",
          "openInNewTab": false,
          "href": "/products/studio"
        }
      ]
    }
  ]
}
```

Pass the title as a parameter — `{navTitle: 'Main navigation'}` — rather than interpolating it into the query string. To generate TypeScript types for the result, see [Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/sanity-typegen).

## Best practices

### Use references whenever possible

Referencing internal documents like `page` keeps your links in sync when slugs change. Let your data be the source of truth.

### Model the content, not the UI

Your schema should reflect the structure of the content itself, not how it's currently rendered in your frontend. This keeps your data flexible and portable, so if your design or framework changes, you're not stuck with a schema built around a previous layout.

### Make it editor-friendly

Give fields clear names and descriptions. Use previews so editors can see what they're editing at a glance.

### Reuse structures

Objects like `navItem` or `megaMenuColumn` can be reused in footers, mobile navigation, or anywhere else you need links.

## Next steps

Navigation is one of the most important systems on your site. With Sanity you can make it flexible for developers and friendly for editors.

To take the model further:

- Add localization support for translated navigation
- Add call-to-action buttons, featured items, or media
- Add a custom preview to the `navItem` object in `nav-item.ts`

```typescript
preview: {
  select: {title: 'label', linkType: 'link.0._type'},
  prepare({title, linkType}) {
    return {
      title,
      subtitle: linkType === 'internalLink' ? 'Internal link' : 'External link',
    }
  },
},
```

Editors see this preview:

![The Edit Navigation Column dialog, where a link item previews as its title, Product 1, above the subtitle Internal • /products/product-1.](https://cdn.sanity.io/images/3do82whm/next/19973e2a51ecc55189c6896d1d07a04a36f69c31-1268x776.png)

Start small, keep it structured, and grow the model as the site does.



# An opinionated guide to Sanity Studio

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

I’ve been creating Sanity projects since 2019 and, in that time, have developed a few preferences about how new Studios should be structured. I’ve had a version of this guide written down for quite some time as my own reference while testing new schema and plugins. As I spin up several new projects a month, this guide has become increasingly valuable.

## Using Cursor?

I've compiled the majority of these opinions into a [Cursor rules](https://docs.cursor.com/context/rules-for-ai) document you can add to any Sanity project so that as you prompt new configuration files into existence they follow these rules.

You can get my [opinionated Sanity Studio Cursor rules here](https://github.com/sanity-io/ai-rules/blob/main/AGENTS.md).

Read the documentation guide on [AI-assisted Sanity development](https://www.sanity.io/docs/ai/get-started).

## Why I wrote this guide

I hope that you find this guide useful to eliminate decision paralysis. When I first learned React, [Sara Vieira’s Opinionated Guide to React](https://opinionatedreact.com/) was foundational in removing the sinking feeling of wondering if I was *doing it wrong* and instead showed one set of conventions I could accept. Paving the way to focusing on the job of actually solving problems.

The intention of this guide is not to *slap your hand* and make you feel bad if you’re *not* building Sanity projects just like me. If you feel stuck between multiple choices and need a little nudge to pick one and move forward, you can once you’ve read this guide!

The inverse is also true. These strong opinions are loosely held. The realities of your project may come into conflict with some of the patterns demonstrated here. Make adjustments as you feel if strict adherence to this guide slows down the progress toward your goals.

All of the following are my own personal opinions and do not represent those of Sanity or my colleagues in our wonderful engineering teams. There are valid reasons to diverge from the patterns demonstrated in this guide, and you should not feel bad if you do!

## Getting started

Initialize any new Sanity Studio with the following command:

**npm**

```shell
npm create sanity@latest -- --typescript --template clean
```

**pnpm**

```shell
pnpm create sanity@latest --typescript --template clean
```

**yarn**

```shell
yarn create sanity@latest --typescript --template clean
```

**bun**

```shell
bun create sanity@latest --typescript --template clean
```

Or to quickly generate a new project, use these additional flags:

**npm**

```shell
npm create sanity@latest -- --template clean --create-project "showcase" --dataset production --typescript
```

**pnpm**

```shell
pnpm create sanity@latest --template clean --create-project "showcase" --dataset production --typescript
```

**yarn**

```shell
yarn create sanity@latest --template clean --create-project "showcase" --dataset production --typescript
```

**bun**

```shell
bun create sanity@latest --template clean --create-project "showcase" --dataset production --typescript
```

**Why: **I’ll never start a new project without TypeScript again and prefer to work with no schema files instead of modifying existing ones.

## Linting and formatting

Install [prettier](https://prettier.io/) and [configure with eslint](https://github.com/prettier/eslint-plugin-prettier) along with [simple-import-sort](https://github.com/lydell/eslint-plugin-simple-import-sort).

**npm**

```shell
npm install --save-dev eslint-plugin-prettier prettier eslint-plugin-simple-import-sort eslint-plugin-import
```

**pnpm**

```shell
pnpm add --save-dev eslint-plugin-prettier prettier eslint-plugin-simple-import-sort eslint-plugin-import
```

**yarn**

```shell
yarn add --dev eslint-plugin-prettier prettier eslint-plugin-simple-import-sort eslint-plugin-import
```

**bun**

```shell
bun add --dev eslint-plugin-prettier prettier eslint-plugin-simple-import-sort eslint-plugin-import
```

Replace `eslint.config.js` with the following:

```javascript
// eslint.config.js

import studio from '@sanity/eslint-config-studio'
import prettier from 'eslint-plugin-prettier'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import importPlugin from 'eslint-plugin-import'
import * as typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'

export default [
  ...studio,
  {
    ignores: ['dist', 'node_modules', '.sanity'],
    files: ['**/*.ts', '**/*.tsx'],
    plugins: {
      prettier,
      'simple-import-sort': simpleImportSort,
      import: importPlugin,
      '@typescript-eslint': typescriptEslint,
    },
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        project: './tsconfig.json',
        ecmaVersion: 'latest',
        sourceType: 'module',
      },
    },
    rules: {
      'prettier/prettier': 'error',
      'simple-import-sort/imports': 'error',
      'simple-import-sort/exports': 'error',
      '@typescript-eslint/consistent-type-imports': 'error',
      'import/no-default-export': 'error',
    },
  },
  {
    files: ['**/sanity.config.ts', '**/sanity.cli.ts'],
    rules: {
      'import/no-default-export': 'off',
    },
  },
]
```

Add a linting command to your `package.json` file and run it to format all existing files instantly:

```json
"scripts": {
  // ...all other scripts
  "lint": "eslint . --fix",
}
```

**Why:** I never want unformatted code in any file in my codebase ever. I also want code formatting every time I press “save” on a file. Regarding options like line length, semicolons, or single vs double quotes, I agree with [Prettier’s philosophy on options](https://prettier.io/docs/en/option-philosophy), and I genuinely don’t care.

That code **is** consistently formatted in every file of a project is far more important to me than **how** it is formatted.

## General rules

The linting rules above include throwing an error if a default export is used. Default exports can create issues that are more difficult to debug, especially when renaming files or functions. Named exports are more explicit and, therefore, more reliable.

## File organization

Create a `/src` directory and put `/schemaTypes` in it.

Note: A workspace can only have one “schema” which is a collection of “schema types.” So, it would be incorrect to use “schemas” here.

```sh
mkdir src; mv schemaTypes src/schemaTypes
```

Remember to also update the import to your schema types file in `sanity.config.ts`

```typescript
// sanity.config.ts

import {schemaTypes} from './src/schemaTypes'
```

All Sanity Studio-specific files will now live in `./src`

For example, I like to create structure configuration files like so:

```
src/
└─ structure/
   ├─ index.ts
   └─ defaultDocumentNode.ts
```

**Why:** I don’t like anything in the root directory other than project-impacting configuration files. Collating all Studio-specific files into a single folder makes it more easily portable between Studio projects.

## Schema types and form components

All schema types should always use the `defineType`, `defineField`, and `defineArrayMember` helper plugins. They are optional, but they make authoring and debugging schema in TypeScript simpler.

All registered schema export a named `const` that matches the filename. This only applies if it does not have input components.

```typescript
// src/schemaTypes/lessonType.ts

import {defineField, defineType} from 'sanity'

export const lessonType = defineType({
  name: 'lesson',
  title: 'Lesson',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
  ],
})
```

```
src/
└─ schemaTypes/
   ├─ index.ts
   └─ lessonType.ts
```

If a schema type has input components, they should be colocated with the schema type file. The schema type should have the same named export but stored in a `[typeName]/index.ts` file:

```typescript
// src/schemaTypes/seoType/index.ts

import {defineField, defineType} from 'sanity'

import seoInput from './seoInput'

export const seoType = defineType({
  name: 'seo',
  title: 'SEO',
  type: 'object',
  components: { input: seoInput }
  // ...
})
```

These components should be named `[name]-[componentType]`

```
src/
└─ schemaTypes/
   └─ seoType/
      ├─ index.ts
      ├─ seoInput.ts
      └─ seoField.ts
```

They can all be imported and collated in your schema types like this:

```typescript
// src/schemaTypes/index.ts

import {lessonType} from './lessonType'
import {seoType} from './seoType'

export const schemaTypes = [lessonType, seoType]
```

**Why:** Named exports are simpler to debug, import, and refactor.

### Decorating schema types

Sanity Studio offers many ways to enrich the content editing UI on behalf of your authors – and you should use them.

**Every** document and object schema type should:

- Have an `icon` property from either [@sanity/icons](https://icons.sanity.build/all?scheme=light), or if you need more variety, the [Lucide](https://lucide.dev/) set has a larger selection.
- Have a [customized preview](https://www.sanity.io/docs/studio/previews-list-views) property so that desk lists, reference fields, and search results show rich content about the document.
- Use [Field Groups](https://www.sanity.io/docs/studio/field-groups) when the schema type has more than a few fields to collate related fields and only show the most important group by default. These Groups should use the `icon` property as well.

### Avoid boolean fields

Prefer string literal fields. The `string` field type accepts a list of options that the author can choose from.

It is tempting to use the `boolean` field type for instances where a document needs to express one of two "modes." In practice, two is often not enough and refactoring a boolean into a string field later is more work than adding more string options.

Consider the following scenario: You want some documents to be only available to "internal" users. You might create a boolean field named `isInternal`. You can enforce a default value in your GROQ queries like so:

```groq
"isInternal": isInternal == true
```

Later, a request is made for an indeterminate state, users who are logged in but not "internal." Do you create another boolean field? This complicates the logic further.

Instead, a more flexible way to achieve the same goal, create a string field named `visibility` with the options "public" and "internal." It can be extended to include "authenticated." Thanks to the `coalesce` function, a default value can still be returned from GROQ queries.

```groq
"visibility": coalesce(visibility, "public")
```

### Avoid single references

Prefer an array of references. At the beginning of your project, with limited content, it may seem correct to use only a singular reference field. A post may only have one author. Eventually, your needs grow, and posts need multiple authors.

It is better to use plurals for your reference field names and prepare for a future where you have more than one – than have to refactor your fields, values, and GROQ queries later.

```typescript
// ❌ Avoid single reference fields
defineField({
  name: "author",
  type: "reference",
  to: { type: "author" },
})

// ✅ Prefer starting with an array of references
defineField({
  name: "authors",
  type: "array",
  of: [defineArrayMember({ type: "reference", to: { type: "author" } })],
  // Optionally limit the number of items
  validation: (rule) => rule.max(1),
})
```

Validation rules can enforce the maximum number of items in an array, so you could still limit the number of references to one.

And your GROQ queries can still return just a single reference.

```groq
"author": authors[0]->
```

## Organizing plugin configuration files

Some plugins – like Structure and Presentation – may require complex configuration. Those files will need to live somewhere in your project.

Keep a `./src/plugins` folder for any plugins you **create** in your Studio. There is an example of this in the next section.

Instead, create a folder for each plugin and store your configuration functions within these. For example, create a `structure` folder for your configuration of the `structureTool` plugin.

```typescript
// sanity.config.ts

// ...other imports
import {structure, defaultDocumentNode} from './src/structure'

export default defineConfig({
  // ...other settings
  plugins: [
    // ...other plugins
    structureTool({structure, defaultDocumentNode})
  ],
})
```

This should leave you with folder structures for the Structure and Presentation plugins like:

```
src
├─ structure
│   └─ index.ts
└─ presentation
    └─ locate.ts
```

## Collate shared functionality as a custom plugin

If you are developing a suite of functions, components, and Studio features that are related – combine them as a new plugin. Plugins do not need to be distributed to npm – just create a plugins directory and store related files there.

Plugins in Sanity Studio v3 are mini encapsulations of a Workspace config. So you could register a new tool, schema, document badges and actions, form components, and more in a single file.

**Why:** This makes sharing functionality between projects simpler but can also help with debugging by being able to disable an entire set of features by removing a single plugin from the Workspace config.

### Example plugin

Imagine you’ve been tasked to indicate the exhilaration of an approval process in the Studio. To visualize this, you’ll build a custom form component to display confetti when a document is approved. As well as rendering a document badge on approved documents.

This requires several parts of the Studio configuration API and could be useful in more than a single project.

*Sanity Studio showing document with "approved" status*

So you’d create a plugins folder inside your Studio, and in it, register the plugin’s schema types, form components, and document badges inside.

```typescript
// src/plugins/approval/index.ts

import {definePlugin} from 'sanity'

import {ApprovedBadge} from './badges'
import {approvedType} from './schemaTypes/approvedType'

export const approval = definePlugin({
  name: 'approval',
  schema: {types: [approvedType]},
  document: {badges: (prev) => [...prev, ApprovedBadge]},
})
```

All these files would look like this in your project:

```
src
└─ plugins
   └─ approval
      ├─ index.ts
      ├─ badges
      │  └─ index.ts
      └─ schemaTypes
         ├─ approvedType
         │  ├─ ApprovedInput.tsx
         │  └─ index.ts
         └─ index.ts
```

The plugin can then be activated in the Studio by adding it to your `sanity.config.ts` and deactivated for specific users, environments, or just by commenting it out.

```typescript
// sanity.config.ts

// ...other imports
import {approval} from './src/plugins/approval'

export default defineConfig({
  // ...other settings
  plugins: [
    // ...other plugins
    approval()
  ],
})
```

## Final folder structure for Studio projects

Putting all of the file tree diagrams together in the previous examples ends up with a Studio repository that looks something like this.

Note that this doesn't cover every permutation of the possibilities of handling multiple workspaces. You may choose to apply this differently.

```
src

│  // Required: Root-level config files
├─ tsconfig.json
├─ package-lock.json
├─ package.json
├─ sanity.cli.ts
├─ sanity.config.ts

│  // Optional: Root-level files
├─ .gitignore
├─ eslint.config.js
├─ README.md

│  // Required: Automatically generated folders
├─ node_modules
│  └─ ...
├─ dist
│  └─ ...
├─ static
│  └─ ...

│  // Required: Configure your Studio schema types
├─ schemaTypes
│  ├─ index.ts
│  ├─ lessonType.ts
│  └─ seoType
│     ├─ index.ts
│     ├─ seoField.ts
│     └─ seoInput.ts

│  // Optional: Configure Studio plugins and tools
│  // Your Studio may have none, some or more than these
├─ actions
│  └─ index.ts
├─ badges
│  └─ index.ts
├─ plugins
│  └─ approval
│     ├─ badges
│     │  └─ index.ts
│     ├─ index.ts
│     └─ schemaTypes
│        ├─ approvedType
│        │  ├─ ApprovedInput.tsx
│        │  └─ index.ts
│        └─ index.ts
├─ presentation
│  └─ locate.ts
└─ structure
   └─ index.ts

│  // Optional: CLI commands
├─ migrations
│  └─ ...
```

### Embedded or standalone Studio

Since Sanity Studio is "just" a React component, it can be "embedded" in a route as part of your front-end application. This can be especially convenient if your use of Sanity is predominately relevant for your website.

However, this convenience potentially constrains your thinking for Sanity to website-specific use cases, and turns Sanity Studio into little more than a website CMS. This is a limited view of the potential of the Studio and structured content.

For teams with ambitious projects that go beyond modeling a website, it is preferable to maintain the Studio as a separate application – either with its own history of version control or in a mono-repo. So that its content could be consumed by many applications and its configuration committed to by many developer teams.

## Writing and formatting GROQ queries

Variable names used for GROQ queries should be written in “screaming snake case,” for example, `POSTS_QUERY`. This is purely a stylistic preference and has no functional benefit. Remember: this is an extremely opinionated guide.

In many programming languages, this casing is used for variables that are not expected to change. While your GROQ query can be any string value, it should be considered an anti-pattern to generate them from functions or have logic in your app modify the query string.

GROQ query strings should be prefixed with the `defineQuery` helper from the `groq` package, as it provides syntax highlighting in VS Code when you have the [Sanity.io VS Code extension installed](https://marketplace.visualstudio.com/items?itemName=sanity-io.vscode-sanity), and are required when using Sanity TypeGen.

```typescript
import { defineQuery } from 'groq'

export const POSTS_QUERY = defineQuery(`*[_type == "post"]`)
```

Short queries like the above are fine on one line. Longer queries, especially those with projections, should use many lines so the logic in both the filter (the `[]` bit) and projection (the `{}` bit) are easier to read. They’re also simpler to debug, as you can remove filter arguments or parts of the projection by commenting out those lines.

```typescript
export const POST_QUERY = defineQuery(`*[
  _type == "post"
  && slug.current == $slug
][0]{
  _id,
  title,
  image,
  author->{
    _id,
    name
  }
}`)
```

### Explicit projections

[Array/Object expansion](https://www.sanity.io/docs/specifications/groq-operators) (three dots like this `...` commonly called a “spread” operator) should be used sparingly.

During development, you may find it simpler not to use a projection or return all attributes in a filter using this operator. However, this leads to “over-fetching,” where more data is returned than necessary. Slowing down response times.

Explicitly naming attributes in a projection also makes it clearer what data is relied upon by the application that consumes it.

By the time your app is in production:

- Every **filter** `[]` in every GROQ query should have a projection
- No query **projection** `{}` should contain array/object expansion `...`
- Query projections should explicitly name the **attributes** required

One way to ensure parity between your queries and your application is to run the result of your query through a validation library like [Zod](https://zod.dev/). This can validate returned data to ensure attributes are the correct value, and [strict](https://zod.dev/?id=strict) mode checking ensures no missing or additional attributes. It will also help by generating TypeScript types.

You’ll likely be less frustrated adding Zod the sooner you add it to a project, so I recommend it during development. For more on this topic, I have written a separate blog post on using [Zod with Sanity](https://www.simeongriggs.dev/type-safe-groq-queries-for-sanity-data-with-zod).

```groq
// Not good, what data is your app actually using?
*[_type == "post"]

// Still not good, now also returning way too much data
*[_type == "post"]{
  ...,
  categories[]->
}

// Better!
*[_type == "post"]{
  title,
  slug,
  categories[]->{ title, slug }
}
```

### Filter out nulls and set fallback values

By default, when you name individual attributes to retrieve in a GROQ query, any empty value will return `null`. Because of this your application will need to do a lot of defensive coding ("null checking") to see if a value exists.

Take this query for example:

```groq
// Without naming attributes, any of them could be undefined
*[_type == "post"]

// Naming these attributes will return their keys
// but slug.current could be null and trying to
// access it could throw an error
*[_type == "post"]{
  title,
  slug,
  categories[]->{ title, slug }
}
```

Now consider how our application needs to handle this data.

In the (not great) React component example code below you must check that `categories` is an array, not `null`. And check `slug.current` exists, and is not `null`.

```tsx
{Array.isArray(post.categories) ? (
  <ul>
    {post.categories.map((category, index) => (
      <li key={category?.slug?.current || index}>
        {category?.slug?.current ? (
          <a href={`/categories/${category.slug.current}`}>
            {category.title}
          </a>
        ) : (
          category.title
        )}
      </li>
    ))}
  </ul>
) : null}
```

When configuring Visual Editing your application may be consuming data from queries for draft document that *only* have `null` values.

Guard against **documents** that will return `null` values by filtering them out of results. The GROQ function `defined()` will check if a value is `null`. 

```groq
// Filter out any documents that don't have slug.current
*[
  _type == "post"
  && !defined(slug.current)
]{
  title,
  slug,
  categories[]->{ title, slug }
}
```

Prevent **attributes** from returning `null` by using the GROQ function `coalesce()` to return a different value, if the current value would return `null`. 

In this example `categories` always returns an array.

```groq
// categories could be an array or null, annoying to check for
*[
  _type == "post" 
  && defined(slug.current)
]{
  title,
  slug,
  categories[]->{ title, slug }
}
    
// return categories if they exist, or an empty array
*[
  _type == "post" 
  && defined(slug.current)
]{
  title,
  slug,
  "categories": coalesce(
    categories[]->{ title, slug },
    []
  )
}
```

### Variables vs string interpolation

Use `$variables` in your queries instead of string interpolation to insert values.

Variables are safer and make queries easier to understand.

```typescript
// Don't do this, it breaks easily and is difficult to read
const TYPE_QUERY = (type) => defineQuery(`*[_type == "${type}"]`)

// Do this and pass parameters to Sanity Client
const TYPE_QUERY = defineQuery(`*[_type == $type]`)
```

There are occasions when string interpolation is unavoidable. It’s currently not possible to use variables to declare attributes. So the following is acceptable.

```typescript
const fieldName = 'bedrooms'
const FIELD_QUERY = defineQuery(`*[${fieldName} > 5]`)
```

### "Fragments"

Rules were meant to be broken!

An exception to several of the opinions above is when you wish to re-use a GROQ query’s filter or projection inside of multiple queries, as GROQ does not yet support “fragments” or reusable query segments.

In the example below, a GROQ projection has been created as its own standalone variable, which can be reused with string interpolation into multiple complete query strings.

```typescript
const PAGE_BUILDER_PROJECTION = defineQuery(`{
  _key,
  _type,

  // ...any other attributes common to all types

  _type == "pageBuilderVideo" => {
    video->
  },
  _type == "pageBuilderTeam" => {
    staff[]->
  },
}`)

export const PAGE_QUERY = defineQuery(`*[_type == "page" && slug.current == $slug][0]{
    _id,
    title,
    pageBuilder[]${PAGE_BUILDER_PROJECTION},
}`)

export const HOME_QUERY = defineQuery(`*[_id == "home"][0]{
    _id,
    title,
    pageBuilder[]${PAGE_BUILDER_PROJECTION},
}`)
```

To demonstrate just how loosely held these strong opinions are – here’s a guide I wrote for this precise kind of query where a [helper function is used to build a GROQ query dynamically](https://www.sanity.io/guides/how-to-parallelize-complex-groq-queries).

### Custom GROQ functions

String interpolation works well for sharing GROQ across queries in your application code, but the function definitions still live as plain strings. [Custom GROQ functions](https://www.sanity.io/docs/content-lake/custom-groq-functions) offer a GROQ-native alternative: declare a function once at the top of a query, then call it like any built-in function.

```typescript
import {defineQuery} from 'groq'

const postQuery = defineQuery(`
  fn ex::author($author) = $author-> {
    "name": firstName + " " + lastName,
    "slug": slug.current,
  };
  *[_type == "post"] {
    title,
    "author": ex::author(author)
  }
`)
```

Custom functions must be declared at the start of each query because there is no global function registry on the server. To reuse a function across multiple queries, the string interpolation pattern still applies: define the function declaration as a constant and prepend it where needed. The two patterns are complementary, custom functions give you a GROQ-native syntax for the projection itself, and string interpolation lets you share that declaration across queries in your codebase.

## Have I missed anything?

If there’s any part of working with Sanity Studio, Sanity Client, or any of the APIs where you’ve got it working but aren’t quite convinced you’re *doing it right,* [please let me know](https://twitter.com/simeongriggs)!

### Extra reading

Some more opinionated reading that unpack opinionated guides or best practices for other parts of the Sanity ecosystem:

- [Patterns for Next.js, Sanity.io, Catch-all Routes, and Slugs](https://www.simeongriggs.dev/nextjs-sanity-slug-patterns)
- [Type-safe GROQ Queries for Sanity Data with Zod](https://www.simeongriggs.dev/type-safe-groq-queries-for-sanity-data-with-zod)
- [High-performance GROQ](https://www.sanity.io/docs/developer-guides/high-performance-groq)
- [Best practices for App SDK](https://www.sanity.io/docs/app-sdk/sdk-best-practices)





# Browsing Content How You Want with Structure Builder

> [!NOTE]
> This developer guide was contributed by Hidde de Vries (Developer Relations Specialist at Sanity.io).

How can you go beyond the default document lists for Sanity Studio’s Desk Tool? The [Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-reference) lets you improve the editorial experience with tailored workflows. This guide will help you get started with custom document lists and views.

Let’s say you’ve built a Studio to keep track of books you are reading. It contains documents of a `book` type with some metadata. By default, your Sanity Studio displays a long list of books under a “Books” heading. If you read a lot, this could run into hundreds of documents. Wouldn’t it be cool if it was a bit more like the old iPod interface? “A thousand songs in your pocket,” it was advertised, but the UI wouldn’t have worked so well if it was just one giant list of songs.

![white original iPod with on the screen a music screen with options for playlists, artists, albums, songs, genres and composers](https://cdn.sanity.io/images/3do82whm/next/5f45e10edb60e9067ce2a35e6b7091d5eaf42ad1-800x400.jpg)
*The first iPod could browse by artists and songs*

The first iPod displayed songs, but you could browse them also by artist, album, and genre. Were you in a Vengaboys mood, you would find that artist to Shuffle play all their songs. Were you in more of a genre-based mood, you could do that, though it was a little trickier as genres overlap. Anyway, it turns out that this kind of browsing by your data’s properties can be done in Sanity Studio using the [Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-reference). Here’s how it works.

## Including a Desk Tool

In your Sanity Studio, a “tool” is a page or route accessed via the main menu at the top. Which tools you use is up to you. They are all optional, but pretty much all studios will have the Desk Tool. It comes built-in with Sanity Studio and is what content editors use to browse, find and edit content. You can also build your own tools, like a map view of all branches of your franchise, to name one example.

To use the Desk Tool in your Studio, you add it by enabling the Desk Tool plugin. In `sanity.config.js`, you import that plugin:

```javascript
// sanity.config.js 
import { deskTool } from 'sanity/desk'
```

This imported `deskTool` is a function. In the configuration object that is passed to `defineConfig` , you include it in the plugins array:

```javascript
// sanity.config.js
import { defineConfig } from 'sanity'
import { deskTool } from 'sanity/desk'

export default defineConfig({
  plugins: [
    deskTool()
	],
	// other config items like name, 
	// title, projectId, schema
})
```

## Creating a custom list

### The default structure

With no arguments added, your desk tool will only display the content you have, organized by document type:

![Sanity Studio titled Hidde's Books with one item under the Content column, called Books ](https://cdn.sanity.io/images/3do82whm/next/92ba786a07418238edb2c928bea0f7938e2d08f8-2154x972.png)
*The Studio's default view: your content displayed by document types*

In my case, I only added books, so it shows those:

![Books studio with Books list item under which a list of books is displayed, a mix of Dutch and English titles with thumbnails](https://cdn.sanity.io/images/3do82whm/next/460d618af2c53d32b2a7270b2d950d81c016d6ae-2192x950.png)
*The Books list item opens a list of all books*

On the left is a “List” titled “Content,” the default. The list has items, one in this case: “Books.” Items open a “child,” which can be a new list, like a list of books, or a “view,” which is used to render fields to edit content, previews of content, and more. This “Books” list item that opens a list of books is the default structure Sanity Studio provides us. If we were to add a “Movies” document type, it would be displayed right underneath “Books” and open a pane with all the movies.

### Customizing the title and adding an item

We can customize the structure to our needs with the [Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-reference). It allows anything from slightly augmenting what’s already there to completely inventing our own structure.

Let’s start with the essential thing: change the title and add an item:

```javascript
// sanity.config.js
// (…)
deskTool({
  structure: (S) =>
    S.list()
    .title('Browse books')
      .items(
      [
        ...S.documentTypeListItems(),
        S.listItem()
          .title('Hello world')
      ]
    )          
}),
// (…)
```

So, first, you pass in an object to `deskTool` with a `structure` property. Its value is a function that receives the structure as an argument (named `S` by convention). This is the structure resolver function. You can work with `S` to set up our structure:

- add a list with `S.list()`
- name it “Browse books”
- add list items- first, spread `S.documentTypeListItems()`, a convenience method provided by the Structure Builder API that returns list items for all your document types. As I’m overwriting the whole structure, this adds the existing default structure back (in this case, it adds the “Books” list item)
- then, add an item with `S.listItem()` and title that “Hello world”



So, it is basically like the default, but now with a new title for the list and a new item called” Hello world”:

![Same view of the Studio as earlier, but with a new item added under the Books item called Hello World](https://cdn.sanity.io/images/3do82whm/next/eb54cd4cd11cc697e73c42befe137dc624fbc1b3-2190x946.png)
*A “Hello world” item is added*

### Populating our list item with items

In the previous example, there is no definition of what lives inside of “Hello world,” so clicking it will currently do nothing. Maybe you want to fix that and make it open a pane with items. Ratings, for example. Each book has a rating (1, 2, or 3 stars), and you want to allow browsing by rating. You could create a list item called “Ratings,” which opens a pane listing the available ratings (3 stars, 2 stars, 1 star), which opens a pane with the relevant documents.

This is how to add a list item called “Ratings”:

```javascript
// sanity.config.js

// setting passed to structure resolver
S.listItem()
  .title(`Ratings`)
```

The list item opens a pane. You can add that with `.child`, passing a list with a `title`, say, “Ratings,” passed into `items`:

```javascript
// sanity.config.js

// setting passed to structure resolver
S.listItem()
  .title('Ratings')
  .child(
      S.list()
        .title('Ratings')
        .items([])
  )
```

The array passed to `items` is where you’d add the list’s items. In this case, you’ll want one array item for each rating option, so three in total. This is what one of them would look like:

```javascript
// sanity.config.js

// setting passed to structure resolver
S.listItem()
  .title(`3 stars`)
  .child(
    S.documentList()
    .title(`3 stars`)        
    .menuItems(
      S.documentTypeList('book')
        .getMenuItems()
    )              
    .filter(`rating == 3`)                       
)
```

To break it down:

- make a list item and title it “3 stars”
- when it opens, show a pane that displays a list of documents, and title that pane “3 stars”
- within it, add a list of books
- filter it by only the books that have a rating of 3 stars (`filter` takes [GROQ](https://www.sanity.io/docs/content-lake/how-queries-work))

### Generating list items

Usually, you would not add each list item manually. You would generate them dynamically. You could put the possible ratings in an array and `map` that to an array of list items:

```javascript
// sanity.config.js
// ... other code

const RATINGS = [1, 2, 3];

// setting passed to structure resolver

S.listItem()
  .title('Ratings')
  .child(
      S.list()
        .title('Ratings')
        .items(RATINGS.map(rating =>
          S.listItem()
          .title(`${rating} star${rating > 1 ? 's' : ''}`)
          .child(
            S.documentList()
            .title(`Books with rating ${rating}`)
            .schemaType('book')
            .filter(`rating == ${rating}`)
        )
      )
    )
  )
```

Let’s break this down:

- You add a list item called “Ratings” that opens a panel with a document list called “Ratings”
- In `items`, pass in the resulting array from mapping over `1`, `2`, and `3` and returning a list item, using the relevant rating in the title (only adding an “s” if we have more than 1 star) and in the filter

You may not want to hardcode the properties used to generate these list items. And you don’t have to, as you have the information already in your Sanity data.

With this GROQ query, you can find a list of all unique ratings given to your books:

```javascript
// GROQ query
array::unique(*[_type == "book"].rating)

// possible response
[1, 2, 3]
```

You can send in this GROQ to request your Sanity data wherever you request your Sanity data, but you can also use it right in your structure builder definition. In the structure resolver function, you optionally get access to a `context` argument. Among other things, it provides access to your Sanity data via its `getClient` method.

Instead of:

```javascript
const RATINGS = [1, 2, 3]
```

You can do the following:

```javascript
// sanity.config.js 

structure: async (S, context) => {
	const RATINGS = await context
          .getClient({apiVersion: '2023-01-16'})
          .fetch(`array::unique(*[_type == "book"].rating)`)
  return /* return your list items */ 
}
```

You only [need to pass in the API version](https://www.sanity.io/docs/js-client#specifying-api-version). The dataset and project ID are derived from the project. For this to work, you’ll need your structure resolver function to be an asynchronous function, hence the added `async` keyword, and you’ll need to  `return` your structure explicitly.

Here’s a complete example where you map the array that we fetched with the Sanity client to list items you want to display:

```javascript
// sanity.config.js 

deskTool({
  structure: async (S, context) => {
    const RATINGS = await context
      .getClient({apiVersion: '2023-01-16'})
      .fetch(`array::unique(*[_type == "book"].rating)`)
      
    return S.list()
      .title('Browse books')
      .items([
        ...S.documentTypeListItems(),
        S.listItem()
          .schemaType('book')
          .title('Ratings')
          .child(
            S.list()
              .title('Ratings')
              .items(
                RATINGS.map(rating =>
                  S.listItem()
                    .title(`${rating} star${rating > 1 ? 's' : ''}`)
                    .schemaType('book')
                    .child(
                      S.documentList()
                        .title(`Books with rating ${rating}`)
                        .schemaType('book')
                        .filter(`rating == ${rating}`)
                    )
                )
              )
          ),
      ])
  },
}),
```

## Getting document lists based on specific fields

In the example above, you’ve added a ‘Browse by rating’ list to your Studio based on what was in your book’s `rating` fields. You could do the same with other fields so Studio users can also browse by other properties, like author, year, and publisher. You would fetch an array of unique authors, years, and publishers and build items from them.

Life could be easier, now and in the future. You could have accounted for authors and publishers in your content model, creating document types for each. Then in a book, the field is a [reference](https://www.sanity.io/docs/studio/reference-type) to documents of that type.

So, rather than having a string field for an author like this…

![field labeled author](https://cdn.sanity.io/images/3do82whm/next/57b317d22899d41a0e9ef7b8e08fb0bf58f1ce59-1070x158.png)
*A field to input one author*

```javascript
// schema.js 

export const schemaTypes = [
  {
    name: 'book',
    type: 'document',
    title: 'Books',
    fields: [
      {
        name: 'author',
		  	type: 'string'     
      }
    ]
  }
]
```

…you would create an Author document type and make the book’s author field a reference to authors:

```javascript
// schema.js 

export const schemaTypes = [
  {
    name: 'book',
    type: 'document',
    title: 'Books',
    fields: [
      {
        name: 'author',
		  	type: 'reference',
        to: [{type: 'author'}]
      }
    ]
  },
  {
    name: 'author',
    type: 'document',
    title: 'Authors',
    fields: [
      {
        name: 'name',
		  	type: 'string'
      }
    ]
  }
]
```

Which looks like this in the Studio:

![Author field with type to search, a list of suggestions is popped out, displaying two authors, next to the field is a Create new button](https://cdn.sanity.io/images/3do82whm/next/9363fa1886b8c1849b9f15258c282a6a73f89f17-1326x452.png)
*Author is not just a random string, but one of the Authors from your dataset*

Now, authors show up as their own entities. When you edit your book, you can look for existing Authors to select or create a new one right there.

## Next steps

This Guide showed how to use the [Structure Builder API](https://www.sanity.io/docs/studio/structure-builder-reference) to customize pretty much every aspect of finding content in Sanity with the Desk Tool. You have learned how to add custom items and make it so that content editors can browse items by their properties. Now that you know how it works technically, the next step could be to check with your content editors: how do they want to browse? There is no right or wrong here! How you organize the editor experience is up to what works best for your team. And once you know, go ahead and make your Studio yours.



# Deciding on fields and relationships

> [!NOTE]
> This developer guide was contributed by Knut Melvær (Head of Developer Community and Education) and Ronald Aveling (Ronald works with content for Sanity.io).

Content modeling is an art of solving tricky problems. This guide shows you how to work through those dilemmas and build relationships that can stand the test of time.

Our journey to build a content model with Sanity is well and truly underway. We’ve been through the [what](https://www.sanity.io/guides/content-modeling-guide-introduction) and [why](https://www.sanity.io/guides/content-modeling-guide-why), looked at [mental models](https://www.sanity.io/guides/discover-your-contents-mental-model), and built a [foundation in code](https://www.sanity.io/guides/implementing-a-content-model-in-sanity-io) that we can use with real content. Here’s what we’ve made so far:

![CandiCorp content mode diagram showing category and article content types as complete.](https://cdn.sanity.io/images/3do82whm/next/c8ca35878b252527c7552cd7c3fb0bc0eb0b35f8-1866x1173.png)
*Our content model progress at the end of the last chapter*

We have the basics in place for categories and articles, but there’s more to be done. Let’s dive back in...

## Set up subscriptions

> [!NOTE]
> **TODO:**
> - Provide *small* and *large* subscription plans.
> - Let subscription issues include different product variations (size, flavor, etc) every month.
> **Note:**
> For the purposes of this demo, product variations exist on CandiCorp’s PIM platform but are accessible in Sanity Studio (thanks to the “magic” of APIs). It’s possible to build out *all* these functionalities in Sanity, but we chose to include 3rd party services because:
> - Modeling product variations, subscribers, and payment fields would require another chapter.
> - API integrations are an advantage of headless content platforms worth highlighting.

We could build a fixed array of subscription plans containing the small and large sizes we need. But using a document type is better in this case as it lets CandiCorp add more plans (including discounts and limited offers) later on.

```javascript
// schemas/documents/subscriptionPlan.js

export default {
  title: 'Subscription plan',
  name: 'subscriptionPlan',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string',
    },
    {
      title: 'Slug',
      name: 'slug',
      type: 'slug',
      options: {
        source: 'title',
        maxLength: 96,
        auto: true
      }
    },
    {
      title: 'Active?',
      name: 'active',
      type: 'boolean',
      // in case we retire old plans
    },
    {
      title: 'Summary',
      name: 'summary',
      type: 'text',
      // visible on the frontend when subscribers sign up
    },
    {
      title: 'Price',
      name: 'price',
      type: 'number',
      // pricing info, sent to Stripe for payments
      validation: Rule => Rule.required().positive().precision(2)
      // ensures a value is added before publishing
      // forces the input to be a positive number with 2 decimal places
    }
  ]
}
```

Subscription issues need lists of different products, but they also need a quantity associated with each product listing. Let's bind quantities to products with a new `productSelect` object:

```javascript
// schemas/objects/productSelect.js

export default {
  title: 'Product select',
  name: 'productSelect',
  type: 'object',
  fields: [
    {
      title: 'Product',
      name: 'product',
      type: 'reference',
      to: [{type: 'product'}]
      // ✨ Sanity Studio magically displays a list of active products from the PIM via API integration ✨ 
      // Learn more at https://youtu.be/AaKfuhndEf8
    },
    {
      title: 'Quantity',
      name: 'quantity',
      type: 'number',
      validation: Rule => Rule.required().positive().integer()
    },
  ]
}
```

> [!TIP]
> The [object](https://www.sanity.io/docs/studio/object-type) type is used to define custom content types that have fields including strings, numbers, arrays, and other object types.

Now we can reference that object in the `subscriptionIssue` document type:

```javascript
// schemas/documents/subscriptionIssue.js

export default {
  title: 'Subscription issue',
  name: 'subscriptionIssue',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string',
    },
    {
      title: 'Ship date',
      name: 'shipDate',
      type: 'date',
      // the month the subscription is shipped in
    },
    {
      title: 'Plan type',
      name: 'planType',
      type: 'reference',
      to: [
        {type: 'subscriptionPlan'},
        // add a single plan only
      ]
    },
    {
      title: 'Products',
      name: 'products',
      type: 'array',
      of: [
        {
          type: 'productSelect',
          // add many product/quantity combinations
        }
      ]
    },
  ]
}
```

## Build an organization

> [!NOTE]
> **TODO:**
> - An easy-to-manage staff directory with contact info.
> - Include *role* and *department* details for each member of staff.
> - Credit staff members who write articles.
> **Nice to have:**
> - An organizational chart generated from staff and department data.

Our original mental model includes the *staff members* as a type. But can we say that all article authors will be staff members? Hard to know for certain. Let’s leave room for other possibilities and change this type to a `person`.

*Changing the Staff Member type to a Person type allow outside contributors and be more flexible overall.*

We can throw in some [boolean](https://www.sanity.io/docs/studio/boolean-type) fields to declare if our person is a *staff member* or *author* so we can filter based on the status of those fields when referencing person records within other documents.

```javascript
// schemas/documents/person.js

export default {
  title: 'Person',
  name: 'person',
  type: 'document',
  fields: [
    {
      title: 'First Name',
      name: 'firstName',
      type: 'string',
    },
    {
      title: 'Last Name',
      name: 'lastName',
      type: 'string',
    },
    {
      title: 'Slug',
      name: 'slug',
      type: 'slug',
    },
    {
      title: 'Image',
      name: 'image',
      type: 'image',
      options: {hotspot: true},
    },
    {
      title: 'Bio',
      name: 'bio',
      type: 'text',
    },
    {
      title: 'Staff member?',
      name: 'staff',
      type: 'boolean',
    },
    {
      title: 'Author?',
      name: 'author',
      type: 'boolean',
      // makes it easy to reference only people who are authors in article documents
    },
    {
      title: 'Role',
      name: 'role',
      type: 'reference',
      to: [
        {type: 'role'},
      ]
    },
    {
      title: 'Department',
      name: 'department',
      type: 'reference',
      to: [
        {type: 'department'},
      ]
    }
  ]
}
```

Roles and departments can be basic document types with a `title` and `slug` to start out. References to role and department can be left empty if the person is not a staff member.

In order to produce an organizational chart from structured content, we’ll need to add hierarchical relationships between different departments. We’ll tackle that in [the next chapter](https://www.sanity.io/guides/hierarchies-graphs-navigation).

> [!TIP]
> **It’s OK to change your mind**
> Don’t be concerned by the way we changed *staff members* to *people*. Iteration is an essential part of a healthy content modeling process.
> When you’re asking lots of questions and entertaining many possibilities you’re doing it right. Every choice comes with consequences and constraints, and your first idea for how to solve a problem may not be the best. So don’t let your initial hunch get in the way of exploring things from every angle.
> These dilemmas come with the territory, and you should expect to encounter lots of them. We’re all operating with built-in biases and incomplete perspectives. So embrace the questions, have an open mind to different points of view, and trust in the process.

## Connect people to publications

> [!NOTE]
> **TODO:**
> - Category references ✅
> - Author references
> - Implement rich text field
> - References to products and other articles within rich-text

Our [first iteration of the Article type](https://www.sanity.io/guides/implementing-a-content-model-in-sanity-io#917f053e1e1d) needs more fields. Articles are handy in lots of places like websites and catalogs, but what about newsletters or for documentation? We don't have a crystal ball, so let's leave wiggle room for our future selves with something flexible.

We can reference authors the same way we did categories, but how do we set up the main article field?

### Wrangling Rich-Text

A big "blob" of Rich Text content lives at the heart of most articles. These fields usually contain headings, bullet lists, etc, and are a home for all the unique stuff you can’t turn into reusable parts. You’re reading rich-text right now, and Sanity’s solution to rich text is called [Portable Text](https://www.sanity.io/blog/why-structured-text-is-awesome-and-you-totally-want-it-in-your-cms). We can add a basic Portable Text instance using the [block](https://www.sanity.io/docs/studio/block-type) type like so:

```javascript
{ title: 'Content', name: 'content', type: 'array', of: [{type: 'block'}] }
```

Portable Text is great because you get to [store rich text as data](https://youtu.be/dt5K6gHGpr0?t=100), but you can also trick it out with custom references and annotations. Let’s do that with a new `portableText` array, add connections to products and articles, and throw an image block in for good measure.

```javascript
// schemas/objects/portableText.js

export default {
  title: 'Rich Text',
  name: 'portableText',
  type: 'array',
  of: [
    {
      title: 'Block',
      type: 'block',
      styles: [
        {title: 'Normal', value: 'normal'},
        {title: 'H1', value: 'h1'},
        {title: 'H2', value: 'h2'},
        {title: 'Quote', value: 'blockquote'},
        // block level styles
      ],
      marks: {
        decorators: [
          {title: 'Strong', value: 'strong'},
          {title: 'Emphasis', value: 'em'},
          // add your own decorator
        ],
        annotations: [
          {
            title: 'URL',
            name: 'link',
            type: 'object',
            fields: [
              {
                title: 'URL',
                name: 'href',
                type: 'string',
              }
            ]
            // everybody needs a URL link
          },
          {
            title: 'Internal link',
            name: 'internalLink',
            type: 'reference',
            to: [
              {type: 'article'},
              {type: 'product'},
            ],
            // links, but to internal docs
          }
        ]
      }
    },
    {
      title: 'Product',
      name: 'product',
      type: 'reference',
      to: [
        {type: 'product'},
      ]
      // product embed
    },
    {
      title: 'Article',
      name: 'article',
      type: 'reference',
      to: [
        {type: 'article'},
      ]
      // article embed
    },
    {
      title: 'Image',
      type: 'image',
      fields: [
        {
          name: 'alt',
          type: 'string',
          title: 'Alt text',
          description: 'Alternative text for screen readers.',
        },
      ]
      // image + alt text!
    },
  ]
}

```

Then we can connect it to the article document along with our author refs and some other handy fields:

```javascript
// schemas/documents/article.js

export default {
  title: 'Article',
  name: 'article',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string',
    },
    {
      title: 'Slug',
      name: 'slug',
      type: 'slug',
      options: {
        source: 'title',
        auto: true
      }
    },
    {
      title: 'Publication date',
      name: 'publishDate',
      type: 'date',
    },
    {
      title: 'Categories',
      name: 'categories',
      type: 'array',
      of: [
        {
          type: 'reference',
          to: [
            {type: 'category'},
          ]
        }
      ]
    },
    {
      title: 'Summary',
      name: 'summary',
      type: 'text',
      // handy for content previews
    },
    {
      title: 'Authors',
      name: 'authors',
      type: 'array',
      // an array of refs leaves room for multiple authors
      of: [
        {
          type: 'reference',
          to: [
            {type: 'person'},
          ]
        }
      ]
    },
    {
      title: 'Content',
      name: 'content',
      type: 'portableText',
      // rich text on steroids
    },
  ]
}
```

And here it is in action: 

*Adding custom internal links and document embeds to Portable Text.*

> [!NOTE]
> That `portableText.js` file is no different than a regular field. We can include it in any document we like. Need a version of Portable Text with more or fewer features? Then make a new field with the necessary configuration.

## Create a catalog for print and web

> [!NOTE]
> **TODO:**
> - Eliminate layout concerns from the catalog authoring process to allow for print and web versions.
> - Include articles in the catalog.
> - Increase the frequency of editions.

Let’s create a new document type called `catalog`. We’ll need the basics of `title` , `slug`, and an `image` field for print and digital covers. A **content builder** will handle the main content assembly. In Sanity Studio, a content builder is an [array](https://www.sanity.io/docs/studio/array-type) of items. 

> [!TIP]
> If you’re new to content builders, [read this guide](https://www.sanity.io/docs/developer-guides/how-to-use-structured-content-for-page-building) to learn more about them and why you should model them based on what they mean, not how they should look.

We want articles and products for sure. Adding single articles to the content builder makes sense, but adding products one at a time would be far too time-consuming. Grouping is required.

If we make categories available in the content builder we reuse a product grouping mechanism that‘s already in place. We also get the added benefit of being able to list articles that share the same category, or not.

If we add a third product grouping option to the builder we can include ad hoc collections of products based on themes that unrelated to categories. Discounted lines and Easter and Halloween bundles come to mind. Let‘s iterate on the mental model to reflect the new thinking and build it

*Connecting categories and product groups to the catalog builder. *

```javascript
// schemas/objects/productGroup.js

export default {
  title: 'Product group',
  name: 'productGroup',
  type: 'object',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string',
      // useful for editing, and in a table of contents
    },
    {
      title: 'Description',
      name: 'description',
      type: 'text',
      // handy for introducing the group
      // portableText would also work a charm 
    },
    {
      title: 'Products',
      name: 'products',
      type: 'array',
      of: [
        {
          type: 'reference',
          to: [
            {type: 'product'},
          ]
        }
      ]
    }
  ]
}
```

```javascript
// schemas/documents/catalog.js

export default {
  title: 'Catalog',
  name: 'catalog',
  type: 'document',
  fields: [
    {
      title: 'Title',
      name: 'title',
      type: 'string',
    },
    {
      title: 'Slug',
      name: 'slug',
      type: 'slug',
      options: {
        source: 'title',
        auto: true
      }
    },
    {
      title: 'Image',
      name: 'image',
      type: 'image',
      options: {hotspot: true},
    },
    {
      title: 'Release date',
      name: 'releaseDate',
      type: 'date'
    },
    {
      title: 'Content builder',
      name: 'contentBuilder',
      type: 'array',
      of: [
        {
          type: 'productGroup',
        },
        {
          type: 'reference',
          to: [
            {type: 'article'},
            {type: 'category'},
          ]
        }
      ]
    }
  ]
}
```

We now have drag-and-drop curation of large content blocks ready to roll. And because we’ve avoided presentation concerns there’s nothing stopping these catalogs from being formatted for print on demand, or PDF, or responsive web layouts.

*Sorting array items via drag-and-drop in Sanity Studio.*

## What we made

Here‘s what we achieved with a few schema files and a good measure of critical thinking. That’s quite a foundation!

*A diagram of the content types and objects we’ve created. And how they connect to inputs and outputs.*

## What we learned

This guide has taught us a lot about the many ways we can build and connect things with Sanity. But more importantly, it offered a mental framework for reasoning about content dilemmas. Your problems and solutions will no doubt be different and that’s the way it should be. We’ve discovered that are no hard rules or bulletproof solutions, just compromises that strike the balance between today’s needs and tomorrow’s possibilities. 

Next up, we’ll wrap our minds around the different ways [we can handle hierarchies and navigation with Sanity](https://www.sanity.io/guides/hierarchies-graphs-navigation).



# Create richer array item previews

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Object types use a `preview` property to display contextual information about an item when they are inside of an array; customizing the preview component can make them even more useful for content creators.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll be making

*Array items with additional components*

## Schema preparation

In this guide, you’ll create a document type named `campaign` which has an array of `offer` fields.

Each `offer` has a `title`, `discount` and an expiry date.

Create a new object type for the offer, taking note of the detailed preview configuration.

```typescript
// ./schema/offer/offerType.ts

import {defineField, defineType} from 'sanity'
import {TagIcon} from '@sanity/icons/Tag'

export const offerType = defineType({
  name: 'offer',
  title: 'Offer',
  type: 'object',
  icon: TagIcon,
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (Rule) => Rule.required().min(0).max(100),
    }),
    defineField({
      name: 'discount',
      description: 'Discount percentage',
      type: 'number',
      validation: (Rule) => Rule.required().min(0).max(100),
    }),
    defineField({
      name: 'validUntil',
      type: 'date',
    }),
  ],
  preview: {
    select: {
      title: 'title',
      discount: 'discount',
      validUntil: 'validUntil',
    },
    prepare({title, discount, validUntil}) {
      return {
        title: title,
        subtitle: !discount
          ? 'No discount'
          : validUntil
          ? `${discount}% discount until ${validUntil}`
          : `${discount}% discount`,
      }
    },
  },
})
```

Also add a new document type for the campaign:

```typescript
// ./schema/campaign.ts

import {defineField, defineType} from 'sanity'

export const campaignType = defineType({
  name: 'campaign',
  title: 'Campaign',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'offers',
      type: 'array',
      of: [
        defineField({
          name: 'offer',
          type: 'offer',
        }),
      ],
    }),
  ],
})
```

Add both these files to your Studio and remember to import them to the schemas loaded in `sanity.config.ts`

Create a new campaign document, add some offers and your document should look something like this:

*A plain array input with a configured preview*

The list item previews here are useful, but because dates are hard to read, it’s not immediately clear which dates are expired, soon to expire or far into the future. You could write validation rules to give warnings or errors, but perhaps you don’t want the dates to block publishing.

With some quick edits to the array item preview, these can be much richer.

## Create a custom preview component

The preview form component works a little differently from others in the customization API. You cannot add click handlers or any other interactivity because in most cases this component is rendered inside a button. It also does not have access to the `value` of the field but instead receives the **values of the preview property** in the schema type definition.

So any customizations will need to be visual, and any extra data required will be deliberately passed down in the schema type definition.

Create a new component file for your item preview:

```jsx
// ./schema/offer/OfferPreview.tsx

import {Badge, Flex, Box} from '@sanity/ui'
import {PreviewProps} from 'sanity'

export function OfferPreview(props: PreviewProps) {
  return (
    <Flex align="center">
      <Box flex={1}>{props.renderDefault(props)}</Box>
      <Badge tone="positive">Hello!</Badge>
    </Flex>
  )
}
```

Notice how you can use `renderDefault(props)` to output the out-of-the-box UI that is defined in the function for the `prepare` property.

And load it into the offer schema:

```typescript
// ./schema/offer/offerType.ts

import {OfferPreview} from './OfferPreview'

export const offerType = defineType({
  name: 'offer',
  // ...other settings
  components: {preview: OfferPreview},
  preview: {
    select: {
      title: 'title',
      discount: 'discount',
      validUntil: 'validUntil',
    },
    // Remove "prepare" from the preview key!
    // You'll handle this in the component soon
  },
})
```

Return to your documents and look at the offers array. There’s a little green badge alongside each one.

It’s pretty!

*Array previews customised with the same Badge*

But pretty useless. Since the valid date is available to the component, you can update the component to display a different badge depending on the value of the date.

## Customize the component

Update the custom preview component to use the code below.

Because this is TypeScript, you’ll notice the need to recast the props, this is because a component’s `PreviewProps` type does not receive the field’s value. So instead the the offer schema preview passed down `discount` and `validUntil` where usually you would setup `title` and `subtitle`.

The component intercepts these values, performs some logic to generate a new subtitle for the `props.renderDefault(props)` and also displays a relevant `Badge` alongside the preview.

```jsx
// ./schema/offer/OfferPreview.tsx

import {useMemo, PropsWithChildren} from 'react'
import {Badge, Flex, Box, BadgeProps} from '@sanity/ui'
import {PreviewProps} from 'sanity'

type CastPreviewProps = PreviewProps & {
  discount?: number
  validUntil?: string
}

export function OfferPreview(props: PreviewProps) {
  // Item previews don't have access to the field's value or path
  // So we are passing in non-standard props in the schema
  // And recasting the type here to match
  const castProps = props as CastPreviewProps
  const {discount, validUntil} = castProps

  const badgeProps: (PropsWithChildren & BadgeProps) | null = useMemo(() => {
    if (!validUntil) {
      return null
    }

    const validUntilDate = new Date(validUntil)

    if (validUntilDate < new Date()) {
      // Offer has expired
      return {
        children: 'Expired',
        tone: 'critical',
      }
    } else if (validUntilDate < new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)) {
      // Offer expires in less than a week
      return {
        children: 'Expiring soon',
        tone: 'caution',
      }
    } else {
      // Offer is still valid
      return {
        children: 'Valid',
        tone: 'positive',
      }
    }
  }, [validUntil])

  const subtitle = !discount
    ? 'No discount'
    : validUntil
    ? `${discount}% discount until ${validUntil}`
    : `${discount}% discount`

  return (
    <Flex align="center">
      {/* Customize the subtitle for the built-in preview */}
      <Box flex={1}>{props.renderDefault({...props, subtitle})}</Box>
      {/* Add our custom badge */}
      {badgeProps?.children ? (
        <Badge mode="outline" tone={badgeProps.tone}>
          {badgeProps.children}
        </Badge>
      ) : null}
    </Flex>
  )
}
```

Return to your document and take a look at the new contextual badges. It’s now much clearer for authors to understand the status of each item.

*Each preview is now contextual to a value in the array item*

## Next steps

- Consider also adding validation to display warnings or errors on the object if you require the date value to prevent the document from being published.
- Decorating preview items is just the beginning! You could take a similar approach to render richer previews like images.



# Dynamic folder structure using the currentUser and workflow states

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

Building dynamic structures is easy enough using the filters in `documentTypeLists` or `documentList`. But what if you want to get more from the structure, such as a document count, which will also update whenever something in your content changes?

Well, you can do so using listeners.

In this guide, you will learn how to set up such a dynamic structure, using an example based on the [workflow plugin](https://github.com/sanity-io/sanity-plugin-workflow) and a structure that will show users their assigned documents filtered by states. 

The crux will be getting the number of documents in each state into the pane titles and also using them in the folder icon component for some 💅 bling.

Why can’t we just use a filter for this? 
Because we need the states and assignments stored on the meta-document, not the documents in question. 
Plus, we want to get the number of documents in each state (folder), which we cannot just get as easily from the query itself. 
This is because we cannot use the document IDs in the initial queries without doing a lot of acrobatics, thus making the query less performant.

Let’s talk about the main concepts first and then get to the overall code.

![Screenshot of dynamic structure in use](https://cdn.sanity.io/images/3do82whm/next/899756cd6c4b9606ba947dc3b0701b0f8afd6d4a-1035x426.png)
*This is what the finished structure will look like in the studio *

## Understanding the overall functionality

> [!WARNING]
> Besides the small code snippets in the first chapter, the code will be in TypeScript. 
> The thought behind this is that reasons for the way we construct code will be more easily understandable if we use types. If you need to use JavaScript, you can just remove the types behind variables and props.
> For example: (TS) `export const WORKFLOW_STATES: State[] = [...]` will become (JS) `export const WORKFLOW_STATES = [...]`

### Workflow meta documents and documents

To understand how the workflow metadata relates to the documents we want to display in our lists (folders), we need to see how these two documents rely on other data. Not displayed in the illustration is the value  `documentId` of the `workflow.metadata` document, which is the same as the document `_id`. This ID string is **not a reference**, which is why I decided not to show it here. 

![Illustration of how workflow metadata relates to their corresponding document](https://cdn.sanity.io/images/3do82whm/next/c07524ed94ce7f764d60704a2c20c1a0f9cedb04-7868x4206.png)
*Illustration of how workflow metadata relates to their corresponding document*

### What we need to make thins work

In our workflow plugin definition in `sanity.config.ts` we need to define an array of `states`. In order for us to later map the titles to the assignment data and determine the amount of documents in each folder in our dynamic structure, we define the `states` objects in a standalone file and then import the array in both the config as well as our custom list file (use your own here please).



```typescript
// workflow_states.ts

// these types are the same as the ones used in the plugin
declare type State = {
  id: string
  transitions: string[]
  title: string
  roles?: string[]
  requireAssignment?: boolean
  requireValidation?: boolean
  color?: 'primary' | 'success' | 'warning' | 'danger'
}

export const WORKFLOW_STATES: State[] = [
  {
    id: 'draft',
    title: 'Draft',
    transitions: ['inReview'],
  },
  {
    id: 'inReview',
    title: 'In Review',
    color: 'warning',
    roles: ['publisher', 'administrator'],
    requireAssignment: true,
    requireValidation: true,
    transitions: ['draft', 'changesRequested', 'approved', 'published'],
  },
  {
    id: 'changesRequested',
    title: 'Changes Requested',
    color: 'danger',
    roles: ['publisher', 'administrator'],
    requireAssignment: true,
    requireValidation: true,
    transitions: ['inReview'],
  },
  {
    id: 'approved',
    title: 'Approved',
    color: 'success',
    roles: ['publisher', 'administrator'],
    requireAssignment: true,
    requireValidation: true,
    transitions: ['published'],
  },
  {
    id: 'published',
    title: 'Published',
    color: 'primary',
    roles: ['publisher', 'administrator'],
    requireValidation: true,
    transitions: ['inReview'],
  },
]
```

### StructureBuilderContext

Some parts of the configuration export their own contexts, as is the case for the `StructureBuilder`. This means that we have things like the `currentUser`, `getClient` and the `documentSore` passed down from the context we can access in our custom structure.

> [!TIP]
> In TypeScript, you can follow the type definitions by right-clicking on the variable or type definition and following its trail.

### How listeners work

When we fetch data from the content lake, this data will be static. Since we want our list to update *automagically *🪄 when we update the workflow metadata (for example, changing the state or the assignees), we need to fetch the data and **listen to changes**.

In order to do so, we can use the `documentStore.listenQuery` from in our `context`:

```typescript
// type definition context.documentStore.listenQuery
DocumentStore.listenQuery: (query: string | {
    fetch: string;
    listen: string;
}, params: QueryParams, options: ListenQueryOptions) => Observable<any>
```

As you can see, you can either pass down a query string or an object with two queries – one to fetch and one to listen.

*Why is that?*

Because you cannot use some of the GROQ [functions](https://www.sanity.io/docs/specifications/groq-functions) in listening queries, you have the option to pass down a fetch query which uses `score()` for example – **and** a listening query which does not:

```typescript
const queryListening = `*[$userId in assignees[]]
  {state, _id, _score}`

  // This query will be used for fetching the data
  // we mimic the same sorting as in the workflow plugin
  const queryWithSorting = `*[$userId in assignees[]]
  | score(
    boost(state == "draft", 1),
    boost(state == "inReview", 2),
    boost(state == "changesRequested", 3),
    boost(state == "approved", 4),
    boost(state == "published", 5)
  )
  {state, _id, _score}
  | order(_score asc)`
  const params = {userId: userId as string}

  const queryAssignments = () => {
    return documentStore.listenQuery({fetch: queryWithSorting, listen: queryListening}, params, {
      tag: 'assignments',
    })
  }
```

As a `listener` will not return the usual array of results but an [observable](https://rxjs.dev/guide/observable) we need to make sure to get the results rendered out correctly. Additionally, the results need to be mapped to the values we get back from the listener. This is important because we want the values to update without reloading the studio page.

Just resolving the promise(s) would make the values static again. 

So we use the [rxjs](https://rxjs.dev) way and `pipe` then `map` over the observable variable:

```typescript
// this is a rxjs observable variable
const $assignments = queryAssignments()

// and this is how we then us the variable later on
return $assignments.pipe(
  // every time we get an updated list of assignments
  // map from rxjs just applies a function to the latest value
	map((assignments) => { 
		/* Do something to the observable data -> Next step */
		return /* Your dynamic list based on the data */
 }))
```

### 
Remove duplicate states from the returned data and add `titles` and `count` via `WORKFLOW_STATES`

Removing duplicate states returned from the listenQuery can be done with `new Set()` and removing items that share the same `state`

```typescript
// create shallow copy for the assignments without duplicate states
const uniqueStates = new Set(
	assignments.map((assignment) => assignment.state)
)
```

Next, we need to use `uniqueStates` and merge it with the titles defined in `WORKFLOW_STATES`. Following a similar approach we determine `count` by using the original `assigments` from our rxjs `map` function:

```typescript
// create count for each state and get title from WORKFLOW_STATES
const statesWithCount = Array.from(uniqueStates).map((state) => {
	return {
      state,
      title: states.find((workflowState) => workflowState.id === state)?.title!,
      count: assignments.filter((assignment: Assignment) => assignment.state === state).length,
	}
})
```

Now we have everything to next construct dynamic lists for each of our states in `statesWithCount` 🥳

Let’s go and construct the lists we’ve been talking about! 💪

## Setting things up (final code in TypeScript)

**Recap**

In order to make things work in tandem with the workflow plugin, we need to refactor the workflow plugin config for the states into its own file and export it. Then we add two more files, `workflowStructureByUserId.tsx` and `SateIcon.tsx`, to our project.

In this `workflowStructureByUserId.tsx` , we will export our list for the structure, which we import into our `deskTool` later.

### Building dynamic lists with `documentStore.listenQuery` and the Structure Builder

In `workflowStructureByUserId.tsx` we export our list for the custom structure (all steps are explained inline), that we then import into our `deskTool` in `sanity.config.ts`:



```typescript
import groq from 'groq'
import { map } from 'rxjs'
import { StructureBuilder, StructureResolverContext } from 'sanity/structure'
import { StateIcon } from './StateIcon'

// The workflow states are defined in their own file, and imported to both the plugin config as well as used here to get the titles and colorsfor later use
// see https://github.com/sanity-io/sanity-plugin-workflow?tab=readme-ov-file#configuring-states for more info

interface Assignment {
  state: string
  count: number
  title: string
}

export const workflowStructureByUserId = (
  S: StructureBuilder,
  context: StructureResolverContext,
) => {
  // get the current user id from the context to be able to dynamically get the documents assigned to the user
  const userId = context.currentUser?.id

  // get the workflow states from workflow_states.ts also used for the plugin config
  const states = WORKFLOW_STATES

  // We need to get all assignments for the current user and then group them by state
  // This query will be used for listening, because score() is not supported for listening
  const queryListening = groq`*[_type == 'workflow.metadata' && $userId in assignees[]]
  {state, _id, _score}`

  // This query will be used for fetching the data
  // we mimic the same sorting as in the workflow plugin
  const queryWithSorting = groq`*[_type == 'workflow.metadata' && $userId in assignees[]]
  | score(
    boost(state == "draft", 1),
    boost(state == "inReview", 2),
    boost(state == "changesRequested", 3),
    boost(state == "approved", 4),
    boost(state == "published", 5)
  )
  {state, _id, _score}
  | order(_score asc)`
  const params = { userId: userId as string }

  // get the document store from the context
  const { documentStore } = context
  // listen to the query to make sure it updates when the states change
  const queryAssignments = () => {
    return documentStore.listenQuery(
      { fetch: queryWithSorting, listen: queryListening },
      params,
      {
        tag: `assignments-${userId}`,
      },
    )
  }

  // return the list item for the workflow structure
  return S.listItem()
    .title('Your Assignments')
    .child(() => {
      // this is a rxjs observable variable
      const $assignments = queryAssignments()

      return $assignments.pipe(
        // every time we get an updated list of assignments
        // map from rxjs just applies a function to the latest value
        map((assignments) => {
          // create shallow copy for the assignments without duplicate states
          const uniqueStates = new Set(
            assignments.map((assignment: Assignment) => assignment.state),
          )

          // create count for each state
          const statesWithCount = Array.from(uniqueStates).map((state) => {
            return {
              state,
              count: assignments.filter(
                (assignment: Assignment) => assignment.state === state,
              ).length,
              title: states.find((workflowState) => workflowState.id === state)
                ?.title!,
            }
          })

          // create a list item for each state
          return S.list()
            .title('Assignments by State')
            .items(
              // map over assigments to create a list item for each state
              statesWithCount.map((assignment) => {
                return S.listItem()
                  .title(assignment.title)
                  .icon(() => (
                    // use the state icon component to show the state and count
                    <StateIcon
                      state={
                        (assignment.state as StateIconProps['state']) ||
                        'unknown'
                      }
                      count={assignment.count}
                    />
                  ))
                  .child(
                    // create a document list returning all documents which are assigned to the current user and has the current state in the meta document
                    S.documentList()
                      .title(
                        `${assignment.title} documents (${assignment.count})`,
                      )
                      .id('workflow-documents')
                      .filter(
                        '_id in *[$userId in assignees[] && state == $state].documentId',
                      )
                      .params({ userId, state: assignment.state })
                      .apiVersion('v2023-08-01'),
                  )
              }),
            ) // end of items
        }), // end of rxjs map
      ) // end of pipe
    })
}

```

### Define a `StateIcon` component – Our indicator for the amount of documents in each state folder



```typescript

import { WORKFLOW_STATES } from '@/sanity/plugins/workflow-states'
import { Card, CardTone, Text } from '@sanity/ui'
import { ComponentType } from 'react'

export interface StateIconProps {
  state:
    | 'draft'
    | 'changesRequested'
    | 'inReview'
    | 'approved'
    | 'published'
    | 'unknown'
  count: number
}

const StateIcon: ComponentType<StateIconProps> = (props) => {
  const { state, count } = props

  const CardToneMap: Record<StateIconProps['state'], CardTone> = {
    draft: 'default',
    published: 'primary',
    approved: 'positive',
    inReview: 'caution',
    changesRequested: 'critical',
    unknown: 'transparent',
    //undefined: 'inherit',
  }

  return (
    <Card tone={CardToneMap[state]} padding={3}>
      <Text>{count}</Text>
    </Card>
  )
}
export default StateIcon

```



![Icons in use ](https://cdn.sanity.io/images/3do82whm/next/71cd1b806c44fc0c8803a2e75ab188ffc82db091-94x313.png)
*This is how these will look later on  in the list *

### Add the new list to your `structure.ts`

> [!WARNING]
> If you don’t know how to import structure into your `deskTool` config, check the first chapter of the guide 😉



```typescript
// structure.ts

import {StructureBuilder, StructureResolverContext} from 'sanity/structure'

import {workflowStructureByUserId} from './workflowStructureByUserId'

const hiddenDocTypes = (listItem: any) =>
  ![
    // your hidden document type names
  ].includes(listItem.getId())

export const structure = (S: StructureBuilder, context: StructureResolverContext) =>
  S.list()
    .title('Content')
    .items([
      workflowStructureByUserId(S, context),
      
      S.divider(),
      
      // The rest of this document is from the original manual grouping in this series of articles
      ...S.documentTypeListItems().filter(hiddenDocTypes),
    ])
```

And we are done! 🥳

> [!TIP]
> A similar approach can also be used to generate folders for 
> - documents and their translations in tandem with the `translation.metadata` documents
> - documents that are scheduled for publishing – as a possible extension of the example above
> - marketing resource workflows AND scheduling combined: think editing, approving and scheduling social media posts – where you can additionally leverage the power of Sanity AI Assist to help create posts from your other content! 
> And and and ... 

![Screenshot of dynamic structure in use](https://cdn.sanity.io/images/3do82whm/next/899756cd6c4b9606ba947dc3b0701b0f8afd6d4a-1035x426.png)
*Finished dynamic structure looking good!*





# Create a time duration object field

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Delight your content creators with intelligent inputs for more complex data structures

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll be making

An object with two string fields for time, with a custom input that allows you to reset one or all fields back to a default value.

![Duration input demo](https://youtu.be/ZQzEMpMVK8g)

## Get started

In this guide, you’ll create a `duration` object type with two fields: a start and finish time. Times will be selected from a list of predefined options. You’ll also learn how to use paths to make fine-grained updates to object fields without replacing the entire object value.

Create the following schema files in your Studio and register them to the schema in `sanity.config.ts`

First, you’ll need to register a field to select the time:

```typescript
// ./schema/duration/timeValueType.ts

import {defineType} from 'sanity'

export const timeValueType = defineType({
  name: 'timeValue',
  title: 'Time',
  type: 'string',
  options: {
    list: ALLOWED_TIMES(),
  },
})

// A function that generates an array of times from 00:00 to 23:30
export function ALLOWED_TIMES() {
  const times = []
  for (let h = 0; h < 24; h++) {
    for (let m = 0; m < 60; m += 30) {
      times.push(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`)
    }
  }
  return times
}

```

Next, a `duration` field which is an `object` with `start` and `finish` values:

```typescript
// ./schema/duration/durationType.ts

import {defineField, defineType} from 'sanity'

export const durationType = defineType({
  name: 'duration',
  title: 'Duration',
  description: 'A start and finish time for a promotion',
  type: 'object',
  fields: [
    defineField({
      name: 'start',
      type: 'timeValue',
    }),
    defineField({
      name: 'end',
      type: 'timeValue',
    }),
  ],
  // make the fields render next to each other
  options: {columns: 2},
})
```

Lastly, you’ll need a document schema type to render this custom field. The below example is a `promotion` document schema with a `title` and the `duration` field.

```typescript
// ./schema/promotionType.ts

import {defineField, defineType} from 'sanity'

export const promotionType = defineType({
  name: 'promotion',
  title: 'Promotion',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'duration',
      type: 'duration',
    }),
  ],
})
```

With these files created and the schema types registered, you should be able to create a new promotion document type and see the following fields:

*A string field and an object of two string fields with preconfigured options*

Content creators can now create new documents with some valid values. However, it’s not visually interesting. It’s not possible to remove values. You could [set an initial value](https://www.sanity.io/docs/studio/initial-value-templates) on the field but cannot “reset” those values.

## Create a custom duration input

More complex field structures mean slightly more complex custom inputs.

Create the component as shown below. Note that this field type’s props are now a generic, which can take the object's value.

Also, to render the object's fields individually, you cannot use `props.renderDefault` as that would render the entire object. Instead, search for the member you want to display and use the `ObjectInputMember` component.

The benefit of using this component and passing along props is that if any child fields also use custom inputs – they’ll still be used. You’re not overwriting the tree of customizations.

```jsx
// ./schema/duration/DurationInput.tsx

import {Box, Stack, Button, Flex, Grid} from '@sanity/ui'
import {ObjectInputMember, ObjectInputProps} from 'sanity'

type DurationValue = {
  _type?: 'duration'
  start?: number
  end?: number
}

export function DurationInput(props: ObjectInputProps<DurationValue>) {
  const {members} = props

  const startMember = members.find((member) => member.kind === 'field' && member.name === 'start')
  const endMember = members.find((member) => member.kind === 'field' && member.name === 'end')

  if (!startMember || !endMember) {
    console.error(`Missing "start" or "end" member in DurationInput: "${props.schemaType.name}"`)
    return props.renderDefault(props)
  }

  // Pass along functions to each member so that it knows how to render
  const renderProps = {
    renderField: props.renderField,
    renderInput: props.renderInput,
    renderItem: props.renderItem,
    renderPreview: props.renderPreview,
  }

  return (
    <Stack gap={3}>
      <Grid gridTemplateColumns={2} gap={3}>
        <Flex align="flex-end" gap={2}>
          <Box flex={1}>
            <ObjectInputMember member={startMember} {...renderProps} />
          </Box>
          <Button mode="ghost" text="Reset" />
        </Flex>
        <Flex align="flex-end" gap={2}>
          <Box flex={1}>
            <ObjectInputMember member={endMember} {...renderProps} />
          </Box>
          <Button mode="ghost" text="Reset" />
        </Flex>
      </Grid>
      <Button text="Default Duration" mode="ghost" />
    </Stack>
  )
}
```

With this created, next you’ll assign it to the duration object:

```typescript
// ./schema/duration/durationType.ts

import {defineField, defineType} from 'sanity'
import {DurationInput} from './DurationInput'

export const durationType = defineType({
  // ...all other settings
  components: {input: DurationInput},
})
```

Create a new promotion document, and you’ll see the updated object input with new buttons. Clicking those buttons won’t write anything, so you must change that next.

*The object now has buttons to reset their values*

## Handling changes

You’ll need to access the `onChange` function from the component’s props to write patches to the document.

This function wraps any [patch](https://www.sanity.io/docs/content-lake/http-patches) – such as setting or unsetting the value of a field – and ensures the rest of the Studio stays up to date with changes.

> [!TIP]
> When working with forms in React, you’re often recommended to store values in a component’s state. This is an anti-pattern working with Sanity Studio input components. Writing content to state is only reflected in the browser of the person using the input. By using Sanity’s real-time APIs you allow content creators to collaborate and avoid overwriting each other’s changes by always syncing directly to the Content Lake.

When customizing primitive input field components (like string, number, etc) you’re only updating the value of that field.

You can replace the entire field value working with objects or arrays, but it is cleaner to “surgically” update individual fields.

In the updated code below, the buttons have been given `onClick` handlers can either update the entire object when the “Default Duration” button is clicked. Or update a single field when either of the “Reset” buttons are clicked.

How these work is explained in more detail below.

```jsx
// ./schema/duration/DurationInput.tsx

import {Box, Stack, Button, Flex, Grid} from '@sanity/ui'
import {ObjectInputMember, ObjectInputProps, set} from 'sanity'
import {useCallback} from 'react'

type DurationValue = {
  _type?: 'duration'
  start?: number
  end?: number
}

const DEFAULT_START = '09:00'
const DEFAULT_END = '17:00'

export function DurationInput(props: ObjectInputProps<DurationValue>) {
  const {onChange, members} = props

  const handleChange = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      const {name, value} = event.currentTarget

      if (name === 'reset') {
        // Reset the entire object with default values
        onChange(
          set({
            _type: 'duration',
            start: DEFAULT_START,
            end: DEFAULT_END,
          })
        )
      } else if (name === 'start' || name === 'end') {
        // Set the "_type" field if it's not already set
        // Update only the "start" or "end" field value
        // The second parameter is a "Path" to the field from the root object
        const patches =
          props?.value?._type === 'duration'
            ? [set(value, [name])]
            : [set('duration', ['_type']), set(value, [name])]

        onChange(patches)
      }
    },
    [onChange, props.value?._type]
  )

  const startMember = members.find((member) => member.kind === 'field' && member.name === 'start')
  const endMember = members.find((member) => member.kind === 'field' && member.name === 'end')

  if (!startMember || !endMember) {
    console.error(`Missing "start" or "end" member in DurationInput: "${props.schemaType.name}"`)
    return props.renderDefault(props)
  }

  // Pass along functions to each member so that it knows how to render
  const renderProps = {
    renderField: props.renderField,
    renderInput: props.renderInput,
    renderItem: props.renderItem,
    renderPreview: props.renderPreview,
  }

  return (
    <Stack gap={3}>
      <Grid gridTemplateColumns={2} gap={3}>
        <Flex align="flex-end" gap={2}>
          <Box flex={1}>
            <ObjectInputMember member={startMember} {...renderProps} />
          </Box>
          <Button
            mode="ghost"
            text="Default"
            name="start"
            value={DEFAULT_START}
            onClick={handleChange}
          />
        </Flex>
        <Flex align="flex-end" gap={2}>
          <Box flex={1}>
            <ObjectInputMember member={endMember} {...renderProps} />
          </Box>
          <Button
            mode="ghost"
            text="Default"
            name="end"
            value={DEFAULT_END}
            onClick={handleChange}
          />
        </Flex>
      </Grid>
      <Button text="Reset Duration" mode="ghost" name="reset" onClick={handleChange} />
    </Stack>
  )
}
```

### Using `path` to make fine-grained changes

Write updates to an individual field by supplying a `path` parameter to the `set()` function. The path is an array of any combination of strings, indexes, or key values to target the change. The root of the path is the object itself.

```typescript
// This onChange handler...
onChange(set(value, [name]))

// ...is saying "set the 'start' field in the object to '09:00'"
// and leave other fields in the object unchanged
onChange(set('09:00', ['start']))
```

This works in the `unset()` function as well!

Now click the buttons on your custom input to see how they can change either the individual field or the entire object.

## Next steps

1. Add a validation rule to the `duration` object to ensure the end time is *after* the start time.
2. Or even better, add logic that makes *end* times before the *start* time unselectable (and vice-versa).
3. Calculate the hours and minutes between the start and finish times and display the duration in plain text (or use something like [formatDuration](https://date-fns.org/v2.29.3/docs/formatDuration) from `date-fns`).



# Level up Your Edit Modal with Next/Previous Navigation Buttons for Array Items

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

When working with arrays in **Sanity Studios**, editing individual items can get tedious — especially when you have to open and close the modal for each one. 

What if you could streamline that process with simple **next** and **previous** buttons right inside the modal?

In this guide, you'll learn how to **enhance the default edit modal with navigation controls** that let you move smoothly between array items — without ever closing the modal. It's a small UX improvement that makes a big difference in editor efficiency and workflow satisfaction. 

Let’s dive in! 🚀

Before digging into the code, it is useful to understand how array item components work.

Every item has its (object) input passed down to its props as `children` (JSX Elements) which means we can access them in a [custom item component](https://www.sanity.io/docs/studio/form-components-reference) and add our buttons to the object input component rendered in the modal. 

![Screenshot of an edit modal for array items with navigation buttons on the top](https://cdn.sanity.io/images/3do82whm/next/c183eeb14ab30f7337ad438fa7e5f629f7c0682f-686x552.png)
*This is how the solution will look at the end: with buttons to navigate through array items without the need to close the modal.*

You can find the [finished code in the last chapter](https://www.sanity.io#c3dd686a9062). 



## Create a custom component for the array items

Let's start then!

Create a file called `ArrayItemWithNavigator.tsx` in your studio components folder.  

In that file, add this bare-bone item component: 

```typescript
import { ComponentType } from 'react'
import {
  ItemProps,
} from 'sanity'

const ArrayItemWithNavigator: ComponentType<ItemProps> = (props) => {
  
  return props.renderDefault({
    ...props,
    // this is how we can extend the props which get rendered out in the item
  })
}

export default ArrayItemWithNavigator
```

As you can see, we can extend the props passed down to `renderDefault` in order to change individual props. 

Next, we’ll retrieve the array value so we can access all its items and their corresponding paths. These will be used later to navigate between items within the modal.

```typescript
import { ComponentType } from 'react'
import {
  ItemProps,
} from 'sanity'

const ArrayItemWithNavigator: ComponentType<ItemProps> = (props) => {
    // * Get the array value from the form
  const arrayValue = useFormValue(['arrayNavigator']) as Array<
    ObjectItem & { title: string }
  >
  // * Get the path to the array (parent) for later focusing
  const arrayPath = props.path.slice(0, -1)

  /** Find the previous and next item in the array
   *
   * Returns the previous and next item in the array
   */
  const findPreviousAndNextArrayItems = () => {
    // * Get the current item key
    const currentItemKey = (props.value as ObjectItem)?._key

    const currentIndex = arrayValue.findIndex(
      (item) => item._key === currentItemKey,
    )
    // return both the previous and next item in the array, and if currentIndex is the first item, previous will be the last item and visa versa.
    return {
      previous:
        currentIndex === 0
          ? arrayValue[arrayValue.length - 1]
          : arrayValue[currentIndex - 1],
      next:
        currentIndex === arrayValue.length - 1
          ? arrayValue[0]
          : arrayValue[currentIndex + 1],
    }
  }

  return props.renderDefault({
    ...props,
    // this is how we can extend the props which get rendered out in the item
  })
}

export default ArrayItemWithNavigator
```

### Create a custom `Children` component

Alright, with that out of the way, let’s add a `Children` component to `ArrayItemWithNavigator.tsx`. Since it’s only used internally, we can place it right above the `ArrayItemWithNavigator`.

```typescript
const Children = ({
  children,
  navigation,
  arrayPath,
}: {
  children: ObjectItemProps['children']
  navigation: {
    previous: ObjectItem & { title: string }
    next: ObjectItem & { title: string }
  }
  arrayPath: Path
}) => {

  return(
    <Stack>
      <Flex justify="flex-end" gap={4} id="navigatorButtons">
        {/* our buttons will go here */}
      </Flex>
      {children}
    </Stack>
  )
}

```



We need to define a navigation handler for the buttons next, which will take the path to the array and return a path for the items before and after the current one. 

Because we need something to open those paths, we can make use of `onPathOpen` and `onFocus` which we can get from the `useDocumentPane` hook (please read the Gotcha below carefully).

```typescript
  //* We use this INTERNAL hook to focus the next or previous item in the array
  // Since it is internal changes can be made to it without notice -> ADD CLEAR DEBUGGING INSTRUCTIONS FOR YOURSELF HERE!
  const { onFocus, onPathOpen } = useDocumentPane()

  /** will open any item in the parentArray and loop over it */
  const handleNavigation = (key: string) => {
    onPathOpen(arrayPath.concat({ _key: key }, 'title'))
    onFocus(arrayPath.concat({ _key: key }, 'title'))
  }
```

> [!WARNING]
> **The useDocumentPane hook is marked as internal** and should only be used sparingly. Internal APIs can change **without notice**, and you will be responsible for maintaining and debugging your code that uses the hook.
> **Make sure to add error handlers and debug instructions anywhere you use it.**

### Defining the buttons

With that in place, we need to add our buttons to the `Flex` component. We will also add tooltips to the buttons because we want our editors to have more insights into where they are navigating. In those tooltips, we will display the title of the previous/next item.

```tsx
const Children = ({
  children,
  navigation,
  arrayPath,
}: {
  children: ObjectItemProps['children']
  navigation: {
    previous: ObjectItem & { title: string }
    next: ObjectItem & { title: string }
  }
  arrayPath: Path
}) => {

  return(
    <Stack>
      <Flex justify="flex-end" gap={4} id="navigatorButtons">
        {/* PREVIOUS BUTTON */}
        <Tooltip
          portal
          padding={3}
          content={
            <Box>
              <Stack gap={3}>
                <Box>
                  <Text>Open item: </Text>
                </Box>
                <Box>
                  <Text size={1} style={{ fontStyle: 'italic' }}>
                    {navigation.previous.title}
                  </Text>
                </Box>
              </Stack>
            </Box>
          }
        >
          <Button
            id="previous-array-item-button"
            text={'Previous item'}
            icon={ArrowUpIcon}
            onClick={() => handleNavigation(navigation.previous?._key)}
            mode="ghost"
            size={1}
            padding={2}
          />
        </Tooltip>
        {/* NEXT BUTTON */}
        <Tooltip
          portal
          padding={3}
          content={
            <Box>
              <Stack gap={3}>
                <Box>
                  <Text size={1}>Open item: </Text>
                </Box>
                <Box>
                  <Text size={1} style={{ fontStyle: 'italic' }}>
                    {navigation.next.title}
                  </Text>
                </Box>
              </Stack>
            </Box>
          }
        >
          <Button
            id="next-array-item-button"
            text={'Next item'}
            icon={ArrowDownIcon}
            onClick={() => handleNavigation(navigation.next?._key)}
            mode="ghost"
            size={1}
            padding={2}
          />
        </Tooltip>
      </Flex>
      {children}
    </Stack>
  )
}
```

### Extend `props.children` with the custom `Children` component

Now that we have the custom `Children` component we can use it to extend `children` in the props we pass down to `renderDefault` in the array item component:

```typescript

  return props.renderDefault({
    ...props,
    //* Because children holds the object input component for the modal, we can extend what is going to be rendered in the modal.
    children: (
      <Children
        children={props.children}
        navigation={findPreviousAndNextArrayItems()}
        arrayPath={arrayPath}
      />
    ),
  })
```

## Add the ArrayItemWithNavigator item component to array members

We're almost finished! The only remaining step is to add an custom item component to the array members in your field schema:

```typescript
defineField({
  name: 'arrayNavigator',
  title: 'Array with navigator',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'object',
      name: 'item',
      components: { item: ArrayItemWithNavigator },
      fields: [
        defineField({
          name: 'title',
          type: 'string',
          title: 'Title',
          validation: (Rule) => Rule.required(),
        }),
        defineField({
          name: 'description',
          type: 'text',
          title: 'Description',
        }),
      ],
    }),
  ],
})
```



## Finished code

And we're done 🥳 you will now be able to navigate between item edit modals, without closing them.

```typescript
// ArrayItemWithNavigator.tsx

import { ArrowDownIcon } from '@sanity/icons/ArrowDown'
import { ArrowUpIcon } from '@sanity/icons/ArrowUp'
import { Box, Button, Flex, Stack, Text } from '@sanity/ui'
import { Tooltip } from '@sanity/ui/tooltip'
import { ComponentType } from 'react'
import {
  defineArrayMember,
  defineField,
  ItemProps,
  ObjectItem,
  ObjectItemProps,
  Path,
  useFormValue,
} from 'sanity'
import { useDocumentPane } from 'sanity/structure'

const Children = ({
  children,
  navigation,
  arrayPath,
}: {
  children: ObjectItemProps['children']
  navigation: {
    previous: ObjectItem & { title: string }
    next: ObjectItem & { title: string }
  }
  arrayPath: Path
}) => {
  //* We use this INTERNAL hook to focus the next or previous item in the array
  // Since it is internal changes can be made to it without notice -> ADD CLEAR DEBUGGING INSTRUCTIONS FOR YOURSELF HERE!
  const { onFocus, onPathOpen } = useDocumentPane()

  /** will open any item in the parentArray and loop over it */
  const handleNavigation = (key: string) => {
    onPathOpen(arrayPath.concat({ _key: key }, 'title'))
    onFocus(arrayPath.concat({ _key: key }, 'title'))
  }

  return (
    <Stack>
      <Flex justify="flex-end" gap={4} id="navigatorButtons">
        <Tooltip
          portal
          padding={3}
          content={
            <Box>
              <Stack gap={3}>
                <Box>
                  <Text>Open item: </Text>
                </Box>
                <Box>
                  <Text>{navigation.previous.title}</Text>
                </Box>
              </Stack>
            </Box>
          }
        >
          <Button
            id="previous-array-item-button"
            text={'Previous item'}
            icon={ArrowUpIcon}
            onClick={() => handleNavigation(navigation.previous?._key)}
            mode="ghost"
            size={1}
            padding={2}
          />
        </Tooltip>
        <Tooltip
          portal
          padding={3}
          content={
            <Box>
              <Stack gap={3}>
                <Box>
                  <Text size={1}>Open item: </Text>
                </Box>
                <Box>
                  <Text size={1} style={{ fontStyle: 'italic' }}>
                    {navigation.next.title}
                  </Text>
                </Box>
              </Stack>
            </Box>
          }
        >
          <Button
            id="next-array-item-button"
            text={'Next item'}
            icon={ArrowDownIcon}
            onClick={() => handleNavigation(navigation.next?._key)}
            mode="ghost"
            size={1}
            padding={2}
          />
        </Tooltip>
      </Flex>
      {children}
    </Stack>
  )
}
const ArrayItemWithNavigator: ComponentType<ItemProps> = (props) => {
  // * Get the array value from the form
  const arrayValue = useFormValue(['arrayNavigator']) as Array<
    ObjectItem & { title: string }
  >
  // * Get the path to the array (parent) for later focusing
  const arrayPath = props.path.slice(0, -1)

  /** Find the previous and next item in the array
   *
   * Returns the previous and next item in the array
   */
  const findPreviousAndNextArrayItems = () => {
    // * Get the current item key
    const currentItemKey = (props.value as ObjectItem)?._key

    const currentIndex = arrayValue.findIndex(
      (item) => item._key === currentItemKey,
    )
    // return both the previous and next item in the array, and if currentIndex is the first item, previous will be the last item and visa versa.
    return {
      previous:
        currentIndex === 0
          ? arrayValue[arrayValue.length - 1]
          : arrayValue[currentIndex - 1],
      next:
        currentIndex === arrayValue.length - 1
          ? arrayValue[0]
          : arrayValue[currentIndex + 1],
    }
  }

  return props.renderDefault({
    ...props,
    //* Because children holds the object input component for the modal, we can extend what is going to be rendered in the modal.
    children: (
      <Children
        children={props.children}
        navigation={findPreviousAndNextArrayItems()}
        arrayPath={arrayPath}
      />
    ),
  })
}

// schema field definition 
defineField({
  name: 'arrayNavigator',
  title: 'Array with navigator',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'object',
      name: 'item',
      components: { item: ArrayItemWithNavigator },
      fields: [
        defineField({
          name: 'title',
          type: 'string',
          title: 'Title',
          validation: (Rule) => Rule.required(),
        }),
        defineField({
          name: 'description',
          type: 'text',
          title: 'Description',
        }),
      ],
    }),
  ],
})

```



# Create a “coupon generator” string field input

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Take the guesswork out of creating fields with correct values and automate content creation for authors.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll be making

A string field that can generate its own valid strings:



## Getting started

In this example, you’ll build a custom coupon generator. In this instance, a coupon is a string field that is four characters long, containing only uppercase letters and numbers.

The simplest way to add this sort of input to a document in Sanity would be a string field with a validation rule. This is functional but not pleasant to your authors. With a custom input they can generate these codes in one click while still having full control over the editing input.

Create a new field in your Studio, and register it to your `schema` in `sanity.config.ts`:

```typescript
// ./schema/coupon/couponType.ts

import {defineType} from 'sanity'

export const couponType = defineType({
  name: 'coupon',
  title: 'Coupon',
  description: 'A unique, all uppercase, four-character alphanumeric code',
  type: 'string',
  validation: (rule) =>
    rule
      .min(4)
      .max(4)
      .regex(/^[A-Z0-9]+$/),
})
```

Creating a new schema type for this string allows more flexible reuse throughout your Studio. For example, if multiple document types use this coupon field type with its custom input; but with unique `options`. By importing this schema type to the Studio schema, you can refer to this `type` with it’s value for `name` , in other words `type: 'coupon'`, as seen below.

Add the coupon field type to a document type’s `fields`, and register this to the Studio’s schema:

```typescript
// ./schema/storeType.ts

import {defineField, defineType} from 'sanity'

export const storeType = defineType({
  name: 'store',
  title: 'Store',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'coupon',
      type: 'coupon',
    }),
  ],
})
```

Create a new `store` document type as above, and you should see both string fields like the example below:

*Two string fields with default functionality*

## Create an input component

Create a new component in your Studio using the code below:

```jsx
// ./schema/coupon/CouponInput.tsx

import {Box, Button, Flex} from '@sanity/ui'
import {StringInputProps} from 'sanity'

export function CouponInput(props: StringInputProps) {
  return (
    <Flex gap={3} align="center">
      <Box flex={1}>{props.renderDefault(props)}</Box>
      <Button mode="ghost" text="Generate coupon" />
    </Flex>
  )
}
```

Notice the following:

1. On line 3, the imports from Sanity UI help you create custom components that look like first-class editorial experiences with the same UI and design language as the rest of the Studio. See ”[Getting started with Sanity UI](https://www.sanity.io/ui/docs)” to learn more.
2. On line 9, `props.renderDefault(props)` is used to render the original string input. This is super convenient because you won’t need to handle complex APIs like validation and presence.

To use this component, you’ll need to load it into the correct slot back on the `coupon` schema. You’ll use `input` here because you don’t want to replace the field’s title and description.

```typescript
// ./schema/coupon/couponType.ts

import {CouponInput} from './CouponInput'

export const couponType = defineType({
  name: 'coupon',
  // ...all other settings
  components: {input: CouponInput},
})
```

Look at your `store` document type again; you’ll see your custom input and the “Generate” button. Which is great, but it doesn’t yet do anything!

*The coupon string field now has a custom button*

Let’s `onChange` that.

## Handling changes and patching data

Custom inputs contain helpful functions and details in their `props` – for this input, you’ll only need one: `onChange`.

This function wraps any [patch](https://www.sanity.io/docs/content-lake/http-patches) – such as setting or unsetting the value of a field – and ensures the rest of the Studio stays up to date with changes.

> [!TIP]
> When working with forms in React, you’re often recommended to store values in a component’s state. This is an anti-pattern working with Sanity Studio input components. Writing content to state is only reflected in the browser of the person using the input. By using Sanity’s real-time APIs you allow content creators to collaborate and avoid overwriting each other’s changes by always syncing directly to the Content Lake.

Update your `CouponInput` component to use the code below:

```jsx
// ./schema/coupon/CouponInput.tsx

import {Box, Button, Flex} from '@sanity/ui'
import {Code} from '@sanity/ui/code'
import {set, StringInputProps} from 'sanity'
import {useCallback} from 'react'

export function CouponInput(props: StringInputProps) {
  // onChange handles patches to the document
  const {onChange} = props

  const generateCoupon = useCallback(() => {
    const coupon = Math.random().toString(36).substring(2, 6).toUpperCase()
    // "set()" will write a value to this field
    onChange(set(coupon))
  }, [onChange])

  return (
    <Flex gap={3} align="center">
      <Box flex={1}>{props.renderDefault(props)}</Box>
      {/* Display the value in a monospaced font */}
      {props.value ? <Code size={4}>{props.value}</Code> : null}
      <Button mode="ghost" onClick={generateCoupon} text="Generate coupon" />
    </Flex>
  )
}
```

1. Notice how `onChange` is destructured from the component’s `props`.
2. The `onChange` function is then used inside `generateCoupon`, with the `set()` function, to update the field’s value. This means the new value will be instantly validated in the document and updated in the browser of any other authors currently viewing the same document.
3. The `generateCoupon` function is registered with a `useCallback` hook to [cache it between re-renders](https://react.dev/reference/react/useCallback).
4. Sometimes these codes can contain easily confused characters (like `0` and `O`) so rendering the current field’s value in the `<Code>` component can make them clearer.

Now you have a fully functional, automated, and editable coupon generator field with a handy visual preview!

*The button now writes to the string field along with a rich visual preview*

## Next steps

Some ideas to extend this custom input include:

1. Extend the `coupon` field’s validation rule to use `rule.custom()` and check that no other document in the dataset contains the same coupon code.
2. Perhaps add some configurability to the `coupon` field schema, like a setting in `options` to determine the length of the generated coupon string.
3. Change the `generateCoupon` function – and the field validation – so that it does not create or allow potentially confusing characters such as `0` and `O`
4. Import `unset` from `sanity` and add an extra button to remove the coupon from the field



# Managing redirects with Sanity

> [!NOTE]
> This developer guide was contributed by Chris LaRocque (Senior Solution Architect).

It’s tough to nail down a ‘one size fits all’ approach to redirects with Sanity, as different frameworks handle redirects differently. This guide will explain how to model and implement redirects for a few of the major JavaScript frameworks.

## What’s a redirect?

A redirect is a way to navigate users from one URL to another. For example, if we had a page at "https://our-site.com/old-link" and updated the URL for that content to "https://our-site.com/new-link", a redirect would allow us to ensure that anybody with the old URL was brought to the new URL without seeing a 404 or error page.

## Why use redirects?

Redirects exist to ensure your users have a great experience on your website and that search engines can find and index your site content. When users hit a 404 page on your site, it disrupts their journey to finding your products/content, and when search engine crawlers see 404s, it can begin to impact the search ranking for those pages negatively. Common reasons for implementing redirects include:

- **Bring users to a new version of a page on your site** - Sometimes, when migrating sites or simply removing outdated content in favor of newer pages, you’ll need to redirect from the old to the new URL to avoid 404 errors. Examples include:- During a site migration: `/old-blog/my-blog => /blog/my-blog`
- When replacing old content: `/outdated-page => /new-better-page`


- **Short/memorable URLs that link to an existing page on your website** - Often for print ads (or any type of ad you can’t click on) it’s helpful to have an easy-to-memorize URL that points to other content on your site with a longer URL- `https://your-website.com/learn => <https://your-website.com/resources/blog/learn-about-our-area-of-expertise`>



### Why redirects have been tricky in the past for headless architecture

In “monolithic” CMSes like WordPress or Drupal, redirects are typically just a plugin away, as those platforms control your server as well as your content. When implementing a headless CMS, your front-end and CMS become “decoupled” so an extra bit of integration work is needed to have redirects live alongside your content. Because every front-end framework is different, the integration work needed for each is slightly different, but this guide, coupled with Sanity’s flexibility, should make it quick and easy.

## Best practices

### Redirect to relevant content

When possible, redirect users to content relevant to the initial page they were expecting to visit. Redirecting to a relevant page helps user experience, and search engine crawlers will penalize pages that redirect to content unrelated to the original page.

### Avoid redirect chains

Several redirects chained together can complicate crawling by search engines, as such testing your redirects should include checking if there are multiple redirects chained together. If your site has chained redirects, modify the redirect to get from A to B directly.

### Implement Search Console (or a similar tool)

Tools like Google Search Console give you insight into how your site is being indexed and where potential issues may live on your site.

### Fix errors at the source

If your content contains “old” links to redirected pages, redirects can act as a ‘band-aid’, but it is best to go back through these links and ensure they get updated to point directly to the new, non-redirected content instead of relying on the redirect to bring users where they need to go.

### Test your redirects

Make sure your team has an environment where redirects can be checked before going live

## Next.js

[Next.js redirect docs](https://nextjs.org/docs/app/api-reference/next-config-js/redirects)

In Next.js, redirects can be defined in `next.config.js` in a function called `redirects` . This approach works in both app and page routers.

First, create a schema in Sanity for the redirect document type. Note that this schema matches the options expected in `next.config`

```typescript
// schemas/redirect.ts

import { defineType, defineField, type Rule, type Slug } from 'sanity'

// Shared validation for our redirect slugs
const slugValidator = (rule: Rule) =>
  rule.required().custom((value: Slug) => {
    if (!value || !value.current) return "Can't be blank";
    if (!value.current.startsWith("/")) {
      return "The path must start with a /";
    }
    return true;
  });
  
export const redirectType = defineType({
    name: 'redirect',
    title: 'Redirect',
    type: 'document',
    description: 'Redirect for next.config.js',
    fields: [
        defineField({
            name: 'source',
            type: 'slug',
            validation: (rule: Rule) => slugValidator(rule),
        }),
        defineField({
            name: 'destination',
            type: 'slug',
            validation: (rule: Rule) => slugValidator(rule),
        }),
        defineField({
            name: 'permanent',
            type: 'boolean',
        }),
    ],
    // null / false makes it temporary (307)
    initialValue: {
	    permanent: true
	  },
})
```

Next, fetch the redirects from Sanity and return them in the `redirects` function inside `next.config.js` .

```javascript
// next.config.js	

const { createClient } = require("@sanity/client");

// Initialize Sanity client
const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  useCdn: false, // Ensure no accidental 'stale' data
  apiVersion: "2023-05-03" // use current date (YYYY-MM-DD) to target the latest API version
});

/** @type {import('next').NextConfig} */
const nextConfig = {
	// Fetch redirects from Sanity
  async redirects() {
    const redirects = await client.fetch(
      `*[_type == "redirect"]{
        "source":source.current, 
        "destination":destination.current, 
        permanent
      }`,
    );
    return redirects;
  },
  // rest of next.config
};

module.exports = nextConfig;
```

## Astro

[Astro redirect docs](https://docs.astro.build/en/guides/routing/#configured-redirects)

Redirects can be placed in your `astro.config` file.

First model the redirects, I chose `from` and `to` as the name here, but they can be whatever you like (Astro just requires a key/value pair):

```typescript
// schemas/redirect.ts

import { defineType, defineField, type Rule, type Slug } from 'sanity'

// Shared validation for our redirect slugs
const slugValidator = (rule: Rule) =>
  rule.required().custom((value: Slug) => {
    if (!value || !value.current) return "Can't be blank";
    if (!value.current.startsWith("/")) {
      return "The path must start with a /";
    }
    return true;
  });
  
export const redirectType = defineType({
    name: 'redirect',
    title: 'Redirect',
    type: 'document',
    description: 'Redirect for astro.config',
    fields: [
        defineField({
            name: 'from',
            type: 'slug',
            validation: (rule: Rule) => slugValidator(rule),
        }),
        defineField({
            name: 'to',
            type: 'slug',
            validation: (rule: Rule) => slugValidator(rule),
        })
    ],
})
```

Then, in `astro.config`:

1. Fetch our redirect documents from Sanity
2. Loop through the redirects to turn them into key/value pairs
3. Pass the new `redirects` object to our `defineConfig` function

```javascript
// astro.config.(ts|mjs)

import { createClient } from "@sanity/client";

// Initialize Sanity client
const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  useCdn: false, // Ensure no accidental 'stale' data
  apiVersion: "2023-05-03", // use current date (YYYY-MM-DD) to target the latest API version
});

// Fetch our redirects from Sanity via GROQ
const redirectData = await client.fetch(
  `*[_type == "redirect"]{
	  "from": from.current,
	  "to": to.current
	}`
);

// Create empty object to add our redirects to
const redirects = {};

// Loop through redirects from Sanity and make them key/value pairs as Astro expects
redirectData.map((redirect) => (redirects[redirect.from] = redirect.to));

// Pass redirects to the config object
export default defineConfig({
  integrations: [
   // all your normal integrations / config info
  ],
  redirects, // pass the object we made above
});
```

Astro allows you to pass a redirect status code as well; you’d just need to add it to the schema in Sanity and modify the `redirectData.map()` function a bit.

## Remix

[Remix entry.server file docs](https://remix.run/docs/en/main/file-conventions/entry.server)

Remix is slightly different from Next/Astro (where you provide an array of redirects in the root config file), as Remix asks you to have an `entry.server` file where you can handle these redirects.

If you don’t already have an `entry.server` file in your Remix project, you can create one using `npx remix reveal` .

> [!WARNING]
> It's important to use `npx remix reveal` instead of just creating your own file, as the default `entry.server` file contains crucial logic for your app that we’ll simply be adding redirects to

Our schema is general, as Remix doesn’t expect a specific object shape/naming convention:

```typescript
// schemas/redirect.ts

import { defineType, defineField, type Rule, type Slug } from 'sanity'

// Shared validation for our redirect slugs
const slugValidator = (rule: Rule) =>
  rule.required().custom((value: Slug) => {
    if (!value || !value.current) return "Can't be blank";
    if (!value.current.startsWith("/")) {
      return "The path must start with a /";
    }
    return true;
  });
  
export const redirectType = defineType({
  name: "redirect",
  title: "Redirect",
  type: "document",
  description: "Redirect for Remix"
  fields: [
    defineField({
      name: "from",
      type: "slug",
      validation: (rule: Rule) => slugValidator(rule),
    }),
    defineField({
      name: "to",
      type: "slug",
      validation: (rule: Rule) => slugValidator(rule),
    }),
  ],
});
```

In `entry.server` change the default export `handleRequest` to an async function and add the following to the top of the function:

```tsx
// entry.server.(tsx|jsx)

import { createClient } from "@sanity/client";

// Initialize Sanity client
const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  apiVersion: "2023-05-03", // use current date (YYYY-MM-DD) to target the latest API version
});

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  loadContext: AppLoadContext
) {
  
  // Pathname for querying slugs, origin for creating new URL
  let { pathname, origin } = new URL(request.url);

  // Check for the specific redirect in Sanity
  const redirectData = await client.fetch(
    `*[_type == "redirect" && from.current == $pathname][0]{
      "from": from.current,
      "to": to.current
    }`,
    { pathname }
  );
  
  // If there is a redirect in Sanity for the current path, redirect to it.
  if (redirectData) {
	  // Redirects to home page come back as null
	  if(!redirectData.to){
	      return Response.redirect(`${origin}/`);
	  }
    return Response.redirect(`${origin}/${redirectData.to}`);
  }
  // rest of handleRequest function code
 }
```

In the code above we:

1. Check if the current pathname has a redirect stored in Sanity
2. If a redirect exists, redirect to that page

## Nuxt

[Nuxt server directory docs](https://nuxt.com/docs/guide/directory-structure/server)

[Guide that helped me understand Nuxt redirects](https://deltener.com/blog/creating-redirects-with-nuxt/)

Nuxt redirects are handled similar to the Remix example above, where they’re implemented as middleware for all requests to the app.

First, model a redirect in Sanity. Our schema is general, as Nuxt doesn’t expect a specific object shape/naming convention:

```typescript
// schemas/redirect.ts

import { defineType, defineField, type Rule, type Slug } from 'sanity'

// Shared validation for our redirect slugs
const slugValidator = (rule: Rule) =>
  rule.required().custom((value: Slug) => {
    if (!value || !value.current) return "Can't be blank";
    if (!value.current.startsWith("/")) {
      return "The path must start with a /";
    }
    return true;
  });
  
export const redirectType = defineType({
  name: "redirect",
  title: "Redirect",
  type: "document",
  description: "Redirect for Nuxt"
  fields: [
    defineField({
      name: "from",
      type: "slug",
      validation: (rule: Rule) => slugValidator(rule),
    }),
    defineField({
      name: "to",
      type: "slug",
      validation: (rule: Rule) => slugValidator(rule),
    }),
  ],
});
```

Then in your Nuxt app’s `server` directory add a `middleware` directory with a file inside called `index.ts` (or whatever you want, any file in `server/middleware` be ran as middleware).

```typescript
// server/middleware/index.ts

import { createClient } from "@sanity/client";

const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  apiVersion: "2023-05-03", // use current date (YYYY-MM-DD) to target the latest API version
});

export default defineEventHandler(async (event) => {
  let { pathname } = getRequestURL(event);

  // Check for the specific redirect in Sanity
  const redirectData = await client.fetch(
    `*[_type == "redirect" && from.current == $pathname][0]{
    "from": from.current,
    "to": to.current
  }`,
    { pathname }
  );
	
  // If we found a redirect, make it so!
  if (redirectData) {
    // When no 'to' is provided, it means redirect to the homepage
    if (!redirectData.to) {
      await sendRedirect(event, "/");
    }
    await sendRedirect(event, `/${redirectData.to}`);
  }
});
```

## Extra considerations

### For developers

- Whenever possible use [server-side redirects](https://developers.google.com/search/docs/crawling-indexing/301-redirects#serverside). All these examples are showing how to create server-side redirects.
- Understand how your framework handles redirects - This can include - **How to set redirects in your framework** - Some frameworks like Next.js or Astro let you set redirects in their configuration file, others like Remix or Nuxt have you set redirect logic in middleware.
- **What happens when there’s a conflict between a redirect and a created page** - Some frameworks will nullify redirects for a certain path if a page was created there, other frameworks allow the redirect to take precedence. Be sure to communicate this behavior to the folks who will be creating the redirects in Sanity so they know what to expect.
- **Understand how splat/dynamic route redirects work in your framework** - Some frameworks make it easy to implement splat redirects, others require a bit more work. It’s also worth considering if you *want* splat based redirects to be controlled in your CMS or rather have a pre-defined list stored in code, as often these types of redirects are used during a site migration and are somewhat “code-y” for CMS users.


- Provide authors an environment to test redirects - Give authors the ability to create + publish redirects autonomously by providing some type of staging area for them to ensure their redirects work before going live.

### For folks implementing redirects in Sanity

- Having multiple redirects for the same path can lead to unpredictable behavior - There should only ever be 1 redirect for each ‘from’ path, and that 1 redirect should only have 1 ‘to’ path set.
- When changing fields like slugs, make sure you're implementing redirects for those changed paths. We have [a guide for developers to automate the creation of redirects](https://www.sanity.io/guides/nextjs-automatic-redirects) if desired.



# Create a document form progress component

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Summarise form progression by decorating the entire editing form for a document with a component loaded at the root level.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll learn

In this guide, you will learn how to:

- Customize the document form and interact with its values
- Make a form customization that’s composable using render methods
- Use Sanity UI in combination with a third-party library to make a custom form progress bar UI



## Schema preparation

The imaginary scenario is that your Studio contains `preflight` documents which contain a checklist to complete before getting approval to proceed. Users of this Studio could benefit from clearly showing how close to completion the current form is.

To complete this guide you’ll need to add a new document type first. Create the following file in your Studio and make sure to import it into the `schema` in `sanity.config.ts`:

```typescript
// ./schema/preflight/preflightType.ts

import {defineType, defineField} from 'sanity'

export const preflightType = defineType({
  name: 'preflight',
  title: 'Preflight',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({name: 'copyApproved', type: 'boolean'}),
    defineField({name: 'lighthouse', type: 'boolean'}),
    defineField({name: 'accessibility', type: 'boolean'}),
    defineField({name: 'seo', title: 'SEO', type: 'boolean'}),
    defineField({name: 'bestPractices', type: 'boolean'}),
  ],
})
```

Now create a new document. It’s a functional column of boolean fields.

*A standard document with default boolean fields*

All these fields **should** have detailed `description` values, but for brevity in this guide, they’ve been omitted. Now you can make this a truly excellent editing experience.

## Customizing the document form

Create a custom form component to display the form’s current progress:

```jsx
// ./schema/preflight/Progress.tsx

import {ObjectInputProps, ObjectMember} from 'sanity'
import {Flex, Card, Box, Stack} from '@sanity/ui'

interface ProgressProps extends ObjectInputProps {
  members: ObjectMember[]
}

type FieldProgress = {
  name: string
  value: boolean
}

export function Progress(props: ProgressProps) {
  const {members = []} = props
  const booleanFieldProgress = members.reduce<FieldProgress[]>((acc, member) => {
    const isFieldMember = member.kind === 'field' && member.field.schemaType.name === 'boolean'

    if (!isFieldMember) {
      return acc
    }

    return [...acc, {name: member.name, value: Boolean(member.field.value)}]
  }, [])
  const totalCount = booleanFieldProgress.length
  const completeCount = booleanFieldProgress.filter((field) => field.value).length
  const isComplete = completeCount === totalCount

  return (
		<Stack gap={4}>
	    <Card tone={isComplete ? `positive` : `transparent`} border padding={3} radius={2}>
	      <Flex align="center" gap={3}>
	        <Box>
	          {completeCount} / {totalCount} Tasks Complete
	        </Box>
	      </Flex>
	    </Card>
      {/* Render the default form */}
			{props.renderDefault(props)}
    </Stack>
  )
}
```

Unlike other guides in this series where the component is decorating or replacing a built-in part of the Studio – this component will receive props and be rendered on its own.

The props it receives will be the field members that make up the form. In the component you’ll check for every boolean type field, and create array of just their names and whether they’re currently to true or falsy.

The component will also be loaded from a different location, as demonstrated below:

```jsx
// ./sanity.config.tsx

import {defineConfig, isObjectInputProps} from 'sanity'
import {Stack} from '@sanity/ui'
import {Progress} from './schema/preflight/Progress'

export default defineConfig({
  // ...all other settings
  form: {
    components: {
      input: (props) => {
        if (
          props.id === 'root' &&
          props.schemaType.type?.name === 'document' &&
          props.schemaType.name === 'preflight'
        ) {
          return Progress(props as ObjectInputProps)
        }

        return props.renderDefault(props)
      },
    },
  },
})
```

Notice how you’ll only load the `Progress` component if the root of the form is being rendered, and only on the `preflight` schema type and it’s the `document` component. Yes, in this case the Studio treats the *whole document form* as an “input component”.

Open a `preflight` document now and try changing a few boolean fields. A summary of your progress is now displayed at the top of the form. It goes green once all fields are completed. Best of all, the counts will be correct even if boolean fields are added or removed from the document schema.

*A normal document form with a component rendered at the top*

This is good, but we can do even better.

Install [React Circular Progressbar](https://www.npmjs.com/package/react-circular-progressbar) to your Studio:

**npm**

```shell
npm install react-circular-progressbar
```

**pnpm**

```shell
pnpm add react-circular-progressbar
```

**yarn**

```shell
yarn add react-circular-progressbar
```

**bun**

```shell
bun add react-circular-progressbar
```

Now update your component to use the component.

```jsx
// ./schema/preflight/Progress.tsx

import {ObjectInputProps, ObjectMember, TextWithTone} from 'sanity'
import {Flex, Card, Box, Stack} from '@sanity/ui'
import {hues} from '@sanity/color'
import {CircularProgressbarWithChildren} from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'

interface ProgressProps extends ObjectInputProps {
  members: ObjectMember[]
}

type FieldProgress = {
  name: string
  value: boolean
}

export function Progress(props: ProgressProps) {
  const {members} = props
  const booleanFieldProgress = members.reduce<FieldProgress[]>((acc, member) => {
    const isFieldMember = member.kind === 'field' && member.field.schemaType.name === 'boolean'

    if (!isFieldMember) {
      return acc
    }

    return [...acc, {name: member.name, value: Boolean(member.field.value)}]
  }, [])
  const totalCount = booleanFieldProgress.length
  const completeCount = booleanFieldProgress.filter((field) => field.value).length
  const isComplete = completeCount === totalCount
  const percentage = Math.round((completeCount / totalCount) * 100)

  return (
    <Stack gap={4}>
      <Card tone={isComplete ? `positive` : `transparent`} border padding={3} radius={2}>
        <Flex align="center" gap={3}>
          <Box style={{maxWidth: 70}}>
            <CircularProgressbarWithChildren
              value={percentage}
              styles={{
                path: {stroke: hues.green[500].hex},
                trail: {stroke: hues.gray[100].hex},
                text: {fill: hues.green[500].hex},
              }}
            >
              <TextWithTone tone={isComplete ? `positive` : `default`} size={2} weight="semibold">
                {percentage}%
              </TextWithTone>
            </CircularProgressbarWithChildren>
          </Box>
          <Box>
            {completeCount} / {totalCount} Tasks Complete
          </Box>
        </Flex>
      </Card>
      {/* Render the default form */}
      {props.renderDefault(props)}
    </Stack>
  )
}

```

Notice the imports include `hues` from `@sanity/color` so that this 3rd party component can still be styled to look like a consistently designed part of the Studio UI.

*The document form now shows a Sanity color-compliant progress indicator!*

Job done!

## Next steps

- Add a [React confetti package](https://www.npmjs.com/package/react-confetti-explosion) to shower your author with celebratory praise when a document reaches completion.
- Imagine how this might be used to call a 3rd party API to retrieve and display additional information based on values in the form.
- Other ideas include using an image generation package like [Satori](https://github.com/vercel/satori) to generate an image based on values in the document.



# Create an array input field with selectable templates

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Make repetitive content creation tasks a breeze by supplying content creators with buttons to populate complex fields.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll be making

An array field with additional buttons that can add multiple items to the field. The buttons let you add multiple array items based on an assumed value in the referenced documents. In the video example below, you can see that selecting “+ Sales” add items with references to people who belong to the Sales department, as defined in their `person` document.



## Setting the stage

In this example, you’ll be working on a `seminar` type document with a field named `hosts` which is an array of references to `people` documents.

For this imagined scenario, our content creators regularly create new seminar documents, but the same people tend to host the same one based on the topic. Instead of making authors search and add each person one-by-one – we can provide them with some buttons to instantly add all people of a specific type, which they can then edit.

To prepare, create some new schema type files, first a `person` document type:

**schema/personType.ts**

```typescript
import {defineField, defineType} from 'sanity'
import {UserIcon} from '@sanity/icons/User'

export const DEPARTMENTS = [
  {title: 'Engineering', value: 'engineering'},
  {title: 'Sales', value: 'sales'},
  {title: 'Marketing', value: 'marketing'},
]

export const personType = defineType({
  name: 'person',
  title: 'Person',
  type: 'document',
  icon: UserIcon,
  fields: [
    defineField({
      name: 'name',
      type: 'string',
    }),
    defineField({
      name: 'department',
      type: 'string',
      options: {list: DEPARTMENTS},
    }),
  ],
  preview: {
    select: {
      name: 'name',
      department: 'department',
    },
    prepare(selection) {
      const {name, department} = selection
      return {
        title: name,
        subtitle: DEPARTMENTS.find((item) => item.value === department)?.title,
      }
    },
  },
})
```

Second, the `hosts` array of references:

**schema/hosts/hostsType.ts**

```typescript
import {defineField, defineType} from 'sanity'

export const hostsType = defineType({
  name: 'hosts',
  title: 'Hosts',
  type: 'array',
  of: [
    defineField({
      name: 'host',
      type: 'reference',
      to: [{type: 'person'}],
    }),
  ],
})
```

Lastly, the `seminar` document:

**schema/seminarType.ts**

```typescript
import {defineField, defineType} from 'sanity'

export const seminarType = defineType({
  name: 'seminar',
  title: 'Seminar',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'hosts',
      type: 'hosts',
    }),
  ],
})
```

Create these schema type files and ensure they’re imported to your `schema` in `sanity.config.ts`.

Once complete, you should be able to compose a new seminar document that looks like this:

*Default string and array inputs*

This works, but it’s time-consuming! Each person needs to be added individually. Looking up people by their department is time consuming. You can quickly customize this to make it better!

## Create a decorated component

When we talk about “decorated” components, it still uses the same customization API as seen in other custom form component guides (links in the introduction). It simply means we are only adding extra detail or interactivity around the field and not directly changing how it works.

In the code example below, the default array input is rendered by the `props.renderDefault(props)` callback. This is useful because the array input is such a complex piece of the Studio; it wouldn’t be pleasant to try and recreate it!

Also, decorated inputs can *compose*. You may have a plugin that also wraps your input to bring additional functionality. As often as you can render the default input and let the Studio resolve the component, the better.

Make a new component in your project:

**schema/hosts/HostsInput.tsx**

```tsx
import {Grid, Stack, Button} from '@sanity/ui'
import {AddIcon} from '@sanity/icons/Add'
import {ArrayOfObjectsInputProps} from 'sanity'
import {DEPARTMENTS} from '../personType'

export function HostsInput(props: ArrayOfObjectsInputProps) {
  return (
    <Stack gap={3}>
      {props.renderDefault(props)}
      <Grid gridTemplateColumns={DEPARTMENTS.length} gap={1}>
        {DEPARTMENTS.map((department) => (
          <Button key={department.value} icon={AddIcon} text={department.title} mode="ghost" />
        ))}
      </Grid>
    </Stack>
  )
}
```

Then update your `hosts` field to use it

**schema/hosts/hostsType.ts**

```typescript
import {HostsInput} from './HostsInput'

export const hostsType = defineType({
  name: 'hosts',
  // ...all other settings
  components: {input: HostsInput},
})
```

Now create or edit a new `seminar` document and you will see the decorated `hosts` array with some extra buttons.

*A decorated component with additional buttons below the default input*

Extra buttons that right now don’t do anything.

Ideally, when clicked, a query is run to find every person matching that department and attaches them as a reference to the array.

## Fetch and use content from other documents

You’ll need to perform a few actions when a button is pushed:

1. Perform a query to find every `person` document that has the same department value as the one which was clicked
2. Map over every person and create an array item with a unique `_key` value, the correct `_type` value and a reference to the published person document `_id` as a `_ref`
3. Create an array of `insert` patches which will append each person to the end of the array
4. Set the initial empty array value on the field if it is missing

The code below performs all of that!

**schema/hosts/HostsInput.tsx**

```tsx
import {Grid, Stack, Button} from '@sanity/ui'
import {AddIcon} from '@sanity/icons/Add'
import {randomKey} from '@sanity/util/content'
import {ArrayOfObjectsInputProps, Reference, insert, setIfMissing, useClient} from 'sanity'
import {useCallback} from 'react'
import {DEPARTMENTS} from '../personType'

export function HostsInput(props: ArrayOfObjectsInputProps) {
  const {onChange} = props

  const client = useClient({apiVersion: `2023-04-01`})

  // When a department button is clicked
  const handleClick = useCallback(
    async (event: React.MouseEvent<HTMLButtonElement>) => {
      // Find the value of the button, the department name
      const department = event.currentTarget.value

      const query = `*[
        _type == "person" && 
        department == $department && 
        !(_id in path("drafts.**"))
      ]._id`
      const peopleIds: string[] = (await client.fetch(query, {department})) ?? []
      const peopleReferences: Reference[] = peopleIds.map((personId) => ({
        _key: randomKey(12),
        _type: `host`,
        _ref: personId
      }))

      // Individually "insert" items to append to the end of the array
      const peoplePatches = peopleReferences.map((personReference) =>
        insert([personReference], 'after', [-1])
      )

      // Patch the document
      onChange([setIfMissing([]), ...peoplePatches])

      // To reset the array instead you'd do this:
      // onChange(set(peopleReferences))
    },
    [onChange, client]
  )

  return (
    <Stack gap={3}>
      {props.renderDefault(props)}
      <Grid gridTemplateColumns={DEPARTMENTS.length} gap={1}>
        {DEPARTMENTS.map((department) => (
          <Button
            key={department.value}
            value={department.value}
            icon={AddIcon}
            text={department.title}
            mode="ghost"
            onClick={handleClick}
          />
        ))}
      </Grid>
    </Stack>
  )
}
```

With this setup, you should now be able to click one of the buttons and see it populated with matching people – if those documents exist!

For a truly polished experience, you might like to add loading or patching states or toast pop-ups for feedback. See the next steps section below.

*Clicking one of the bottom buttons will populate the array with many items*

## Next steps

Take this input to the next level by adding

1. The `useToast` hook from Sanity UI to notify instances where no people are found, or once a successful patch has been completed.
2. The `useState` hook could disable the field and all buttons while the patch is happening to prevent multiple clicks and race conditions.
3. Using the [document store and a listening query](https://github.com/SimeonGriggs/sanity-plugin-utils#uselisteningquery), you could show a count of the number of people resolved by the query on the button itself before the button is clicked!



# Creating a Parent/Child Taxonomy

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Creating Parent / Child relationships in Sanity goes beyond a `parent` reference field. In this guide we'll include initial value templates, filtered document lists and guides on how to effectively use and query documents that use these taxonomy schema.

In this guide, you'll build: 

- A `category` schema type with Parent documents
- A list item in the Desk Structure for each Parent to edit their Children and
- Initial value templates to make sure every new Child document begins with its Parent reference pre-filled

![Sanity Studio Desk Structure with parent/child taxonomy](https://cdn.sanity.io/images/3do82whm/next/9001d9abad6418d1958474d0c02aef03de8a18bf-2800x844.png)
*Structure builder with parent/child taxonomy relationships. The "Compose" icon here will create a new "Category" document with "Chocolate" already set in the "parent" field.*

## Taxonomy schema

First, you'll need a schema for our taxonomy called `category`.

This guide will focus on building a simple, two-tier parent/child hierarchy. But the ideas here could be extended further to deeper relationships.

- A **"Parent"** Category is any `category` document that does not have the `parent` field defined.
- A **"Child"** Category is any `category` document that with a parent field reference.

Add the schema below to your Studio's files: 

```typescript
// ./schemas/category.js

import {defineField, defineType} from 'sanity'

// Install lucide.dev icons with "npm install lucide-react"
import {TagIcon} from 'lucide-react'

export default defineType({
  name: 'category',
  title: 'Category',
  type: 'document',
  icon: TagIcon,
  fields: [
    defineField({name: 'title', type: 'string'}),
    defineField({
      name: 'parent',
      type: 'reference',
      to: [{type: 'category'}],
      // This ensures we cannot select other "children"
      options: {
        filter: '!defined(parent)',
      },
    }),
  ],
  // Customize the preview so parents are visualized in the studio
  preview: {
    select: {
      title: 'title',
      subtitle: 'parent.title',
    },
    prepare: ({title, subtitle}) => ({
      title,
      subtitle: subtitle ? `– ${subtitle}` : ``,
    }),
  },
})

```

Don't forget to register this new schema in `sanity.config.ts`

```typescript
// ./schemas/index.ts

import category from './category'

export const schemaTypes = [
  category,
  // ...all your other schema types
]

```

## Initial Value Templates

Before setting up the Desk Structure, ensure you have [Initial Value Templates](https://www.sanity.io/docs/studio/initial-value-templates) configured in the Studio.

With the right configuration, we can create Document Lists which show all **Children** of a specific **Parent**, and when creating a new document from that list pre-fill the `parent` reference field with that same Parent!

Here's an updated `sanity.config.ts` with a new `category-child` template included.

```typescript
// ./sanity.config.ts

import {defineConfig} from 'sanity'
import {schemaTypes} from './schemas'

export default defineConfig({
  // ...all other settings
  schema: {
    // All your schema types
    types: schemaTypes,
    
    // Add this 'category child' template
    templates: (prev) => {
      const categoryChild = {
        id: 'category-child',
        title: 'Category: Child',
        schemaType: 'category',
        parameters: [{name: `parentId`, title: `Parent ID`, type: `string`}],
        // This value will be passed-in from desk structure
        value: ({parentId}: {parentId: string}) => ({
          parent: {_type: 'reference', _ref: parentId},
        }),
      }
  
      return [...prev, categoryChild]
    },
  },
```

## Setup Structure Builder

[Desk Structure](https://www.sanity.io/docs/studio/structure-builder-reference) is a complex part of the Studio. The code we’ll use here is no exception. 

Create a file like the below to load into the `deskTool()` plugin in `sanity.config.ts`. 

Notice the `parentChild()` helper function. This has been split out so you can look through it separately.

```typescript
// ./structure/index.ts

import { StructureResolver } from 'sanity/desk'

import parentChild from './parentChild'

export const structure: StructureResolver = (S, context) => S.list()
  .title('Content')
  .items([
    parentChild('category', S, context.documentStore),
    S.divider(),
    // ...all other list items
  ])
```

The `parentChild()` helper function accepts one parameter for the schema – the type name –, but you could extend it further for reuse by including parameters for Titles, Icons, etc.

This desk structure item is more dynamic than most. It will query the `documentStore` for all parent categories and create a `S.listItem()` for each one. Inside those, it will show all category documents with that parent as a reference.

```typescript
// ./src/desk-structure/parentChild.ts

import {DocumentStore} from 'sanity'
import {SanityDocument} from '@sanity/client'
import {StructureBuilder} from 'sanity/desk'
import {map} from 'rxjs/operators'
import {TagIcon} from 'lucide-react'

export default function parentChild(
  schemaType: string,
  S: StructureBuilder,
  documentStore: DocumentStore
) {
  const filter = `_type == "${schemaType}" && !defined(parent) && !(_id in path("drafts.**"))`
  const query = `*[${filter}]{ _id, title }`
  const options = {apiVersion: `2023-01-01`}

  return S.listItem()
    .title('All')
    .icon(TagIcon)
    .child(() =>
      documentStore.listenQuery(query, {}, options).pipe(
        map((parents) =>
          S.list()
            .title('All')
            .menuItems([
              S.menuItem()
                .title('Add')
                .icon(TagIcon)
                .intent({type: 'create', params: {type: schemaType}}),
            ])
            .items([
              // Create a List Item for all documents
              // Useful for searching
              S.listItem()
                .title('All')
                .schemaType(schemaType)
                .child(() =>
                  S.documentList()
                    .schemaType(schemaType)
                    .title('Parents')
                    .filter(filter)
                    // Use this list for displaying from search results
                    .canHandleIntent(
                      (intentName, params) => intentName === 'edit' && params.type === 'category'
                    )
                    .child((id) => S.document().documentId(id).schemaType(schemaType))
                ),
              S.divider(),
              // Create a List Item for Parents
              // To display all documents that do not have parents
              S.listItem()
                .title('Parents')
                .schemaType(schemaType)
                .child(() =>
                  S.documentList()
                    .schemaType(schemaType)
                    .title('Parents')
                    .filter(filter)
                    // Use this list for creating from parents menu
                    .canHandleIntent(
                      (intentName, params) =>
                        intentName === 'create' && params.template === 'category'
                    )
                    .child((id) => S.document().documentId(id).schemaType(schemaType))
                ),
              S.divider(),
              // Create a List Item for each parent
              // To display all its child documents
              ...parents.map((parent: SanityDocument) =>
                S.listItem({
                  id: parent._id,
                  title: parent.title,
                  schemaType,
                  child: () =>
                    S.documentTypeList(schemaType)
                      .title('Children')
                      .filter(`_type == $schemaType && parent._ref == $parentId`)
                      .params({schemaType, parentId: parent._id})
                      // Use this list for creating from child menu
                      .canHandleIntent(
                        (intentName, params) =>
                          intentName === 'create' && params.template === 'category-child'
                      )
                      .initialValueTemplates([
                        S.initialValueTemplateItem('category-child', {
                          parentId: parent._id,
                        }),
                      ]),
                })
              ),
            ])
        )
      )
    )
}

```

Note that accessing the `documentStore` directly like this is uncommon and on a larger dataset may produce undesirable results.

### Pre-flight check

- Now you should be able to view and edit a list of **Parent** documents, as well as click into **Parents** individually to see a list of **Child** documents.
- Test the Initial Value Template by creating a new **Category** document while viewing a list of **Children** documents, the `parent` reference should be pre-filled.

## Using taxonomy references

Consider when using these taxonomies to restrict references to Children. 

For example, in a schema of `post`, instead of an array of references where the author may add Parent and Child category references – have them select only "Child" documents.

```typescript
// ./schemas/post.ts

import {FileText} from 'lucide-react'
import {defineType, defineField} from 'sanity'

export default defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  icon: FileText,
  fields: [
    defineField({
      name: 'category',
      type: 'reference',
      to: [{type: 'category'}],
      options: {filter: 'defined(parent)'},
    }),
    // ...other fields
  ],
})

```

Then when querying for a post, "follow" the Child category up to retrieve its parent.

```groq
*[_type == "post"]{
  category->{
    parent->
  }
}
```

## Dynamically creating Parent and Child slugs

Each `category` document has a slug, but in a hierarchal website structure you may wish for Children to be nested inside Parents.

With some a clever GROQ function, we can do that from inside our query.

Here's a basic query for all `category` titles and slugs:

```groq
*[_type == "category"]{
  title,
  "parentSlug": parent->slug.current,
  "slug": slug.current
}
```

The response will look something like this. Which has the right data, but requires us to post-process the results to build the slug we need.

```json
[
  {
    title: "Liquorice",
    slug: "liquorice"
  },
  {
    title: "Dutch",
    parentSlug: "liquorice",
    slug: "dutch"
  }  
]
```

Instead, using the [select function](https://www.sanity.io/docs/specifications/groq-functions) in GROQ allows us to return a different value depending on a condition. In this case, whether a category has a parent field or not.

`select` works by returning whichever condition returns true first, and resolves the last item if nothing returns true. 

The first condition `defined(parent)` will be true for any Child category. Otherwise, the fallback is the document's own slug.

```groq
*[_type == "category"]{
  title,
  "slug": select(
    defined(parent) => parent->slug.current + "/" + slug.current,
    slug.current
  )
}
```

This would now instead return data that looks like this:

```json
[
  {
    title: "Liquorice"
    slug: "liquorice"
  },
  {
    title: "Dutch"
    slug: "liquorice/dutch"
  }  
]
```

## Conclusion

Hierarchical document schema-like categories express the power of structured content, strong references, and GROQ queries.

Your authors should now be able to confidently create and use these taxonomical documents throughout your content!



# Create interactive array items for featured elements

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Save time going in-and-out of modals by moving some light interactivity to array items.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll be learning

In this guide, you will learn how to:

- Create a custom array item input with a toggle that can write changes to multiple values on the array
- Add advanced customization to list previews
- Write custom validation rules for array fields
- Decorate and customize native array items using `renderProps` and Sanity UI
- Use the `path` argument to patch values in specific array items



## Schema preparation

In this example you’ll create `readingList` documents that have an array of `recommendations`. The array items include an object with a reference to a `book`, and whether it is “featured” or not.

Create the following schema types in your Studio to get started.

First, a simple document type for a book:

```typescript
// ./schema/bookType.ts

import {defineField, defineType} from 'sanity'
import {BookIcon} from '@sanity/icons/Book'

export const bookType = defineType({
  name: 'book',
  title: 'Book',
  type: 'document',
  icon: BookIcon,
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'author',
      description: 'This field should be a reference, but is a string in this demo for brevity',
      type: 'string',
    }),
    defineField({
      name: 'year',
      type: 'number',
    }),
  ],
  preview: {
    select: {
      title: 'title',
      author: 'author',
      year: 'year',
    },
    prepare: ({title, author, year}) => ({
      title,
      subtitle: `${author} (${year})`,
    }),
  },
})
```

Next, a `recommendation` object schema.

Note the comprehensive preview key setup so that list items are displayed with rich information about the object.

```typescript
// ./schema/recommendation/recommendationType.ts

import {defineField, defineType} from 'sanity'
import {BookIcon} from '@sanity/icons/Book'

export const recommendationType = defineType({
  name: 'recommendation',
  title: 'Recommendation',
  type: 'object',
  fields: [
    defineField({
      name: 'book',
      type: 'reference',
      to: [{type: 'book'}],
    }),
    defineField({
      name: 'featured',
      type: 'boolean',
      initialValue: false,
    }),
  ],
  preview: {
    select: {
      title: 'book.title',
      author: 'book.author',
      year: 'book.year',
      featured: 'featured',
    },
    prepare: ({title, author, year, featured}) => ({
      title: [featured ? '⭐️ ' : '', `${title ?? `No book selected`}`].join(` `),
      subtitle: author && year ? `${author} (${year})` : undefined,
      media: BookIcon,
    }),
  },
})
```

Lastly, you’ll need a place to use these fields. Create a new document schema named `readingList`

```typescript
// ./schema/readingListType.ts

import {Reference, defineField, defineType, isKeyedObject} from 'sanity'

type Recommendation = {
  _key?: string
  book?: Reference
  featured?: boolean
}

export const readingListType = defineType({
  name: 'readingList',
  title: 'Reading list',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'recommendations',
      type: 'array',
      of: [{type: 'recommendation'}],
      validation: (rule) =>
        rule.custom((items?: Recommendation[]) => {
          const featuredItems = items ? items.filter((item) => item.featured) : []

          if (featuredItems.length > 1) {
            return {
              paths: featuredItems.filter(isKeyedObject).map((item) => [{_key: item._key}]),
              message: 'Only one book can be featured',
            }
          }

          return true
        }),
    }),
  ],
})
```

Take note of the validation rule above that will look through the list and check if there is more than one “featured” item. If so, an array of `paths` is returned to mark each featured item as invalid. It’s important to give authors absolute clarity if something is invalid and what must be done to resolve it.

Create and publish some book documents and a “Reading list” document with some recommendations. It should look something like this:

*An array of objects with two items marked as invalid*

This is a great start; rich list previews and clear validation warnings where necessary.

The authoring experience could still be better. Consider the many operations our content creators will take to remove all the “featured” values from each array item individually!

Instead, you could create a custom item to render a button to write changes to the field without having to enter any modals.

## Custom items

A schema field’s “item” is used when an object is displayed in an array – including inside a Portable Text field.

*The different parts of an array item*

For more details, see the [Form Components documentation](https://www.sanity.io/docs/studio/form-components).

To start, create a new item component which will render a `Switch` input component from Sanity UI, along side each array item’s out-of-the-box preview:

```jsx
// ./schema/recommendation/RecommendationItem.tsx

import {ObjectItemProps} from 'sanity'
import {Box, Flex, Switch} from '@sanity/ui'
import {Recommendation} from './recommendationType'

export function RecommendationItem(props: ObjectItemProps<Recommendation>) {
  return (
    <Flex gap={3} paddingRight={2} align="center">
      <Box flex={1}>{props.renderDefault(props)}</Box>
      <Switch checked={props?.value?.featured} />
    </Flex>
  )
}
```

A TypeScript thing to notice is that the `ObjectItemProps` type is generic and can take in the `Recommendation` type, this will be applied to `value` of `props`.

Import this component in the `recommendation` schema field and add it to the `components.item` property in the schema type definition:

```typescript
// ./schema/recommendation/recommendationType.ts

import {RecommendationItem} from './RecommendationItem'

export const recommendationType = defineType({
  name: 'recommendation',
  // ...all other settings
  components: {item: RecommendationItem},
})
```

Now when editing the same field, you get the same experience, but an additional toggle has been added to the right-hand side of the item.

*Array items with a switch component*

You can click it, but it won’t do anything … yet!

## Handling updates for values in array items

Customizing the array item is typically used to add extra context. Since this example will write changes to the document, you’ll need to dig a bit deeper for some functions.

> [!WARNING]
> The example below uses a hook currently marked as internal: `useDocumentPane`. There may be upcoming changes to the Studio that break its functionality. This guide will be updated when that happens.

Update the component code to match the example below.

```jsx
// ./schema/recommendation/RecommendationItem.tsx

import {ObjectItemProps, PatchEvent, set, useFormValue} from 'sanity'
import {Box, Flex, Switch} from '@sanity/ui'
import {useDocumentPane} from 'sanity/desk'
import {useCallback} from 'react'
import {Recommendation} from './recommendationType'

export function RecommendationItem(props: ObjectItemProps<Recommendation>) {
  const {value, path} = props

  // Item props don't have `onChange`, but we can get it from useDocumentPane()
  // This hook is currently marked internal – be aware that this can break in
  // future Studio updates
  const {onChange} = useDocumentPane()

  // Get the parent array to check if any other items are featured
  const parentPath = path.slice(0, -1)
  const allItems = useFormValue(parentPath) as Recommendation[]

  const handleClick = useCallback(() => {
    const nextValue = value?.featured ? false : true
    const clickedFeaturedPath = [...path, 'featured']
    const otherFeaturedPaths = allItems.length
      ? allItems
          ?.filter((p) => p._key !== value?._key && p.featured)
          .map((p) => [...parentPath, {_key: p._key}, 'featured'])
      : []

    // Because onChange came from useDocumentPane
    // we need to wrap it in a PatchEvent
    // and supply the path to the field
    onChange(
      PatchEvent.from([
        // Update this field
        set(nextValue, clickedFeaturedPath),
        // Maybe update other fields
        ...otherFeaturedPaths.map((path) => set(false, path)),
      ])
    )
  }, [value?.featured, value?._key, path, allItems, onChange, parentPath])

  return (
    <Flex gap={3} paddingRight={2} align="center">
      <Box flex={1}>{props.renderDefault(props)}</Box>
      <Switch checked={value?.featured} onClick={handleClick} />
    </Flex>
  )
}
```

Note some of the hooks being used to power this component.

- `useDocumentPane` contains the root-level context for many of the functions passed down to individual inputs. Because a custom item does not currently receive `onChange` – like a custom input – the document context is where you need to access it
- `useFormValue` is a way to retrieve values from the current document at a specified path. Since this custom component loads for each individual item in the array, this hook is required to get the outer “parent” value of every item in the array. This is how the component knows to remove `featured` from other items, when adding it to this item.
- The `handleClick` function updates the `featured` value of this item to either true or false – as well as setting other items false if they are true. Notice how each `set()` function includes a path to each specific item.

Now back to your custom item component; not only can you update the featured value from the array list itself – other featured items will be set to false. Not only is this experience faster, but it’s also better! It’s impossible to put any item into an invalid state using these new controls.

*Now only one item can be marked correct as featured!*

## Next steps

- Consider other ways an interactive element displayed in an array item might be used. Consider copy and paste between documents, and multi-select.
- This idea might apply to other schema types, like an array of quiz questions where only one can be marked as correct.



# Create a visual string selector field input

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Go beyond a plain radio list of inputs by giving authors more contextually useful buttons to select values from.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll create

A string field with larger buttons to select a value packed with more relevant information.

![Plan Selector input example](https://youtu.be/HEazNdQU_EA)

## Getting started

In this guide, you’ll be creating a document type for a product `feature`. Each feature is only available on a certain `plan` and above. Plans are selected from [a list of predefined strings](https://www.sanity.io/docs/studio/string-type).

Create the following schema type files and ensure they’re loaded into the `schema` property in `sanity.config.ts`

First, create a custom string type called `plan`. Creating a new schema type for this string type allows more flexible reuse throughout your Studio. For example, if multiple document types use this `plan` type with its custom input; but with unique `options`. By importing this schema type to the Studio schema, you can refer to this `type` with it’s value for `name` , in other words `type: 'plan'`.

**./schema/plan/planType.ts**

```typescript
import {defineType} from 'sanity'

// We need will extend and import these in the custom input component later
export const PLANS = [
  {title: 'Free', value: 'free'},
  {title: 'Premium', value: 'premium'},
  {title: 'Enterprise', value: 'enterprise'},
]

export const planType = defineType({
  name: 'plan',
  title: 'Plan',
  type: 'string',
  options: {
    list: PLANS.map(({title, value}) => ({title, value})),
    layout: 'radio',
  },
})
```

Now, add a `feature` document type:

**./schemas/featureType.ts**

```typescript
import {defineField, defineType} from 'sanity'

export const featureType = defineType({
  name: 'feature',
  title: 'Feature',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'plan',
      type: 'plan',
      description: 'Minimum plan required to access this feature',
    }),
  ],
})
```

Create a new `feature` type document, and you should see both string fields like below:

*Two string fields, one with predefined options*

## Create an input component

Let’s say you want to add more information to these plans with an icon and a description. The neat solution would to have a document type for plans and bring the selection from them, but for the sake of simplicity, you’ll be hard coding this information by extending the `PLANS` array with properties and values for `icon` and `description`. The out-of-box radio list doesn’t support these properties, but we are going to build an UI for them in the custom input component:

**./schema/plan/planType.ts**

```typescript
import {defineType} from 'sanity'
import {PlanInput} from './PlanInput'
import {UserIcon} from '@sanity/icons/User'
import {UsersIcon} from '@sanity/icons/Users'
import {EarthGlobeIcon} from '@sanity/icons/EarthGlobe'

export const PLANS = [
  {title: 'Free', value: 'free', description: 'For personal use', icon: UserIcon},
  {title: 'Premium', value: 'premium', description: 'For small teams', icon: UsersIcon},
  {title: 'Enterprise', value: 'enterprise', description: 'For large teams', icon: EarthGlobeIcon},
]

export const planType = defineType({
  name: 'plan',
  title: 'Plan',
  type: 'string',
  options: {
    list: PLANS.map(({title, value}) => ({title, value})),
    layout: 'radio',
  },
  components: {input: PlanInput},
})
```

Create a new input component using the code below:

**./schema/plan/PlanInput.tsx**

```tsx
import {StringInputProps, set} from 'sanity'
import {Stack, Button, Grid, Label, Text} from '@sanity/ui'
import {createElement} from 'react'
import {PLANS} from './planType'

export function PlanInput(props: StringInputProps) {
  const {value, onChange} = props

  return (
    <Grid gridTemplateColumns={PLANS.length} gap={3}>
      {PLANS.map((plan) => (
        <Button
          key={plan.value}
          value={plan.value}
          mode={value === plan.value ? `default` : `ghost`}
          tone={value === plan.value ? `primary` : `default`}
        >
          <Stack gap={3} padding={2}>
            <Text size={4} align="right">
              {createElement(plan.icon)}
            </Text>
            <Label>{plan.title}</Label>
            <Text>{plan.description}</Text>
          </Stack>
        </Button>
      ))}
    </Grid>
  )
}
```

Now you’ll take whatever value is saved to the field and match it against a plan in the component’s `PLANS` array.

To see this component in the Studio, you’ll need to add it to the plan schema type:

**./schemas/plan/planType.ts**

```typescript
import {PlanInput} from './PlanInput'

export const planType = defineType({
  name: 'plan',
  // ...all other settings
  components: {input: PlanInput},
})
```

Now when editing the document you’re shown a beautiful set of buttons with much more details and context for your authors.

You can click these buttons but they won’t write anything to the document, yet!

*The string field input now shows beautiful buttons!*

## Handling changes and patching data

Custom inputs contain helpful functions and details in their `props` – for this input, you’ll only need one: `onChange`.

This function wraps any [patch](https://www.sanity.io/docs/content-lake/http-patches) – such as setting or unsetting the value of a field – and ensures the rest of the Studio stays up to date with changes.

> [!TIP]
> When working with forms in React, you’re often recommended to store values in a component’s state. This is an anti-pattern working with Sanity Studio input components. Writing content to state is only reflected in the browser of the person using the input. By using Sanity’s real-time APIs you allow content creators to collaborate and avoid overwriting each other’s changes by always syncing directly to the Content Lake.

Update your `PlanInput` component to use the code below:

**./schema/plan/PlanInput.tsx**

```tsx
import {StringInputProps, set} from 'sanity'
import {Stack, Button, Grid, Label, Text} from '@sanity/ui'
import {UserIcon} from '@sanity/icons/User'
import {UsersIcon} from '@sanity/icons/Users'
import {EarthGlobeIcon} from '@sanity/icons/EarthGlobe'
import {useCallback, createElement} from 'react'
import {PLANS} from './planType'

export function PlanInput(props: StringInputProps) {
  const {value, onChange} = props

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      const nextValue = event.currentTarget.value
      onChange(set(nextValue))
    },
    [onChange]
  )

  return (
    <Grid gridTemplateColumns={PLANS.length} gap={3}>
      {PLANS.map((plan) => (
        <Button
          key={plan.value}
          value={plan.value}
          mode={value === plan.value ? `default` : `ghost`}
          tone={value === plan.value ? `primary` : `default`}
          onClick={handleClick}
        >
          <Stack gap={3} padding={3}>
            <Text size={4} align="right">
              {createElement(plan.icon)}
            </Text>
            <Label>{plan.title}</Label>
            <Text>{plan.description}</Text>
          </Stack>
        </Button>
      ))}
    </Grid>
  )
}
```

1. Notice how `onChange` is destructured from the component’s props.
2. It is then called inside the `handleClick` function with the `set()` function, to update the field’s value in the Content Lake. This means the new value will be instantly validated in the document and updated in the browser of any other authors currently viewing the same document.
3. Both the `mode` and `tone` of the button are updated to highlight which value is currently selected.

Now you have a fully functional, automated, and editable coupon generator field with a handy visual preview!

*The buttons now write to and display the value of the field*

## Next steps

Some ideas to extend this custom input include:

1. A similar component with richly detailed buttons but for selecting a `reference` instead of a `string`.
2. Import `unset` from `sanity` and add an extra `Button` to remove the value from the document.



# Create a survey rating number field input

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Give content creators quick access to valid values by replacing the default number field input with a list of options.

## What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

## Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the [Form Components API in the documentation](https://www.sanity.io/docs/studio/form-components-reference).

- [Create a “coupon generator” string field input](https://www.sanity.io/docs/developer-guides/create-a-coupon-generator-string-field-input)
- [Create a visual string selector field input](https://www.sanity.io/docs/developer-guides/create-a-rich-string-selector-field-input)
- [Create a survey rating number field input](https://www.sanity.io/docs/developer-guides/create-a-survey-rating-number-field-input)
- [Create a time duration object field](https://www.sanity.io/docs/developer-guides/create-a-time-duration-object-field)
- [Create an array input field with selectable templates](https://www.sanity.io/docs/developer-guides/create-an-array-input-field-with-selectable-templates)
- [Create interactive array items for featured elements](https://www.sanity.io/docs/developer-guides/create-interactive-array-items-for-featured-elements)
- [Create richer array item previews](https://www.sanity.io/docs/developer-guides/create-richer-array-item-previews)
- [Create a document form progress component](https://www.sanity.io/docs/developer-guides/create-a-document-progress-root-level-component)

## What you’ll build

A number field input with predefined values that are easy to select:

![Demo of the number rating input](https://youtu.be/b0SufmwJeR0)

## Getting started

In this guide, you’ll build an input for a number field with a button for each valid option. Uses might include survey responses to rank questions from 1–10, or a movie review for 1–5 stars.

First, create the minimum schema types required to create content.

Create a new field in your Studio, and import it to your schema in `sanity.config.ts`

```typescript
// ./schema/rating/ratingType.ts

import {defineType} from 'sanity'

export const ratingType = defineType({
  name: 'rating',
  title: 'Rating',
  type: 'number',
  validation: (rule) => rule.min(1).max(10),
})
```

Creating a new schema type for this string type allows more flexible reuse throughout your Studio. For example, if multiple document types use this `rating` type with its custom input; but with unique `options`. By importing this schema type to the Studio schema, you can refer to this `type` with it’s value for `name` , in other words `type: 'rating'`.

Next, edit a document schema type and use this new `rating` field type. In the example below it is used twice for different fields.

```typescript
// ./schema/survey.ts

import {defineField, defineType} from 'sanity'

export const surveyType = defineType({
  name: 'survey',
  title: 'Survey',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'wouldRecommend',
      description: 'How likely are you to recommend this product to a friend?',
      type: 'rating',
			validation: (rule) => rule.min(1).max(10),
    }),
    defineField({
      name: 'wouldBuyAgain',
      description: 'How likely are you to buy this product again?',
      type: 'rating',
			validation: (rule) => rule.min(1).max(5),
    }),
  ],
})
```

Create a new `survey` type document, and look at the two number fields.

*One string field and two plain number fields*

They’re functional but not practical. It can be much better!

## Create a component

Create a new component in your Studio using the code below. Notice that you are accessing the validation rules in the `schemaType` to find the range of numbers to print out. This means that wherever you’re using `type: 'rating'`, you’ll also have to add a validation rule for `min()` and `max()`:

```jsx
// ./schema/rating/RatingInput.tsx

import {Grid, Button} from '@sanity/ui'
import {NumberInputProps} from 'sanity'
import {useMemo} from 'react'

export function RatingInput(props: NumberInputProps) {
  const {schemaType, value} = props
  const {validation = []} = schemaType

  const range = useMemo(() => generateRange(validation as any[]), [validation])

  return (
    <Grid gridTemplateColumns={range.length} gap={1}>
      {range.map((index) => (
        <Button
          key={index}
          mode={value === index ? 'default' : 'ghost'}
          tone={value === index ? 'primary' : 'default'}
          text={index.toString()}
          value={index}
        />
      ))}
    </Grid>
  )
}

/**
 * Function that finds the `min` and `max` rules from validations,
 * and generates the range of numbers between them
 **/
function generateRange(validation: any[]) {
  const [min, max] = validation
    .reduce((acc, {_rules}) => {
      return [...acc, ..._rules]
    }, [])
    .filter((rule: any) => ['max', 'min'].includes(rule.flag))
    .map((rule: any) => rule.constraint)

  let range = []
  for (let i = min; i <= max; i++) {
    range.push(i)
  }

  return range
}
```

Notice the following:

1. On line 3, the imports from Sanity UI help you create custom inputs that look like first-class editorial experiences with the same components and design language as the rest of the Studio. See [getting started with Sanity UI](https://www.sanity.io/ui/docs) for more information.
2. The code here assumes that both `min()` and `max()` is set. If you’re making a plugin or plan to ship to production, you should probably add additional guardrails to make sure both or set. There is also some TypeScript shortcuts using `any` to keep the code a bit more readable.
3. In this input we aren’t rendering the default input – usually rendered with `props.renderDefault(props)` – though you might choose to render it for debugging purposes.

To use this component, you’ll need to load it into the correct slot back on the `rating` schema. You’ll use `input` here because you don’t want to replace the field’s title and description.

```typescript
// ./schema/rating/ratingType.ts

import {RatingInput} from './RatingInput'

export const ratingType = defineType({
  name: 'rating',
  // ...all other settings
  components: {input: RatingInput},
})
```

Looking at your survey document again, it now contains clickable buttons. You can change the numbers for `rule ⇒ rule.min(1).max(10)` to see the number of boxes change. 

Next, you’ll need to make them do something when selected!

*Number fields now have easy buttons to select values from the minimum and maximum acceptable range!*

## Updating the field value

You’ll need to access the `onChange` function from the component’s props to write patches to the document.

This function wraps any [patch](https://www.sanity.io/docs/content-lake/http-patches) – such as setting or unsetting the value of a field – and ensures the rest of the Studio stays up to date with changes.

> [!TIP]
> When working with forms in React, you’re often recommended to store values in a component’s state. This is an anti-pattern working with Sanity Studio input components. Writing content to state is only reflected in the browser of the person using the input. By using Sanity’s real-time APIs you allow content creators to collaborate and avoid overwriting each other’s changes by always syncing directly to the Content Lake.

Update your `RatingInput` component to use the code below:

```jsx
// ./schema/rating/RatingInput.tsx

import {Grid, Button} from '@sanity/ui'
import {NumberInputProps, set} from 'sanity'
import {useMemo, useCallback} from 'react'

export function RatingInput(props: NumberInputProps) {
  const {onChange, schemaType, value} = props
  const {validation = []} = schemaType

  const range = useMemo(() => generateRange(validation as any[]), [validation])
	
	const handleScore = useCallback(
    (event: MouseEvent<HTMLButtonElement>) => {
      const value = Number(event.currentTarget.value)
      onChange(set(value))
    },
    [onChange]
  )

  return (
    <Grid gridTemplateColumns={range.length} gap={1}>
      {range.map((index) => (
        <Button
          key={index}
          mode={value === index ? 'default' : 'ghost'}
          tone={value === index ? 'primary' : 'default'}
          text={index.toString()}
          value={index}
					onClick={handleScore}
        />
      ))}
    </Grid>
  )
}
/**
 * Function that finds the `min` and `max` rules from validations,
 * and generates the range of numbers between them
 **/
function generateRange(validation: any[]) {
  const [min, max] = validation
    .reduce((acc, {_rules}) => {
      return [...acc, ..._rules]
    }, [])
    .filter((rule: any) => ['max', 'min'].includes(rule.flag))
    .map((rule: any) => rule.constraint)

  let range = []
  for (let i = min; i <= max; i++) {
    range.push(i)
  }

  return range
}
```

1. Notice how `onChange` and `value` are destructured from the component’s props.
2. `onChange` is called inside the `handleScore()` function with the `set()` function, to update the field’s value. This means the new value will be instantly validated in the document and updated in the browser of any other authors currently viewing the same document.
3. The `handleScore` function is registered with a `useCallback` hook to [cache it between re-renders](https://react.dev/reference/react/useCallback).

The rating field is now much more author-friendly, with selectable values and a clear indication of the current value.

*The custom input now displays and writes changes to the field's value*

## Next steps

Some ideas to extend this custom input include:

1. Add additional guardrails, or defaults, to support only a `max()` rule being set to increase the developer experience for your team
2. Increase accessibility by supporting the number keys on a keyboard when the field has the focus
3. Improve the UI for scales with more than 10 values in them by spreading them on more `rows`
4. Import `unset` from `sanity` and add an extra button to remove the value from the field.
5. Add text below the rating buttons to explain what each end of the scale represents, for example: “more likely” and “less likely”.



# How to use structured content for page building

> [!NOTE]
> This developer guide was contributed by Knut Melvær (Head of Developer Community and Education), Simeon Griggs (Principal Educator), and Irina Blumenfeld (Solution Architect @ Sanity).

You can use structured content to make landing page builders that will be useful beyond your next redesign. This guide shows you the basics of page building, and offers advice for dealing with presentation-related concerns.

> [!TIP]
> Building with Next.js? We have a complete course on Sanity Learn covering why and [how to implement a page builder](https://www.sanity.io/learn/course/page-building) within an application. Check it out!

Sanity can be used to manage things like **landing page builders**: they give editors enough control over page composition to get their message across using content modules, *without* breaking layout.

In this guide, you’ll find suggestions for how to create content modules for page builders that should nicely translate to a component-based frontend framework or design system. 

> [!TIP]
> While page builders can be a very handy approach to content creation, it's worth asking yourself if a page builder is what you actually need. You can also arrive at compelling combinations of content and presentation by sourcing content from from various places using simple rules in your frontend.

## Why you should model for meaning, not presentation

The goal of structured content is to make sure that your content stays resilient, adaptable, and easy to integrate wherever you need it. That’s why you should generally make content models that reflect your content's meaning rather than how it is presented. Because different presentation contexts (even within the same medium) come with different constraints: what makes sense on the web might not make sense in an app, and so on.

This guide makes no assumptions about presentation: no colors, floats, etc. While it might be tempting to add these, we think it best to leave those kinds of concerns to your code. They can add complexity to the implementation and to the things editors need to keep track of.

Think about your next redesign. Would you rather:

- Start with clean content that you can apply to a new channel or design?
- Or, have to untangle your core content from a lot of presentation-related stuff that only made sense to your last design?

We find that modeling for meaning leads to better workflows and more durable content.

> [!TIP]
> The rest of this guide involves a basic knowledge of schema building with Sanity.io. If you’ve never made one before, take a 3 minute detour to [learn the basics of schema configuration](https://www.sanity.io/guides/how-to-configure-schemas), and/or keep our [schema docs](https://www.sanity.io/docs/studio/schema-types) open as a reference . 

## Set up a page builder

The page builder is typically an [array of custom object or reference types](https://www.sanity.io/docs/studio/array-type) that can be reordered. It's the container for all your building blocks. With Sanity, there are no pre-built blocks for you to use, but it's fast and easy to make what you need.

If you use **objects**, the content is easier to query but trapped within the document.

If you use **references**, the content can be reused between documents, and your queries must resolve them.

![Page Builder Array](https://cdn.sanity.io/images/3do82whm/next/f4dbdbbb570744a0b9c561ff745dbc877eb7c282-1765x1367.png)
*A "page builder" in Sanity Studio: an array of objects*

Let's add some blocks you’d expect to see on a typical landing page: 

- **Hero**: for your boldest statements
- **Text + illustration**: when words aren’t enough
- **Call to action**: a reference to a "promotion" document
- **Gallery**: for eye candy 🍬
- **Form**: newsletter signups, contact, etc
- **Video**: for your latest promo clip or live stream recording

Now let's bring them to life in a bare-bones [document](https://www.sanity.io/docs/studio/document-type) type called `page`:

```typescript
// ./schemas/pageType.ts

import {defineArrayMember, defineField, defineType} from 'sanity'

export const pageType = defineType({
  name: 'page',
  type: 'document',
  title: 'Page',
  fields: [
    defineField({name: 'title', type: 'string'}),
    defineField({
      name: 'pageBuilder',
      type: 'array',
      title: 'Page builder',
      of: [
        defineArrayMember({
          name: 'hero',
          type: 'hero',
        }),
        defineArrayMember({
          name: 'textWithIllustration',
          type: 'textWithIllustration',
        }),
        defineArrayMember({
          name: 'gallery',
          type: 'gallery',
        }),
        defineArrayMember({
          name: 'form',
          type: 'form',
        }),
        defineArrayMember({
          name: 'video',
          type: 'video',
        }),
        defineArrayMember({
          name: 'callToAction',
          type: 'reference',
          to: [{type: 'promotion'}],
        }),
        // etc...
      ],
    }),
  ],
})
```

All the fields within the `pageBuilder` array are selectable types that authors can build with. The custom types named here are not yet [registered to the schema](https://www.sanity.io/help/schema-lift-anonymous-object-type) and will need to be created. As well as the "promotion" document type used by the `callToAction` [reference](https://www.sanity.io/docs/studio/reference-type) field.

## Modeling the content blocks

### Hero

Let's setup `heroType.ts` as an [object](https://www.sanity.io/docs/studio/object-type) type so that it can be reused elsewhere in our schema if we need it. We’ll add fields for `heading`, `tagline`, and an `image`.

```typescript
// ./schemas/heroType.ts

import {defineField, defineType} from 'sanity'

export const heroType = defineType({
  name: 'hero',
  type: 'object',
  title: 'Hero',
  fields: [
    defineField({
      name: 'heading',
      type: 'string',
    }),
    defineField({
      name: 'tagline',
      type: 'string',
    }),
    defineField({
      name: 'image',
      type: 'image',
      options: {hotspot: true},
      fields: [
        defineField({
          name: 'alt',
          type: 'string',
          title: 'Alternative text',
        }),
      ],
    }),
  ],
})
```

We enabled the hotspot option for art direction in the image field and added a simple string field for **alternative text. **Alt-text** **provides a text-based alternative to non-text content (like images) on web pages. Among other things, it helps vision-impaired people understand the meaning of your images.

> [!TIP]
> You may consider enforcing the existence of alt-text by applying [validation](https://www.sanity.io/docs/studio/validation) to this field.

Those fields will look like this in your Sanity Studio:

![Sanity user interface for a hero block content module](https://cdn.sanity.io/images/3do82whm/next/d0b1baf14a4800d8227eb894408b10492c42faf4-1600x1677.png)
*The hero block contains two string fields and an image*

### Text with illustration

This object looks a lot like our hero, except we’ve added a field called `excerpt` to store multiline [text](https://www.sanity.io/docs/studio/text-type) content.

```typescript
// ./schemas/textWithIllustration.js

import {defineField, defineType} from 'sanity'

export const textWithIllustrationType = defineType({
  name: 'textWithIllustration',
  type: 'object',
  title: 'Text with Illustration',
  fields: [
    defineField({
      name: 'heading',
      type: 'string',
    }),
    defineField({
      name: 'tagline',
      type: 'string',
    }),
    defineField({
      name: 'excerpt',
      type: 'text',
    }),
    defineField({
      name: 'image',
      type: 'image',
      options: {hotspot: true},
      fields: [
        defineField({
          name: 'alt',
          type: 'string',
          title: 'Alternative text',
        }),
      ],
    }),
  ],
})
```

> [!TIP]
> If you need more than plain text you could use the [block content type](https://www.sanity.io/configuration) to include things like **bold**, *italics*, etc.

![Sanity user interface for Text with Illustration page builder content module](https://cdn.sanity.io/images/3do82whm/next/cd500c37fafd4311e0ee22503ee82d85b28120a4-1605x1930.png)
*Sanity user interface for Text with Illustration page builder content module*

### Image gallery

When you strip away all the presentation concerns, a gallery is just a sortable list of images. Normally the array type presents a vertically draggable list, but if you set it to `grid` it will do look like the example above. Here's how you do it:

```typescript
// imageGallery.js

import {defineField, defineType} from 'sanity'

export const imageGalleryType = defineType({
  name: 'gallery',
  type: 'object',
  title: 'Gallery',
  fields: [
    {
      name: 'images',
      type: 'array',
      of: [
        defineField({
          name: 'image',
          type: 'image',
          options: {hotspot: true},
          fields: [
            {
              name: 'alt',
              type: 'string',
              title: 'Alternative text',
            },
          ],
        }),
      ],
      options: {
        layout: 'grid',
      },
    },
  ],
})
```

![Sanity array of images using grid layout option.](https://cdn.sanity.io/images/3do82whm/next/0a56402ddf83f5ad1e5a96d2bac0603abb9de868-1602x882.png)
*Sanity array of images using grid layout option.*

### Form

Forms come in many different shapes and sizes. In order to preserve the durability of our content structure beyond the next redesign, all we really need to do is declare the kind of form we want to embed in our page builder array. Here's an example presenting 3 variations for `newsletter`, `register`, and `contact` form types:

```typescript
// ./schemas/formType.js

import {defineField, defineType} from 'sanity'

export const formType = defineType({
  name: 'form',
  type: 'object',
  fields: [
    defineField({
      name: 'label',
      type: 'string',
    }),
    defineField({
      name: 'heading',
      type: 'string',
    }),
    defineField({
      name: 'form',
      type: 'string',
      description: 'Select form type',
      options: {
        list: ['newsletter', 'register', 'contact'],
      },
    }),
  ],
})
```

![Sanity user interface for a basic form field](https://cdn.sanity.io/images/3do82whm/next/880f0d6b312dd140fb2ced17829c1c2e0806f791-1602x1112.png)
*Sanity user interface for a basic form field*

You can then use frontend code to provide varying presentations of your forms depending on the page context, and the type of form you selected.

### Video

If you strip away presentation-based thinking, a video object can be modeled in the same way as our **call to action** object:

- a [URL](https://www.sanity.io/docs/studio/url-type) field to define the resource location of your video file
- a `string` field for the video's label

```typescript
// ./schemas/videoType.js

import {defineField, defineType} from 'sanity'

export const videoType = defineType({
  name: 'video',
  type: 'object',
  fields: [
    defineField({
      name: 'videoLabel',
      type: 'string',
    }),
    defineField({
      name: 'url',
      type: 'string',
      title: 'URL',
    }),
  ],
})
```

![Sanity user interface for video content module.](https://cdn.sanity.io/images/3do82whm/next/b041bc7cc2474493f07b5fee59e771b4e6571483-1600x742.png)
*Sanity user interface for video content module.*

### Call to action

The call to action field inside the pageBuilder is a reference to a new document type. Using references opens up the potential to re-use content across multiple pages – or have those references be pages of their own.

For this we'll need to create a new document type:

```typescript
// ./schemas/promotionType.ts

import {defineField, defineType} from 'sanity'

export const promotionType = defineType({
  name: 'promotion',
  type: 'document',
  title: 'Promotion',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'link',
      type: 'url',
    }),
  ],
})
```

![Sanity user interface for creating a new document from a reference field](https://cdn.sanity.io/images/3do82whm/next/77a2de7989ebef1851c96cbb1d59e76bcd8e57da-2518x1186.png)
*Sanity user interface for creating a new document from a reference field*

## Register new types to your schema

With these new schema files created, ensure they're registered to your Studio's schema by loading them into the `schemaTypes` array of your `sanity.config.ts`

```typescript
// ./schemas/index.ts

import {callToActionType} from './callToActionType'
import {formType} from './formType'
import {heroType} from './heroType'
import {imageGalleryType} from './imageGalleryType'
import {pageType} from './pageType'
import {textWithIllustrationType} from './textWithIllustrationType'
import {videoType} from './videoType'

export const schemaTypes = [
  pageType,
  heroType,
  callToActionType,
  textWithIllustrationType,
  imageGalleryType,
  formType,
  videoType,
]
```

### Improved UI with custom item previews

You now have an interface for content creators to build new layouts from predetermined "blocks". This authoring experience is currently lacking some flair and the individual blocks are difficult to differentiate.

In any object or document schema type, the [preview key can be customized](https://www.sanity.io/docs/studio/previews-list-views) so that the items can contain an icon or image and more contextual information about themselves.

Revisiting the schema in `heroType.ts`, customize the icon and preview keys to improve the user interface for creating new Hero items and viewing existing Hero items in an array.

```typescript
// ./schemas/heroType.ts

import {DocumentTextIcon} from '@sanity/icons/DocumentText'
import {defineField, defineType} from 'sanity'

export const heroType = defineType({
  // ... existing configuration
  icon: DocumentTextIcon,
  preview: {
    select: {
      title: 'heading',
      image: 'image',
    },
    prepare({title, image}) {
      return {
        title: title || 'Untitled',
        subtitle: 'Hero',
        media: image || DocumentTextIcon,
      }
    },
  },
})

```

Repeat this for all custom object types and documents. Once complete, the page builder array should look something more like this:

![Page builder array with customized object previews](https://cdn.sanity.io/images/3do82whm/next/02be99ec767c2d77d25d6d75ee368929f341411c-1420x1161.png)
*Page builder array with customized object previews*

### Add groups and create a grid layout

Now let’s add the options object to our `pageBuilder` array to create a grid layout, and add an [insertMenu](https://www.sanity.io/docs/studio/array-type) to separate the modules into groups, such as Landing Page, Promotions and Black Friday.

```typescript
  options: {
    layout: 'grid',
    insertMenu: {
      filter: true,
      groups: [
        {
          name: 'landing',
          title: 'Landing Page',
          of: ['hero', 'promotion', 'form'],
        },
        {
          name: 'promotions',
          title: 'Promotions',
          of: ['gallery', 'video', 'promotion'],
        },
        {
          name: 'blackFriday',
          title: 'Black Friday',
          of: ['textWithIllustration', 'gallery', 'video'],
        }
      ],
      views: [
        {name: 'list'},
        {name: 'grid', 
          previewImageUrl: (schemaTypeName) => `/static/preview-${schemaTypeName}.jpg`
        }
      ]
    }
  },
```

Groups allow faster findability of related modules for a specific purpose. 

[Filter](https://www.sanity.io/docs/studio/array-type) makes it easier to search for modules if there is a long list. 

[Views](https://www.sanity.io/docs/studio/array-type) allow you to toggle between list and grid options with optional preview images for each type. If the optional preview image is not defined, the icon associated with the respective schema type will be displayed.

![Array with page building blocks separated into groups](https://cdn.sanity.io/images/3do82whm/next/c7f62b5dc3c20b7212a8efa1952ee5f4655604e7-1720x1421.png)
*Array with page building blocks separated into groups*

![Array with page building blocks inside "Black Friday" Group](https://cdn.sanity.io/images/3do82whm/next/21b1f5abfcb0deca287f670a91a22a01a899faf8-1773x1857.png)
*Array with page building blocks inside "Black Friday" Group*

![Toggle Grid View Icon](https://cdn.sanity.io/images/3do82whm/next/1639bcc6c181e0f15bc4714539cb2b87538f74cf-1827x1849.png)
*Toggle Grid View Icon*

If you toggle grid view, you will see the following view that includes the preview image for each block. 

If the optional preview image is not defined inside the `pageBuilder` array schema, the icon associated with the respective schema type will be displayed.

![Page Builder Array with Preview Images](https://cdn.sanity.io/images/3do82whm/next/274182b1446b1cf06f90df2ce8c8f00699611243-1451x1221.png)
*Page Builder Array with Preview Images*

Much better for both creating and reading!

## Use your front end for flexible presentations

Because we avoided embedding presentation concerns in our page builder, you can now present that content in many ways in front end code. For example, perhaps your `hero` item renders its `heading` field as an` <h1>` if it is the first item in the array; otherwise, as an `<h2>` with a different layout.

It's possible to present those fields in countless ways without compromising the content's meaning.

### Querying the page builder array with GROQ

When querying an array of objects with GROQ you may need to resolve different fields – and resolve references – from different types. To do this, you can use the shorthand form of [GROQ's select() function](https://www.sanity.io/docs/specifications/groq-functions) to create a unique [projection](https://www.sanity.io/docs/content-lake/how-queries-work) for each unique type in the array.

```groq
*[_type == "page"]{
  pageBuilder[]{
    // "hero" in an "object" from which we can "pick" fields
    _type == "hero" => {
      _type,
      heading,
      tagline,
      image
    },
    // "callToAction" is a "reference"
    // We can resolve "itself" with the @ operator
    _type == "callToAction" => @-> {
      _type,
      title,
      link
    }
    // ...continue for each unique "_type"
  },
}
```

## What we have learned

We've learned the basics of modeling a page builder with Sanity.io. We've primed the pump with a few common builder modules that you can alter or extended to fulfill the unique needs of your project. 

Along the way, we made a case for keeping presentation-related concerns out of your content models. Content editing will be less complicated, and code maintenance will be easier, and your next redesign budget will thank you for it!

## Page building demo

The example code in this guide can be found in [this example Sanity Studio](https://github.com/sanity-io/page-building).

Get started by cloning this repository, using your own project and then render the content into one of [our starter templates](https://www.sanity.io/templates).



# Create a recycling bin for deleted documents via Sanity Functions

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer) and Benjamin Weinberger (Support Engineer at Sanity.io).

Set up a custom 'recycle bin' logic in your Studio, enabling users to restore deleted documents of a certain type with 2 clicks, using Sanity Functions and a singleton document type, to which we add some custom components using the Component API and the Sanity UI.

## In this guide, you will: 

- Define a singleton document type and create your singleton document using the CLI. 
The `deletedDocs.bin` type will have a `deletedDocLogs` array with `log` items (objects) where we store the `documentId` (string), `type` (string),  `deletedAt` (datetime) and `documentTitle` (string) of each deleted document. There can also be a more straightforward (optional) array `deletedDocIds` with just the `_id` strings.
- Set up a Sanity Blueprint and Function which will be triggered upon deletion of a subset of documents. The document handler will then patch the deleted document to the logs of the `bin` singleton document. 
- Create a custom item component for the `log` items, including the intent button for opening the deleted documents in question.
- Create a second function, which will remove all document logs, which have been restored already.

*In this guide, we will use TypeScript to make the code more reliable, but you can use JavaScript if you prefer. If you don't know how to do this, you can ask in our Discord community for help! *

*You can find the whole code for the solution with functions here and the older version with webhooks here.*

## Background: Restoring deleted Documents using the `_id`

When you delete a document, you can restore it using the unique document `_id` (either via the History API, or the Studio). In the Studio it is as simple as opening up the document in the structure using the default folder for that particular document type and adding the ID to the URL:

```text
https://<domain>/studio/default/structure/<document type name>;<deleted document _id>
```

Although this trick is helpful, you still need to know the deleted document `_id`. To find the IDs of documents that have already been deleted, see [Find and restore deleted documents](https://www.sanity.io/docs/developer-guides/find-and-restore-deleted-documents).

> [!TIP]
> Try this by deleting a document and just using the Go back button in your browser, which will reopen the document you just deleted. Below the form header, you will now see a banner with a button to restore the document at its latest revision. 

### Intent routing in the Studio

Internally, Sanity typically uses an [Intent Link](https://www.sanity.io/docs/reference/api/sanity/router/IntentLink) to navigate to a document in the structure. We can use the same intent to open deleted documents and use the restore functionality automatically proposed for any deleted document opened in the Studio. 

> [!WARNING]
> Although `IntentLink` is a stable and public part of our API ([reference documentation](https://www.sanity.io/docs/reference/api/sanity/router/IntentLink)), the `IntentButton` is not. 
> We decided to use the `IntentButton`, because it is what we use internally, but this will mean that things might change, and there is no documentation you can check. 
> If you are uncomfortable with this, you can instead use a `Button` component from the [Sanity UI](https://www.sanity.io/ui/docs/primitive/button) and wrap it with an `IntentLink`. 

## Workflow

![Flowchart demonstrating document deletion, logging in a recycling bin, restoration, and subsequent cleanup from the bin logs.](https://cdn.sanity.io/images/3do82whm/next/7e669f07d314f9381dfcfd792a452726ec9bda01-8576x5888.png)
*This is how the deletion of a document will then trigger a function, which adds a log item of the deleted document to the bin singleton document. The log will then enable you to open the deleted document again and restore it. Once you restore a document the second function will remove the log entry. *



## Step 1: Singleton document schema

Create a document type called `deletedDocs.bin` in your schema folder (in our case we have an additional subfolder called `singletons`) and add it to your schema as a singleton:

**schemas/singletons/deletedDocBinDocument.ts**

```tsx
// schemas/singletons/deletedDocBinDocument.ts
import { TrashIcon } from "@sanity/icons/Trash";
import { defineArrayMember, defineField, defineType } from "sanity";


export const deletedDocBinDocument = defineType({
  // We use a dot in the _id to make sure this is a private document which cannot be read unless you are authenticated. We chose to do the same in the type name as a personal naming choice.
  name: "deletedDocs.bin",
  title: "Bin: Deleted Document Log",
  type: "document",
  icon: TrashIcon,
  // we want to skip a draft version of this document, so we set this 👇
  liveEdit: true,
  // Fieldset to "hide away" the deletedDocIds array from view unless we need them
  fieldsets: [
    {
      name: "deletedDocIdLogs",
      title: "All Deleted Doc Id Logs",
      options: {
        collapsible: true,
        collapsed: true,
      },
    },
  ],
  fields: [
    // * Main log for restoring documents
    defineField({
      name: "deletedDocLogs",
      title: "Deleted Doc Logs",
      type: "array",
      readOnly: true,
      options: {
        sortable: false,
      },
      description:
        "Log of deleted documents. All items have the revision ID as the _key value and might have already been restored again.",
      of: [
        // optimally you would lift this up into it's own schema type, but for brevity its defined inline 👇
        defineArrayMember({
          type: "object",
          name: "log",
          title: "Log",
          readOnly: true,
          fields: [
            defineField({
              name: "docId",
              title: "Doc Id",
              type: "string",
              validation: (Rule) => Rule.required(),
            }),
            defineField({
              name: "deletedAt",
              title: "Deleted At",
              type: "datetime",
              validation: (Rule) => Rule.required(),
            }),
            defineField({
              name: "type",
              title: "Type",
              type: "string",
            }),
            defineField({
              name: "documentTitle",
              title: "Document Title",
              type: "string",
              validation: (Rule) => Rule.required(),
            }),
            defineField({
              name: 'deletedBy',
              title: 'Deleted By',
              type: 'string',
            }),
          ],
        }),
      ],
    }),
    // Backup of all deleted doc ids -> optional and not used but can be useful to track
    defineField({
      name: "deletedDocIds",
      title: "Deleted Doc Ids",
      type: "array",
      readOnly: true,
      options: {
        sortable: false,
      },
      fieldset: "deletedDocIdLogs",
      of: [
        defineArrayMember({
          name: "deletedDocId",
          type: "string",
          readOnly: true,
          validation: (Rule) => Rule.required(),
        }),
      ],
    }),
    // title for the document (will be set during creation via CLI)
    defineField({
      name: "title",
      title: "Title",
      type: "string",
      hidden: true,
    }),
  ],
});

```

We set all arrays to `readOnly` and also hide away the `title` field since we will set the title in the next step and only need it for a better UI. 

In addition we disabled sorting for arrays to have a cleaner look.

> [!TIP]
> **Why are all array item fields required? **
> When the fields are set to `required`, you can find errors in the data via the [validation CLI command](https://www.sanity.io/docs/content-lake/schema-and-content-migrations). Since things can always go wrong, adding validation rules can make your debugging life much easier!

 

### Custom TypeScript interface for the `deletedDocLogs` items

As we always want to make the TypeScript Dogs happy, we need to extend the Sanity ObjectItem with our data keys. 

Add the custom interface to the schema definition or a separate types file.

```typescript
// import ObjectItem from sanity

export interface LogItem extends ObjectItem {
  docId: string
  deletedAt: string
  type: string
  documentTitle: string | 'Unknown 🥲'
  deletedBy?: string
  revisionId: string
}
```

### Create a singleton document via the CLI

For the next step, we will create a **private** document by using a dot in `_id`. Our personal choice was to use the same logic in the `_type` name, but you can use a name without a dot if you want to. 

At the root of your project, create a `newBinSingleton.json` and add this data to it: 

```json
{
  "_id": "deletedDocs.bin",
  "_type": "deletedDocs.bin",
  // feel free to add your own title 
  "title": "Bin: Deleted Document Logs"
}

```

Next, you need to open your terminal in the root of the project folder and **create a document via the CLI**: 

```sh
$ sanity documents create newBinSingleton.json

// or if you dont have @sanity/cli installed globally
$ npx sanity documents create newBinSingleton.json
```

🥳 Now, there should be a singleton document visible in your structure. 

![Bin singleton document list item in structure](https://cdn.sanity.io/images/3do82whm/next/bbb46a55f54b8336926f356a928db0a3ceaeb504-348x65.png)
*Bin singleton document in structure*

## Step 2: Adding custom components

Now we are ready to give our arrays some bling and add custom input components 💅.

**We will: **

- Remove the Add Item buttons from the arrays ([go to section](https://www.sanity.io/docs/developer-guides/bin-for-restoring-deleted-documents)).
- Add custom components: - **DeletedDocIdInputComponent.tsx**: input component for `deletedDocIds` array ([go to section](https://www.sanity.io/docs/developer-guides/bin-for-restoring-deleted-documents)).
- **DeletionLogItemComponent.tsx**: item components for `log` objects in the `deletedDocLogs` array ([go to section](https://www.sanity.io/docs/developer-guides/bin-for-restoring-deleted-documents)).





### Remove the `Add Item` buttons from arrays

Because we don't need a UI for adding new items to any of our arrays, we will not only set them to `readOnly: true`, but also remove the buttons under the inputs by adding custom input components. 

In those components, we define that we want to render out the default inputs (by using `props.renderDefault` from the [Component API](https://www.sanity.io/docs/studio/form-components)) minus the `arrayFunctions` (which will render out the button to add new items to arrays). 

**components/recycling-bin/DeletionLogInputComponent.tsx**

```tsx
import { Stack } from '@sanity/ui'
import { ComponentType } from 'react'
import { ArrayOfObjectsInputProps } from 'sanity'

/** ### Array Input Component without any array functions (like "Add Item" button)
 */
export const DeletionLogInputComponent: ComponentType<ArrayOfObjectsInputProps> = (props) => {
  return (
    <>
      <Stack gap={4}>
        {/* Remove the Add Item button below the Array input */}
        {props.renderDefault({ ...props, arrayFunctions: () => null })}
      </Stack>
    </>
  )
}

```

**./schemas/singletons/deletedDocBinDocument.ts**

```tsx
// in your schema definitions for both array fields we need to customise the input components

// * Main log for restoring documents
defineField({
  name: 'deletedDocLogs',
  title: 'Deleted Doc Logs',
  type: 'array',
  components: {
    input: DeletionLogInputComponent,
  },
  
//... 

// * Backup of all deleted doc ids
defineField({
  name: 'deletedDocIds',
  title: 'Deleted Doc Ids',
  type: 'array',
  components: {
    /* Remove the `Add Item` button below the Array input  */
    input: (props: ArrayOfPrimitivesInputProps) =>
      props.renderDefault({ ...props, arrayFunctions: () => null }),
    },
  
// ... 
```

Your bin document should look like this now: 

![Screenshot of the bin document and their arrays without the default add item button. ](https://cdn.sanity.io/images/3do82whm/next/0bb469ab769f17bd57f62d978f965a76ad157b9f-661x970.png)
*By setting the arrayFunctions to null the buttons for adding items are removed from the array inputs.*

### Custom input component for the simple (optional) `deletedDocIds` array items

Add a file `DeletedDocIdInputComponent.tsx` for the simple string items and add the component to your `deletedDocIds` array member string field.

**components/recycling-bin/DeletedDocIdInputComponent.tsx**

```tsx
// in DeletedDocIdInputComponent.tsx
import { Card, Flex, Text } from '@sanity/ui'
import { ComponentType } from 'react'
import { StringInputProps } from 'sanity'

/** ### String Input Component for `deletedDocIds` items
 */
export const DeletedDocIdInputComponent: ComponentType<StringInputProps> = (
  props,
) => {
  return (
    <Flex
      justify={'space-between'}
      align={'center'}
      gap={2}
      paddingLeft={2}
      paddingY={2}
    >
      <Card>
        <Text>{props.value}</Text>
      </Card>
    </Flex>
  )
}
```

Then add the custom input component to the `deletedDocIds` array by adding this snippet to the `deletedDocId` array member: 

**schemas/singletons/deletedDocBinDocument.tsx**

```typescript
// ... 
defineArrayMember({
  name: 'deletedDocId',
  type: 'string',
  readOnly: true,
  validation: (Rule) => Rule.required(),
  components: {
    input: DeletedDocIdInputComponent,
  },
}) 
```

Your field should look like this now

![screenshot of how the array will look now (with dummy data in this case)](https://cdn.sanity.io/images/3do82whm/next/762f7a059b6cc2c2801e6787e953d4f3b76b03d3-640x193.png)
*This is how the array will look now (with dummy data in this screenshot)*

### Custom item component for the `log` objects (`deletedDocLogs` array members)

Now that we have the easy part behind us, we can dive deeper into the restoring functionality itself. 

First, we need to create a file `DeletionLogItemComponent.tsx` and override the default preview since we do not want to use the array for editing the `log` object values, but only display each deletion and add a button which will lead us to the deleted document in the structure, where we can restore it. 

**components/recycling-bin/DeletionLogItemComponent.tsx**

```tsx
// DeletionLogItemComponent.tsx
import { SanityUser } from '@sanity/client'
import { RestoreIcon } from '@sanity/icons/Restore'
import { Card, Flex, Stack, Text } from '@sanity/ui'
import { ComponentType, useEffect, useState } from 'react'
import { IntentButton, ObjectItemProps, useClient } from 'sanity'
import { apiVersion } from '../../lib/api'
import { LogItem } from '../../schemaTypes/singletons/deletedDocBinDocument'
import User from './User'

/** ### Array Item Component for each log entry
 *
 * with Intent Button to open the document and restore it
 */
export const DeletionLogItemComponent: ComponentType<ObjectItemProps<LogItem>> = (props) => {
  // * Get the value from the props
  const value = props.value
  // * Set up user client to get the user name of the user who deleted the document
  const client = useClient({ apiVersion }).withConfig({ withCredentials: true })
  const [user, setUser] = useState<SanityUser | undefined>()

  useEffect(() => {
    // * Get the user name of the user who deleted the document
    value.deletedBy &&
      client.users
        .getById(value.deletedBy)
        .then((user) => {
          setUser(user)
        })
        .catch((error) => {
          console.error('Error fetching user:', error)
        })
  }, [])

  // * Format the date to be nice and universal
  const date = new Date(value.deletedAt)
  // Get full month name in English (change 'en' to `undefined` to use runtime locale)
  const monthName = date.toLocaleString('en', { month: 'long' })
  const formattedDate = `${date.getDate()}. ${monthName} ${date.getFullYear()}`

  return (
    /* only display a border top, if it's not the first one 💅 */
    <Card borderTop={props.index > 0}>
      {/*
       * * * Flex container for "custom" item preview and Intent Button */}
      <Flex justify={'space-between'} align={'center'} gap={2} paddingX={4} paddingY={4}>
        {/*
         * * * Custom item preview with the document title, type and date */}
        <Stack gap={3}>
          <Text weight="semibold">{value.documentTitle}</Text>

          <Text muted size={1}>
            Type: {value.type}
          </Text>

          <Text muted size={1}>
            Deleted: {formattedDate}
          </Text>
          {user && <User {...user} />}
          <Text muted size={0}>
            ID: {value.docId}, Revision: {value.revisionId as string}
          </Text>
        </Stack>
        {/*
         * * * Intent Button */}
        {value.docId && (
          <IntentButton
            icon={RestoreIcon}
            tone={'positive'}
            mode="ghost"
            intent="edit"
            params={{
              type: value.type,
              id: value.docId,
              revision: value.revisionId,
            }}
            text="Open to restore"
            tooltipProps={{
              placement: 'top',
              content: 'You can restore this document after opening it',
            }}
          />
        )}
      </Flex>
    </Card>
  )
}

```

With this item component in our pockets, we still have to add it to our `log` object array members: 

```typescript
// Add this to your `log` object, in your `deletedDocLogs` array
components: {
  item: DeletionLogItemComponent,
},
```



Very good, our document should look like this now 💅: Super fancy and easy to use! 

![Screenshot of document with some deleted document logs](https://cdn.sanity.io/images/3do82whm/next/d3e14fa08ecd906dff70d66518a1f312c956bbcd-1456x1122.png)
*This is how it will look in when we have some deleted document logs*

## Step 3: Setting up your Blueprint and Functions

[Functions](https://www.sanity.io/docs/functions/functions-introduction) and [Blueprints](https://www.sanity.io/docs/blueprints/blueprints-introduction) are a new feature for all Sanity projects which allow you to create infrastructure as code—which means you can automate workflows via code directly and deploy those to the Sanity infrastructure. No more need for custom API endpoints or other external server functions. 

If you haven’t tried them before, please make sure to read the docs and have a look at our [101 YouTube video](https://www.youtube.com/watch?v=rDju_qgoJVY)** **and **especially the Sanity Learn module.**

### Use CLI to create both a blueprint and functions

You can follow the Sanity learn module[ to initialize and add a blueprint and function via the CLI](https://www.sanity.io/docs/functions/function-quickstart) or follow the [Functions quick start](https://www.sanity.io/docs/functions/function-quickstart). 

You will need 2 functions: 

1. `recyclingBin` adds logs for every deleted document.
2. `cleanUpBinLogs` removes the recreated documents from the logs.

> [!TIP]
> You can also use [Blueprint Stacks](https://www.sanity.io/docs/blueprints/blueprints-introduction) if you’re working with multiple teams or environments. 

Initialize each function, add them to your blueprint, and you should now have these files / folders in your project root:

**new files and folders**

```
└── functions/
  └── recyclingBin/
    └── index.ts
  └── cleanUpBinLogs/
    └── index.ts

└── sanity.blueprints.ts (you can choose another format of course)
```

While you’re in the CLI, now is a good time to install two dependencies you’ll need for the functions. They’ll both use the Sanity client and the id-utils helper library.

**npm**

```shell
npm install @sanity/client @sanity/id-utils
```

**pnpm**

```shell
pnpm add @sanity/client @sanity/id-utils
```

**yarn**

```shell
yarn add @sanity/client @sanity/id-utils
```

**bun**

```shell
bun add @sanity/client @sanity/id-utils
```

#### Document handler and blueprint resource for `recyclingBin` function

Now that you’ve created the overall infrastructure, let’s start setting up our resource and document handlers.

While the CLI gives you a resource for your blueprint for each function we need to modify it to fit our needs.

**sanity.blueprints.ts**

```
// in the resource array 

defineDocumentFunction({
  name: 'recyclingBin',
  type: 'sanity.function.document',
  src: './functions/recyclingBin',
  event: {
    // This function is triggered when a document is deleted
    on: ['delete'],
    // we can include drafts but versions are not relevant here
    includeDrafts: true,
    includeAllVersions: false,
    // we need to narrow down which documents can trigger the function -> DO NOT RUN THIS ON ALL DOCUMENTS
    filter: '_type in ["language", "listOption", "page"]',
    projection: '{ _id, _type, "rev": _rev, "deletedAt": now(), "deletedBy": identity(), "documentTitle": coalesce(title, name) }',
  },
}),
```

> [!WARNING]
> High traffic datasets & drafts
> If you have a high traffic dataset you should **not enable these functions for drafts**. 
> Since invocations are metered this can ramp up quickly and hit technical limits in our infrastructure. Please make sure to adjust the blueprint resources according to YOUR situation and need!

Let’s have a closer look at the filter and projection. 

The **filter** needs to be as narrow as possible for any function or webhook. In this case we only want to trigger it for those document types that should be restorable, in my case thats `language`, `listOption` and `page`, but you have to add yours. 

The **projection** defines, which values should be passed down from the trigger-document to the document handler of your function. This is particularly important for all deleted documents, since you will not be able to directly query them anymore. In our case we need the document and [revision](https://www.sanity.io/docs/http-reference/history) ID, the time of deletion and the identity of the person/token who deleted the document (returns the ID), and we also need something human readable. In my case I want to have a title, but since the document types use both title and name I coalesce them. You can also adjust which field values you want to use here. 
If you need to include values from other documents you can query them in the document handler.

**functions/recyclingBin/index.ts**

```
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'
import { getPublishedId } from '@sanity/id-utils'

export const handler = documentEventHandler(async ({ context, event }) => {
  const clientOptions = context.clientOptions
  const client = createClient({
    ...clientOptions,
    apiVersion: '2026-01-01',
    requestTagPrefix: 'recycling-bin',
    perspective: 'raw',
    useCdn: false,
  })
    const dataset = clientOptions.dataset

  
  const { data } = event
  if (!data || !data._id) {
    console.error('No data found.')
    return
  }
  console.group('::: DATA ::: ')
  console.dir(data, { depth: null })
  console.groupEnd()

  const { _id, _type, deletedAt, documentTitle, rev, deletedBy } = data
  const publishedId = getPublishedId(_id)
  // Check if this document was published 
  const hasPublishedVersion = await client
    .request({
      method: 'GET',
      uri: `/data/doc/${dataset}/${publishedId}?includeAllVersions=true`,
    })
    .then((res) => {
      console.group(`::: hasPublishedVersion docs ::: `)
      console.dir(res)
      console.groupEnd()
      // return false if both documents
      return res.documents.length > 0
    })
    .catch(console.error)

  if (hasPublishedVersion) {
    console.log(
      `Document has been deleted: ${data._id} but it has other existing versions: ${hasPublishedVersion}`,
    )
    return
  }

  if (!hasPublishedVersion) {
    const idLogPatch = client
      .patch('deletedDocs.bin')
      .setIfMissing({ deletedDocIds: [] })
      .insert('before', 'deletedDocIds[0]', [_id])

    const logPatch = client
      .patch('deletedDocs.bin')
      .setIfMissing({ deletedDocLogs: [] })
      .insert('before', 'deletedDocLogs[0]', [
        {
          docId: _id,
          deletedAt,
          type: _type,
          documentTitle,
          revisionId: rev,
          deletedBy,
          _type: 'log',
        },
      ])
    await client
      .transaction()
      .createIfNotExists({
        _id: 'deletedDocs.bin',
        _type: 'deletedDocs.bin',
        title: 'Bin: Deleted Document Logs',
      })
      .patch(idLogPatch)
      .patch(logPatch)
      .commit({ autoGenerateArrayKeys: true, dryRun: true })
      .then((res) => {
        console.group('Recycling bin logs successfully updated')
        console.dir(res, { depth: null })
        console.groupEnd()
      })
      .catch(console.error)
  }
})

```

> [!NOTE]
> In the code snippet above you can see we run a test using the [doc endpoint ](https://www.sanity.io/docs/http-reference/doc). This endpoint allows us to circumvent the indexing time which will impact queries, but not this endpoint, making sure our check will not come back with a false negative. 

#### DocumentHandler and resource for `cleanUpBinLogs` function

Similar to the other function we need to define the blueprint resource and document handler for the cleanup workflow. 

**sanity.blueprint.ts**

```
// in the resources array add this

defineDocumentFunction({
  name: 'cleanUpBinLogs',
  type: 'sanity.function.document',
  src: './functions/cleanUpBinLogs',
  event: {
    on: ['create'],
    includeDrafts: true,
    includeAllVersions: false,
    filter: '_type in ["language", "listOption", "page"]',
    projection: '{ _id }',
  },
}),
```

> [!WARNING]
> High traffic datasets & drafts
> If you have a high traffic dataset you should **not enable these functions for drafts**. 
> Since invocations are metered this can ramp up quickly and hit technical limits in our infrastructure. Please make sure to adjust the blueprint resources according to YOUR situation and need!

Since we only have to check if the newly created document or draft is actually a restored one, the `_id` is enough in the `projection`. 

**functions/cleanUpBinLogs/index.ts**

```
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'
import { getPublishedId, isDraftId } from '@sanity/id-utils'

export const handler = documentEventHandler(async ({ context, event }) => {
  const clientOptions = context.clientOptions
  const client = createClient({
    ...clientOptions,
    apiVersion: '2026-01-01',
    requestTagPrefix: 'recycling-bin-cleanup',
    perspective: 'published',
    useCdn: false,
  })
  const { data } = event
  if (!data || !data._id) {
    console.error('No data found. Cannot clean up bin logs.')
    return
  }

  const { _id } = data

  const isDraft = isDraftId(_id)
  const publishedId = getPublishedId(_id)
  
  // Check if this document was published
  const restoredItemKeys = await client
    .fetch(
      `*[_type == "deletedDocs.bin" && _id == 'deletedDocs.bin'][0].deletedDocLogs[docId in $createdDocIds]._key`,
      { createdDocIds: isDraft ? [_id, publishedId] : [_id] },
    )
    .catch(console.error)

  if (!restoredItemKeys) {
    console.log('No logs exist for:', _id)
    return
  }
  // Clean up the bin logs by removing the document item
  const itemsToUnset = restoredItemKeys.map((key: string) => `deletedDocLogs[_key == "${key}"]`)
  await client
    .patch('deletedDocs.bin')
    .unset(itemsToUnset)
    .commit({dryRun: true})
    .then((res) => {
      console.log(`Cleaned up bin logs for document: ${_id}`)
      console.dir(res)
    })
    .catch((err) => {
      console.error('Error cleaning up bin logs:', err)
    })
})

```

## Test and deploy your blueprint 

Now that you have everything in order you can test your functions using the dev console or the CLI (see [docs](https://www.sanity.io/docs/functions/functions-local-testing)). 

If you have been satisfied that your functions work well and don’t cause a loop, you change all `commit` options in the document handlers to `dryRun: false` and deploy them to our infrastructure using the CLI. 

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

## Finished!

Now you are ready to test things in your project. 

[Don't forget to have a look at my Meetup Repo (specifically the recycling bin branch) where you can find a full version of the code.](https://github.com/bobinska-dev/meetup/tree/recycling-bin)





# Add live content to your application

The Live Content API lets you deliver live content experiences without the complexity and infrastructure requirements traditionally found in real-time apps.

The `next-sanity` library wraps the Live Content API for Next.js apps. The JavaScript client offers helper utilities to get you started, but you'll need to build additional functionality.

This guide shows two ways to add live content to an application: with `next-sanity` in a Next.js app, and with the JavaScript client in any other framework.

## Add live content with next-sanity

Enable live content with only a few lines of code with `next-sanity`.

#### Next.js + Sanity + Visual Editing
If you plan to set up Next.js, Sanity, Visual Editing, and the Live Content API, see the Next.js Visual Editing guide for a complete implementation.
[Set up Next.js, live content, and Visual Editing](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router)

### Prerequisites

- A new or existing Sanity project.
- Add your frontend or deployment target's origin to the project's [CORS origins](https://www.sanity.io/docs/content-lake/cors). This is found in the project's API section at [sanity.io/manage](https://sanity.io/manage).
- A Next.js application built with the [app router architecture](https://nextjs.org/docs/app/getting-started/layouts-and-pages). The Live Content features in `next-sanity` do not support apps built with the pages router.
- This guide assumes `next-sanity` v13 or later, which requires Next.js 16, React 19.2 or later, and `@sanity/client` 7.26.1 or later.

### Install and configure the client

You can install, set up, and configure Sanity in your existing Next.js project with `init`:

**npm**

```shell
npx sanity@latest init
```

**pnpm**

```shell
pnpm dlx sanity@latest init
```

**yarn**

```shell
yarn dlx sanity@latest init
```

**bun**

```shell
bunx sanity@latest init
```

Alternatively, install the package or update it to the latest version:

**npm**

```shell
npm install next-sanity@latest
```

**pnpm**

```shell
pnpm add next-sanity@latest
```

**yarn**

```shell
yarn add next-sanity@latest
```

**bun**

```shell
bun add next-sanity@latest
```

Next, confirm that you have an existing Sanity client configured:

**src/sanity/lib/client.ts**

```typescript
import { createClient } from "next-sanity";

import { dataset, projectId } from "../env";

export const client = createClient({
  projectId,
  dataset,
  apiVersion: "2026-03-01",
  useCdn: true
});
```

### Create the live utilities

Create a live utility file and configure the `sanityFetch` helper and `SanityLive` component by passing in your local Sanity client and a token. `defineLive` requires a browser and server token to fetch draft content when using Draft Mode. If you aren't using Visual Editing or draft previews, set `serverToken: false` and `browserToken: false` to opt out and silence the development warnings:

**src/sanity/lib/live.ts**

```typescript
import { defineLive } from "next-sanity/live";
// import your local configured client
import { client } from "@/sanity/lib/client";

// set your viewer token
const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
  throw new Error("Missing SANITY_API_READ_TOKEN")
}

// export the sanityFetch helper and the SanityLive component
export const { sanityFetch, SanityLive } = defineLive({
  client,
  serverToken: token,
  browserToken: token,
})
```

> [!NOTE]
> Tokens
> Tokens passed to `defineLive` need [viewer access rights](https://www.sanity.io/docs/user-guides/roles) to fetch draft content.
> The token for `serverToken` and `browserToken` can be the same. The `browserToken` is only used when Draft Mode is enabled and initiated by Presentation Tool or Vercel Toolbar.

### Fetch your queries

Whenever you need to query data in your Sanity dataset, import the `sanityFetch` helper and call it as you would any Sanity client by passing in a GROQ query and any query parameters:

**app/page.tsx**

```typescript
import { sanityFetch } from "@/sanity/lib/live"
import { POST_QUERY } from "./queries"

const {data: post} = await sanityFetch({query: POST_QUERY, params: {}})
```

In this example, the `data` response is destructured to `post` and `sanityFetch` receives a GROQ query and an optional `params` object.

### Enable the SanityLive component

The final step to enable the Live Content API is adding the `SanityLive` React component. It listens for changes in your data and works with your `sanityFetch` queries to efficiently update content. Include it in your application so it renders on any page that needs live content.

> [!WARNING]
> Embedded studios
> This section adds the SanityLive component to the root layout. If you're using an embedded studio—one that renders on a route in your Next.js app—include the SanityLive and VisualEditing components only in your content layouts.
> Including `SanityLive` in your studio route can cause unexpected reloads.

In this example, it lives just before the closing body tag in the `RootLayout` component:

**app/layout.tsx**

```tsx
import { SanityLive } from "@/sanity/lib/live"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
      </body>
    </html>
  )
}
```

> [!NOTE]
> Make updates instant in next-sanity v13
> In `next-sanity` v13, `<SanityLive>` revalidates with a stale-while-revalidate profile by default, so a published change is *eventually consistent* — some connected visitors may need to navigate or refresh before they see it. To make updates instant for every visitor (and to invalidate caches that sit in front of Next.js, such as a CDN), pair `<SanityLive>` with a [Sync Tag Invalidate Function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) and set `waitFor="function"`. The quick start covers the function; the migration guide's [Opting in to guaranteed live content updates](https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v12-to-v13.md#opting-in-to-guaranteed-live-content-updates) section shows the matching Next.js revalidation route and `waitFor` wiring.

### Next steps

- To learn more about the `next-sanity` toolkit and how it fits together with Visual Editing and caching, see the [Next.js overview](https://www.sanity.io/docs/nextjs/introduction).
- Level up with [Work-ready Next.js](https://www.sanity.io/learn/track/work-ready-next-js) on Sanity Learn.
- Dive into [the Clean Next.js + Sanity starter](https://www.sanity.io/templates/nextjs-sanity-clean).
- For instant updates across CDNs and many statically generated routes, drive revalidation from a [Sync Tag Invalidate Function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) and set `waitFor="function"` on `<SanityLive>`. The quick start covers the function; the migration guide's [Opting in to guaranteed live content updates](https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v12-to-v13.md#opting-in-to-guaranteed-live-content-updates) section shows the matching revalidation route and `<SanityLive>` wiring.

## Create your own integration

If there isn't an official library for your framework that enables live content, you need to create your own integration to use the Live Content API. The Live Content API Examples repository on GitHub collects example projects and is a good starting point for custom implementations.

[Live Content API Examples](https://github.com/sanity-io/lcapi-examples)
A collection of example projects using live content

The minimal example in this section uses the [Sanity JavaScript client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started).

### Prerequisites

- API version `v2021-03-25` or later. Older versions omit `syncTags` from query responses and throw `The live events API requires API version 2021-03-25 or later.`
- The real dataset name. The Live Content API does not support dataset aliases.
- A new or existing Sanity project.
- Add your frontend or deployment target's origin to the project's [CORS origins](https://www.sanity.io/docs/content-lake/cors). This is found in the project's API section at [sanity.io/manage](https://sanity.io/manage).

### Install and configure the client

First, install the latest version of the client:

**npm**

```shell
npm install @sanity/client@latest
```

**pnpm**

```shell
pnpm add @sanity/client@latest
```

**yarn**

```shell
yarn add @sanity/client@latest
```

**bun**

```shell
bun add @sanity/client@latest
```

Configure your `@sanity/client` with your project settings and the latest API version:

**src/sanity/lib/client.ts**

```typescript
import { createClient } from "@sanity/client"

export const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  apiVersion: "2026-03-01",
  useCdn: true
})
```

### How it works

Here's a high-level overview of how the Live Content API works:

1. Every response from Content Lake includes *sync tags*. Your application stores the tags for the content it needs to keep up to date in real time.
2. It subscribes to a stream of live updates with the `client.live.events()` method, which returns an Observable that emits an event whenever content in the dataset changes.
3. When an event arrives, it checks whether any of the event tags match the stored sync tags.
4. If there's a match, it refetches the content, passing the event ID as the `lastLiveEventId` argument to `client.fetch` so the CDN returns the latest version of the content instead of stale data.

### Minimal example

Here is a minimal example running in the console. It keeps a single, predefined document in sync using sync tags:

**live-example.ts**

```typescript
import { createClient } from "@sanity/client"

// Create the client instance
const client = createClient({
  projectId: "YOUR_PROJECT_ID",
  dataset: "YOUR_DATASET",
  apiVersion: "2026-03-01",
  useCdn: true
})

const query = '*[_type == "post" && slug.current == $slug][0]'
const slug = "were-doing-it-live"

let syncTags = []

function render(lastLiveEventId?: string) {
  // Query the content lake
  client.fetch(
    query,
    { slug },
    { filterResponse: false, lastLiveEventId }
  ).then(
    (res) => {
      // Store the syncTags and "render" the data
      syncTags = res.syncTags
      const data = res.result
      console.log(data)
    })
}

// Kick off initial render
render()

// Subscribe to live updates
const subscription = client.live.events().subscribe(
  (event) => {
    // Check if incoming tags match saved sync tags
    if (event.type === "message" && event.tags.some((tag) => syncTags.includes(tag))) {
      // Refetch with ID to get latest data
      render(event.id)
    }
    if (event.type === "restart") {
      // A restart event is sent when the `lastLiveEventId` we've been given earlier is no longer usable
      render()
    }
})

// Later, unsubscribe when no longer needed (such as on unmount)
// subscription.unsubscribe()
```

In this example:

1. The example creates a Sanity client instance with the necessary configuration.
2. It defines a query to fetch posts and executes it, setting `filterResponse: false` to get the `syncTags` along with the result.
3. It stores the returned syncTags and renders the initial data.
4. It subscribes to live updates using `client.live.events()`.
5. Whenever an update event arrives, it checks whether any of the event's tags match the stored syncTags.
6. If there's a match, it refetches the data, passing the event ID as `lastLiveEventId` to get the latest version.
7. It updates the stored syncTags and re-renders with the fresh data.
8. Finally, it unsubscribes from the live updates when they're no longer needed.

This pattern keeps your application's content in sync with the latest changes in your Sanity dataset. For additional examples, including listening for drafts, see the [JavaScript client documentation](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started).

### Next steps

- Learn more about sync tags and the underpinnings of the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api).
- For reference details when interacting directly with the API, check the [Live reference docs](https://www.sanity.io/docs/http-reference/live).

## Troubleshooting

`client.live.events()` reports failures as an error on the observable rather than throwing, so pass an error handler to `subscribe` to see them at all.

### Origin not allowed by CORS

An unlisted origin makes the connection fail without a usable reason, so the client checks the project's CORS configuration and reports a `CorsOriginError`. In a browser, the message ends with a link that pre-fills the origin: `The current origin is not allowed to connect to the Live Content API. Add it here:` followed by the URL. On a server, where no origin is available, it reads `The current origin is not allowed to connect to the Live Content API. Change your configuration here:` followed by the project's API settings URL.

The stream errors and doesn't retry. The client only reports this error when it can confirm the rejection, so an ambiguous check surfaces the underlying connection error instead. Add the origin in the project's API settings at sanity.io/manage.

In a Next.js app, `SanityLive` logs a warning instead of failing the render: `Sanity Live is unable to connect to the Sanity API as the current origin - ORIGIN - is not in the list of allowed CORS origins for this Sanity Project.` Set `onError="throw"` to surface it to the nearest error boundary instead.

### Connection rejected by the API

A rejected token produces `EventSource connection failed` on the observable, with the HTTP status on the error's `status` property. Any 4xx other than 408 and 429 is fatal: the client stops and doesn't reconnect. A 5xx, a 408, or a 429 is retried, and the stream emits a `reconnect` event first.

The `status` property is only populated where the `eventsource` package provides the connection. Native browser and Node implementations expose no status, so the client can't tell a rejected token from a dropped network and retries instead. A silent reconnect loop with no error is the symptom of an authentication problem in those environments.

A token used to read drafts needs viewer rights or lower. Requesting drafts with no token throws before any request is made: `The live events API requires a token or withCredentials when 'includeDrafts: true'. Please update your client configuration. The token should have the lowest possible access role.`

### Respond to a restart event

A `restart` event means the `lastLiveEventId` you hold is no longer usable. Its payload carries only two fields, `type` and `id`, and no sync tags.

Handle it in three parts:

- Refetch every query, and don't pass the event's ID as `lastLiveEventId`.
- Discard the sync tags you've stored. They can no longer be matched against incoming events.
- Treat `reconnect` the same way. Both events invalidate buffered tags.

In a Next.js app, `SanityLive` calls `router.refresh()` on restart by default, so server components re-render with fresh data.

### Draft content missing from results

Querying with the `published` perspective returns published content only, and nothing tells you that's what happened. There's no error and no console message, and the response looks identical to one from a dataset with no drafts. Since API version `v2025-02-19`, `published` is the default.

Set `perspective: 'drafts'` and supply a token to read drafts. In Next.js, `defineLive` pins its internal client to `published`, so pass a `serverToken` to read drafts on the server and a `browserToken` for live preview in the browser. Without them, `defineLive` warns in development only.



# Forms with Sanity

> [!NOTE]
> This developer guide was contributed by Chris LaRocque (Senior Solution Architect).

How to manage forms for your front-end with Sanity

## The two types of form integration

Integrating a form service is no different than [integrating any external system with Sanity](https://www.sanity.io/docs/developer-guides/integrating-external-data). That being said, integrating an external form service with Sanity most commonly falls into two buckets, you either want to:

1. Author forms in an external service, then reference those forms by ID in your content (*”I want my-marketing-form from MailChimp to go here on my page”*), or…
2. Author forms inside Sanity, with the external service being used as a “bucket” for all the form submissions on your site (services like Netlify Forms or Formspree)

There are other form use cases Sanity can cover, you could even use Sanity to *collect* form submissions and manage user generated content (like we do on this site and [https://www.sanity.io/learn](https://www.sanity.io/learn)), but this guide will cover the above two most common use cases.

## [@sanity/form-toolkit](https://www.npmjs.com/package/@sanity/form-toolkit)

The plugin @sanity/form-toolkit offers pre-built tooling for both types of form integrations if you’re looking to have something “out of the box”. This guide will dive into a more general look at how form-toolkit goes about creating these integrations.

form-toolkit currently has integrations for these services:

- MailChimp
- HubSpot

form-toolkit also exposes the `formSchema` plugin and `FormRendering` React component, which provides a pre-built form schema for your Studio and a component to render those forms respectively.

## Syncing external forms with Sanity

Syncing external forms with Sanity typically assumes that the service you’re syncing has some way to embed forms on your front-end that expects an ID to know which form to render. In such cases [@sanity/sanity-plugin-async-list](https://www.npmjs.com/package/@sanity/sanity-plugin-async-list) allows you to add a string field to your Sanity documents that fetches data from a remote source. Our [guide on syncing external data sources](https://www.sanity.io/docs/developer-guides/integrating-external-data) includes a section outlining this approach in detail.

## Authoring forms in Sanity

For basic form authoring, it may be preferred to author the form structure and fields in Sanity, pass that data to a component to render the form, and then use a ‘catch-all’ service for form submissions like Formspree or Netlify forms.

> [!TIP]
> [@sanity/form-toolkit](https://www.npmjs.com/package/@sanity/form-toolkit) includes a package, `formSchema` for building and rendering forms from your Sanity Studio

If you’d prefer to build your own implementation instead of using form-toolkit, the process is the following:

1. Model a typical form in Sanity schemas, including 1. A `form` type containing an array of `formField` objects
2. A `formField` type with various props for the HTML `input` element like `type` , `placeholder` , `name`, `required` , `label` and others based on your needs
3. Additional properties on `form` or `formField` based on your needs, perhaps your forms need multiple sections or you want to control the form action from the CMS, all can be built into your schema


2. Create a component that renders your form in your front-end framework of choice 1. Take the `form` from a GROQ query, render a `<form>` element, passing the relevant props from your data
2. Take the `fields` array, and return an appropriate input for each provided field and its `type`
3. Optionally, use a form package like [TanStack Form](https://tanstack.com/form/latest/docs/overview) or [react-hook-form](https://react-hook-form.com/) for better error and state management


3. Create logic for how your form handles and sends submissions.1. With some platforms like Netlify forms this means adding data attributes to the `<form>` element
2. In other cases like Formspree its adding their URL as the `action` attribute on your form





# Vercel integration

Sanity’s Vercel integration lets you connect your Vercel and Sanity projects. The integration lets you manage features like billing and plan management directly in Vercel.

The integration adds environment variables to your Vercel projects with the project ID, a given dataset name, and API tokens for reading and writing data (a read token and a write token). You can use these environment variables to connect your project on Vercel to Sanity’s Content Lake and fetch, create, and update data in it.

## Prerequisites

- A [Vercel account](https://vercel.com/signup).
- A Vercel project to connect to your Sanity project.

## Installation

1. Go to [Vercel's integrations marketplace](https://vercel.com/marketplace/sanity) and follow the instructions.
2. We recommend choosing the **Native Integration** option, as this lets you have a deeper integration with Vercel’s ecosystem.
3. Select **Install** and follow the steps to set up the connection. These are a few key decisions you need to make:- Specify a prefix for your environment variables: This guide uses the default, `NEXT_PUBLIC_`, in the examples below.
- Select a plan: We offer Free and Growth self-serve options through Vercel at this time. To sign up for an Enterprise plan, [contact sales](https://www.sanity.io/contact/sales).
- Select a project name, or use the default suggested one.


4. Once the installation completes, follow the *Getting Started* guide for your framework of choice.

## Usage

This integration adds your project information as environment variables. You can go to [Vercel's documentation](https://vercel.com/docs/environment-variables) to learn more about how to use and configure them.

The **project ID** will be exposed by the following environment variables in your Vercel project:

- `SANITY_PROJECT_ID`
- `SANITY_STUDIO_API_PROJECT_ID`
- `NEXT_PUBLIC_SANITY_PROJECT_ID`

*Project IDs are considered public.*

The **dataset** will be exposed by the following environment variables in your Vercel project:

- `SANITY_DATASET`
- `NEXT_PUBLIC_SANITY_DATASET`
- `SANITY_STUDIO_API_DATASET`

*Dataset names are considered public.*

The **write token** and **read token** are exposed by the following environment variables in your Vercel project:

- `SANITY_API_WRITE_TOKEN`: a write token provisioned as a server-side secret.
- `SANITY_API_READ_TOKEN`: a read token, also provisioned as a server-side secret.

Since write tokens give access to changing data in your dataset, they should be considered secret.

### Accessing environment variables

Here is an example of a serverless function that runs on Vercel, takes a request with some data, and creates a new document from that data in the Content Lake:

```javascript
const {createClient} = require('@sanity/client');

const config = {
  projectId: process.env.SANITY_PROJECT_ID,
  dataset: process.env.SANITY_DATASET,
  token: process.env.SANITY_API_WRITE_TOKEN,
  useCdn: false,
  apiVersion: '2026-07-01'
};

async function handleForm(req, res) {
  const payload = JSON.parse(req.body);
  try {
    const result = await createClient(config).create(payload);
    return res.status(200).send('ok');
  } catch (error) {
    return res.status(500).send('error');
  }
}

export default handleForm;
```

## Troubleshooting

### Select an authentication method in the CLI or Studio

When you sign in to your Studio, or when the CLI prompts you to authenticate, select the sign-in method that matches your Sanity account configuration:

- **If you connected a sign-in method earlier**: During the *Getting Started* guide, if you selected "Open in Sanity" on the integration dashboard and linked a sign-in method in [Account Settings](https://www.sanity.io/manage/personal/account-settings), use that same method (GitHub or Google).
- **If you skipped that step**: Use GitHub or Google to log in with the same email address you use for Vercel.
- **If neither applies**: Open the Sanity management interface from the Vercel integration dashboard, navigate to [Account Settings](https://www.sanity.io/manage/personal/account-settings), and add a sign-in method to your account.

### Missing projects or data after signing in

If you've signed in to Sanity but don't see the projects or data you expect, you may have multiple accounts.

#### For existing Sanity users

If you used Sanity before the Vercel integration, you likely already had an account. During integration provisioning, a new account may have been created. To resolve this:

1. Add another sign-in method to consolidate your accounts in [Account Settings](https://www.sanity.io/manage/personal/account-settings).
2. Sign out of both the CLI and Sanity.
3. Continue with the *Getting Started* guide using your original account credentials.

#### For new Sanity users

You may have accidentally created a separate account. You have two options:

- **Keep the account**: Sign out of both Sanity and the CLI, then continue with the *Getting Started* guide.
- **Delete the account**: Verify this is an unused account before proceeding. Deleted accounts cannot be recovered.

Before continuing with the *Getting Started* guide, add another sign-in method in [Account Settings](https://www.sanity.io/manage/personal/account-settings) to ensure seamless access between sign-in methods.

## Limitations

Projects set up via the Vercel integration need to be managed through the integration dashboard in Vercel.

Deleting a project via Sanity management interface is not supported. Plan changes need to happen through the integration dashboard in Vercel.



# Build your blog with Astro and Sanity

> [!NOTE]
> This developer guide was contributed by Knut Melvær (Head of Developer Community and Education), Chris LaRocque (Senior Solution Architect), and Rune Botten (Principal Solutions Engineer and Architect at Sanity working mainly with our enterprise clients.).

Astro is a modern web framework that lets you build fast, content-focused websites. Combined with Sanity's flexible content platform, you can create a blog with powerful content management and real-time editing capabilities.

In this guide, we'll dive deeper into what you'll need to know in order to make a blog with Astro and Sanity. You'll learn how to:

- Set up static and dynamic routes based on content from your Sanity project
- Implement block content with [Portable Text](https://www.sanity.io/docs/developer-guides/presenting-block-text), and add custom block types
- Work with images from the [Sanity CDN](https://www.sanity.io/docs/apis-and-sdks/presenting-images)
- Configure Sanity's [Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool) tool for live [Visual Editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)

This guide won't add styling to the markup, we'll leave that up to you. That said, it's often easier to develop the design when the basic markup and content are in place.



## Prerequisites

This guide uses TypeScript for code examples, but you can adapt them to JavaScript if preferred. You don't need prior experience with Sanity or Astro, though familiarity with the 

Before starting, make sure you have 

- Node.js 22 or later. ([link](https://nodejs.org/en/download/package-manager))
- A code editor.
- Basic familiarity with TypeScript (optional).
- This guide uses Astro v6 and Sanity v5.16. We recommend following along with these major versions.

## Initialize a new Astro project

To create a new Astro project, run the following command:

**npm**

```shell
npm create astro@latest
```

**pnpm**

```shell
pnpm create astro@latest
```

**yarn**

```shell
yarn create astro@latest
```

**bun**

```shell
bun create astro@latest
```

Follow the instructions. When asked `How would you like to start your new project?` select `A basic, minimal starter` . You don't need to use TypeScript, but the examples in this guide will be using it.

### Add dependencies

To add Sanity to your Astro project, install the official [Sanity integration for Astro](https://www.sanity.io/plugins/sanity-astro):

**npm**

```shell
npx astro add @sanity/astro @astrojs/react
```

**pnpm**

```shell
pnpm dlx astro add @sanity/astro @astrojs/react
```

**yarn**

```shell
yarn dlx astro add @sanity/astro @astrojs/react
```

**bun**

```shell
bunx astro add @sanity/astro @astrojs/react
```

The command should add the Sanity and React configuration to your `astro.config.mjs` file. This is where you'll tell Astro what your Sanity project ID is, as well as the name of your dataset (most likely `production`).

The `@astrojs/react` dependency is needed to embed the Studio on a route.

**Note:** If you plan to add server-rendered pages or use the Visual Editing features mentioned later in this guide, you’ll also need to add a server adapter. For this example, we’ll use Node.

**npm**

```shell
npx astro add @astrojs/node
```

**pnpm**

```shell
pnpm dlx astro add @astrojs/node
```

**yarn**

```shell
yarn dlx astro add @astrojs/node
```

**bun**

```shell
bunx astro add @astrojs/node
```

Then update the `astro.config.mjs` to include it.

**astro.config.mjs**

```
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  integrations: [
    // ...
  ],
});
```

> [!NOTE]
> Note for static-site users
> If you don't plan to use the Presentation Tool and want a fully static build, you can avoid the adapter by adding `studioRouterHistory: 'hash'` to the Sanity integration config instead. This switches the embedded Studio to hash-based routing, which allows it to be prerendered. However, the default browser history mode provides cleaner Studio URLs and is required for the Presentation Tool.

To add TypeScript support, create a file `/src/env.d.ts` and add the types for the Astro module:

```typescript
// ./src/env.d.ts
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
```

You may need to restart your TypeScript server for this file to be recognized.

## Initialize a new Sanity project

To initialize your Sanity project and configure environment variables, run:

**npm**

```shell
npx sanity@latest init --env .env
```

**pnpm**

```shell
pnpm dlx sanity@latest init --env .env
```

**yarn**

```shell
yarn dlx sanity@latest init --env .env
```

**bun**

```shell
bunx sanity@latest init --env .env
```

Follow the instructions from the CLI, and don't worry about messing up, with Sanity, you can make as many projects as you want. You can always go to [sanity.io/manage](https://sanity.io/manage) to find information about your projects.

When the init command is completed Astro will have written 2 new environment variables to your `.env` file: `PUBLIC_SANITY_PROJECT_ID` and `PUBLIC_SANITY_DATASET`. These variables are prefixed with `PUBLIC_` because they're not considered secrets. 

### Sanity Client configuration

Astro has a unique limitation where you can't use variables from `.env` files directly in your `astro.config.mjs` file. Because your project ID and dataset name aren't considered sensitive you can directly copy + paste their values from your `.env` file. [Go here for instructions](https://docs.astro.build/en/guides/environment-variables/#in-the-astro-config-file) if you wish to use the `.env` file instead.

**Update** the `sanity` integration in your `astro.config.mjs` file to include the information needed by the Sanity client.

```javascript
// astro.config.mjs
import { defineConfig } from "astro/config";

import sanity from "@sanity/astro";
import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
  integrations: [
    sanity({
      projectId: 'YOUR_PROJECT_ID',
      dataset: '<dataset-name>',
      useCdn: false, // See note on using the CDN
      apiVersion: "2025-01-28", // insert the current date to access the latest version of the API
    }),
    react(),
  ],
});

```

> [!TIP]
> **CDN or not?**
> Sanity lets you query content through a global CDN. If you plan to keep the site static and set up webhooks that trigger rebuilds when updates are published, then you probably want `useCdn` to be `false` to make sure you don't hit stale content when the site builds.
> If you plan to use Server Side Rendering, then you probably want to set `useCdn` to `true` for performance and cost. You can also override this setting if you run the site in hybrid, for example:
> `useSanityClient.config({useCdn: false}).fetch(*[_type == "liveBlog"])`.

### Embedding Sanity Studio

Sanity Studio is where you can edit and manage your content. It's a Single Page Application that's easy to configure and that can be customized in a lot of ways. It's up to you to keep the Studio in a separate repository, in a separate folder (as a monorepo), or embed it into your Astro website. 

For the sake of simplicity, this guide will show you how to embed the Studio on a dedicated route (remember `/wp-admin`?).

**Update** `astro.config.mjs` to add a Studio at `yoursite.com/studio`

```javascript
// astro.config.mjs
import { defineConfig } from "astro/config";
import sanity from "@sanity/astro";
import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
  integrations: [sanity({
    projectId: 'YOUR_PROJECT_ID',
    dataset: '<dataset-name>',
    useCdn: false, // See note on using the CDN
    apiVersion: "2025-01-28", // insert the current date to access the latest version of the API
    studioBasePath: '/studio' // If you want to access the Studio on a route
  }), react()]
});
```

You must also add a configuration file for Sanity Studio in the project root. **Create** a new file called `sanity.config.ts` and add the following, note that we're able to use environment variables here:

```javascript
// ./sanity.config.ts
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";

export default defineConfig({
  projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
  dataset: import.meta.env.PUBLIC_SANITY_DATASET,
  plugins: [structureTool()],
  schema: {
    types: [],
  },
});

```

**Start** the Astro local development server, you should be able to visit the Studio at [http://localhost:4321/studio](http://localhost:4321/studio). The first time you load this URL, you'll be asked to add the URL to your project's CORS Origins. This is to enable authenticated requests from the browser to the Sanity APIs. Follow the instructions and reload the Studio route once you've added the setting.

Your project folder should now look like this:

```text
.
├── public/
│   └── favicon.svg
├── src/
│   ├── assets/
│   │   ├── astro.svg
│   │   └── background.svg
│   ├── components/
│   │   └── Welcome.astro
│   ├── layouts/
│   │   └── Layout.astro
│   ├── pages/
│   │   └── index.astro
│   └── env.d.ts
├── .env
├── .gitignore
├── astro.config.mjs
├── package-lock.json
├── package.json
├── README.md
├── sanity.config.ts
└── tsconfig.json
```

## Define the studio schema

Sanity is different from most headless CMSes. Content Lake, where your content is stored, is a schema-less backend that lets you store any JSON document and makes it instantly queryable with GROQ. Sanity Studio is a decoupled application that enables you to define a schema using simple JavaScript objects. The Studio uses the schema to build an editor interface where you can collaborate on content in real-time.

This guide isn't going to cover schema creation in-depth, for now we'll copy and paste some starting schema definitions.

**Create** a new directory inside the `src` directory, called `sanity` with a directory `schemaTypes` inside of it. **Create** the following files inside `/src/sanity/schemaTypes` :

```typescript
// ./src/sanity/schemaTypes/author.ts
import { defineField, defineType } from "sanity";

export const authorType = defineType({
  name: "author",
  type: "document",
  fields: [
    defineField({
      name: "name",
      type: "string",
    }),
    defineField({
      name: "slug",
      type: "slug",
      options: {
        source: "name",
        maxLength: 96,
      },
    }),
    defineField({
      name: "image",
      type: "image",
      options: {
        hotspot: true,
      },
      fields: [
        {
          name: "alt",
          type: "string",
          title: "Alternative Text",
        },
      ],
    }),
    defineField({
      name: "bio",
      type: "array",
      of: [
        {
          type: "block",
          styles: [{ title: "Normal", value: "normal" }],
          lists: [],
        },
      ],
    }),
  ],
  preview: {
    select: {
      title: "name",
      media: "image",
    },
  },
});

```

```typescript
// ./src/sanity/schemaTypes/blockContent.ts
import { defineType, defineArrayMember } from "sanity";

/**
 * This is the schema type for block content used in the post document type
 * Importing this type into the studio configuration's `schema` property
 * lets you reuse it in other document types with:
 *  {
 *    name: 'someName',
 *    title: 'Some title',
 *    type: 'blockContent'
 *  }
 */

export const blockContentType = defineType({
  title: "Block Content",
  name: "blockContent",
  type: "array",
  of: [
    defineArrayMember({
      type: "block",
      // Styles let you define what blocks can be marked up as. The default
      // set corresponds with HTML tags, but you can set any title or value
      // you want, and decide how you want to deal with it where you want to
      // use your content.
      styles: [
        { title: "Normal", value: "normal" },
        { title: "H1", value: "h1" },
        { title: "H2", value: "h2" },
        { title: "H3", value: "h3" },
        { title: "H4", value: "h4" },
        { title: "Quote", value: "blockquote" },
      ],
      lists: [{ title: "Bullet", value: "bullet" }],
      // Marks let you mark up inline text in the Portable Text Editor
      marks: {
        // Decorators usually describe a single property – e.g. a typographic
        // preference or highlighting
        decorators: [
          { title: "Strong", value: "strong" },
          { title: "Emphasis", value: "em" },
        ],
        // Annotations can be any object structure – e.g. a link or a footnote.
        annotations: [
          {
            title: "URL",
            name: "link",
            type: "object",
            fields: [
              {
                title: "URL",
                name: "href",
                type: "url",
              },
            ],
          },
        ],
      },
    }),
    // You can add additional types here. Note that you can't use
    // primitive types such as 'string' and 'number' in the same array
    // as a block type.
    defineArrayMember({
      type: "image",
      options: { hotspot: true },
      fields: [
        {
          name: "alt",
          type: "string",
          title: "Alternative Text",
        },
      ],
    }),
  ],
});

```

```typescript
// ./src/sanity/schemaTypes/category.ts
import { defineField, defineType } from "sanity";

export const categoryType = defineType({
  name: "category",
  type: "document",
  fields: [
    defineField({
      name: "title",
      type: "string",
    }),
    defineField({
      name: "description",
      type: "text",
    }),
  ],
});
```

```typescript
// ./src/sanity/schemaTypes/post.ts
import { defineField, defineType } from "sanity";

export const postType = defineType({
  name: "post",
  type: "document",
  fields: [
    defineField({
      name: "title",
      type: "string",
    }),
    defineField({
      name: "slug",
      type: "slug",
      options: {
        source: "title",
        maxLength: 96,
      },
    }),
    defineField({
      name: "author",
      type: "reference",
      to: { type: "author" },
    }),
    defineField({
      name: "mainImage",
      type: "image",
      options: {
        hotspot: true,
      },
      fields: [
        {
          name: "alt",
          type: "string",
          title: "Alternative Text",
        },
      ],
    }),
    defineField({
      name: "categories",
      type: "array",
      of: [{ type: "reference", to: { type: "category" } }],
    }),
    defineField({
      name: "publishedAt",
      type: "datetime",
    }),
    defineField({
      name: "body",
      type: "blockContent",
    }),
  ],

  preview: {
    select: {
      title: "title",
      author: "author.name",
      media: "mainImage",
    },
    prepare(selection) {
      const { author } = selection;
      return { ...selection, subtitle: author && `by ${author}` };
    },
  },
});
```

**Create** a file `index.ts` inside `/src/sanity/schemaTypes` 

```typescript
// ./src/sanity/schemaTypes/index.ts
import type { SchemaTypeDefinition } from "sanity";
import { authorType } from "./author";
import { blockContentType } from "./blockContent";
import { categoryType } from "./category";
import { postType } from "./post";

export const schema: { types: SchemaTypeDefinition[] } = {
  types: [authorType, blockContentType, categoryType, postType],
};

```

Update your `sanity.config.ts` file to include the new schema:

```typescript
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { schema } from "./src/sanity/schemaTypes";

export default defineConfig({
  projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
  dataset: import.meta.env.PUBLIC_SANITY_DATASET,
  plugins: [
    structureTool(),
  ],
  schema,
});
```

To recap: you created 3 document types - `author`, `category`, and `post`; as well as a reusable array type `blockContent` for editing Portable Text. If you refresh your Studio at `http://localhost:4321/studio` you should see the 3 document types listed, and the `blockContent` array will be visible when creating a post in the next step.

## Create some example content

**Create** a post titled “Hello world” inside your Studio. At the slug field, press “Generate” to make a slug. Then press “Publish” – this makes the content publicly available via the API.

![The studio interface showing fields for title, slug, author, main image and categories. In the right bottom corner is a big green Publish button](https://cdn.sanity.io/images/3do82whm/next/39364f136a4df450c4029e9a782eaf77792aa9de-2908x1936.png)
*What's a better way to get started with your blog than creating some Hello World content? *

With the content created, the next step is to return to your Astro site and set it up to display your content.

## Set up a blog post route in Astro

When you selected `A basic, minimal starter` template while creating this project, your Astro site was generated with only one route: an index page. To surface posts on our site, you'll want to create routes for each post. In Astro, routes exist as files on the file system in `src/pages` and are picked up by Astro as routes. 

You could manually create routes for each post, but your posts are dynamic: when everything's up and running, you probably want to publish new content without pushing code. Astro, like most web frameworks, offers dynamic routing to make your life easier: you can create one route to catch them all using parameters.

In your blog's schema, every post has a slug, the unique bit of the URL (eg “hello-world” for your “Hello world” post). To use a slug parameter in the route, you must wrap the filename in brackets. So, if you want your posts route to be `/post/slug`, you need to create a folder called `post`, which contains a file named `[slug].astro`.

In `[slug].astro`, you need to export a function called [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths) that returns an array of objects. In our case, at the minimum, each object needs to include `slug` in its `params`. To get started, use this as the contents of your `[slug].astro` file:

```javascript
---
// ./src/pages/post/[slug].astro
export function getStaticPaths() {
  return [
    {params: {slug: 'hello-world'}},
    {params: {slug: 'my-favorite-things'}},
    {params: {slug: 'summertime'}},
  ];
}

const { slug } = Astro.params;
---

<h1>A post about {slug}</h1>
```

This code sets up the data in this route within the code fences (`---`). Data returned from `getStaticPaths` is available in the `Astro.params` variable. This is a bit of magic the framework does for you. Now you can use the `slug` in your template. In this case, it results in a heading that contains whatever the slug is. 

With the example above, Astro will generate three files, 'hello-world', 'my-favorite-things', and 'summertime' in the production build, with a heading that includes the slug. You can now browse to these on your local server. For instance, `localhost:4321/post/summertime` will display the heading “A post about summertime”.

![screenshot of browser rendering a heading 'A post about summertime' on localhost:3000/post/summertime](https://cdn.sanity.io/images/3do82whm/next/285869ebf19a29c0bb5acb612da2055cee86fb2a-2908x1936.png)
*We can use 'slug' in our content*

Of course, you want to display more than just the slugs, and you don't want to hardcode the slugs in this file. Let's get your data from Sanity and dynamically populate your post routes with your content.

## Integrate your blog posts from Sanity in Astro

**Create** a new directory at `./src/sanity` called `lib` and add a new file `load-query.ts`:

```typescript
// ./src/sanity/lib/load-query.ts
import type { QueryParams } from "sanity";
import { sanityClient } from "sanity:client";

export async function loadQuery<QueryResponse>({
  query,
  params,
}: {
  query: string;
  params?: QueryParams;
}) {
  const { result } = await sanityClient.fetch<QueryResponse>(
    query,
    params ?? {},
    { filterResponse: false }
  );

  return {
    data: result,
  };
}
```

> [!TIP]
> You may ask "Why wouldn't I use the client directly in my Astro template?" and that's a very valid question. We're setting up a wrapper around the Sanity integration's client to make implementing Presentation later easier, but if you don't plan to use Presentation you can feel free to just use the client directly in your Astro files.

Head back to your `[slug].astro` file, import the `loadQuery` function, and use it to fetch your posts' slugs, like this:

```typescript
---
// ./src/pages/post/[slug].astro
import { loadQuery } from "../../sanity/lib/load-query";

export async function getStaticPaths() {
  const { data: posts } = await loadQuery({
    query: `*[_type == "post"]`,
  });

  return posts.map(({ slug }) => {
    return {
      params: {
        slug: slug.current,
      },
    };
  });
}

const { slug } = Astro.params;
---

<h1>A post about {slug}</h1>

```

Within the code fences, we export that same `getStaticPaths` function as before, but we've made it automatic so that we can wait for the data before returning the posts. With the `loadQuery` function, we fetch the posts using the Sanity client's fetch method (note: this is Sanity's fetch, not the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)). 

The argument we're passing into this fetch function, if you've not seen this syntax before, is a [GROQ query](https://www.sanity.io/docs/content-lake/how-queries-work).

> [!TIP]
> The GROQ syntax in this tutorial can be read like this:
> - `*`  👈 select all documents
> - `[_type == 'post' && slug.current == $slug]` 👈 filter the selection down to documents with the type "post" and those of them who have the same slug to that we have in the parameters
> - `[0]`  👈 select the first and only one in that list

Now you need to fetch the right blog post given a certain slug. **Update** `[slug].astro` with the following:

```typescript
---
// ./src/pages/post/[slug].astro
import { loadQuery } from "../../sanity/lib/load-query";

export async function getStaticPaths() {
  const { data: posts } = await loadQuery({
    query: `*[_type == "post"]`,
  });

  return posts.map(({ slug }) => {
    return {
      params: {
        slug: slug.current,
      },
    };
  });
}

const { params } = Astro;

const { data: post } = await loadQuery({
  query: `*[_type == "post" && slug.current == $slug][0]`,
  params,
});
---

<h1>A post about {post.title}</h1>
```

So that's it! You should now be able to see the title of your "Hello World" post under `/post/hello-world`. 

![heading “Hello world” rendered in a browser in the default stylesheet](https://cdn.sanity.io/images/3do82whm/next/13c0a043a0f93347059121c5a7f7d006726d2d0a-2908x1936.png)
*If you have a post titled “Hello world” with “hello-world” as the slug, you should be able to find it in localhost:3000/post/hello-world.*

> [!TIP]
> "Why not return the post during getStaticPaths?" - another excellent question dear reader. Similar to the reasoning for wrapping our client in loadQuery, we're fetching data outside getStaticPaths to allow it to refresh when using Presentation. If you don't plan to use Presentation you can return all the data in getStaticPaths.

## Render images and block content

Now that you've seen how to display the title, continue to add the other bits of our posts: block content and images.

### Background

With Sanity, your blog posts are part of your content model. They can be set up to be whatever you want, but we've used some boilerplate blog post schemas. Your post's title is a [string](https://www.sanity.io/docs/studio/string-type), the published date is saved as a [datetime](https://www.sanity.io/docs/studio/datetime-type) and so on. Sanity has specific tooling for **images** and **block content**, so we'll add those first.

### Images

When you use the [image](https://www.sanity.io/docs/studio/image-type) field type to allow users to upload images in your Studio, the images are uploaded to [Sanity's CDN (the Asset Pipeline)](https://www.sanity.io/docs/apis-and-sdks/asset-cdn). It's set up so you can request them however you need them: in specific dimensions, image formats, or crops, just to name a few [image transformations](https://www.sanity.io/docs/apis-and-sdks/presenting-images). The way this works is that the image is represented as an ID in your data structure. You can then use this ID to construct image URLs. 

Use the image URL builder from the [@sanity/image-url package](https://www.sanity.io/docs/apis-and-sdks/image-urls) for this. First install the dependency:

**npm**

```shell
npm install @sanity/image-url
```

**pnpm**

```shell
pnpm add @sanity/image-url
```

**yarn**

```shell
yarn add @sanity/image-url
```

**bun**

```shell
bun add @sanity/image-url
```

**Create** a new file inside `./src/sanity/lib` called `url-for-image.ts`:

```javascript
// ./src/sanity/lib/url-for-image.ts
import { sanityClient } from 'sanity:client';
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";

export const imageBuilder = createImageUrlBuilder(sanityClient);

export function urlForImage(source: SanityImageSource) {
  return imageBuilder.image(source);
}
```

As you set up block content in the next step you'll use this function to performantly render your images

### Block content and rich text

The blog template saves your blog content in a `array` field of the [block](https://www.sanity.io/docs/studio/block-type) type. This will give you block content with rich text, which Sanity saves in a structured format called [Portable Text](https://github.com/portabletext/portabletext). From Portable Text, you can generate Markdown, HTML, PDFs, or whatever else you want. It's very flexible. For this tutorial, you'll convert your Portable Text content to Astro components with the `astro-portabletext` library:

**npm**

```shell
npm install astro-portabletext
```

**pnpm**

```shell
pnpm add astro-portabletext
```

**yarn**

```shell
yarn add astro-portabletext
```

**bun**

```shell
bun add astro-portabletext
```

If you're using TypeScript it may be helpful to include the types for Portable Text: 

**npm**

```shell
npm install @portabletext/types
```

**pnpm**

```shell
pnpm add @portabletext/types
```

**yarn**

```shell
yarn add @portabletext/types
```

**bun**

```shell
bun add @portabletext/types
```

Then, for convenience, create an Astro component to render our Portable Text for us. **Create** a new file called `PortableText.astro` inside of `./src/components`:

```javascript
---
// ./src/components/PortableText.astro
import { PortableText as PortableTextInternal } from 'astro-portabletext'
const { portableText } = Astro.props;
---

<PortableTextInternal value={portableText} />
```

This will render our Portable Text blocks, but we haven't yet added a component to handle any custom blocks we added to the Portable Text field, like `image`.

**Create** a file called `PortableTextImage.astro` in the same `components` folder:

```javascript
---
// ./src/components/PortableTextImage.astro
import { urlForImage } from "../sanity/lib/url-for-image";

const { asset, alt } = Astro.props.node;

const url = urlForImage(asset).url();
const webpUrl = urlForImage(asset).format("webp").url();
---

<picture>
  <source srcset={webpUrl} type="image/webp" />
  <img class="responsive__img" src={url} alt={alt} />
</picture>

```

This component will pass the relevant node from the Portable Text content, and we use our `urlForImage` function to calculate the asset URLs to display. Now, you can register this component to be rendered when PortableText encounters an `image` block:

```javascript
---
// ./src/components/PortableText.astro
import { PortableText as PortableTextInternal } from 'astro-portabletext'
import PortableTextImage from "./PortableTextImage.astro";

const { portableText } = Astro.props;

const components = {
  type: {
    image: PortableTextImage,
  }
};
---

<PortableTextInternal value={portableText} components={components} />
```

**Update** `[slug].astro` to use the `PortableText` component to render the post content:

```javascript
---
// ./src/pages/post/[slug].astro
import { loadQuery } from "../../sanity/lib/load-query";
import PortableText from "../../components/PortableText.astro";

export async function getStaticPaths() {
  const { data: posts } = await loadQuery({
    query: `*[_type == "post"]`,
  });

  return posts.map(({ slug }) => {
    return {
      params: {
        slug: slug.current,
      },
    };
  });
}

const { params } = Astro;

const { data: post } = await loadQuery({
  query: `*[_type == "post" && slug.current == $slug][0]`,
  params,
});
---

<h1>A post about {post.title}</h1>
<PortableText portableText={post.body} />
```

This uses the `PortableText` component we just added renders any content you've added, including links, images, and headings. This is an example of what it could look like:

![browser screenshot with heading my favorite things, some text with links and an image, the logo of Astro](https://cdn.sanity.io/images/3do82whm/next/255b07f650a483334ef69f4e5e8ec2318df9241f-2908x1936.png)
*Bold text, links, images: authored in one rich text field and rendered in one PortableText component*

## Enable the Presentation Tool

Live Visual Editing is made possible via Sanity's Presentation Tool. To enable the Presentation Tool, we'll follow the steps outlined in the [documentation for the Astro integration](https://www.sanity.io/plugins/sanity-astro#enabling-visual-editing). The Presentation Tool provides two key benefits:

1. **Overlays** - All content stored in Sanity has an overlay added that when clicked brings users directly to editing that content in the Studio
2. **Live mode** - Edits made in the Studio are reflected on the front-end to provide authors immediate feedback

### Create a layout file with the VisualEditing component

**Update **the `Layout.astro` file inside the `src/layouts` directory with the following:

```javascript
---
// ./src/layouts/Layout.astro
import { VisualEditing } from "@sanity/astro/visual-editing";

const visualEditingEnabled =
  import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === "true";
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width" />
		<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
		<meta name="generator" content={Astro.generator} />
		<title>Astro Basics</title>
  </head>
  <body>
    <slot />
    <VisualEditing enabled={visualEditingEnabled} />
  </body>
</html>

<style>
	html,
	body {
		margin: 0;
		width: 100%;
		height: 100%;
	}
</style>

```

In `Layout.astro` you're importing the `VisualEditing` component, which enables overlays and live mode for the Presentation Tool. Note the `visualEditingEnabled` constant tied to an environment variable `PUBLIC_SANITY_VISUAL_EDITING_ENABLED` set to `true`. If you haven't already, update your `.env` file to include this variable. When you're ready to deploy your site you'll want to have this variable set to `false` in production, but have another environment that's a copy of production with this variable set to `true`.

**Update** your `[slug].astro` template to be wrapped in the new layout:

**/src/pages/post/[slug].astro**

```javascript
---
// ./src/pages/post/[slug].astro
import type { SanityDocument } from "@sanity/client";
import { loadQuery } from "../../sanity/lib/load-query";
import Layout from "../../layouts/Layout.astro";
import PortableText from "../../components/PortableText.astro";

export async function getStaticPaths() {
  const { data: posts } = await loadQuery<SanityDocument[]>({
    query: `*[_type == "post"]`,
  });

  return posts.map(({ slug }) => {
    return {
      params: {
        slug: slug.current,
      },
    };
  });
}

const { params } = Astro;

const { data: post } = await loadQuery<{ title: string; body: any[] }>({
  query: `*[_type == "post" && slug.current == $slug][0]`,
  params,
});
---

<Layout>
  <h1>A post about {post.title}</h1>
  <PortableText portableText={post.body} />
</Layout>

```

**Update** your `index.astro` template to be wrapped in the new layout:

**/src/pages/index.astro**

```javascript
---
import Layout from "../layouts/Layout.astro";
---

<Layout>
  <h1>Astro</h1>
</Layout>

```

### Update settings in `astro.config` file

**Update** the Sanity integration settings in `astro.config.mjs` to include `stega.studioUrl`

```javascript
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

import sanity from "@sanity/astro";
import react from "@astrojs/react";

import { loadEnv } from "vite";
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
  process.env.NODE_ENV,
  process.cwd(),
  "",
);

// https://astro.build/config
export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  integrations: [
    sanity({
      projectId: PUBLIC_SANITY_PROJECT_ID,
      dataset: PUBLIC_SANITY_DATASET,
      useCdn: false, // See note on using the CDN
      apiVersion: "2025-01-28", // insert the current date to access the latest version of the API
      studioBasePath: "/studio",
      stega: {
        studioUrl: "/studio",
      },
    }),
    react(),
  ],
});

```

Adding this to the configuration allows the overlays to link to the appropriate place.

### Generate a viewer token

In Sanity, drafts are considered private and are not accessible without a token. 

In the top right of the Studio click on your user avatar, and click "Manage project"

*Select "manage project" from this drop down*

In your manage dashboard, navigate to "API" and down to "Tokens". Click "Add token", give it any name you wish, ensure it has "Viewer" permissions, and click "Save"

*Navigate to the token settings in manage and create a viewer token*

**Add** this token to your `.env` file with the name `SANITY_API_READ_TOKEN`.

### Update `loadQuery` to work with the Presentation Tool

**Update** `./src/sanity/lib/load-query.ts` to the following:

```typescript
// ./src/sanity/lib/load-query.ts
import { type QueryParams } from "sanity";
import { sanityClient } from "sanity:client";

const visualEditingEnabled =
  import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === "true";
const token = import.meta.env.SANITY_API_READ_TOKEN;

export async function loadQuery<QueryResponse>({
  query,
  params,
}: {
  query: string;
  params?: QueryParams;
}) {
  if (visualEditingEnabled && !token) {
    throw new Error(
      "The `SANITY_API_READ_TOKEN` environment variable is required during Visual Editing.",
    );
  }

  const perspective = visualEditingEnabled ? "drafts" : "published";

  const { result, resultSourceMap } = await sanityClient.fetch<QueryResponse>(
    query,
    params ?? {},
    {
      filterResponse: false,
      perspective,
      resultSourceMap: visualEditingEnabled ? "withKeyArraySelector" : false,
      stega: visualEditingEnabled,
      ...(visualEditingEnabled ? { token } : {}),
    },
  );

  return {
    data: result,
    sourceMap: resultSourceMap,
    perspective,
  };
}

```

There are a few things going on here, you're:

- Modifying the `perspective` setting in the client to use `previewDrafts` when Visual Editing is enabled
- Returning a `resultSourceMap` for the overlays to know where to link to
- Passing the token to the client to view drafts and enable Stega encoding (which powers the overlays)

### Add the Presentation Tool to the Studio

**Update** your `sanity.config.ts` file to include the Presentation Tool in the plugins

```typescript
// ./sanity.config.ts
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { schema } from "./src/sanity/schemaTypes";
import { presentationTool } from "sanity/presentation";

export default defineConfig({
  projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
  dataset: import.meta.env.PUBLIC_SANITY_DATASET,
  plugins: [
    structureTool(),
    presentationTool({
      previewUrl: location.origin,
    }),
  ],
  schema,
});

```

Note the `previewUrl`, set to `location.origin` due to the Studio being embedded in your existing Astro app. If your Studio and front-end were hosted at different URLs you would update this value to point to the hosted front-end.

If you navigate to `http://localhost:4321/studio/presentation` you should see the Presentation Tool, and if you put the path to your blog post (`/post/hello-world`) in the tool's address bar you should see your front-end with overlays that bring you directly to your blog post. 

## Add a document location resolver

[The Document Locations Resolver API](https://www.sanity.io/docs/visual-editing/presentation-resolver-api) allows you to define *where* data is being used in your application(s), and it also allows you to quickly preview a document from the Structure.

For example if you have an author document open, enabling locations puts a widget at the top of the document with links to all documents on the site where this author is linked to.



![Location resolver widget](https://cdn.sanity.io/images/3do82whm/next/7b37f2a7653e21999a56fea8a4b263814c0cb083-1565x435.png)
*Location resolver adds this widget on top of the document*

### Create a new location resolver

**Create** a new file in ` ./src/sanity/lib/` , called `resolve.ts`

```typescript
// ./src/sanity/lib/resolve.ts

import { defineLocations } from "sanity/presentation";
import type { PresentationPluginOptions } from "sanity/presentation";

export const resolve: PresentationPluginOptions["resolve"] = {
  locations: {
    // Add more locations for other post types
    post: defineLocations({
      select: {
        title: "title",
        slug: "slug.current",
      },
      resolve: (doc) => ({
        locations: [
          {
            title: doc?.title || "Untitled",
            href: `/post/${doc?.slug}`,
          },
        ],
      }),
    }),
  },
};

```

### Add the location resolver to the Studio

**Update **your `sanity.config.ts` file to include the Location tool (`resolve`) inside the `presentationTool`

```typescript
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { schema } from "./src/sanity/schemaTypes";
import { presentationTool } from "sanity/presentation";
import { resolve } from "./src/sanity/lib/resolve";

export default defineConfig({
  projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
  dataset: import.meta.env.PUBLIC_SANITY_DATASET,
  plugins: [
    structureTool(),
    presentationTool({
	  resolve,
      previewUrl: location.origin,
    }),
  ],
  schema,
});
```

Now each `post` document in your Studio should include a link to open it in the Presentation Tool

*Document locations show on "post" type documents*

### Enable vite overrides (if needed)

With the release of Astro v6, there are some in-progress compatibility updates. If you’re experiencing issues with visual editing, add the configuration below.

Several transitive dependencies of `@sanity/visual-editing` are CommonJS modules that Vite doesn't automatically pre-bundle. Without this, the `VisualEditing` component fails to hydrate in the browser with errors like:

**astro.config.mjs**

```javascript
export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  integrations: [
    // ...
  ],
  vite: {
    optimizeDeps: {
      include: [
        "react/compiler-runtime",
        "lodash/isObject.js",
        "lodash/groupBy.js",
        "lodash/keyBy.js",
        "lodash/partition.js",
        "lodash/sortedIndex.js",
      ],
    },
  },
});
```

This will likely be fixed in upcoming versions of @sanity/astro.

> [!NOTE]
> **This is a dev-server-only issue**. Production builds (`astro build`) are unaffected because Rollup handles CJS-to-ESM interop during bundling. The `optimizeDeps` config only controls Vite's dev-server pre-bundling step, where individual modules are served to the browser on demand. It's still important to include because developers will hit these errors immediately when running `npm run dev`.

## Demo of Visual Editing with Astro

![Demo of Visual Editing with Astro](https://youtu.be/qEtT2v_cQq8)

## Next steps

And there you are: you now have an Astro site to display our blog content and a Sanity Studio to manage it. It uses Astro's [dynamic routes](https://docs.astro.build/en/core-concepts/routing/#dynamic-routes) feature to generate static files for each of the blog posts in your Studio that you can host wherever. You're only getting the content at build time—when people read your content, they're reading the version built when your build process last ran.

Feel free to ask us questions on [Discord](https://snty.link/community), or however else you might find us.



# How to implement front-end search with Sanity

> [!NOTE]
> This developer guide was contributed by Irina Blumenfeld (Solution Architect @ Sanity).

This guide provides a comprehensive walkthrough on integrating Sanity's structured content platform with Algolia's powerful search capabilities using Sanity Functions, and an example front-end implementation using React and Next.js.

It will walk through how to set up indexing of your Sanity content in Algolia v5, including initial indexing of existing content and incremental updates as your content changes.

![a diagram showing the process of building a website search engine .](https://cdn.sanity.io/images/3do82whm/next/f42eb0ccb659cf08a61c6e4daf3229bedab5128e-3671x1089.jpg)
*Sanity Function runs on create, update, and delete events for the specified content types. It sends relevant data to Algolia index. *

By following these steps, you can provide your users with fast, relevant search results while leveraging the benefits of Sanity's content platform.

## Steps to implement:

1. Create schema in Sanity and create Next.js app
2. Set up environment variables
3. Run a first time indexing script
4. Incremental indexing
5. Set up and deploy an Algolia sync Sanity Function
6. Test the sync function locally
7. Customization
8. Indexing long records
9. Create a front-end search component
10. Add `Search` component to your page

> [!TIP]
> If you'd like to see a complete example - [View Repo on Github](https://github.com/sanity-io/sanity-algolia-sync)

## Create schema in Sanity and create Next.js app

**We will be using:**

- [Clean Next.js + Sanity app template](https://www.sanity.io/templates/nextjs-sanity-clean)
- [Algolia search API client](https://www.npmjs.com/package/algoliasearch) for JavaScript - `algoliasearch`
- [React instant search](https://www.npmjs.com/package/react-instantsearch) and [React instant search Next.js](https://www.npmjs.com/package/react-instantsearch-nextjs) - `react-instantsearch` and `react-instantsearch-nextjs`.  

Follow the instructions for [Clean Next.js + Sanity app template](https://www.sanity.io/templates/nextjs-sanity-clean) to install and deploy your project. 

The template includes a [Next.js](https://nextjs.org/) app with a [Sanity Studio](https://www.sanity.io/) – an open-source React application that connects to your Sanity project’s hosted dataset.

Either start with a sample content included with the template, or create your own. 

We will make it possible to search the `post` type documents using Algolia. 
The `post` type has these fields:

```groq
{
  _id,
  title,
  slug,
  content,
  coverImage,
  date,
  _createdAt,
  _updatedAt
}
```

## Environment variables

You must add  `NEXT_PUBLIC_ALGOLIA_APP_ID, NEXT_PUBLIC_ALGOLIA_API_KEY, and ALGOLIA_WRITE_KEY `as environment variables in Vercel. You can find these in your Algolia account dashboard. 

Algolia comes with a set of predefined API keys. `Search API Key` works on all your Algolia application indices and is safe to use in your production frontend code. `Write API Key` is used to create, update and DELETE your indices.

**.env**

```text
NEXT_PUBLIC_ALGOLIA_APP_ID="your-algolia-app-id"
NEXT_PUBLIC_ALGOLIA_API_KEY="your-algolia-search-api-key"
ALGOLIA_WRITE_KEY="your-private-algolia-key"
```

## First time indexing

If you are indexing for the first time with Algolia, you can add a script within your `studio` folder and run it locally to do the initial indexing of all the existing content.

Create  `/scripts` directory inside of `/studio`

Make sure you have these packages installed inside of your /studio directory:

**npm**

```shell
npm install algoliasearch dotenv
```

**pnpm**

```shell
pnpm add algoliasearch dotenv
```

**yarn**

```shell
yarn add algoliasearch dotenv
```

**bun**

```shell
bun add algoliasearch dotenv
```

### Add script

> [!TIP]
> Customization
> In this script we query documents with `"post"` content type. You can modify the query based on the document types and fields you'd like to sync - (e.g. 'articles', 'products', 'events', etc.)
> We are also truncating the body size. [Algolia has size limits](https://support.algolia.com/hc/en-us/articles/4406981897617-Is-there-a-size-limit-for-my-index-records) depending on your Algolia plan. Please plan the script accordingly.

**algolia-initial-sync.ts**

```
// studio/scripts/algolia-initial-sync.ts

import {env} from 'node:process'
import {algoliasearch} from 'algoliasearch'
import {getCliClient} from 'sanity/cli'
import dotenv from 'dotenv'
import path from 'path'

// Load environment variables from .env file in project root
dotenv.config({ path: path.resolve(__dirname, '../../.env') })

const {
  ALGOLIA_APP_ID = '',
  ALGOLIA_WRITE_KEY = '',
} = env

// TODO: Allow this script to run on multiple indexes/post types (e.g. 'posts', 'products', 'events', etc.)
const ALGOLIA_INDEX_NAME = 'posts'

// Get Sanity client using CLI configuration
const sanityClient = getCliClient()

async function initialSync() {
  console.log('Starting initial sync to Algolia...')

  // Validate required environment variables
  if (!ALGOLIA_APP_ID || !ALGOLIA_WRITE_KEY) {
    console.error('Missing required environment variables:')
    console.error('- ALGOLIA_APP_ID:', ALGOLIA_APP_ID ? '✓' : '✗')
    console.error('- ALGOLIA_WRITE_KEY:', ALGOLIA_WRITE_KEY ? '✓' : '✗')
    console.error('')
    console.error('Note: Sanity configuration is automatically loaded from your studio configuration.')
    process.exit(1)
  }

  const algoliaClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_WRITE_KEY)

  try {
    // Fetch all post documents from Sanity
    const posts = await sanityClient.fetch(`
      *[_type == "post"] {
        _id,
        title,
        slug,
        "content": pt::text(content),
        _type,
        "coverImage": coverImage.asset->url,
        date,
        _createdAt,
        _updatedAt
      }
    `)

    console.log(`Found ${posts.length} posts to sync`)

    if (posts.length === 0) {
      console.log('No posts found to sync')
      return
    }

    // Prepare documents for Algolia
    const algoliaDocuments = posts.map((post: any) => {
      // Ensure content is within Algolia's size limits (10KB max per record)
      // We'll be more conservative and limit to 8000 characters to leave room for other fields
      const content = post.content ? post.content.slice(0, 8000) : ''
      
      const document = {
        objectID: post._id,
        title: post.title?.slice(0, 500) || '', // Limit title length
        slug: post.slug?.current || post.slug || '',
        content,
        _type: post._type,
        coverImage: post.coverImage || null,
        date: post.date,
        _createdAt: post._createdAt,
        _updatedAt: post._updatedAt,
      }

      // Check document size and warn if it's getting close to the limit
      const documentSize = JSON.stringify(document).length
      if (documentSize > 9000) {
        console.warn(`Document ${post._id} is ${documentSize} bytes (close to 10KB limit)`)
      }

      return document
    })

    // Clear existing documents in the index to ensure we're overwriting, not appending
    console.log('Clearing existing documents from Algolia index...')
    await algoliaClient.clearObjects({
      indexName: ALGOLIA_INDEX_NAME,
    })

    // Save all documents to Algolia
    console.log('Uploading documents to Algolia...')
    await algoliaClient.saveObjects({
      indexName: ALGOLIA_INDEX_NAME,
      objects: algoliaDocuments,
    })

    console.log('Initial sync to Algolia completed successfully')
    console.log(`Synced ${algoliaDocuments.length} documents to index: ${ALGOLIA_INDEX_NAME}`)
  } catch (error) {
    console.error('Error during initial sync to Algolia:', error)
    process.exit(1)
  }
}

// Run the script if called directly
if (require.main === module) {
  initialSync()
    .then(() => {
      console.log('Script completed successfully')
      process.exit(0)
    })
    .catch((error) => {
      console.error('Script failed:', error)
      process.exit(1)
    })
}

initialSync()

```

### Execute script 

**npm**

```shell
npx sanity exec scripts/algolia-initial-sync.ts --with-user-token
```

**pnpm**

```shell
pnpm dlx sanity exec scripts/algolia-initial-sync.ts --with-user-token
```

**yarn**

```shell
yarn dlx sanity exec scripts/algolia-initial-sync.ts --with-user-token
```

**bun**

```shell
bunx sanity exec scripts/algolia-initial-sync.ts --with-user-token
```

> [!NOTE]
> Executing scripts
> [Documentation on executing scripts](https://www.sanity.io/docs/cli-reference/exec)

### Check the Algolia index

If it synced successfully, your Algolia Application index should now have a number of records based on your query inside the Initial Sync Script.

## Incremental indexing

When a content editor publishes, updates or deletes a blog post, the Sanity function that we are setting up below will automatically:

1. **Trigger** on the `create`, `update`, `delete` event for `post` documents
2. **Extract** the document data ( `title` , `ID`, `coverImage`, `coverImageAlt`, `date`, `slug`, `_createdAt`, `_updatedAt`, `_type`. )
3. **Send** the data to Algolia using the Algolia client
4. **Update** the search index with the latest content

![Sanity+Algolia index](https://cdn.sanity.io/images/3do82whm/next/bdd17c79d3f3261b1bbdeb548d22dc2208ea31e7-4558x2405.png)
*Screenshots of the Sanity Studio with matching indexed data in Algolia*

## Set up and deploy an Algolia Sync Sanity Function

> [!TIP]
> Complete example with code
> [View the complete example and source code ](https://github.com/sanity-io/sanity/tree/main/examples/functions/algolia-document-sync)

This Sanity Function automatically syncs documents to Algolia's search index, ensuring your search functionality always reflects your latest content. When a post is published, the function sends the document data to Algolia, either creating a new search record or updating an existing one. We also track when documents are updated and deleted, using the `delta` operation. Our function can remove an item from Algolia under the `delete` operation.

> [!NOTE]
> Getting Started with Functions
> [Functions Quick Start](https://sanity-docs.sanity.build/docs/compute-and-ai/function-quickstart#k9ef7ef8d924b)

### Initialize Blueprints

To create your first function, you need to initialize a blueprint. [Blueprints are templates](https://www.sanity.io/docs/blueprints/blueprint-config) that describe Sanity resources.

> [!NOTE]
> Learn More about Blueprints
> [Blueprints CLI reference documentation](https://sanity-docs.sanity.build/docs/cli-reference/cli-blueprints)

**Prerequisites:**

- `sanity` CLI v4.9.0 or higher is recommended to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands with `npx sanity@latest`.
- Node.js v22.x. We highly suggest working on this version as it is the same version that your functions will run when deployed to Sanity.
- An existing project and [a role with Deploy Studio permissions](https://sanity-docs.sanity.build/docs/user-guides/roles) (the `deployStudio` grant).

It's recommended keeping functions and blueprints a level above your Studio directory.

If you're using [Clean Next.js + Sanity App template](https://github.com/sanity-io/sanity-template-nextjs-clean), your project structure may look like this: 

```text
main-project-folder/
├─ studio/
├─ frontend/
```

If you initialize the blueprint in the `main-project-folder` directory, functions and future resources will live alongside the `studio` and `frontend` directory.

**npm**

```shell
npx sanity blueprints init
```

**pnpm**

```shell
pnpm dlx sanity blueprints init
```

**yarn**

```shell
yarn dlx sanity blueprints init
```

**bun**

```shell
bunx sanity blueprints init
```

You'll be prompted to select your organization and Sanity studio.

### Add the Algolia Function example

**npm**

```shell
npx sanity blueprints add function --example algolia-document-sync
```

**pnpm**

```shell
pnpm dlx sanity blueprints add function --example algolia-document-sync
```

**yarn**

```shell
yarn dlx sanity blueprints add function --example algolia-document-sync
```

**bun**

```shell
bunx sanity blueprints add function --example algolia-document-sync
```

If you followed the directory structure mentioned earlier, you project structure may look like this:

```text
main-project-folder/
├─ studio/
├─ frontend/
├─ sanity.blueprint.ts
├─ package.json
├─ node_modules/
├─ functions/
│  ├─ algolia-document-sync/
│  │  ├─ index.ts
```

### Add configuration to your blueprint

`sanity.blueprint.ts` already exists in your `root` directory because you added the `algolia-document-sync` function example in the previous step. 

We will modify it below and add `SANITY_PROJECT_ID`,
`SANITY_DATASET` environment variables, as well as more document fields inside the projection

**sanity.blueprint.ts**

```
// sanity.blueprint.ts

import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
import 'dotenv/config'
import process from 'node:process'

const {
  ALGOLIA_APP_ID,
  ALGOLIA_WRITE_KEY,
  SANITY_PROJECT_ID,
  SANITY_DATASET,
} = process.env

if (typeof ALGOLIA_APP_ID !== 'string' || typeof ALGOLIA_WRITE_KEY !== 'string') {
  throw new Error('ALGOLIA_APP_ID and ALGOLIA_WRITE_KEY must be set')
}

if (typeof SANITY_PROJECT_ID !== 'string' || typeof SANITY_DATASET !== 'string') {
  throw new Error('SANITY_PROJECT_ID and SANITY_DATASET must be set')
}

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      type: 'sanity.function.document',
      name: 'algolia-document-sync',
      memory: 1,
      timeout: 10,
      src: './functions/algolia-document-sync',
      event: {
        on: ['create', 'update', 'delete'],
        filter: "_type == 'post'",
        projection: `{
          _id,
          title,
          slug,
          "content": pt::text(content),
          _type,
           "coverImage": {
          "assetRef": coverImage.asset._ref,
          "alt": coverImage.alt
          },
          date,
          _createdAt,
          _updatedAt,
          "operation": delta::operation()
        }`,
      },
      env: {
        COMMENT:
          'ALGOLIA_APP_ID and ALGOLIA_WRITE_KEY env variables are required to sync documents to Algolia',
        ALGOLIA_APP_ID,
        ALGOLIA_WRITE_KEY,
        SANITY_PROJECT_ID,
        SANITY_DATASET,
      },
    }),
  ],
})

```

### Install dependencies 

Inside your `functions/algolia-document-sync` directory install `@sanity/asset-utils` which we'll be using to build the image url from the document image

**npm**

```shell
npm install @sanity/asset-utils
```

**pnpm**

```shell
pnpm add @sanity/asset-utils
```

**yarn**

```shell
yarn add @sanity/asset-utils
```

**bun**

```shell
bun add @sanity/asset-utils
```

In the project root directory, we need to install `dotenv` package:

**npm**

```shell
npm install dotenv
```

**pnpm**

```shell
pnpm add dotenv
```

**yarn**

```shell
yarn add dotenv
```

**bun**

```shell
bun add dotenv
```

### Update your Function

We will modify the Initial [Algolia Sync Function script](https://www.sanity.io/docs/functions/functions-cheatsheet) and add additional fields to the query projection, such as `coverImage`, `coverImageAlt`, `date`, `slug`, `_createdAt`, `_updatedAt`, `_type`. We will also truncate the `body` and `title` length. [Algolia has size limits](https://support.algolia.com/hc/en-us/articles/4406981897617-Is-there-a-size-limit-for-my-index-records) depending on your Algolia plan. 

**functions/algolia-document-sync.ts**

```
// functions/algolia-document-sync/index.ts

import {env} from 'node:process'

import {documentEventHandler} from '@sanity/functions'
import {algoliasearch} from 'algoliasearch'
import {buildImageUrl, parseImageAssetId, isImageAssetId} from '@sanity/asset-utils'

const {
  ALGOLIA_APP_ID = '',
  ALGOLIA_WRITE_KEY = '',
  SANITY_PROJECT_ID = '',
  SANITY_DATASET = '',
} = env

// This example is for 'posts' document type. You can modify it to run on multiple indexes/post types (e.g. 'posts', 'products', 'events', etc.)
const ALGOLIA_INDEX_NAME = 'posts'

const urlFromAssetRef = (assetRef?: string | null) => {
  if (!assetRef || !isImageAssetId(assetRef)) return null
  const parts = parseImageAssetId(assetRef)

  const url = buildImageUrl({
    ...parts,
    projectId: SANITY_PROJECT_ID,
    dataset: SANITY_DATASET,
  })

  return url
}

export const handler = documentEventHandler(async ({event}) => {
  const {_id, title, slug, content, _type, coverImage, date, _createdAt, _updatedAt, operation} =
    event.data

  const algolia = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_WRITE_KEY)
  if (operation === 'delete') {
    try {
      // We are assuming you already have an algolia instance setup with an index called 'posts'
      // addOrUpdateObject documentation: https://www.algolia.com/doc/libraries/javascript/v5/methods/search/delete-object/?client=javascript
      await algolia.deleteObject({
        indexName: ALGOLIA_INDEX_NAME,
        objectID: _id,
      })

      console.log(`Successfully deleted document ${_id} ("${title}") from Algolia`)
    } catch (error) {
      console.error('Error syncing to Algolia:', error)
      throw error
    }
  } else {
    try {
      const coverImageUrl = urlFromAssetRef(coverImage?.assetRef)
      // Truncating the body if it's too long.
      // Another approach: defining multiple records:https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/how-to/indexing-long-documents/
      const limitedContent = content ? content.slice(0, 8000) : ''
      const limitedTitle = title ? title.slice(0, 500) : ''
      const slugValue = slug?.current || slug || ''

      const document = {
        title: limitedTitle,
        slug: slugValue,
        content: limitedContent,
        _type,
        coverImage: coverImageUrl,
        coverImageAlt: coverImage?.alt ?? '',
        date,
        _createdAt,
        _updatedAt,
      }

      // Check document size and warn if it's getting close to the limit
      const documentSize = JSON.stringify(document).length
      if (documentSize > 9000) {
        console.warn(`Document ${_id} is ${documentSize} bytes (close to 10KB limit)`)
      }

      // We are assuming you already have an algolia instance setup with an index called 'posts'
      // addOrUpdateObject documentation: https://www.algolia.com/doc/libraries/javascript/v5/methods/search/add-or-update-object/?client=javascript
      await algolia.addOrUpdateObject({
        indexName: ALGOLIA_INDEX_NAME,
        objectID: _id,
        body: document,
      })

     const coverImageInfo = coverImageUrl ? `coverImage: ${coverImageUrl}` : 'No cover image'
      console.log(`Synced ${_id} ("${limitedTitle}") – ${coverImageInfo}`)
    } catch (error) {
      console.error('Error syncing to Algolia:', error)
      throw error
    }
  }
})

```

### Deploy a Function

Once you're satisfied that the function works as expected, you can deploy it by deploying the blueprint.

**npm**

```shell
npx sanity blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity blueprints deploy
```

**yarn**

```shell
yarn dlx sanity blueprints deploy
```

**bun**

```shell
bunx sanity blueprints deploy
```

You can begin using your function when the deployment is finished. If you set a filter earlier, edit a document that matches it and publish the changes to trigger the function.

**If you need to change the function, update your code and re-run the deploy command to push the new changes live.**

> [!WARNING]
> Functions rate limits
> Note that Functions have [rate limits](https://sanity-docs.sanity.build/docs/compute-and-ai/functions-introduction#ef28ecbb33c6) to protect against recursive functions limitations and rate limits

## Customization

### Modify Indexed Fields

Update the fields sent to Algolia by modifying the object in `addOrUpdateObject`:

**functions/algolia-document-sync/index.ts**

```
// functions/algolia-document-sync/index.ts

const document = {
  // update existing fields
}

await algolia.addOrUpdateObject({
        indexName: ALGOLIA_INDEX_NAME,
        objectID: _id,
        body: document,
      })
```

### Change Target Index

Modify the index name (currently set to `'posts'`) to sync to a different Algolia index, alternatively pass _type into the projection so you can sync to indexes based on the post type, allowing one function to update many indexes:

**functions/algolia-document-sync/index.ts**

```
// functions/algolia-document-sync/index.ts

await algolia.addOrUpdateObject({
  indexName: 'your-custom-index', // Different index name
  objectID: _id,
  body: {
    title,
  },
})
```

### Add Document Filtering

Update the filter to sync specific document types or conditions:

```
filter: "_type == 'post' && defined(publishedAt)"
```

## Testing the Function Locally

> [!TIP]
> Functions testing tips
> [Tips and best practices](https://sanity-docs.sanity.build/docs/compute-and-ai/functions-local-testing#c0930eed2018)

[There are several ways you can test your function](https://sanity-docs.sanity.build/docs/compute-and-ai/functions-local-testing#c0930eed2018)

### Using Sanity CLI

You can test the `algolia-document-sync` function locally using the Sanity CLI before deploying. To see a full list, see the [functions CLI reference documentation](https://sanity-docs.sanity.build/docs/cli-reference/functions#k3cc1c78c098d) or run `npx sanity functions test --help.`

This function writes directly to Algolia, so we can test locally with our `document.json` without relying on any Sanity schema.

- **Test with the included sample document:**

**npm**

```shell
npx sanity functions test algolia-document-sync --file functions/algolia-document-sync/document.json --dataset production --with-user-token
```

**pnpm**

```shell
pnpm dlx sanity functions test algolia-document-sync --file functions/algolia-document-sync/document.json --dataset production --with-user-token
```

**yarn**

```shell
yarn dlx sanity functions test algolia-document-sync --file functions/algolia-document-sync/document.json --dataset production --with-user-token
```

**bun**

```shell
bunx sanity functions test algolia-document-sync --file functions/algolia-document-sync/document.json --dataset production --with-user-token
```

- **Supply data from a document in your dataset**

Use the `--dataset`, `--project-id`, and `--document-id` flags to fetch real documents to use as source data for your function.

```
npm sanity@latest functions test algolia-document-sync --document-id 52df8926-1afe-413b-bd23-e9efbc32cea3 --project-id 123456 --dataset production
```

### Running local server

The command below opens a local dev server on `http://localhost:8080. `You can also specify a port with the `--port` flag.

You need to select project, dataset, Document ID, choose Event, and click **Run button** to see the results.

- Toggle **"With Token"** to supply a `token` to the function handler's `context.clientOptions` object. A token is omitted from local testing unless this is toggled.

**npm**

```shell
npx sanity functions dev
```

**pnpm**

```shell
pnpm dlx sanity functions dev
```

**yarn**

```shell
yarn dlx sanity functions dev
```

**bun**

```shell
bunx sanity functions dev
```

![a screenshot of the algolia document sync function](https://cdn.sanity.io/images/3do82whm/next/c405f2a07d1ad1db47871dccc33778ae8e4c0104-2262x1460.png)
*Testing function locally with a local dev server*

### Check the Logs

When you tested the function locally, you saw the logs directly in your console. Once deployed, the function and its logs are in the cloud.

**npm**

```shell
npx sanity functions logs algolia-document-sync

INFO Successfully deleted document 77a50e6d-518a-422f-8e04-941c9e464d60 ("This is my test post") from Algolia

INFO Synced 633f34b8-a68c-480a-a2ff-59b8dc168871 (“Really awesome post”) – coverImage: https://cdn.sanity.io/images/e29s7c8p/production/b79d80d029395f5205ce09857644a75cc97fa356-5132x3157.jpg
```

**pnpm**

```shell
pnpm dlx sanity functions logs algolia-document-sync

INFO Successfully deleted document 77a50e6d-518a-422f-8e04-941c9e464d60 ("This is my test post") from Algolia

INFO Synced 633f34b8-a68c-480a-a2ff-59b8dc168871 (“Really awesome post”) – coverImage: https://cdn.sanity.io/images/e29s7c8p/production/b79d80d029395f5205ce09857644a75cc97fa356-5132x3157.jpg
```

**yarn**

```shell
yarn dlx sanity functions logs algolia-document-sync

INFO Successfully deleted document 77a50e6d-518a-422f-8e04-941c9e464d60 ("This is my test post") from Algolia

INFO Synced 633f34b8-a68c-480a-a2ff-59b8dc168871 (“Really awesome post”) – coverImage: https://cdn.sanity.io/images/e29s7c8p/production/b79d80d029395f5205ce09857644a75cc97fa356-5132x3157.jpg
```

**bun**

```shell
bunx sanity functions logs algolia-document-sync

INFO Successfully deleted document 77a50e6d-518a-422f-8e04-941c9e464d60 ("This is my test post") from Algolia

INFO Synced 633f34b8-a68c-480a-a2ff-59b8dc168871 (“Really awesome post”) – coverImage: https://cdn.sanity.io/images/e29s7c8p/production/b79d80d029395f5205ce09857644a75cc97fa356-5132x3157.jpg
```

The above command outputs the function's logs. Try updating your document, publishing the change, and running the command again to see new logs.

### Indexing long records

Your Algolia plan has limits on the number of records and the size of records you can import. If you exceed these limits, you might get an error: `Algolia error: Record too big.` 

To work around this Algolia suggests to break the page into sections or even paragraphs, and store each as a separate record. When you split a page, the same content might appear in multiple records.  [To avoid duplicates](https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/how-to/indexing-long-documents/#avoid-duplicates), you can turn on `distinct` and set `attributeForDistinct`.  

> [!TIP]
> Algolia provides [documentation](https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/how-to/indexing-long-documents/) on indexing long documents

## Create a Front-end Search Component

In your `frontend` directory, you will need to install `react-instantsearch` and `react-instantsearch-nextjs `packages.  

**npm**

```shell
npm install react-instantsearch react-instantsearch-nextjs
```

**pnpm**

```shell
pnpm add react-instantsearch react-instantsearch-nextjs
```

**yarn**

```shell
yarn add react-instantsearch react-instantsearch-nextjs
```

**bun**

```shell
bun add react-instantsearch react-instantsearch-nextjs
```

> [!TIP]
> Algolia provides [a detailed documentation](https://www.algolia.com/doc/guides/building-search-ui/getting-started/react/) on implementing Search in your React Application. 

In the `/app/components` directory add a new file `Search.tsx`

> [!WARNING]
> Make sure to use the `Search API Key` [provided by Algolia](https://www.algolia.com/doc/guides/security/api-keys/) - a public API key which can be safely used in your frontend. 

**app/components/Search.tsx**

```
// app/components/Search.tsx

'use client';
import { liteClient as algoliasearch } from 'algoliasearch/lite';
import { SearchBox, Hits, useSearchBox } from 'react-instantsearch';
import { InstantSearchNext } from 'react-instantsearch-nextjs';
import Link from 'next/link';

const algoliaAppId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!;
const algoliaApiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY!;

const searchClient = algoliasearch(algoliaAppId, algoliaApiKey);

function SearchResults() {
    const { query } = useSearchBox();

    if (!query) {
        return null;
    }

    return (
        <div className="text-left">
            <h2 className="text-2xl font-semibold mb-4">Results for: {query}</h2>
            <Hits
                hitComponent={({ hit }) => (
                    <div className="p-2 border-b">
                        <Link href={`/posts/${hit.slug}`}
                            passHref
                            className="text-blue-600 hover:text-blue-700 hover:underline">
                            {hit.title}
                        </Link>
                        <p>{hit.description}</p>
                    </div>
                )}
            />
        </div>
    );
}

export function Search() {
    return (
        <InstantSearchNext
            indexName="posts"
            searchClient={searchClient}
            ignoreMultipleHooksWarning={true}
            future={{ preserveSharedStateOnUnmount: true }}
            routing={{
                router: {
                    cleanUrlOnDispose: false,
                    windowTitle(routeState) {
                        const indexState = routeState.indexName || {};
                        return indexState.query
                            ? `MyWebsite - Results for: ${indexState.query}`
                            : 'MyWebsite - Results page';
                    },
                }
            }}
        >
            {/* SearchBox for input */}
            <SearchBox
                placeholder="Search for items..."
                classNames={{
                    input: `
                      border-2 border-gray-500 rounded-lg 
                      p-3 m-2 w-full max-w-2xl mx-auto
                      text-lg
                      focus:border-blue-500 focus:ring-2 focus:ring-blue-400
                      shadow-sm
                    `,
                    submit: 'hidden',
                    reset: 'hidden',
                }}
            />

            {/* Search results component */}
            <SearchResults />
        </InstantSearchNext>
    );
}

```

### Add the Search Component to the Page

Add your Search to the front-end component in your site. 

In our example we'll add it to the main page in `/app/page.tsx`

**app/page.tsx**

```
// /app/page.tsx

... rest of the imports
import { Search } from "@/app/components/Search";

... page content

<Search />

... page content


```

Run your `frontend` project and test to make sure you're getting search results:

**npm**

```shell
 npm run dev
```

**pnpm**

```shell
 pnpm run dev
```

**yarn**

```shell
 yarn run dev
```

**bun**

```shell
 bun run dev
```

![a web page that says search with algolia on it](https://cdn.sanity.io/images/3do82whm/next/b7d140e430d0a0dd3038e7b91e58324777355ab7-2302x830.png)
*Our search results showing on the page*

> [!TIP]
> Algolia provides [documentation on refining your search results](https://www.algolia.com/doc/guides/managing-results/relevance-overview/), such as adding filters, synonyms, sorting strategies, search analytics, and more.

## Front-end Implementation Demo

The example code in this guide can be found in [https://github.com/sanity-io/sanity-algolia-sync](https://github.com/sanity-io/sanity-algolia-sync)

Live Demo: [https://sanity-algolia-sync-one.sanity.dev](https://sanity-algolia-sync-one.sanity.dev/)

## Conclusion

- By integrating Sanity and Algolia, you can provide powerful search capabilities for your content. This guide walked through the steps to set up indexing of your Sanity content in Algolia, including:
- Initial indexing in Algolia with a CLI script
- Deploying a Sanity function to handle `create`, `update`, `delete` document events
- Setting up Algolia search in your front-end application and using the Algolia JavaScript API client to send search queries and display the results.



# Displaying Sanity content in Shopify

By default, the Sanity Connect application will sync Products, Product Variants, and Collections from Shopify into a Sanity dataset. This guide outlines how to sync additional data from Sanity into Shopify. This allows you to power your storefront with a range of content, provided through Shopify's metaobject and metafield APIs.

## Prerequisites

- Sanity Connect installed in your Shopify store and connected to a Sanity project and dataset. See [Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify).
- Permission to grant Sanity Connect additional access scopes on your Shopify store. Syncing content into Shopify requires allowing the app to edit metaobjects.

## Visual walkthrough

This video walks through how you can leverage synced metafields and metaobjects within Shopify's native collection and theme tooling.

![Walkthrough of displaying Sanity content within Shopify](https://youtu.be/Obu3ea6J-8k)

## Configuring synced objects and fields

To sync Sanity content:

1. On the Sanity Connect dashboard, enable the option 'Sync content from Sanity to Shopify.'
2. Save the configuration, and you will be prompted to grant new access permissions to Sanity Connect. Allow the application to edit metaobjects within your store.
3. After confirming permissions, you will return to the Sanity Connect dashboard. Visit the ‘Metaobject’ tab listed at the top of your page. You will see a list of all document types available within your linked Sanity dataset. Select the document types that you want available within Shopify. These will sync alongside the native Product, Product Variant, and Collection objects.
4. Return to the Sanity Connect dashboard and trigger a full sync.

Only published documents will be synced to Shopify. Drafts are not processed.

## Updating the list of synced fields

After the initial configuration, you can review your synced resources from the 'Metafields' and 'Metaobjects' tabs within the Sanity Connect application.

### Metafields

This section covers the outbound direction — Sanity fields becoming Shopify metafields. The same Metafields tab also configures [importing Shopify metafields into Sanity](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify), which is a separate setting.

The Metafields tab displays data for the standard Product, Product Variant, and Collection objects. Within Sanity, these entities sync over with the [native fields from the Shopify API](https://shopify.dev/docs/api/storefront/latest/objects/Product). However, you can extend these documents with custom fields. Your custom fields are available within Shopify as metafields.

On the Metafields tab, you can review which custom fields are configured to sync, review the inferred data type for each field, select whether to [pin the field](https://help.shopify.com/en/manual/custom-data/metafields/pinning-metafield-definitions) within your Shopify storefront configuration, or remove the synced metafield definition.

Removing the metafield definition is a temporary action. This feature will be used if you're troubleshooting syncing issues or updating the inferred data type of the field. A removed metafield definition will be reset during the next sync event.

### Metaobjects

The Metaobjects tab displays data for all custom document types within Sanity. Each object is displayed along with its fields, similar to the Metafield tab.

Unique to metaobjects, there is an option to control whether the document type will sync to Shopify.

If you deselect a previously synced metaobject, we will remove the synced metaobjects and its definition from your Shopify store once you save the configuration.

## Inferring field types

When data is synced from Sanity to Shopify, we create each metafield with a static data type. Data is mapped to Shopify using the below table:

| Sanity Schema Types | Shopify Metafield Types |
| --- | --- |
| Date | Date |
| Datetime | Date time |
| Number | Number (either decimal or integer) |
| String | String |
| URL | URL |
| Slug | String |
| Reference | Reference (Product, Variant, Collection) |
| Array | List |
| Block | JSON |
| Span | JSON |
| Text | Multiline text |
| Image | Shopify File Reference (plugin-enabled) |
| File | Shopify File Reference (plugin-enabled) |
| Geopoint | JSON |
| Object | JSON |

Portable Text is serialized as JSON and can be integrated into Liquid storefronts using [portable-text-to-liquid](https://github.com/portabletext/portable-text-to-liquid).

Images are available in Shopify as a file reference when they are added to Sanity using the [Shopify Assets plugin](https://github.com/sanity-io/sanity-plugin-shopify-assets). Examples rendering these assets are available in the [portable-text-to-liquid](https://github.com/portabletext/portable-text-to-liquid) repository. Images and files added without the Shopify Assets plugin will sync to Shopify as JSON, referencing the asset hosted on Sanity’s CDN.

These data types are inferred from the values available during the first sync. If Sanity Connect encounters a value that doesn't match the expected type, then that field will be skipped. Unaffected fields will continue to sync.

## Accessing your data within Shopify

Metaobjects are available via the [metaobject API](https://shopify.dev/docs/api/storefront/latest/objects/metaobject) and within your Shopify Admin at **Settings > Custom data**.

Metafields on the native Shopify objects are visible on each resource in a dedicated **Metafields** section.

## Collections

You can create a Dynamic Collection referencing metafields on your products. Any of your synced metafields will be available when specifying the conditions to match products.

You will not be able to delete a metafield definition (for example, to reset the type inference) if that field is being used by a Dynamic Collection.

## Pages

You can [create custom pages](https://help.shopify.com/en/manual/custom-data/metaobjects/webpages) in Shopify based on your metaobjects. You will select the metaobject definition to use and then create a template for displaying your content.

When you host pages on Shopify, the page URL is derived from your metaobject's handle. To customize this handle, use the 'Use slug as handle' setting, available on the Metaobjects tab in Sanity Connect.

## Visual editor

In the [visual theme editor](https://shopify.com/admin/themes/current/editor), you can select metafields to serve as a dynamic source for an element. Compatible elements will have a ‘Connect Dynamic Source’ option available. This option will list available metafields whose type definitions match the inputs required for the component.



## Liquid

### Native object metafields

For Products, Product Variants, and Collections, you'll access metafields using the `app--6007307--sanity-fields` namespace.

```liquid
<div>Spiciness Level: {{ product.metafields['app--6007307--sanity-fields'].spicinessLevel }}</div>
<div>Season: {{ product.metafields['app--6007307--sanity-fields'].season }}</div>
<div>
  <h2>This product pairs well with</h2>
  <ul>
    {%- for p in product.metafields['app--6007307--sanity-fields'].pairsWellWith.value -%}
      <li>{{ p.title }}</li>
    {%- endfor -%}
  </ul>
</div>
```

### Metaobjects

Metaobjects are accessed by their Type ID, which is a concatenation of:

1. `app--6007307`: The Sanity Connect app ID
2. `sanity-documents`: The namespace where Sanity metaobjects are stored
3. `your-document-type-name`: The name of your synced metaobject

For example, this Liquid would list the `name` of all of our `recipe` documents:

```liquid
{% for o in shop.metaobjects['app--6007307--sanity-documents-recipe'].values -%}
	<li>{{ o.name }}</li>
{%- endfor -%}
```

To reference an individual document, you use the metaobject's handle. There are two possible values for the handle depending on your configuration.

1. The default handle is your document's Sanity ID. However, you must transform the dashes in the ID to underscores. For example, this Liquid would display the picture associated with a specific document:

```liquid
{{
  shop.metaobjects['app--6007307--sanity-documents-recipe'].ea53f398_e42b_4f2c_9495_e750a00eafaf.picture
  | image_url: width: 300
  | image_tag
}}
```

2. On the Metaobjects tab in Sanity Connect, you can enable a setting to 'Use slug as handle.' When enabled, your metaobject's handle will be the value you have set in a `slug`-type field that you have configured on the document. The first slug field found on the document is used. If there is no value for a slug field, then the handle defaults back to the Sanity document ID.

Handle names must follow a set of rules [documented in Shopify's platform](https://shopify.dev/docs/api/liquid/basics#handles). When a slug field is used as the handle, Shopify automatically transforms the field value. So a slug field of `exampleSlug` in Sanity would be `exampleslug` as a Shopify handle.

> [!NOTE]
> Gotcha
> Updating the slug in Sanity will update the API handle for the object. Beware of hardcoded references to your objects.

## Storefront API

You would use [Metafields](https://shopify.dev/docs/api/storefront/latest/objects/Product#field-product-metafield) and [Metaobjects](https://shopify.dev/docs/api/storefront/latest/objects/Metaobject) within the Storefront API.

- Native object metafields- Namespace: `app--6007307--sanity-fields`
- Key: your metafield name


- Metaobjects- Handle: `app--6007307--sanity-documents-foo`, with `foo` replaced with the name of your metaobject



## Troubleshooting

### Stale data

If you have automatic syncing enabled for Sanity Connect, updates to your documents should sync to Shopify within a few seconds. Most changes should appear right away in your storefronts. Shopify provides different caches for managing the content on your storefronts. Some destinations could take up to 5 minutes to update.

If you have stale data, first check the 'Logs' tab within Sanity Connect. That will report any sync failures.

Then check your Shopify Admin. You can navigate to your custom metaobjects and metafields to see if the new values have synced.

### Field types

You may encounter issues syncing if:

1. You change your schema within Sanity and transform the type of data returned by a field
2. The values of your field could be interpreted as multiple data types

For the first situation, you should be able to remove the metafield definition and trigger a new sync. Your next sync should capture the new values and infer your new data type.

For the second situation, you may need to review your data within Sanity.

### String types

Strings could be evaluated as four different data types in Shopify. They are evaluated in this order:

1. If `YYYY-MM-DD`, then we consider it a `date`.
2. If `YYYY-MM-DD[T]HH:MM:SS`, then we consider it a `date_time`.
3. If the string contains a newline, then we consider it a `multi_line_text_field`.
4. Otherwise, we consider it a `single_line_text_field`.

### Number types

Sanity has a single [number data type](https://www.sanity.io/docs/studio/number-type) which can represent integers or decimal types. Shopify treats these as two different data types.

During sync, numbers are evaluated in this order:

1. If the number contains a decimal, it is `number_decimal`.
2. Otherwise, it is `number_integer`.

### Setting your desired type

If the sync process fails due to a type mismatch, you’ll need to make updates within the Sanity Connect application to complete your sync.

Consider the following scenario:

You have a `pageDescription` field that supports multi-line text. The first document that Sanity attempts to sync is a placeholder page you published with the value `"Placeholder description"`. Sanity Connect doesn’t see your schema, so this gets interpreted as a `single_line_text_field` field in Shopify. The next document that syncs has a longer description value that requires a `multi_line_text_field` field. Sanity Connect fails to sync this document.

Here you would take the following steps:

1. In Sanity Connect, navigate to the Metaobjects page. Navigate to the document type that failed.
2. Find the `pageDescription` field and select 'Edit Type.' Confirm the update.

We provide automatic type updates for string fields (between `single_line_text_field` and `multi_line_text_field`) and number fields (between `number_integer` and `number_decimal`).

### Missing document types

In general, we make any document types within Sanity available to sync. In some situations, a document type may not be available:

1. The document type name must only contain letters, numbers, and underscores (`_`).
2. Document type names must be unique when case-insensitive. For example, `aboutPage` and `aboutpage` will not sync as two metaobjects within Shopify.



# Sanity Connect for Shopify

The [Sanity Connect application for Shopify](https://apps.shopify.com/sanity-connect) is used to synchronize content between a Sanity dataset and your Shopify store. This gives you flexibility to use the tools that are right for your needs. You can take a headless approach using Shopify's Hydrogen framework and Next.js, or you can sync data into Shopify's platform and use Liquid or the Storefront API.

## Requirements

To take advantage of Sanity Connect, you will need:

- A Shopify store
- A Sanity project and dataset

If you are starting with a new Sanity dataset, you can create the dataset and a pre-configured Studio instance using:

**npm**

```shell
npm create sanity@latest -- --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

**pnpm**

```shell
pnpm create sanity@latest --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

**yarn**

```shell
yarn create sanity@latest --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

**bun**

```shell
bun create sanity@latest --template shopify --create-project "Shopify Store" --dataset production --typescript --output-path shopify-store
```

## Installation

To install Sanity Connect in your Shopify store and connect it to a project:

1. Find [Sanity Connect on the Shopify App Store](https://apps.shopify.com/sanity-connect) and push the “**Install**” button.
2. If you have multiple Shopify accounts, you need to choose the one that contains the store you want to add the app to.
3. After choosing the store, Shopify will show you the permissions Sanity Connect needs to work and its data policies. You can push the Install app button to continue.
4. The app will ask you to connect to your Sanity account. If you don't have one, you can choose to **Create new account**.
5. When you're logged in, you will need to connect your shop with a project on Sanity. You can choose between existing projects or create a new one (for free).
6. Select an organization to list its projects, then select the project and dataset you want to sync to.
7. You are now ready to configure the app.

> [!WARNING]
> Gotcha
> Once you choose Start synchronizing now, the app will add product documents to your Content Lake. It can be wise to test it against a non-production dataset if you haven't tried it before.

You might also want to consider using our [Shopify asset plugin](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-shopify-assets), which allows you to select assets from your Shopify store in the context of your Sanity Studio, allowing you to serve assets from the Shopify CDN in your frontends.

## Settings

You can configure how and when Sanity Connect should synchronize products to your Content Lake, and whether content should be synchronized back to your Shopify store. You can change these options at any time.

![Settings panel showing synchronization options during initial setup](https://cdn.sanity.io/images/3do82whm/next/8c633519b6b003dd7a95026d8e8c13df9df5b809-1274x1346.png)
*Synchronization activated at initial setup*

![Settings panel showing synchronization options after initial setup](https://cdn.sanity.io/images/3do82whm/next/f8a87a923c694905c6c8293814534ea436df29fc-1282x1438.png)
*Synchronization activated after initial setup*

### Sync content from Sanity to Shopify

This setting allows you to sync any custom fields and document types you've created in Sanity back into Shopify. Your custom content will sync as Shopify metafields and metaobjects.

For a deeper dive, review our documentation on [displaying Sanity content within Shopify](https://www.sanity.io/docs/developer-guides/displaying-sanity-content-in-shopify).

This is the outbound direction — Sanity content becoming Shopify metafields. To bring Shopify's own metafields into Sanity, see Import Shopify metafields below.

### How to synchronize

Sanity Connect offers two ways to synchronize content from Shopify into your Content Lake: direct sync and custom sync.

**Direct sync**

This will synchronize all products, product variants, and collections as documents to your Content Lake. You can check the [reference](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify-reference) to preview the data model for these documents.

> [!WARNING]
> Gotcha
> Synced documents created by Sanity Connect will count towards your Sanity document usage limit. One document will be created for every product, product variant, and collection in your storefront.

**Custom sync**

This option will let you enter an endpoint that receives updates from Shopify and syncs data to your Content Lake. Typically that will be a serverless function handler where you can reshape the data and do other business logic as part of the sync.

You may, for example, want to reduce document usage by syncing products but not variants, or sync variants as objects on a product document rather than individual variant documents.

We have further documentation on [custom sync handlers](https://www.sanity.io/docs/developer-guides/custom-sync-handlers-for-sanity-connect) including an example serverless function.

### When to synchronize

**Sync data automatically:** Automatically sync whenever you save products. Note: The sync will update the Shopify information for both published and draft documents. An update is typically available in your Content Lake after a couple of seconds.

**Sync manually:** There will be no automatic sync, and you'll have to go into the Sanity Connect settings to trigger a synchronization manually.

Sanity Connect will do an initial synchronization once you choose one of these options.

> [!WARNING]
> Sanity Connect will not sync versions
> Content Release document versions are not supported at this time. Sanity Connect will only sync published and draft documents.

> [!NOTE]
> Automatic drift correction
> Sanity Connect runs a daily reconciliation check against Shopify for every connected shop. Each run fetches only what has changed since the last one, repairs any differences in your dataset, and catches deletions that a webhook may have missed. There is nothing to configure. The Logs tab in the Sanity Connect app shows recent runs and a plain-language explanation if one fails. [Learn how drift correction works →](https://www.sanity.io/docs/apis-and-sdks/automatic-drift-correction-in-sanity-connect)

### Sync collections

The Sanity Connect app can optionally sync collections data. This will sync data and properties about your collection, but it will not sync the product membership of your collections.

### Import Shopify metafields

Sanity Connect can import your Shopify metafields onto the synced product and collection documents as a read-only `store.metafields` array. Custom data you already keep in Shopify — specifications, care instructions, or an external ID — becomes queryable in GROQ alongside the rest of the product. Shopify remains the source of truth: each sync overwrites the array, so edits made in Sanity are replaced.

Choose which data comes across in the Metafields tab, at the namespace level. Selecting a namespace imports every metafield in it; clearing your selection turns import off. Changes apply to each document as it next syncs — run a Resync to apply them everywhere at once, whether you are adding metafields or removing them.

The Metafields tab hosts both directions: importing Shopify metafields into Sanity, and syncing your Sanity fields out to Shopify as metafields. They are configured independently. The outbound section appears only if you have enabled syncing content from Sanity to Shopify.

Variant metafields and metaobjects are not supported for import. See the [reference](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify-reference) for the data shape.

## Set up your Studio

You can install a production-ready reference Studio that's set up with a great editor experience by running this command in your local shell. Replace the `PROJECT_ID` and `DATASET_NAME` placeholders with the actual values from the project your Shopify store is connected to:

**npm**

```shell
npx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

**pnpm**

```shell
pnpm dlx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

**yarn**

```shell
yarn dlx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

**bun**

```shell
bunx @sanity/cli init --template shopify --project PROJECT_ID --dataset DATASET_NAME
```

You'll find comprehensive documentation for this studio in its `README.md`.

![Screenshot of Shopify reference studio](https://cdn.sanity.io/images/3do82whm/next/58ebc2e9801b90061c4184d22ff0d267f534a25e-720x427.png)
*The Shopify reference studio*

### Integrate with an existing Studio

If you've already set up a Studio instance, you can follow the patterns exposed in this [example Studio setup](https://github.com/sanity-io/cli/tree/main/packages/%40sanity/cli/templates/shopify). This repository showcases the same Studio customizations that are implemented when creating a new Studio with the `shopify` template.

## Further reading

[Sanity Studio for Shopify](https://github.com/sanity-io/cli/tree/main/packages/%40sanity/cli/templates/shopify)

[Shopify asset selection for Sanity Studio](https://github.com/sanity-io/plugins/tree/main/plugins/sanity-plugin-shopify-assets)



# Custom sync handlers for Sanity Connect

A custom sync handler allows you to provide an endpoint which receives updates from Shopify and passes data into your Content Lake. Typically, this will be a serverless function where you can reshape the data from Shopify and apply business logic before it is passed to your Content Lake.

## Prerequisites

- Sanity Connect installed in your Shopify store and connected to a Sanity project and dataset, with custom sync selected as the sync method. See [Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify).
- A Sanity API token with write access, so your handler can create and update documents on your behalf. See [Authentication and tokens](https://www.sanity.io/docs/content-lake/http-auth).
- A publicly reachable HTTPS endpoint to deploy your handler to, typically a serverless function. It must respond within 10 seconds.

## When to use a custom sync handler

There are a number of scenarios where you may choose to implement a custom sync handler. Common examples include:

- Where you need to apply additional logic to the data, for example, querying additional APIs to retrieve data that Sanity Connect does not sync.
- You may want to reduce your document usage on Sanity by only syncing selected products, or syncing variants as an object on product documents rather than variant documents.
- Where you want to amend the default manner in which Sanity Connect handles a product being deleted on Shopify (by setting `isDeleted` to `true`) to fully delete the document from your Content Lake.

## How custom sync handlers work

When enabled, the custom sync handler will send a payload on every update from Shopify as a POST request. You can write your custom business logic in your endpoint and [update](https://www.sanity.io/docs/content-lake/transactions) your Content Lake accordingly in the function, or respond with a set of documents which Sanity Connect will update for you.

Sanity Connect expects a response header with `content-type: application/json` and will regard a `200` status code as a success. Any other status code will be considered a failure.

You can find the [shape of the payload your handler](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify-reference) will receive in our Sanity Connect reference.

When you have selected [metafield namespaces to import](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify), those metafields arrive on the `Product` and `Collection` objects in the payload as a `metafields` array. You do not need to call the Shopify API to fetch them.

> [!WARNING]
> Gotcha
> The request has a 10s timeout and your handler needs to reply before that. Requests that fail with a 5xx status code will be retried up to 10 times; other error responses are not retried.
> If your handler needs more time to complete updates (for example if it calls a third-party API), a common pattern would be to store the payload in a queue for background processing, and respond `200 OK` immediately to acknowledge receipt of the payload.

> [!WARNING]
> Gotcha
> This operation will be batched when manually syncing, especially when dealing with larger catalogs.

> [!WARNING]
> Gotcha
> Changes in product inventory (through sales) will also trigger updates to your custom handler.
> Make sure to tailor your custom handler to account for how our [API CDN invalidates cache](https://www.sanity.io/docs/content-lake/api-cdn) on writes to non-draft documents, especially if operating on a high-traffic store with fast-moving content.

## Example custom sync handler function

Below is an example of a barebones custom function that will:

- Create/update/delete products (including drafts) in the Content Lake on Shopify product operations
- Only deal with products (variants are included as objects within products)
- Manual sync will create and update products on your dataset, but will not delete products that have since been removed.

For a more complete example, refer to [this gist](https://gist.github.com/snorrees/1ca7c3191d62ede6b9b5d0a1822d7103#file-requirements-md).

```javascript
import {createClient} from "@sanity/client";

// Document type for all incoming synced Shopify products
const SHOPIFY_PRODUCT_DOCUMENT_TYPE = "shopify.product";

// Prefix added to all Sanity product document ids
const SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX = "product-";

// Enter your Sanity Studio details here.
// You will also need to provide an API token with write access in order for this
// handler to be able to create documents on your behalf.
// Read more on auth, tokens, and securing them: https://www.sanity.io/docs/http-auth
const sanityClient = createClient({
  apiVersion: "2025-07-01",
  dataset: process.env.SANITY_DATASET,
  projectId: process.env.SANITY_PROJECT_ID,
  token: process.env.SANITY_ADMIN_AUTH_TOKEN,
  useCdn: false,
});

/**
 * Sanity Connect sends POST requests and expects both:
 * - a 200 status code
 * - a response header with `content-type: application/json`
 * 
 * Remember that this may be run in batches when manually syncing.
 */
export default async function handler(req, res) {
  // Next.js will automatically parse `req.body` with requests of `content-type: application/json`,
  // so manually parsing with `JSON.parse` is unnecessary.
  const { body, method } = req;

  // Ignore non-POST requests
  if (method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  try {
    const transaction = sanityClient.transaction();
    switch (body.action) {
      case "create":
      case "update":
      case "sync":
        await createOrUpdateProducts(transaction, body.products);
        break;
      case "delete":
        const documentIds = body.productIds.map((id) =>
          getDocumentProductId(id)
        );
        await deleteProducts(transaction, documentIds);
        break;
    }
    await transaction.commit();
  } catch (err) {
    console.error("Transaction failed: ", err.message);
  }

  res.status(200).json({ message: "OK" });
}

/**
 * Creates (or updates if already existing) Sanity documents of type `shopify.product`.
 * Patches existing drafts too, if present.
 *
 * All products will be created with a deterministic _id in the format `product-${SHOPIFY_ID}`
 */
async function createOrUpdateProducts(transaction, products) {
  // Extract draft document IDs from current update
  const draftDocumentIds = products.map((product) => {
    const productId = extractIdFromGid(product.id);
    return `drafts.${getDocumentProductId(productId)}`;
  });

  // Determine if drafts exist for any updated products
  const existingDrafts = await sanityClient.fetch(`*[_id in $ids]._id`, {
    ids: draftDocumentIds,
  });

  products.forEach((product) => {
    // Build Sanity product document
    const document = buildProductDocument(product);
    const draftId = `drafts.${document._id}`;

    // Create (or update) existing published document
    transaction
      .createIfNotExists(document)
      .patch(document._id, (patch) => patch.set(document));

    // Check if this product has a corresponding draft and if so, update that too.
    if (existingDrafts.includes(draftId)) {
      transaction.patch(draftId, (patch) =>
        patch.set({
          ...document,
          _id: draftId,
        })
      );
    }
  });
}

/**
 * Delete corresponding Sanity documents of type `shopify.product`.
 * Published and draft documents will be deleted.
 */
async function deleteProducts(transaction, documentIds) {
  documentIds.forEach((id) => {
    transaction.delete(id).delete(`drafts.${id}`);
  });
}

/**
 * Build Sanity document from product payload
 */
function buildProductDocument(product) {
  const {
    featuredImage,
    id,
    options,
    productType,
    priceRange,
    status,
    title,
    variants,
  } = product;
  const productId = extractIdFromGid(id);
  return {
    _id: getDocumentProductId(productId),
    _type: SHOPIFY_PRODUCT_DOCUMENT_TYPE,
    image: featuredImage?.src,
    options: options?.map((option, index) => ({
      _key: String(index),
      name: option.name,
      position: option.position,
      values: option.values,
    })),
    priceRange,
    productType,
    status,
    title,
    variants: variants?.map((variant, index) => {
      const variantId = extractIdFromGid(variant.id);
      return {
        _key: String(index),
        compareAtPrice: Number(variant.compareAtPrice || 0),
        id: variantId,
        inStock: variant.inventoryPolicy === "continue" || variant.inventoryQuantity > 0,
        inventoryPolicy: variant.inventoryPolicy,
        option1: variant?.selectedOptions?.[0]?.value,
        option2: variant?.selectedOptions?.[1]?.value,
        option3: variant?.selectedOptions?.[2]?.value,
        price: Number(variant.price || 0),
        sku: variant.sku,
        title: variant.title,
      };
    }),
  };
}

/**
 * Extract ID from Shopify GID string (all values after the last slash)
 * e.g. gid://shopify/Product/12345 => 12345
 */
function extractIdFromGid(gid) {
  return gid?.match(/[^\/]+$/i)[0];
}

/**
 * Map Shopify product ID number to a corresponding Sanity document ID string
 * e.g. 12345 => product-12345
 */
function getDocumentProductId(productId) {
  return `${SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX}${productId}`;
}
```



# Cookie consent integrations with Sanity

This guide explains how to integrate cookie consent management with a Sanity-powered website. We'll cover how to implement a cookie banner that operates on the frontend layer while keeping your Sanity content management workflow intact.

Popular cookie consent management platforms like CookieYes, Cookiebot, or OneTrust provide ready-to-use solutions that can be easily integrated into any frontend framework that displays your Sanity content. These solutions offer customizable banners, consent tracking, and compliance with major privacy regulations.

**Cookie banners operate entirely on the frontend layer, so you'll need to implement the banner in your frontend framework of choice that consumes the Sanity content, rather than within Sanity itself. **

While Sanity serves as your content management system and handles your content in the backend, cookie consent management happens in the browser where user interactions take place. This separation means you can implement any cookie consent solution alongside your Sanity-powered website without affecting your content management workflow.

## How to Add a Cookie Banner

We are going to use the Next.js frontend framework, but you can use any other modern frontend framework like React, Vue.js, or Angular to implement the cookie consent banner. The key is to follow the specific installation guidelines provided by your chosen cookie consent platform for your particular framework. The implementation process will be similar across frameworks, typically involving adding a script tag or component to your application's main template or `layout` file.

- Obtain your unique banner code that will be used in the Next.js implementation process. We will be using [Cookiebot](https://www.cookiebot.com/) but you can also use other popular solutions like CookieYes or OneTrust. 
Once you register your domain with Cookiebot, it will give you a script like this:

```javascript
<script id="usercentrics-cmp" src="https://web.cmp.usercentrics.eu/ui/loader.js" data-settings-id="YOUR-UNIQUE-ID" async></script>
```

- Replace `"YOUR-UNIQUE-ID"` with the actual **id** from Cookiebot.
- Install the cookie banner code by following the version-specific guidelines: 
- For Next.js 13 and above: Add the cookie banner script to the **Root** `layout.tsx` file within the `<head></head>` HTML tag. Make sure it is placed before any third-party script that requires user consent.

```typescript
export default async function RootLayout({children}) {
  return (
    <html lang="en">
      <head>
      <script id="usercentrics-cmp" src="https://web.cmp.usercentrics.eu/ui/loader.js" data-settings-id="0wSYESNlZz7kJj" async></script>
      </head>
      <body>
        {children}
      </body>
    </html>
  )
}
```

- Verify the installation by checking if the cookie banner appears on your website and using the verification tool provided by your cookie consent service. If verification fails, check your implementation code and console for errors. 
- Depending on the platform you're using, you can customize various aspects of your cookie banner including: appearance and layout, banner position, color schemes and branding, consent categories and descriptions, language localization, button text and behavior, privacy policy links, and cookie scanning settings.



# Integrating external data sources with Sanity

> [!NOTE]
> This developer guide was contributed by Chris LaRocque (Senior Solution Architect).

This guide will explain the 2 common patterns for integrating external data sources with Sanity. Our [plugins page](https://www.sanity.io/plugins) includes pre-built integrations for popular platforms, but if an integration doesn’t exist this guide can help walk you through how to build one of your own.

## 2 ways to integrate

There are 2 primary ways to bring external data into Sanity:

1. Creating documents for each external “item” (commonly referred to in Sanity terms as a **sync plugin**), or…
2. Saving an “item” as a field’s value on an as-needed basis (often referred to as an **input plugin**)

## Creating a document for each item (sync plugin)

### Overview

Creating a document for each item in an external system is often referred to as a **sync plugin** in Sanity terminology. The best example of a Sanity sync plugin would be [Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify), which has excellent [documentation showing custom handlers](https://www.sanity.io/docs/developer-guides/custom-sync-handlers-for-sanity-connect) that illustrate the approximate process a sync plugin uses to keep Sanity up to date with external data:

1. **A sync is triggered** - this could be from a webhook (if the external system supports them) or something less granular like a cron job
2. **Determine the data to be synced** - Most webhooks will provide exactly what changed, but some cases may require comparing updated timestamps between the external data and Sanity documents
3. **Create or update the relevant Sanity documents** - Use our [client](https://www.sanity.io/docs/js-client) or our [Actions API](https://www.sanity.io/docs/http-actions) to create or update the relevant documents

### Pros and cons

**Pros**

- Data for the front-end can all be fetched from Sanity’s API in 1 query, as opposed to one call to Sanity and a 2nd call to the external service
- Can be expanded to allow 2-way syncing, where changes in Sanity are “pushed” back to the external system (via [GROQ webhooks](https://www.sanity.io/docs/content-lake/webhooks))
- Studio users can see all data for each item

**Cons**

- More infrastructure usage required for sync process - usually a serverless function to run the sync and a cron job or webhook to trigger the syncs
- Typically a more involved development task than adding data to a field
- Depending on external system’s capabilities, changes may not be synced with Sanity immediately
- The schema for the external items used by the Sanity Studio must be kept up to date with the external data

### Example

The following is a simplified breakdown of the [code example](https://www.sanity.io/docs/developer-guides/custom-sync-handlers-for-sanity-connect) shown in the Shopify Sanity Connect docs. It shows a serverless function that receives a webhook from Shopify when products are created, updated, or deleted, and syncs those changes to documents in Sanity.

```typescript
// ./src/pages/api/sync-handler.ts
import { createClient } from "@sanity/client";

// Create a Sanity client with a write token to allow creating and updating of documents
// Read more on auth, tokens and securing them: https://www.sanity.io/docs/http-auth
const sanityClient = createClient({
  apiVersion: "2025-02-04",
  dataset: process.env.SANITY_DATASET,
  projectId: process.env.SANITY_PROJECT_ID,
  token: process.env.SANITY_ADMIN_AUTH_TOKEN,
  useCdn: false,
});

/**
 * A Next.js API route handler for the pages router
 * Takes incoming webhooks and creates/updates/deletes documents based on external system's changes
 */
export default async function handler(req, res) {
  const { body } = req;

  try {
    // Create a transaction to batch operations to Sanity
    const transaction = sanityClient.transaction();

    // Perform different operations based on the webhook action type
    switch (body.action) {
      case "create":
      case "update":
      case "sync":
        await createOrUpdateProducts(transaction, body.products);
        break;
      case "delete":
        const documentIds = body.productIds.map((id) =>
          getDocumentProductId(id)
        );
        await deleteProducts(transaction, documentIds);
        break;
    }
    await transaction.commit();
  } catch (err) {
    console.error("Transaction failed: ", err.message);
  }

  res.status(200).json({ message: "OK" });
}

/**
 * Creates (or updates if already existing) Sanity documents of type `shopify.product`.
 * Patches existing drafts too, if present.
 *
 * All products will be created with a deterministic _id in the format `product-${SHOPIFY_ID}`
 */
async function createOrUpdateProducts(transaction, products) {
  // Extract draft document IDs from current update
  const draftDocumentIds = products.map((product) => {
    const productId = extractIdFromGid(product.id);
    return `drafts.${getDocumentProductId(productId)}`;
  });

  // Determine if drafts exist for any updated products
  const existingDrafts = await sanityClient.fetch(`*[_id in $ids]._id`, {
    ids: draftDocumentIds,
  });

  products.forEach((product) => {
    // Build Sanity product document
    const document = buildProductDocument(product);
    const draftId = `drafts.${document._id}`;

    // Create (or update) existing published document
    transaction
      .createIfNotExists(document)
      .patch(document._id, (patch) => patch.set(document));

    // Check if this product has a corresponding draft and if so, update that too.
    if (existingDrafts.includes(draftId)) {
      transaction.patch(draftId, (patch) =>
        patch.set({
          ...document,
          _id: draftId,
        })
      );
    }
  });
}

/**
 * Delete corresponding Sanity documents of type `shopify.product`.
 * Published and draft documents will be deleted.
 */
async function deleteProducts(transaction, documentIds) {
  documentIds.forEach((id) => {
    transaction.delete(id).delete(`drafts.${id}`);
  });
}

/**
 * Build Sanity document from webhook product payload
 */
function buildProductDocument(product) {
  const {
    featuredImage,
    id,
    productType,
    priceRange,
    status,
    title,
    productId
  } = product;

  // Build Sanity document 
  return {
    _id: getDocumentProductId(productId),
    _type: "shopify.product",
    image: featuredImage?.src,
    priceRange,
    productType,
    status,
    title,
  };
}

/**
 * Map Shopify product ID number to a corresponding Sanity document ID string
 * e.g. 12345 => product-12345
 */
function getDocumentProductId(productId) {
  return `product-${productId}`;
}

```

Again, this is simplified to illustrate a typical workflow, check out the example in the Sanity Connect docs for a better real world example, including things like better error handling that were removed here for brevity.

## Saving as fields (input plugin)

### Overview

Integrating external data as field values is typically referred to as an **input plugin**. Input plugins will provide a custom field type that includes an input for browsing the data in the external system from the Studio, where selecting an item sets the field’s value. The data saved to the field can vary in complexity to match your use case, some plugins will save just a string for an item’s name or ID, others will copy an object with several properties, like a set of URLs for different image formats.

> [!TIP]
> [@sanity/sanity-plugin-async-list](https://www.npmjs.com/package/@sanity/sanity-plugin-async-list) provides an easy starting point for this type of implementation

### Pros and cons

**Pros**

- Less time to implement
- Overall simpler integration - less points of failure and less potential ongoing maintenance
- Allows external system to be the source of truth - can be beneficial if the external data is frequently changing

Cons

- If the data changes in the external system, the data in Sanity will not be updated automatically and will be outdated until a [content migration](https://www.sanity.io/docs/content-lake/schema-and-content-migrations) is ran. For this reason it’s best to sync fields that are considered immutable in the external system, like an `id`.

### Example

Here’s an example using [@sanity/sanity-plugin-async-list](https://www.npmjs.com/package/@sanity/sanity-plugin-async-list) to fetch the names of Disney Characters and add them as an input in a document.

```typescript
// sanity.config.ts
import {defineConfig} from 'sanity'
import {asyncList} from '@sanity/sanity-plugin-async-list'

export default defineConfig({
	// ...rest of config
  plugins: [
    asyncList({
      schemaType: 'disneyCharacter',
      loader: async () => {
        const response = await fetch('https://api.disneyapi.dev/character')
        const result: {data: {name: string}[]} = await response.json()

        return result.data.map((item) => {
          return {value: item.name, ...item}
        })
      },
    }),
   // ...rest of plugins
  ],
})
```

Add the name from schemaType to the document type where you want to use the field

```typescript
// post.ts
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'disney',
      type: 'disneyCharacter',
    }),
  ],
})
```

Then see the field in your Studio

*The field in your Sanity document fetching remote data*



# Klaviyo (email campaigns)

This guide explains how two Sanity Functions working together create and send marketing campaigns through [Klaviyo](https://help.klaviyo.com/hc/en-us/articles/115005054847), integrated with [Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify) setup. [This is the setup we use for the Sanity Swag store](https://www.sanity.io/blog/studio-to-inbox).

> [!TIP]
> E-Commerce Not Required
> We're using the Shopify Connect app here because our use case is sending out email campaigns with products. You could use this to suit any of your needs outside of commerce.

With this guide you will:

- Build a flow using Klaviyo Campaigns directly in the Sanity Studio.
- Update Klaviyo HTML templates directly without opening Klaviyo.
- Send Campaigns to your Klaviyo customers directly from the Sanity Studio with your own editorial workflows.

## Prerequisites 

- An existing or [new Klaviyo account](https://www.klaviyo.com/sign-up)
- An existing or [new Sanity project with a studio ](https://www.sanity.io/docs/getting-started)
- Familiarity with Sanity Functions

[Create a Document Function](https://www.sanity.io/docs/functions/function-quickstart)
Start building with Functions by deploying a new function to Sanity's infrastructure.

[Official Function recipes](https://www.sanity.io/exchange/type=schemas/by=sanity)
Function recipes from the Sanity team

## Overview

The marketing campaign system consists of two main [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction) that work in tandem:

1. `marketing-campaign-create`: Creates and updates marketing campaigns and email templates
2.  `marketing-campaign-send`: Sends campaigns to subscribers

These functions automatically process content changes and integrate with [Klaviyo's API ](https://developers.klaviyo.com/en/reference/api_overview)for email marketing automation.

## How to set up Klaviyo

Before using these functions, you need to set up your Klaviyo account:

1. **Create a Klaviyo Account:** Sign up at [klaviyo.com](https://www.klaviyo.com) and complete account verification
2. **Create a List**1. Navigate to Audience → Lists & Segments in your Klaviyo dashboard
2. Create a new list (e.g., "Newsletter Subscribers")
3. Take note the List ID from the URL or in list settings (you'll need this later)


3. **Generate an API Key: **Go to Account → Settings → API Keys- Create a new Private API key with the following scopes:1. `campaigns:read`
2. `campaigns:write`
3. `templates:read`
4. `templates:write`


4. **Copy the API key** for environment configuration

### Sanity Connect for Shopify (optional)

These functions work with content synced from Shopify via [Sanity Connect for Shopify](https://www.sanity.io/docs/apis-and-sdks/sanity-connect-for-shopify). The system expects:

- Products synced from Shopify as `shopify.product` documents
- Emails created in Sanity that reference these products
- Marketing campaigns that can be created from email content

## Implementation

### Extend your Sanity Studio

We'll be creating 2 new content types for our studio, `post` and `marketingCampaign`. The `post` content type resembles something like a typical post and you could easily repurpose existing content types to suit your needs. Our two functions below use these two content types and could be tweaked as needed.

**documents/post.ts**

```
import {defineField, defineType} from 'sanity'
import {BasketIcon} from '@sanity/icons/Basket'
import {ImageIcon} from '@sanity/icons/Image'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'body',
      title: 'Body',
      type: 'array',
      of: [
        {
          type: 'block',
          styles: [
            {title: 'Normal', value: 'normal'},
            {title: 'Heading 1', value: 'h1'},
            {title: 'Heading 2', value: 'h2'},
            {title: 'Heading 3', value: 'h3'},
            {title: 'Quote', value: 'blockquote'},
          ],
          marks: {
            decorators: [
              {title: 'Strong', value: 'strong'},
              {title: 'Emphasis', value: 'em'},
              {title: 'Underline', value: 'underline'},
            ],
          },
        },
        {
          name: 'products',
          type: 'object',
          title: 'Products',
          icon: BasketIcon,
          fields: [
            {name: 'products', type: 'array', of: [{type: 'reference', to: [{type: 'product'}]}]},
          ],
          preview: {
            select: {
              products: 'products',
            },
            prepare(selection: any) {
              const {products} = selection
              return {
                title: 'Products',
                subtitle: `${products.length} products`,
              }
            },
          },
        },
        {
          type: 'image',
          icon: ImageIcon,
          fields: [
            {
              name: 'alt',
              type: 'string',
              title: 'Alternative text',
              description: 'Important for SEO and accessibility.',
            },
          ],
        },
      ],
    }),
    defineField({
      name: 'status',
      title: 'Status',
      type: 'string',
      options: {
        list: [
          {title: 'In Progress', value: 'inprogress'},
          {title: 'Ready for Review', value: 'ready-for-review'},
          {title: 'Ready', value: 'ready'},
          {title: 'Sent', value: 'sent'},
        ],
      },
      validation: (Rule: any) => Rule.required(),
      initialValue: 'inprogress',
    }),
    defineField({
      name: 'marketingCampaign',
      title: 'Marketing Campaign',
      type: 'reference',
      to: [{type: 'marketingCampaign'}],
      weak: true,
    }),
    defineField({
      name: 'klaviyoListId',
      title: 'Klaviyo List ID',
      type: 'string',
      description: 'Optional: Override the default Klaviyo list ID for this post',
    }),
  ],
  preview: {
    select: {
      title: 'title',
      status: 'status',
      media: 'body.0.asset',
    },
    prepare(selection: any) {
      const {title, status, media} = selection
      return {
        title: title || 'Untitled Post',
        subtitle: status ? `Status: ${status}` : 'No status',
        media: media,
      }
    },
  },
})

```

**documents/marketingCampaign.ts**

```
import {defineField, defineType} from 'sanity'

export const marketingCampaignType = defineType({
  name: 'marketingCampaign',
  title: 'Marketing Campaign',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'post',
      title: 'Post Content',
      type: 'reference',
      to: [{type: 'post'}],
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'status',
      title: 'Status',
      type: 'string',
      options: {
        list: [
          {title: 'Draft', value: 'draft'},
          {title: 'Ready (will trigger Klaviyo Send)', value: 'ready'},
          {title: 'Sent', value: 'sent'},
        ],
      },
      validation: (Rule: any) => Rule.required(),
      initialValue: 'draft',
    }),
    defineField({
      name: 'klaviyoTemplateId',
      title: 'Klaviyo Template ID',
      type: 'string',
      description: 'The template ID from Klaviyo',
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'klaviyoCampaignId',
      title: 'Klaviyo Campaign ID',
      type: 'string',
      description: 'The campaign ID from Klaviyo',
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'updatedAt',
      title: 'Last Updated',
      type: 'datetime',
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'createdAt',
      title: 'Created At',
      type: 'datetime',
      validation: (Rule: any) => Rule.required(),
    }),
    defineField({
      name: 'description',
      title: 'Description',
      type: 'string',
      description: 'A description of this marketing campaign',
    }),
  ],
})

```

### Blueprints configuration

We're assuming you've gong through the setup above to create a blueprint file, we're using configuration code below but reconfigure as needed; the only quirk here is making sure you are setup for env variables with `dotenv` and we have to pass them into our functions with the `env:`key below.

You should also scaffold the function so that each are created running the following command:

**npm**

```shell
npx sanity blueprints add function
```

**pnpm**

```shell
pnpm dlx sanity blueprints add function
```

**yarn**

```shell
yarn dlx sanity blueprints add function
```

**bun**

```shell
bunx sanity blueprints add function
```

**sanity.blueprint.ts**

```

import 'dotenv/config'
import process from 'node:process'
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

const {KLAVIYO_API_KEY, KLAVIYO_LIST_ID} = process.env
if (typeof KLAVIYO_API_KEY !== 'string') {
  throw new Error('KLAVIYO_API_KEY must be set')
}
if (typeof KLAVIYO_LIST_ID !== 'string') {
  throw new Error('KLAVIYO_LIST_ID must be set')
}

export default defineBlueprint({
  "resources": [
    // .. Other Functions
    defineDocumentFunction({
      name: 'marketing-campaign-create',
      src: 'functions/marketing-campaign-create',
      event: {
        on: ['create', 'update'],
        filter: '_type == "post" && status != "sent"',
        projection: '{_id, _type, title, slug, body, marketingCampaign, klaviyoListId, "operation": delta::operation()}',
      },
      env: {
        KLAVIYO_API_KEY,
        KLAVIYO_LIST_ID,
      }
    }),
    defineDocumentFunction({
      name: 'marketing-campaign-send',
      src: 'functions/marketing-campaign-send',
      event: {
        on: ['publish'],
        filter: '_type == "marketingCampaign" && status == "ready"',
        projection: '{_id, _type, title, post, klaviyoCampaignId}',
      },
      env: {
        KLAVIYO_API_KEY,
        KLAVIYO_LIST_ID,
      }
    }),
  ]
})

```

Navigate to the root of the `create` function and use your prefered package manager to install:

**npm**

```shell
npm install @sanity/client @portabletext/to-html
```

**pnpm**

```shell
pnpm add @sanity/client @portabletext/to-html
```

**yarn**

```shell
yarn add @sanity/client @portabletext/to-html
```

**bun**

```shell
bun add @sanity/client @portabletext/to-html
```

And likewise navigate to the `send` function an ensure the `@sanity/client` is installed.

### Set up the marketing campaign create function

**File**: `functions/marketing-campaign-create/index.ts` 
**Trigger**: Document changes on `post` documents
**Purpose**: Automatically creates and updates Klaviyo campaigns and email templates when posts are created or modified.

#### Key features

- **Automatic Template Generation**: Converts Sanity Portable Text content into an HTML email templates
- **Product Integration**: Renders Shopify products within email templates
- **Campaign Management**: Creates Klaviyo campaigns with proper audience targeting
- **Status Tracking**: Updates post status throughout the process
- **Error Handling**: Comprehensive error handling with console logs

#### Process flow

**Document Event Trigger:** Listens for changes to `post` documents- Determines operation type (create/update) based on document state

**Template Generation: **

1. Fetches post content including Portable Text body
2. Converts content to HTML using `@portabletext/to-html` 
3. Renders Shopify products with pricing and images, generates both HTML and text versions

**Klaviyo Integration:**

- Creates email template in Klaviyo, creates marketing campaign with audience targeting, links template to campaign message, and handles template updates for existing campaigns

**Sanity Document Management**

- Creates `marketingCampaign` document
- Links post to marketing campaign
- Updates email status to `ready-for-review`

#### Environment variables required

Find the following information for your Klaviyo account and email list, and paste it into the environment file:

**.env**

```
KLAVIYO_API_KEY=your_klaviyo_api_key
KLAVIYO_LIST_ID=your_klaviyo_list_id
KLAVIYO_FROM_EMAIL=noreply@yourdomain.com
KLAVIYO_REPLY_TO_EMAIL=reply-to@yourdomain.com
KLAVIYO_CC_EMAIL=cc@yourdomain.com
KLAVIYO_BCC_EMAIL=bcc@yourdomain.com
```

#### Add code to the create campaign function file

Replace the boilerplate code in the `index.ts` function file that you scaffolded with the following code:

**marketing-campaign-create/index.ts**

```
import { documentEventHandler, type DocumentEvent } from '@sanity/functions'
import { createClient } from '@sanity/client'
import { toHTML } from '@portabletext/to-html'

interface PostDocument {
  _id: string;
  _type: string;
  title?: string;
  slug?: {
    current: string;
  };
  body?: any[];
  marketingCampaign?: {
    _ref: string;
  };
  klaviyoListId?: string;
  operation?: string;
}

// Note: DocumentEvent from @sanity/functions doesn't include operation property
// We'll need to determine the operation from the event data or use a different approach

interface KlaviyoCampaignResponse {
  data: {
    id: string;
    type: string;
    attributes: {
      name: string;
      status: string;
    };
    relationships: {
      'campaign-messages': {
        data: Array<{
          id: string;
          type: string;
        }>;
      };
    };
  };
}

interface KlaviyoTemplateResponse {
  data: {
    id: string;
    type: string;
    attributes: {
      name: string;
      html: string;
      text: string;
    };
  };
}

export const handler = documentEventHandler(async ({ context, event}: { context: any, event: DocumentEvent<PostDocument> }) => {
  console.log('👋 Marketing Campaign Function called at', new Date().toISOString())
  console.log('👋 Event:', event)

  try {
    const { _id, _type, title, slug, klaviyoListId, operation } = event.data as PostDocument
    
    // Determine operation based on whether marketingCampaign already exists
    console.log('👋 Determined operation:', operation)
    
    // Get Klaviyo API credentials from environment
    const klaviyoApiKey = process.env.KLAVIYO_API_KEY
    const localKlaviyoListId = klaviyoListId || process.env.KLAVIYO_LIST_ID

    if (!klaviyoApiKey) {
      console.error('❌ KLAVIYO_API_KEY not found in environment variables')
      return
    }

    if (!localKlaviyoListId) {
      console.error('❌ KLAVIYO_LIST_ID not found in environment variables')
      return
    }

    if (_type !== 'post') {
      console.log('⏭️ Skipping non-post document:', _type)
      return
    }
      
    const client = createClient({
      ...context.clientOptions,
      dataset: 'production',
      apiVersion: '2025-06-01',
    })

    // Handle different operations based on delta
    if (operation === 'create') {
      console.log('🆕 CREATE operation: Creating new marketing campaign and template')
      await handleCreateOperation(client, _id, title, slug, localKlaviyoListId, klaviyoApiKey)
    } else if (operation === 'update') {
      console.log('🔄 UPDATE operation: Updating existing template only')
      await handleUpdateOperation(client, _id, title, slug, klaviyoApiKey)
    } else {
      console.log('⏭️ Skipping operation:', operation)
      return
    }


  } catch (error) {
    console.error('❌ Error processing post for marketing campaign:', error)
    
    throw error
  }
})

// Handler for CREATE operation - creates new campaign and template
async function handleCreateOperation(
  client: any, 
  postId: string, 
  title: string | undefined, 
  slug: { current: string } | undefined, 
  klaviyoListId: string, 
  klaviyoApiKey: string
) {
  console.log('🆕 CREATE: Creating new marketing campaign and template for post:', postId)
  
  if (!title || title.trim().length === 0) {
    console.error('❌ Post title is required for template creation')
    return
  }

  try {
    // Fetch nested data in the body for html rendering
    const {body: bodyData} = await client.fetch(portableTextBodyQuery(postId))

    console.log('📋 Body data:', bodyData)
    
    // Generate email templates
    const htmlContent = await generateEmailTemplate(title, slug?.current, bodyData)
    const textContent = generateTextContent(title, slug?.current)
    
    // Create Klaviyo template
    console.log('🎨 Creating Klaviyo template for post:', title)
    const templateData = {
      data: {
        type: 'template',
        attributes: {
          name: `${title} - Template`,
          editor_type: 'CODE',
          html: htmlContent,
          text: textContent
        }
      }
    }

    const templateResponse = await fetch('https://a.klaviyo.com/api/templates', {
      method: 'POST',
      headers: {
        'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
        'Content-Type': 'application/json',
        'accept': 'application/vnd.api+json',
        'revision': '2025-07-15'
      },
      body: JSON.stringify(templateData)
    })

    if (!templateResponse.ok) {
      const errorText = await templateResponse.text()
      console.error('❌ Failed to create Klaviyo template:', templateResponse.status, errorText)
      return
    }

    const template: KlaviyoTemplateResponse = await templateResponse.json()
    console.log('✅ Created Klaviyo template:', template.data.id, 'Name:', template.data.attributes.name)

    // Create Klaviyo campaign
    console.log('📢 Creating Klaviyo campaign for post:', title)
    const campaignData = {
      data: {
        type: 'campaign',
        attributes: {
          name: `${title} - Campaign`,
          audiences: {
            "included": [klaviyoListId]
          },
          "send_strategy": {
            "method": "immediate"
          },
          "send_options": {
            "use_smart_sending": true
          },
          "tracking_options": {
            "add_tracking_params": true,
            "custom_tracking_params": [
              {
                "type": "dynamic",
                "value": "campaign_id", 
                "name": "utm_medium"
              },
              {
                "type": "static",
                "value": "email",
                "name": "utm_source"
              }
            ],
            "is_tracking_clicks": true,
            "is_tracking_opens": true
          },
          "campaign-messages": {
            "data": [
              {
                "type": "campaign-message",
                "attributes": {
                  "definition": {
                    "channel": "email",
                    "label": "My message name",
                    "content": {
                      "subject": title,
                      "preview_text": "My preview text",
                      "from_email": process.env.KLAVIYO_FROM_EMAIL || 'noreply@yourdomain.com',
                      "from_label": "My Company",
                      "reply_to_email": process.env.KLAVIYO_REPLY_TO_EMAIL || 'reply-to@yourdomain.com',
                      "cc_email": process.env.KLAVIYO_CC_EMAIL || 'cc@yourdomain.com',
                      "bcc_email": process.env.KLAVIYO_BCC_EMAIL || 'bcc@yourdomain.com'
                    }
                  }
                }
              }
            ]
          }
        }
      }
    }

    const campaignResponse = await fetch('https://a.klaviyo.com/api/campaigns', {
      method: 'POST',
      headers: {
        'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
        'Content-Type': 'application/json',
        'accept': 'application/vnd.api+json',
        'revision': '2025-07-15'
      },
      body: JSON.stringify(campaignData)
    })

    if (!campaignResponse.ok) {
      const errorText = await campaignResponse.text()
      console.error('❌ Failed to create Klaviyo campaign:', campaignResponse.status, errorText)
      return
    }

    const campaign: KlaviyoCampaignResponse = await campaignResponse.json()
    console.log('✅ Created Klaviyo campaign:', campaign.data.id, 'Name:', campaign.data.attributes.name)

    // Assign template to campaign message
    console.log('📎 Assigning template to campaign message...')
    await new Promise(resolve => setTimeout(resolve, 2000));

    const campaignMessageId = campaign.data.relationships['campaign-messages'].data[0].id
    
    const assignTemplateResponse = await fetch(`https://a.klaviyo.com/api/campaign-message-assign-template`, {
      method: 'POST',
      headers: {
        'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
        'Content-Type': 'application/json',
        'accept': 'application/vnd.api+json',
        'revision': '2025-07-15'
      },
      body: JSON.stringify({
        data: {
          type: "campaign-message",
          id: campaignMessageId,
          "relationships": {
            "template": {
              "data": {
                "type": "template",
                "id": template.data.id
              }
            }
          }
        }
      })
    })

    if (!assignTemplateResponse.ok) {
      const errorText = await assignTemplateResponse.text()
      console.error('❌ Failed to assign template to campaign:', assignTemplateResponse.status, errorText)
      throw new Error(`Failed to assign template: ${errorText}`)
    }

    console.log('✅ Template assigned successfully to campaign message')

    // Create marketingCampaign document in Sanity
    console.log('💾 Creating marketingCampaign document in Sanity')
    const marketingCampaignId = `marketingCampaign-${postId}`
    
    const newMarketingCampaign = await client.create({
      _id: marketingCampaignId,
      _type: 'marketingCampaign',
      title: `${title} - Marketing Campaign`,
      klaviyoCampaignId: campaign.data.id,
      klaviyoTemplateId: template.data.id,
      status: 'draft',
      post: { _ref: postId, _type: 'reference' },
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      description: `Marketing campaign for post: ${title}`
    })

    console.log('✅ Created marketingCampaign document:', newMarketingCampaign._id)

    // Update the post with the marketingCampaign reference
    console.log('🔄 Updating post with marketingCampaign reference')
    await client.patch(postId, {
      set: {
        marketingCampaign: { _ref: newMarketingCampaign._id, _type: 'reference' },
        status: 'ready-for-review'
      }
    }).commit()

    console.log('✅ Post updated successfully with marketingCampaign reference')

    console.log('✅ CREATE operation completed:', {
      postId: postId,
      marketingCampaignId: newMarketingCampaign._id,
      klaviyoCampaignId: campaign.data.id,
      klaviyoTemplateId: template.data.id
    })

  } catch (error) {
    console.error('❌ Error in CREATE operation:', error)  
    
    throw error
  }
}

// Handler for UPDATE operation - updates template only
async function handleUpdateOperation(
  client: any, 
  postId: string, 
  title: string | undefined, 
  slug: { current: string } | undefined, 
  klaviyoApiKey: string
) {
  console.log('🔄 UPDATE: Updating template for existing marketing campaign')
  
  try {
    // Get the marketing campaign document to find the template ID
    const marketingCampaignQuery = `*[_type == "marketingCampaign" && post._ref == "${postId}"][0]`
    const marketingCampaignDoc = await client.fetch(marketingCampaignQuery)
    
    if (!marketingCampaignDoc) {
      console.log('ℹ️ No marketing campaign found for post, skipping update')
      return
    }

    const templateId = marketingCampaignDoc.klaviyoTemplateId
    if (!templateId) {
      console.error('❌ No template ID found in marketing campaign document')
      return
    }

    console.log('📋 Found template ID:', templateId, 'for post:', postId)

    // Fetch the latest body data for template update
    const {body: bodyData} = await client.fetch(portableTextBodyQuery(postId))

    // Generate updated email templates
    const htmlContent = await generateEmailTemplate(title, slug?.current, bodyData)
    const textContent = generateTextContent(title, slug?.current)

    // Update the Klaviyo template
    console.log('🔄 Updating Klaviyo template:', templateId)
    const updatedTemplateData = {
      data: {
        type: 'template',
        id: templateId,
        attributes: {
          html: htmlContent,
          text: textContent
        }
      }
    }

    const updatedTemplateResponse = await fetch(`https://a.klaviyo.com/api/templates/${templateId}`, {
      method: 'PATCH',
      headers: {
        'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
        'Content-Type': 'application/json',
        'accept': 'application/vnd.api+json',
        'revision': '2025-07-15'
      },
      body: JSON.stringify(updatedTemplateData)
    })

    if (!updatedTemplateResponse.ok) {
      console.error('❌ Failed to update Klaviyo template:', updatedTemplateResponse.status, updatedTemplateResponse.statusText)
      return
    }

    console.log('✅ Updated Klaviyo template:', templateId)

    // Reassign the updated template to the campaign to refresh the cache
    const klaviyoCampaignId = marketingCampaignDoc.klaviyoCampaignId
    if (klaviyoCampaignId) {
      console.log('🔄 Reassigning updated template to campaign:', klaviyoCampaignId)
      
      // Get the campaign message ID from the campaign
      const campaignResponse = await fetch(`https://a.klaviyo.com/api/campaigns/${klaviyoCampaignId}`, {
        method: 'GET',
        headers: {
          'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
          'accept': 'application/vnd.api+json',
          'revision': '2025-07-15'
        }
      })

      if (campaignResponse.ok) {
        const campaignData = await campaignResponse.json()
        const campaignMessageId = campaignData.data.relationships?.['campaign-messages']?.data?.[0]?.id

        if (campaignMessageId) {
          // Reassign the template to the campaign message
          const assignTemplateResponse = await fetch(`https://a.klaviyo.com/api/campaign-message-assign-template`, {
            method: 'POST',
            headers: {
              'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
              'Content-Type': 'application/json',
              'accept': 'application/vnd.api+json',
              'revision': '2025-07-15'
            },
            body: JSON.stringify({
              data: {
                type: "campaign-message",
                id: campaignMessageId,
                "relationships": {
                  "template": {
                    "data": {
                      "type": "template",
                      "id": templateId
                    }
                  }
                }
              }
            })
          })

          if (assignTemplateResponse.ok) {
            console.log('✅ Successfully reassigned updated template to campaign')
          } else {
            const errorText = await assignTemplateResponse.text()
            console.error('❌ Failed to reassign template to campaign:', assignTemplateResponse.status, errorText)
          }
        } else {
          console.error('❌ No campaign message ID found in campaign data')
        }
      } else {
        console.error('❌ Failed to fetch campaign data for template reassignment')
      }
    } else {
      console.log('ℹ️ No Klaviyo campaign ID found, skipping template reassignment')
    }

    // Update the marketing campaign document's updatedAt timestamp
    await client.patch(marketingCampaignDoc._id, {
      set: {
        updatedAt: new Date().toISOString()
      }
    }).commit()

    console.log('✅ UPDATE operation completed for post:', postId)

  } catch (error) {
    console.error('❌ Error in UPDATE operation:', error)

    throw error
  }
}

// Helper function to generate email template HTML
async function generateEmailTemplate(title: string | undefined, slug: string | undefined, body: any[] | undefined): Promise<string> {
  const postUrl = slug ? `https://yourdomain.com/posts/${slug}` : '#'
  
  return `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>${title || 'New Post'}</title>
    <style>
body,table,td,p,a,li,blockquote{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}table,td{mso-table-lspace:0pt;mso-table-rspace:0pt}img{-ms-interpolation-mode:bicubic;border:0;height:auto;line-height:100%;outline:none;text-decoration:none}body{margin:0;padding:0;background-color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6}.email-container{max-width:600px;margin:0 auto;background-color:#fff}.header{text-align:center;padding:48px 24px}.logo{font-size:24px;font-weight:700;color:#d97706;letter-spacing:2px;margin-bottom:4px}.logo-subtitle{font-size:12px;color:#6b7280;letter-spacing:3px;margin-bottom:32px}.main-headline{font-size:32px;font-weight:300;color:#111827;margin-bottom:16px;line-height:1.2}.main-description{font-size:16px;color:#6b7280;line-height:1.6;max-width:400px;margin:0 auto}.product-section{padding:0 24px}.product-card{margin-bottom:32px;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)}.product-image{width:100%;height:320px;object-fit:contain;display:block}.product-badge{position:absolute;top:16px;left:16px;background-color:#ec4899;color:#fff;padding:4px 12px;font-size:12px;font-weight:500;border-radius:20px}.product-info{padding:24px;background-color:#fff}.product-name{font-size:20px;font-weight:500;color:#111827;margin-bottom:8px}.product-pricing{margin-bottom:16px}.product-price{font-size:20px;font-weight:300;color:#d97706;margin-right:12px}.product-original-price{font-size:16px;color:#6b7280;text-decoration:line-through}.btn{display:inline-block;padding:12px 24px;text-decoration:none;border-radius:6px;font-weight:500;text-align:center;width:100%;box-sizing:border-box}.btn-primary{background-color:#d97706;color:#fff}.btn-outline{background-color:transparent;color:#d97706;border:2px solid #d97706}.btn-secondary{background-color:#ec4899;color:#fff}.collection-cta{padding:48px 24px}.collection-card{background-color:#f9fafb;padding:32px;border-radius:8px;text-align:center}.collection-title{font-size:24px;font-weight:300;color:#111827;margin-bottom:12px}.collection-description{color:#6b7280;margin-bottom:24px;line-height:1.6}.experience-cta{padding:0 24px 48px;text-align:center}.experience-title{font-size:24px;font-weight:300;color:#111827;margin-bottom:12px}.experience-description{color:#6b7280;margin-bottom:24px;line-height:1.6;max-width:400px;margin:0 auto}.footer{padding:32px 24px;border-top:1px solid #e5e7eb;text-align:center}.footer-links{margin-bottom:16px}.footer-link{color:#6b7280;text-decoration:none;margin:0 12px}.footer-text{font-size:12px;color:#6b7280;margin-bottom:8px}.footer-link:hover{color:#ec4899}@media only screen and (max-width:600px){.main-headline{font-size:28px}.product-section{padding:0 16px}.collection-cta,.experience-cta{padding-left:16px;padding-right:16px}}
    </style>
</head>
<body>
    <div class="email-container">
        <!-- Header -->
        <div class="header">
            <div class="logo">SANITY</div>
            <div class="logo-subtitle">Squiggle Mart</div>
            
            ${toHTML(body || [], {
              components: {
                types: {
                  image: ({value}) => {
                    return `<img src="${value.asset.url}" alt="${value.alt || ''}" style="max-width: 100%; height: auto; margin: 20px 0;" />`
                  },
                  products: ({value}) => {
                          console.log('Products block value:', value)
                          if (!value?.products || !Array.isArray(value.products)) return ''
                          console.log('Products:', value.products)
                          return `
                             <div class="product-section">
                               ${value.products
                                 .map(
                                   (product: any) => `
                                     <div class="product-card">
                                       <div style="position: relative;">
                                         <img src="${product.store.previewImageUrl || ''}" alt="${product.title || 'Product'}" class="product-image">
                                         ${product.badge ? `<div class="product-badge">${product.badge}</div>` : ''}
                                       </div>
                                       <div class="product-info">
                                         <h3 class="product-name">${product.store.title || 'Untitled Product'}</h3>
                                         <div class="product-pricing">
                                           <span class="">$${product.store?.priceRange?.minVariantPrice}</span>
                                         </div>
                                         <a href="https://yoursite.com/products/${product.slug || '#'}" class="btn btn-primary">Shop Now</a>
                                       </div>
                                     </div>
                                   `,
                                 )
                                 .join('')}
                             </div>`
                        },
                      },
                marks: {
                  strong: ({children}) => `<strong>${children}</strong>`,
                  em: ({children}) => `<em>${children}</em>`,
                  underline: ({children}) => `<u>${children}</u>`
                },
                block: {
                  h1: ({children}) => `<h1 style="font-size: 24px; margin: 24px 0;">${children}</h1>`,
                  h2: ({children}) => `<h2 style="font-size: 20px; margin: 20px 0;">${children}</h2>`, 
                  h3: ({children}) => `<h3 style="font-size: 18px; margin: 18px 0;">${children}</h3>`,
                  normal: ({children}) => `<p style="font-size: 16px; line-height: 1.6; margin: 16px 0;">${children}</p>`,
                  blockquote: ({children}) => `<blockquote style="font-style: italic; margin: 20px 0; padding-left: 20px; border-left: 4px solid #ccc;">${children}</blockquote>`
                },

              }
            })}
        </div>
        <!-- Collection CTA -->
        <div class="collection-cta">
            <div class="collection-card">
                <h3 class="collection-title">Explore the Complete Collection'</h3>
                <p class="collection-description">
                    Show your love for Squiggle Mart with this limited edition collection.
                </p>
                <a href="https://squigglemart.com/collections/all" class="btn btn-outline">View All Items</a>
            </div>
        </div>

        <!-- Footer -->
        <div class="footer">
            <div class="footer-links">
                <a href="https://www.instagram.com/squigglemart" class="footer-link">Instagram</a>
                <a href="https://www.pinterest.com/squigglemart" class="footer-link">Pinterest</a>
                <a href="https://www.facebook.com/squigglemart" class="footer-link">Facebook</a>
            </div>
            <p class="footer-text">© ${new Date().getFullYear()} Sanity. All rights reserved.</p>
            <p class="footer-text">
                You're receiving this because you subscribed to our newsletter. 
                <a href="https://yoursite.com/unsubscribe" class="footer-link">Unsubscribe</a>
            </p>
        </div>
    </div>
</body>
  `
}

// Helper function to generate text content
function generateTextContent(title: string | undefined, slug: string | undefined): string {
  const postUrl = slug ? `https://yoursite.com/posts/${slug}` : '#'
  
  return `
${title || 'New Post'}

We've just published a new post that we think you'll find interesting.

Read more at: ${postUrl}

Best regards,
Your Team
  `.trim()
}

const portableTextBodyQuery = (postId: string) => `
*[_id == "${postId}"][0]{
      body[]{
        _type,
        _key,
        // Handle image blocks
        _type == "image" => {
          asset->{
            url,
            metadata
          },
          alt
        },
        // Handle product blocks
        _type == "products" => {
          _type,
          products[]->{
            _type,
            ...,
            store
          }
        },
        // Handle text blocks
        _type == "block" => {
          ...,
          children[]{
            ...,
            // Resolve any marks that might have references
            _type == "span" => {
              ...,
              markDefs[]{
                ...,
                _type == "link" => {
                  ...,
                  internalLink->{
                    _id,
                    _type,
                    title,
                    slug
                  }
                }
              }
            }
          }
        }
      }
    }
`
```

### Set up the campaign send function

**File**: `functions/marketing-campaign-send/index.ts` 
**Trigger**: Document changes on `marketingCampaign` documents specifically toggling the `status` to `ready to send`
**Purpose**: Sends approved marketing campaigns to subscribers via Klaviyo.

#### Key Features

- **Campaign Validation**: Ensures campaign is ready for sending
- **Status Management**: Updates campaign and email status after sending
- **Error Handling**: Handles Klaviyo API errors gracefully
- **Rate Limiting**: Respects Klaviyo's API rate limits

#### Process Flow

**Document Event Trigger**

- Listens for changes to `marketingCampaign`  documents
- Validates that campaign has the required Klaviyo campaign ID

**Campaign Sending**

- Calls Klaviyo's send job API
- Handles various error scenarios (rate limits, permissions, etc.)
- Updates campaign status to `sent` 

**Status Updates **

- Updates marketing campaign document with send timestamp
- Updates post status to `sent`
- Creates success/error notifications

#### Add environment variables

Find the API key for your Klaviyo account and email list, and paste it into the environment file:

**.env**

```
KLAVIYO_API_KEY=your_klaviyo_api_key
```

#### Add code to the send campaign function file

**marketing-campaign-send/index.ts**

```
import { documentEventHandler, type DocumentEvent } from '@sanity/functions'
import { createClient } from '@sanity/client'

interface MarketingCampaignDocument {
  _id: string;
  _type: string;
  klaviyoCampaignId?: string;
  post?: {
    _ref: string;
  };
  status?: string;
}

interface KlaviyoSendJobResponse {
  data: {
    id: string;
    type: string;
    attributes: {
      status: string;
    };
  };
}

export const handler = documentEventHandler(async ({ context, event}: { context: any, event: DocumentEvent<MarketingCampaignDocument> }) => {
  console.log('🚀 Marketing Campaign Send Function called at', new Date().toISOString())
  console.log('🚀 Event:', event)

  try {
    const { _id, _type, klaviyoCampaignId, post } = event.data as MarketingCampaignDocument
    
    // Get Klaviyo API credentials from environment
    const klaviyoApiKey = process.env.KLAVIYO_API_KEY

    if (!klaviyoApiKey) {
      console.error('❌ KLAVIYO_API_KEY not found in environment variables')
      return
    }

    if (_type !== 'marketingCampaign') {
      console.log('⏭️ Skipping non-marketingCampaign document:', _type)
      return
    }

    // Check if marketing campaign has a post reference
    if (!post?._ref) {
      console.log('⏭️ Marketing campaign does not have a post reference - skipping')
      return
    }

    const client = createClient({
      ...context.clientOptions,
      dataset: 'production',
      apiVersion: '2025-06-01',
    })

    // Get the post document from the marketing campaign reference
    const postId = post._ref
    const postDocument = await client.getDocument(postId)

    if (!postDocument) {
      console.error('❌ Email document not found:', postId)
      return
    }

    if (!klaviyoCampaignId) {
      console.error('❌ Klaviyo campaign ID not found in marketing campaign document')
      return
    }

    console.log('📢 Sending Klaviyo campaign:', klaviyoCampaignId)

    try {
      // Send the campaign using Klaviyo's send endpoint
      const sendCampaignResponse = await fetch(`https://a.klaviyo.com/api/campaign-send-jobs`, {
        method: 'POST',
        headers: {
          'Authorization': `Klaviyo-API-Key ${klaviyoApiKey}`,
          'Content-Type': 'application/json',
          'accept': 'application/vnd.api+json',
          'revision': '2025-07-15'
        },
        body: JSON.stringify({
          data: {
            type: 'campaign-send-job',
            id: klaviyoCampaignId
          }
        })
      })

      if (!sendCampaignResponse.ok) {
        const errorText = await sendCampaignResponse.text()
        console.error('❌ Failed to send Klaviyo campaign:', sendCampaignResponse.status, errorText)
        
        // Handle specific error cases
        if (sendCampaignResponse.status === 429) {
          console.error('❌ Rate limit exceeded. Klaviyo allows 10/s burst, 150/m steady')
        } else if (sendCampaignResponse.status === 400) {
          console.error('❌ Bad request. Check campaign data format')
        } else if (sendCampaignResponse.status === 403) {
          console.error('❌ Forbidden. Check API key permissions (campaigns:write scope required)')
        } else if (sendCampaignResponse.status === 422) {
          console.error('❌ Unprocessable entity. Campaign may not be ready to send')
        }
        return
      }

      const sendJobResponse: KlaviyoSendJobResponse = await sendCampaignResponse.json()
      console.log('✅ Campaign send job created successfully:', sendJobResponse.data.id)

      // Update the marketing campaign document status to 'sent'
      console.log('🔄 Updating marketing campaign status to sent')
      await client.patch(_id, {
        set: {
          status: 'sent',
          sentAt: new Date().toISOString(),
          updatedAt: new Date().toISOString()
        }
      }).commit()

      console.log('✅ Marketing campaign status updated to sent')

      // Update the email status to 'sent' (this should not trigger further updates)
      console.log('🔄 Updating post status to sent')
      await client.patch(postId, {
        set: {
          status: 'sent'
        }
      }).commit()

      console.log('✅ Post status updated to sent')

      console.log('✅ Campaign send completed successfully:', {
        postId: postId,
        marketingCampaignId: _id,
        klaviyoCampaignId: klaviyoCampaignId,
        sendJobId: sendJobResponse.data.id
      })

    } catch (error) {
      console.error('❌ Error sending Klaviyo campaign:', error)
      
      throw error
    }

  } catch (error) {
    console.error('❌ Error processing campaign send:', error)

    
    throw error
  }
})
```

### Test and deploy the functions

You should test if the functions run locally, and deploy them to production when you have validated that everything is correctly set up.

## Usage guide

Once the functions are deployed, you test out the flow in the Studio. 

### Creating a Marketing Campaign

1. **Create an Post in Sanity Studio** - build an initial post/email in the Sanity studio, include copy/products/etc
2. **Function Automatically Triggers** 
- Creates Klaviyo template with rendered content
- Creates Klaviyo campaign with audience targeting
- Links post to marketing campaign
- Updates post status to `ready-for-review`

### Sending a Campaign

1. **Update Marketing Campaign Status** - When you're ready to send the campaign, go into the campaign that's ready and change the status to ready-to-send
2. **Function Automatically Triggers** 
- Sends campaign via Klaviyo API
- Updates campaign status to `sent`
- Updates post status to `sent`
- Creates success notification

> [!NOTE]
> Why 2 Different Functions?
> Given the complexity of the workflow, it makes more sense to separate these functions so they're easy to troubleshoot and extend with your use cases. Putting all these switch statements into 1 giant function would just increase the technical debt and complexity so we split them up! 

## Troubleshooting

### Common Issues

1. **API Key Issues**- Verify API key has correct permissions, check API key is not expired, ensure API key is properly set in environment variables
2. **List ID Issues**- Verify list exists in Klaviyo,  check list ID is correct, ensure list has subscribers
3. **Template Generation Issues**- Check Portable Text content structure, verify product references are valid, test template rendering in Klaviyo preview
4. **Campaign Sending Issues**- Verify campaign is in correct status, check Klaviyo campaign settings, review rate limit status

### Debugging Steps

1. **Check Function Logs**- Review console output for errors, look for specific error messages, check API response status codes
2. **Verify Environment Variables**- Ensure all required variables are set, check variable values are correct, test API key with Klaviyo directly
3. **Test API Calls**- Use Klaviyo's API documentation, test API calls manually, verify request/response format



# Developing with Next.js on GitHub Codespaces 

> [!NOTE]
> This developer guide was contributed by Eric Streske (Senior Solution Architect @ Sanity).

## Overview

GitHub Codespaces provides a cloud-based development environment, but it presents unique challenges when working with:

- Dynamic URLs that change per codespace instance.
- Proxy headers that can cause CORS and Server Actions issues.
- WebSocket connections that don't work reliably through the Codespaces proxy.

This guide documents solutions to these challenges for a Next.js + Sanity Studio monorepo.

## Prerequisites

- GitHub repository with Next.js and Sanity Studio.- The examples in this guide follow the patterns from the [Turbo Start Sanity](https://www.sanity.io/templates/turbo-start-sanity) template.


- Sanity project with Project ID, Dataset, and API tokens.

## Setup

### Configure Sanity CORS origins

Sanity's API requires explicit CORS configuration to allow requests from your Codespaces URLs.

#### Add CORS origins to Sanity

- Go to [https://www.sanity.io/manage](https://www.sanity.io/docs/cli-reference/manage).
- Select your project.
- Navigate to** API **→ **CORS Origins.**
- Add the following origins:- https://*.app.github.dev
- https://*.github.dev
- https://*.githubpreview.dev


- For each origin:- Check "**Allow Credentials**".
- Select "**Add**".



#### Why wildcards?

- Each Codespace has a unique name in the URL.
- Wildcards allow any Codespace to access your Sanity project.
- **Alternative**: Add specific Codespace URLs (less flexible).

#### Security notes

- These wildcards are safe for development environments.
- Remove them in production and use specific domains.

### Configure port forwardings

#### Make ports public

GitHub Codespaces ports are **private by default,** which prevents external services (like Sanity) from accessing your development servers.

**Method 1: VS Code UI**

- Open the **PORTS** tab in the terminal panel
- Right-click on port **3000 **-> **Port Visibility **-> **Public**
- Right-click on port **3333 **-> **Port Visibility **-> **Public**

**Method 2: CLI**

```sh
gh codespace ports visibility 3000:public -c $CODESPACE_NAME
gh codespace ports visibility 3333:public -c $CODESPACE_NAME
```

#### Why this matters?

- Sanity needs to make requests to your frontend for live preview.
- Studio needs to load the frontend in an iframe.
- Private ports block these cross-origin requests.

### Set up environment variables 

#### Front-end environment file

Create `apps/web/.env` and add the following environment variables:

**apps/web/.env**

```text
# Sanity Configuration
NEXT_PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2025-08-29

# API Tokens (get from https://sanity.io/manage)
SANITY_API_READ_TOKEN=your-token-here
SANITY_API_WRITE_TOKEN=your-token-here

# Studio URL (optional - auto-detected in Codespaces)
NEXT_PUBLIC_SANITY_STUDIO_URL=https://${CODESPACE_NAME}-3333.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}
```

#### Studio environment file

Create `apps/studio/.env` and add the following environment variables:

**apps/studio/.env**

```text
# Sanity Studio Configuration
SANITY_STUDIO_PROJECT_ID=YOUR_PROJECT_ID
SANITY_STUDIO_DATASET=production
SANITY_STUDIO_TITLE="Your Studio Title"

# Presentation URL (optional - auto-detected in Codespaces)
SANITY_STUDIO_PRESENTATION_URL=https://${CODESPACE_NAME}-3000.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}
```

#### How to create API tokens:

- Go to [https://www.sanity.io/manage](https://www.sanity.io/manage).
- Select your project.
- Navigate to **API **-> **Tokens.**
- Create tokens:- **Read token: **Viewer role
- **Write token: **Editor role



You can give these names like "Codespaces read" and "Codespaces write" to make them easier to recognize in the future.

## Code changes for Codespaces compatibility

### Detect Codespaces domain

In many cases (especially if using a starter template), the presentation tool URL defaults to `http://localhost:3000` in development mode, which doesn't work in Codespaces.

Update any helper functions that determine the frontend URL to auto-detect Codespaces environment.

```
/**
 * Determines the presentation URL based on the current environment.
 * Priority order:
 * 1. SANITY_STUDIO_PRESENTATION_URL environment variable (if set)
 * 2. Codespaces: Dynamically constructed URL using CODESPACE_NAME
 * 3. Local development: http://localhost:3000
 * @throws {Error} If URL cannot be determined in production
 */
export const getPresentationUrl = () => {
  // First priority: explicit environment variable
  const presentationUrl = process.env.SANITY_STUDIO_PRESENTATION_URL;
  if (presentationUrl) {
    return presentationUrl;
  }

  // Second priority: GitHub Codespaces (browser-side detection)
  // Codespaces URLs follow pattern: https://{codespace-name}-{port}.{domain}
  if (typeof window !== "undefined") {
    const hostname = window.location.hostname;
    const codespacesMatch = hostname.match(/^(.+?)-(\d+)\.(.+)$/);
    
    if (codespacesMatch) {
      const [, codespaceName, , domain] = codespacesMatch;
      // Replace the current port (e.g., 3333 for studio) with 3000 for the web app
      return `https://${codespaceName}-3000.${domain}`;
    }

  // Third priority: Local development fallback
  if (process.env.NODE_ENV === "development") {
    return "http://localhost:3000";
  }

  // Production: must have explicit URL set
  throw new Error(
    "SANITY_STUDIO_PRESENTATION_URL must be set in production environment",
  );
};
```

### Allow Server Actions from Codespaces Proxy

Next.js blocks Server Actions requests when the `origin` header doesn't match the `x-forwarded-host` header. In Codespaces:

- `origin`: `localhost:3000` (internal routing)
- `x-forwarded-host`: `codespace-name-3000.app.github.dev` (public URL)

This causes 500 errors: `x-forwarded-host header does not match origin header from a forwarded Server Actions request. Aborting the action.`

To resolve this, configure Next.js to trust Codespaces proxy domains.

**File: **`next.config.ts` or `next.config.js`

**next.config.ts**

```
const nextConfig: NextConfig = {
  // ... other config
  experimental: {
    // ... other config
    // Allow Server Actions from Codespaces and other proxied environments
    serverActions: {
      allowedOrigins: [
        "localhost:3000",
        "*.app.github.dev",
        "*.github.dev",
        "*.githubpreview.dev",
      ],
    },
  },
  // ... rest of config
};

export default nextConfig;
```

#### What this does:

- Tells Next.js to accept Server Actions from Codespaces URLs.
- Maintains security by limiting to specific domains.
- Allows localhost for local development.

#### Security considerations:

- Wildcards are safe for development environments.
- In production, use specific domains only.
- Never use `"*"` (all origins) in production.

## Let's make sure it works

### Start development servers

- Install dependencies.
- Start both development servers.

### Access your applications

Get your URLs (Codespaces URLs follow this pattern: `https://{CODESPACE_NAME}-{PORT}.app.github.dev`)

#### Frontend

Select port 3000 in the PORTS tab, or construct manually: `https://your-codespace-name-3000.app.github.dev`

#### Studio

Select port 3333 in the PORTS tab, or construct manually: `https://your-codespace-name-3333.app.github.dev`

### Verify

#### Open Studio (port 3333)

- You should see the Sanity Studio interface.
- If you see project ID errors, confirm your `.env` file has the correct project variables.

#### Open Presentation Tool

- Click **Presentation** in the Studio toolbar.
- You should see your frontend loaded in an iframe.

#### Test Live Editing

- Edit content in Studio.
- Changes should appear in Presentation.

## Troubleshooting

### Issue: 502 Bad Gateway on Port URLs

#### Cause: 

Servers aren't running or ports aren't forwarded correctly.

#### Solution:

```sh
# Check if servers are running
lsof -i :3000  # Should show Node.js process
lsof -i :3333  # Should show Node.js process

# Test local access
curl http://localhost:3000  # Should return HTML
curl http://localhost:3333  # Should return HTML

# Verify port visibility
gh codespace ports  # Should show both ports as public
```

### Issue: "projectId can only contain a-z, 0-9, and dashes"

#### Cause: 

Environment variables not loaded or `.env` file missing.

#### Solution:

```sh
cd apps/studio

# Check if .env exists
ls -la .env

# Verify project ID is set
cat .env | grep SANITY_STUDIO_PROJECT_ID

# If missing, create .env file with correct values
```

### Presentation Mode Shows Blank Screen

#### Cause: 

Frontend URL not configured correctly, or CORS issues.

#### Solutions:

- **Check browser console** in the presentation iframe (right-click → Inspect).
- **Verify CORS **origins in Sanity dashboard include** ***`.app.github.dev`.
- **Check frontend is accessible**: open port 3000 directly in a new tab.
- **Verify the presentation URL**: add `console.log(getPresentationUrl())` to debug.

### Issue: 500 Error - "Invalid Server Actions request"

#### Cause: 

Origin mismatch between forwarded host and origin headers.

#### Solution:

Verify `next.config.ts` includes the `serverActions.allowedOrigins` configuration from *Allow Server Actions from Codespaces Proxy* above.

### Issue: WebSocket Connection Errors

#### Cause: 

This is expected behavior in Codespaces due to the known proxy issues.

#### Solution:

Websocket errors that appear in the console can be safely ignored.  Live Editing should still work as expected.



# Add analytics to Sanity Studio

Sanity Studio is a React application, so you can't add `<script>` tags to its HTML `<head>` directly. This is true for both Sanity-hosted Studios (`*.sanity.studio`) and self-hosted deployments. Instead, you can use a React component that injects the script at runtime when the Studio loads.

This approach works with any analytics provider: Google Tag Manager, Google Analytics, Plausible, PostHog, Fathom, and others.

## Create an analytics component

Create a component that uses `useEffect` to inject a script tag into the document `<head>` when it mounts. The following example uses Google Tag Manager, but you can replace the script source and initialization logic with any provider.

**components/Analytics.tsx**

```typescript
import {useEffect} from 'react'

export function Analytics() {
  useEffect(() => {
    const script = document.createElement('script')
    script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX'
    script.async = true
    document.head.appendChild(script)

    script.onload = () => {
      window.dataLayer = window.dataLayer || []
      function gtag(...args: unknown[]) {
        window.dataLayer.push(args)
      }
      gtag('js', new Date())
      gtag('config', 'G-XXXXXXX')
    }

    return () => {
      document.head.removeChild(script)
    }
  }, [])

  return null
}
```

Replace `G-XXXXXXX` with your actual measurement ID. The component renders nothing visible (`return null`) and cleans up the script tag when it unmounts.

## Add the component to your Studio layout

Use the `studio.components.layout` override in your Studio configuration to mount the analytics component alongside the default layout.

**sanity.config.ts**

```typescript
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {schemaTypes} from './schemaTypes'
import {Analytics} from './components/Analytics'

export default defineConfig({
  name: 'default',
  title: 'My Studio',
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  plugins: [structureTool()],
  schema: {
    types: schemaTypes,
  },
  studio: {
    components: {
      layout: (props) => (
        <>
          <Analytics />
          {props.renderDefault(props)}
        </>
      ),
    },
  },
})
```

The `layout` component wraps the entire Studio. By rendering `<Analytics />` before `props.renderDefault(props)`, the analytics script loads once when the Studio initializes and persists across all navigation within the Studio.

> [!NOTE]
> JSX in config files
> Because this configuration uses JSX, the file extension must be `.tsx` (or `.jsx`). If your config file currently uses `.ts`, rename it before adding the layout override.

## Deploy

Deploy your Studio as usual. If you use Sanity-hosted deployments, run `npx sanity deploy`. The script injection happens in the browser at runtime, so no changes to the hosting configuration are needed.

Open the deployed Studio and verify that your analytics provider is receiving events. In Google Tag Manager, you can check the Tag Assistant or real-time reports to confirm the script is active.

## Using environment variables

To avoid hardcoding your measurement ID, use an environment variable. Sanity Studio supports environment variables prefixed with `SANITY_STUDIO_` which are embedded at build time.

```typescript
// components/Analytics.tsx
import {useEffect} from 'react'

const measurementId = process.env.SANITY_STUDIO_GTAG_ID

export function Analytics() {
  useEffect(() => {
    if (!measurementId) return

    const script = document.createElement('script')
    script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`
    script.async = true
    document.head.appendChild(script)

    script.onload = () => {
      window.dataLayer = window.dataLayer || []
      function gtag(...args: unknown[]) {
        window.dataLayer.push(args)
      }
      gtag('js', new Date())
      gtag('config', measurementId)
    }

    return () => {
      document.head.removeChild(script)
    }
  }, [])

  return null
}
```

Add the variable to your `.env` file:

```bash
SANITY_STUDIO_GTAG_ID=G-XXXXXXX
```

With this approach, the analytics script will only load when the environment variable is set. This lets you skip analytics in local development while keeping it active in deployed environments.



# How to pitch Sanity.io to your team

Sanity.io is the platform for [structured content](https://www.sanity.io/structured-content-platform). It comes with an [open-source headless CMS called Sanity Studio](https://www.sanity.io/studio) that’s built with React, and that you can customize. You also get a [hosted real-time datastore](https://www.sanity.io/developer-experience) with powerful APIs. There are also [libraries and tools](https://www.sanity.io/docs/libraries) that make it easier to use structured content in the products and services that you’re building. And not the least, there’s a growing [friendly community of developers](https://snty.link/community) that will gladly help and learn with you.

## Sanity.io gives your team:

- **Ultra-portable structured content. **Your content is stored as plain JSON documents. That’s it. You can export all your documents from the backend with [one API request](https://www.sanity.io/docs/http-reference/export) or [CLI command](https://www.sanity.io/docs/cli-reference/cli-datasets). And if you need to move them out of Sanity, it’s much easier to import these documents into another system, compared with some specific XML-export from a CMS littered with plugin-specific junk (looking at you WordPress). After all, portability is the hallmark of structured content.
- **A customizable editor environment**. With Sanity.io, you get a CMS that’s open-source and customizable with JavaScript and React. You only need a `name` and a `type` to make a new field, and when you’re ready for it, you can extend with custom [JavaScript validations](https://www.sanity.io/docs/studio/validation), [custom input components](https://www.sanity.io/docs/studio/intro-to-custom-studio-components), and [previews](https://www.sanity.io/docs/studio/studio-components) with React, [CSS-variable overrides](https://www.sanity.io/guides/how-to-brand-your-studio), and you can install [plugins and tools](https://www.sanity.io/docs/studio/installing-and-configuring-plugins) or make your own. You have access to all the APIs that the Studio uses.
- **Something that’s easy to set up**. You are probably way faster on a keyboard compared to dragging and dropping fields with your mouse. Creating a field in Sanity Studio is as easy as writing `{ name: ‘title’, type: ‘string’ } `and hitting “save”. With content models in code, you can create your own snippets, you can bootstrap config, commit them to git, or even publish on npm.
- **The joy of rapid iteration with GROQ**. Sanity.io offer [GROQ (Graph-Relational Object Queries)](https://www.sanity.io/docs/specifications/groq-syntax) as a way to filter your dataset’s documents, join them, and project the data structures that you need for your project. Like GraphQL it gives you one endpoint for all your content, but it’s way more versatile in the way you can shape and wrangle your data. After a couple of minutes, you can learn enough GROQ to be productive. With GROQ there is no need to loop over your data on the client-side after querying, you can shape it how you want it right in the query. This saves both bandwidth and processing time. [GROQ is open source](https://www.sanity.io/blog/we-re-open-sourcing-groq-a-query-language-for-json-documents) and can be used elsewhere as well.
- **Great APIs. **In addition to GROQ, you can query your content with [GraphQL](https://www.sanity.io/docs/content-lake/graphql). If you want to change a deeply nested value or change running text, you can do so with the powerful [mutations API](https://www.sanity.io/docs/http-reference/mutation). The [listener API](https://www.sanity.io/docs/content-lake/realtime-updates) lets your apps subscribe to changes happening in your content in real-time. With the [Asset pipeline](https://www.sanity.io/docs/content-lake/assets), you can get on-demand image transforms. With the [History API](https://www.sanity.io/docs/http-reference/history), you can browse document revisions and see who did what. [Webhooks](https://www.sanity.io/docs/content-lake/webhooks) lets you integrate with other services.
- **The calm of no-ops**. We offer you a scalable backend, both in terms of the amount of data, but also traffic, security, and availability. [CDNs for assets and content delivery](https://www.sanity.io/docs/content-lake/api-cdn). Sanity Studio, the CMS, is a Single Page Application. We can host the HTML and the JavaScript file for you, or you can put it pretty much on any host. You can even deploy different studios connected to the same datastore if you want to build specialized editor experiences.
- **Flexible, transparent pricing**. You won't be forced to change tiers because of traffic or usage. [All tiers are pay-as-you-go with modestly priced overages](https://www.sanity.io/pricing). You can also add more datasets and users on all plans. The tiers differ on SLAs, support, and advanced features. There’s no hidden schemes or gotchas, it’s all on the website for you to scrutinize. We let you upgrade and downgrade whenever you want, and will prorate you for what you haven’t used if you downgrade before the month has ended. You don’t *have to* talk to sales ever (but we sure love to if you want).
- **Privacy and GDPR. **Sanity.io host your data in the heart of GDPR land: Brussels. Sanity.io is designed with GDPR in mind so that it is easy for you to stay compliant. None of your content is shared with third-party services (not even your images). We also offer custom edit history retention if your business requires that.
- **A content platform that has been in production since 2015. **Although Sanity.io is a relatively new product on the market, it has been used in production by companies such as the renowned architecture firm [OMA](https://www.sanity.io/case-studies/oma), and one of Norway’s largest media companies, Amedia. Publicly launched in 2017. Sanity.io is now used by thousands of developers and companies including [Cornerstone OnDemand](https://www.sanity.io/case-studies/cornerstone-ondemand), [Eurostar](https://www.eurostar.com), [Condé Nast](https://www.thelovemagazine.co.uk), and [micro:bit](https://microbit.org/).
- **A tool for modern content strategy and design processes**. If you look at the conversations happening within content strategy, you’ll quickly find *structured content* as a frequent topic. No wonder, since it’s a pattern that prevents duplicated content and tries to connect your text and media to the goals of your team and users. Sanity.io also makes *content-first* approaches to design easier with rapid content modeling and having the content available instantly. This is perfect when you’re building component-based design systems. Which you should be doing!



# Not-profit plan

## The plan

The non-profit plan mirrors the [Growth plan](https://www.sanity.io/pricing), but we offer it for free (no credit card required) as long as you stay within the quotas. Additionally, we've added the following features to the plan:

- 25 users included free of charge, with $15 per additional user without limit
- 3 datasets (+1 from Growth plan)

Note that [add-ons](https://www.sanity.io/docs/platform-management/growth-plan-add-ons) are not available, and you need to add a credit card to pay for additional overages and users.

## Who's eligible?

We offer the non-profit plan to:

- Small and mid-sized organizations that are “organized and operated for a collective, public or social benefit” and where the revenue exceeding expenses goes back into the cause
- Educational and academic institutions of smaller sizes and budgets
- Open-source projects that are based on sponsorships or voluntary effort (so not monetized)

## Who's not eligible?

- Organizations that qualify for our [Enterprise plan](https://www.sanity.io/pricing), including large non-profit organizations like global humanitarian operations, universities, etc.
- Organizations that can’t comply with our [Terms of Service](https://www.sanity.io/legal/tos).

## How to apply?

[Fill out the application form](https://forms.gle/xkQstGLFrujT2me39) and you'll hear back from us within 14 business days. Please note:

- If you don't provide a valid Sanity project ID, your application will be ignored.
- You'll receive an email when a decision has been made, but we're not able to provide technical support over email after this. Please join our community on Discord to get help.



# Agencies: Navigating the Spring 2025 Organization Changes

> [!NOTE]
> This developer guide was contributed by Tom Smith (Principal Solutions Architect at Sanity).

With the recent Spring 2025 release, Sanity has introduced several new powerful features, some of which are on the organization level. This change makes it essential for agencies to consider how they structure their client projects within Sanity.

These new features include:

- Media Library for centralized asset management
- Sanity Canvas for AI-assisted content creation
- Functions for serverless automation
- Agent Actions for schema-aware AI workflows
- A centralized organization Dashboard and Insights for unified content operations 

As part of our Content Operating System launch, Sanity has started a shift from being project-centric to organization-centric. Features like Media Library, Functions (compute resources), and Content Releases are now shared and managed at the organization level, with pricing structured accordingly. This change makes it essential for agencies to consider how they structure their client projects within Sanity.

## Moving to an organization-centric model

Historically some agencies have created a single organization owned by the agency with many projects for each client. This approach was typically done to centralize project management and oversight. However, with Sanity's organization-centric features, agencies who maintain a single organization for all their clients will be sharing these resources and their associated costs across all projects. This can lead to inefficient resource allocation, mix ups and billing challenges when trying to determine which client should be charged for what usage.

By creating separate organizations for each client, you can:

- Clearly separate billing and resource usage by client
- Provide clients with their own Media Library for asset management
- Enable clients to manage their own user permissions and access controls (enterprise projects)
- Allow for more accurate tracking of compute and API usage per client
- Simplify the eventual handoff process when projects are completed

## How to make the switch

### Project Transfer Process

If you're an agency looking to move client projects to their own organizations, here's how to do it. You can also see this in [our documentation](https://www.sanity.io/docs/platform-management/plans-and-payments).

1. **Create a new organization for the client** - You can either create this organization yourself (and be an admin on it initially)
- Or ask your client to create their own organization and prepare to receive the transfer


2. **Initiate the project transfer**  - Log into [Sanity Manage](https://www.sanity.io/manage)
- Select the project you want to transfer
- Navigate to the project settings
- Find the transfer option and select the receiving organization


3. **Complete the transfer** - If you have billing rights in both organizations, the transfer happens instantly
- Otherwise, a billing manager in the receiving organization must approve the transfer
- Once approved, billing is automatically prorated between organizations



### Billing Considerations

When transferring projects between organizations:

- The sender is refunded the already paid amount for the remainder of the month
- The receiver is charged for the remainder of the month at the time of transfer
- The receiving organization becomes responsible for any overage charges accrued on the project
- The transfer does not change the project plan or resource quotas

## Best Practices for Agencies

1. **Plan your organization structure in advance** - Always create client-specific organizations from the start for new projects
- For existing clients, discuss the transfer process and benefits before making changes


2. **Document ownership and access** - Clearly define who will have admin access to the client organization
- Determine if the agency needs ongoing admin access or if it will be fully transferred


3. **Communicate pricing implications** - Explain to clients how organization-level features like Media Library and Functions are billed
- Help clients understand the benefits of having their own dedicated resources


4. **Consider timing** - Schedule transfers at the beginning of billing cycles when possible to minimize proration complexity
- Plan transfers during lower-activity periods to minimize disruption



## Summary: Benefits for Clients

Moving clients to their own organizations provides several advantages:

- **Resource isolation**: Client assets and compute resources are completely separate from other clients
- **Simplified billing**: Clients receive clear, dedicated billing for their Sanity usage
- **Better security**: Access controls are isolated to just the client's content
- **Ownership clarity**: Clients have full control over their content infrastructure, and future changes in agencies won't impact them negatively (although of course, hopefully that doesn't happen!)
- **Easier scaling**: Organization-level features can be scaled according to each client's specific needs

## Next Steps

Moving from a single agency-owned organization to client-specific organizations aligns with Sanity's evolution into a complete Content Operating System. This structural change not only provides clearer resource allocation and billing but also enables both agencies and clients to take full advantage of Sanity's new organization-centric features. For agencies managing multiple client projects, we recommend:

1. **Audit your current organization structure** - Identify which client projects should be moved to their own organizations
2. **Create a migration timeline** - Prioritize transfers based on client needs and feature usage
3. **Update your onboarding process** - Adjust how you set up new client projects to start with dedicated organizations
4. **Communicate the benefits** - Help clients understand why this change improves their experience with Sanity By embracing this organization-centric approach, agencies can provide better service to their clients while taking full advantage of Sanity's powerful new features like Media Library, Canvas, and Functions—all while maintaining clear boundaries between client resources and billing. 

If you have questions about this transition or need assistance with project transfers, please reach out to our support team or join our community Discord for guidance.



# How to generate massive amounts of demo content for Sanity

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

Being able to query demo content through your API can make building and testing front ends, plugins, and integrations easier. Learn how with Faker.js and a Sanity CLI script.

Fake content is useful when you want to build, test, and demo schema, plugins, and integrations. And sometimes you want to generate more than it's reasonable to type out in the Studio. This guide will teach you how to create fake demo content at scale directly in the Sanity Content Lake.

You'll begin by logging your Sanity project's details in a terminal and step-by-step create an advanced content creation script, including:

- Using Sanity CLI to execute scripts with authentication to change content
- Creating documents in transactions for reduced API request usage
- Programmatically generating references when creating new documents
- Uploading image assets during a content creation script
- Converting HTML to block content with Portable Text
- Processing API requests in batches to avoid rate limits

## Prerequisites

- You’re familiar with Sanity Studio and the command line.
- You have a non-production project and/or a non-production dataset where deleting and inserting content won’t get you in trouble!
- The code examples here are written in TypeScript, but you won’t *need* to know or use TypeScript to get the same outcome.

> [!TIP]
> While this guide covers generating fake content, the ideas and examples explored may give you some insight into how to write **content migration scripts** from existing sources. Solving for things like image uploads, concurrency, and avoiding rate limits.

### Getting setup

You may wish to adapt the content creation scripts in this guide to suit your own content model. However, if you’d prefer to use the script as-is, you’ll need to add the following schema types to your Studio to see the content it will create.

First, add the Post type schema:

```typescript
// ./schemas/postType.ts

import {defineArrayMember, defineField, defineType} from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'category',
      type: 'reference',
      to: {type: 'category'},
    }),
    defineField({
      name: 'image',
      type: 'image',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [defineArrayMember({type: 'block'})],
    }),
    defineField({
      name: 'fake',
      type: 'boolean',
      hidden: true,
    }),
  ],
  preview: {
    select: {
      title: 'title',
      media: 'image',
      subtitle: 'category.title',
    },
  },
})
```

And the Category type schema:

```typescript
// ./schemas/categoryType.ts

import {defineField, defineType} from 'sanity'

export const categoryType = defineType({
  name: 'category',
  title: 'Category',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
  ],
})
```

Make sure you import both of these into the schema types of your `sanity.config.ts` file.

Let’s begin!

### Configuring the Sanity CLI

These details were found in your CLI client configuration, held in a file likely generated for you when you created a new Studio. Open `sanity.cli.ts` and take a look. If you don't have this file in your project, create it with the code example below. 

Yours should look something like this:

```typescript
// ./sanity.cli.ts

import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  api: {
    projectId: 'sbbltfn5',
    dataset: 'production',
  },
})
```

If you’d prefer to run your CLI commands using a different dataset, you could update the configuration here in this file.

Or you could overwrite the CLI client config in your script like this:

```typescript
// ./scripts/createData.ts

const client = getCliClient().withConfig({
  dataset: 'development'
})
```

Or even overwrite the dataset dynamically by passing in an argument to `sanity exec`; there are examples of [how to do this in the documentation](https://www.sanity.io/docs/cli-reference/exec).

For this guide, you’ll just use the default CLI client configuration.

## Creating a Sanity CLI script

First, a new directory for your scripts in the root of your Sanity Studio project, and in it, create a new file called `createData.ts`

```typescript
// ./scripts/createData.ts

import {getCliClient} from 'sanity/cli'

const client = getCliClient()

async function createData() {
  console.log(`Create new data with:`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)
}

createData()
```

Now, from the command line [execute the script](https://www.sanity.io/docs/cli-reference/exec) with:

**npm**

```shell
npx sanity@latest exec scripts/createData.ts
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts
```

In the console, you should see something like…

```text
Create new data with:
Project ID: sbbltfn5
Dataset: production
```

…but with *your* project ID and dataset name.

## Install Faker

[Faker](https://fakerjs.dev/) is a package containing various kinds of fake content for all common use cases. [Their API reference](https://fakerjs.dev/api/) shows the extensive list of available fake content types.

Add the Faker package to your Studio with the following install command:

**npm**

```shell
npm install --save-dev @faker-js/faker
```

**pnpm**

```shell
pnpm add --save-dev @faker-js/faker
```

**yarn**

```shell
yarn add --dev @faker-js/faker
```

**bun**

```shell
bun add --dev @faker-js/faker
```

Let’s update the `createData` script to generate five blog posts with just the `_id` and `title`.

```typescript
// ./scripts/createData.ts

import {faker} from '@faker-js/faker'
import type {SanityDocumentLike} from 'sanity'
import {getCliClient} from 'sanity/cli'

const client = getCliClient()
const COUNT = 5

async function createData() {
  console.log(`Create new data with...`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)

  const posts: SanityDocumentLike[] = []

  for (let i = 0; i < COUNT; i++) {
    posts.push({
      _type: 'post',
      _id: faker.string.uuid(),
      title: faker.company.catchPhrase(),
    })
  }

  console.log(posts)
}

createData()
```

> [!TIP]
> It’s optional to supply the `_id` when creating new documents in Sanity. But if you are programmatically building references between new documents, you’ll need to know them in advance.

Now run the script again:

**npm**

```shell
npx sanity@latest exec scripts/createData.ts
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts
```

Now you should see the same messages as before, along with an array of five new post documents with new titles; they look like this:

```json
{
  _type: 'post',
  _id: '732488ef-b2a7-4bae-8c93-f007133afe2f',
  title: 'Polarised multi-tasking array'
}
```

Now you have fake data; the last step is to write it to Sanity.

## Create new documents in a transaction

Update your script again, and this time, instead of creating an array of post objects, you’ll add them to a single [transaction](https://www.sanity.io/docs/content-lake/transactions), which is then committed to perform all the document creation mutations at once.

Update your `createData` script:

```typescript
// ./scripts/createData.ts

import {faker} from '@faker-js/faker'
import {getCliClient} from 'sanity/cli'

const client = getCliClient()
const COUNT = 5

async function createData() {
  console.log(`Create new data with...`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)

  const transaction = client.transaction()

  for (let i = 0; i < COUNT; i++) {
    transaction.create({
      _type: 'post',
      _id: faker.string.uuid(),
      title: faker.company.catchPhrase(),
    })
  }

  transaction
    .commit()
    .then((res) => {
      console.log(`Complete!`, res)
    })
    .catch((err) => {
      console.error(err)
    })
}

createData()
```

> [!WARNING]
> Above, five documents will be created in each transaction based on the `COUNT` constant. You can increase this number, but there are limits to how much data you can send in a single transaction. See [Technical Limits](https://www.sanity.io/docs/content-lake/technical-limits) for more information. Examples later in this guide perform transactions in batches.

Run the script again, this time with the `--with-user-token` argument so that your Sanity credentials are used to write the content:

**npm**

```shell
npx sanity@latest exec scripts/createData.ts --with-user-token
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts --with-user-token
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts --with-user-token
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts --with-user-token
```

You should get a “Complete!” message and a list of all the results and affected document IDs in the transaction. Open your Studio to find your newly created documents.

*Sanity Studio showing 5 fake posts*

Note that because you used your own authentication to run this script – these created documents are attributed to you.

*Document history showing that these fake posts were made by you!*

You could generate an [API Token in Manage](https://www.sanity.io/manage) and use that in your client config to assign these documents to a robot.

## Faking references between documents

While Sanity Content Lake can auto-generate `_id`s for new documents, you can also determine it when you create the document. This lets you create [references](https://www.sanity.io/docs/studio/reference-type) between new documents in the same transaction. To do this, you'll need to modify the script to create all the data ahead of time in memory before committing it.

In this version of the script, both posts and categories are created in advance so that each post can pick a category at random. References are created by taking the published `_id` of another document and assigning it to a `_ref` key.

```typescript
// ./scripts/createData.ts

// Create 10 posts and 5 categories
// Every post has a random category

import {faker} from '@faker-js/faker'
import type {SanityDocumentLike} from 'sanity'
import {getCliClient} from 'sanity/cli'

const client = getCliClient()
const POST_COUNT = 10
const CATEGORY_COUNT = 5

async function createData() {
  console.log(`Create new data with...`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)

  const categories: SanityDocumentLike[] = []

  for (let categoryI = 0; categoryI < CATEGORY_COUNT; categoryI++) {
    categories.push({
      _type: 'category',
      _id: faker.string.uuid(),
      title: faker.company.catchPhraseAdjective(),
    })
  }

  const posts: SanityDocumentLike[] = []

  for (let postI = 0; postI < POST_COUNT; postI++) {
    posts.push({
      _type: 'post',
      _id: faker.string.uuid(),
      title: faker.company.catchPhrase(),
      category: {
        _type: 'reference',
        _ref: categories[Math.floor(Math.random() * CATEGORY_COUNT)]._id,
      },
    })
  }

  const data = [...categories, ...posts]

  const transaction = client.transaction()

  for (let dataI = 0; dataI < data.length; dataI++) {
    transaction.create(data[dataI])
  }

  transaction
    .commit()
    .then((res) => {
      console.log(`Complete!`, res)
    })
    .catch((err) => {
      console.error(err)
    })
}

createData()
```

Now run your script again:

**npm**

```shell
npx sanity@latest exec scripts/createData.ts --with-user-token
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts --with-user-token
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts --with-user-token
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts --with-user-token
```

You should see the new `post` and `category` documents where every `post` has a `category` reference.

## Create a lot of documents while avoiding rate limits

Our script works when making small numbers of documents. However, if you increase the number in the script to hundreds or thousands, you may [run into rate limits while bulk-creating content](https://www.sanity.io/docs/content-lake/technical-limits).

To solve this, update the code to create documents in batches. There are several packages you could use to do this. For this tutorial, you’ll use [p-limit](https://www.npmjs.com/package/p-limit). This gives you a function to control how many requests run concurrently. In the code below, you’ll run them one at a time.

Note that the content creation script becomes somewhat more complicated. However, the extra complexity unlocks the ability to write greater volumes of fake content as well as perform asynchronous operations during the process, which is useful for image uploads.

Install p-limit into your Studio dev dependecies:

**npm**

```shell
npm install --save-dev p-limit
```

**pnpm**

```shell
pnpm add --save-dev p-limit
```

**yarn**

```shell
yarn add --dev p-limit
```

**bun**

```shell
bun add --dev p-limit
```

In the updated code below are several key changes:

1. Every newly created document has a boolean field named `fake` set to `true`.
2. A `client.delete()` method first runs to remove all existing `post` and `category` documents from the dataset that have this boolean field of `fake` set to `true`. This helps clear out any previously generated fake content each time you run the script.
3. The transactions to create `category` and `post` documents are separated. Categories are created first so that references to them in the Post creation transactions will succeed.
4. The script still creates five categories and ten posts, and those ten posts will be created for as many “batches” as specified when running the script. These batches run one at a time.
5. An optional argument to define the number of batches can be used when running the script.

Here’s the updated, asynchronous, more limit-friendly script:

```typescript
// ./scripts/createData.ts

// Create 2 batches of 10 posts with references to one of 5 categories

import {faker} from '@faker-js/faker'
import pLimit from 'p-limit'
import type {SanityDocumentLike} from 'sanity'
import {getCliClient} from 'sanity/cli'

const client = getCliClient()
const POST_COUNT = 10
const CATEGORY_COUNT = 5
const BATCHES_COUNT = 2
const args = process.argv.slice(2)
const batchesArg = args.find((arg) => arg.startsWith('batches='))?.split('=')[1]
const batches = batchesArg ? parseInt(batchesArg) : BATCHES_COUNT
const limit = pLimit(1)

async function createData() {
  console.log(`Create new data with...`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)

  console.log(`Deleting previously faked posts and categories...`)
  await client.delete({query: `*[_type in ["post", "category"] && fake == true]`})

  const categories: SanityDocumentLike[] = []

  for (let categoryI = 0; categoryI < CATEGORY_COUNT; categoryI++) {
    categories.push({
      _type: 'category',
      _id: faker.string.uuid(),
      title: faker.company.catchPhraseAdjective(),
      fake: true,
    })
  }

  const categoriesTransaction = client.transaction()

  for (let categoryI = 0; categoryI < categories.length; categoryI++) {
    categoriesTransaction.create(categories[categoryI])
  }

  const categoriesBatch = limit(async () => {
    return categoriesTransaction
      .commit()
      .then(() => {
        console.log(`Created ${CATEGORY_COUNT} categories`)
      })
      .catch((err) => {
        console.error(err)
      })
  })

  console.log(`Preparing ${batches} batches of ${POST_COUNT} posts...`)

  const postsBatches = Array.from({length: batches}).map((_, batchIndex) => {
    limit(async () => {
      const posts: SanityDocumentLike[] = []

      for (let postI = 0; postI < POST_COUNT; postI++) {
        posts.push({
          _type: 'post',
          _id: faker.string.uuid(),
          title: faker.company.catchPhrase(),
          category: {
            _type: 'reference',
            _ref: categories[Math.floor(Math.random() * CATEGORY_COUNT)]._id,
          },
          fake: true,
        })
      }

      const postTransaction = client.transaction()

      for (let postI = 0; postI < posts.length; postI++) {
        postTransaction.create(posts[postI])
      }

      return postTransaction
        .commit()
        .then(() => {
          console.log(`Post batch ${batchIndex + 1} Complete`)

          if (limit.pendingCount === 0) {
            console.log(`All batches complete!`)
          }
        })
        .catch((err) => {
          console.error(err)
        })
    })
  })

  await Promise.all([categoriesBatch, ...postsBatches])
}

createData()
```

Run the script now, including an argument for the number of batches to create:

**npm**

```shell
npx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

You should now see a number of messages in the console as each transaction completes individually.

You may want to include a timeout function after each transaction to add a delay between each operation. Or, for more advanced controls, replace p-limit with [p-queue](https://github.com/sindresorhus/p-queue).

## Uploading fake demo images

When uploading images to Sanity, the Content Lake generates an [asset document](https://www.sanity.io/docs/content-lake/assets) with a unique ID and metadata (dimensions, color palette, etc.). This means uploading the same image twice would return the same `_id` and only result in one image being stored.

Because adding images to documents involves this two-step process – upload first, receive an `_id` second – you need the document creation to be asynchronous. Fortunately, your script now supports that!

In your updated script are these three lines:

```typescript
const imageUrl = faker.image.urlPicsumPhotos({width: 800, height: 600})
const imageBuffer = await fetch(imageUrl).then((res) => res.arrayBuffer())
const imageAsset = await client.assets.upload('image', Buffer.from(imageBuffer))
```

Here's how image uploads work:

1. First, you'll create a new image URL from Faker. 
2. Next, you'll need to fetch the image data and return a “[Buffer](https://developer.mozilla.org/en-US/docs/Glossary/Buffer).”
3. This buffer can then be uploaded to the Content Lake using `client.assets.upload`. 
4. You'll receive an `_id` to the image asset, which can be used as a reference in the new document.

Here’s the full script now:

```typescript
// ./scripts/createData.ts

// Create 2 batches of 10 posts with references to one of 5 categories

import {faker} from '@faker-js/faker'
import pLimit from 'p-limit'
import type {SanityDocumentLike} from 'sanity'
import {getCliClient} from 'sanity/cli'

const client = getCliClient()
const POST_COUNT = 10
const CATEGORY_COUNT = 5
const BATCHES_COUNT = 2
const args = process.argv.slice(2)
const batchesArg = args.find((arg) => arg.startsWith('batches='))?.split('=')[1]
const batches = batchesArg ? parseInt(batchesArg) : BATCHES_COUNT
const limit = pLimit(1)

async function createData() {
  console.log(`Create new data with...`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)

  console.log(`Deleting previously faked posts and categories...`)
  await client.delete({query: `*[_type in ["post", "category"] && fake == true]`})

  const categories: SanityDocumentLike[] = []

  for (let categoryI = 0; categoryI < CATEGORY_COUNT; categoryI++) {
    categories.push({
      _type: 'category',
      _id: faker.string.uuid(),
      title: faker.company.catchPhraseAdjective(),
      fake: true,
    })
  }

  const categoriesTransaction = client.transaction()

  for (let categoryI = 0; categoryI < categories.length; categoryI++) {
    categoriesTransaction.create(categories[categoryI])
  }

  const categoriesBatch = limit(async () => {
    return categoriesTransaction
      .commit()
      .then(() => {
        console.log(`Created ${CATEGORY_COUNT} categories`)
      })
      .catch((err) => {
        console.error(err)
      })
  })

  console.log(`Preparing ${batches} batches of ${POST_COUNT} posts...`)

  const postsBatches = Array.from({length: batches}).map((_, batchIndex) => {
    limit(async () => {
      const posts: SanityDocumentLike[] = []

      for (let postI = 0; postI < POST_COUNT; postI++) {
        const imageUrl = faker.image.urlPicsumPhotos({width: 800, height: 600})
        const imageBuffer = await fetch(imageUrl).then((res) => res.arrayBuffer())
        const imageAsset = await client.assets.upload('image', Buffer.from(imageBuffer))

        posts.push({
          _type: 'post',
          _id: faker.string.uuid(),
          title: faker.company.catchPhrase(),
          category: {
            _type: 'reference',
            _ref: categories[Math.floor(Math.random() * CATEGORY_COUNT)]._id,
          },
          image: {
            _type: 'image',
            asset: {
              _type: 'reference',
              _ref: imageAsset._id,
            },
          },
          fake: true,
        })
      }

      const postTransaction = client.transaction()

      for (let postI = 0; postI < posts.length; postI++) {
        postTransaction.create(posts[postI])
      }

      return postTransaction
        .commit()
        .then(() => {
          console.log(`Post batch ${batchIndex + 1} Complete`)

          if (limit.pendingCount === 0) {
            console.log(`All batches complete!`)
          }
        })
        .catch((err) => {
          console.error(err)
        })
    })
  })

  await Promise.all([categoriesBatch, ...postsBatches])
}

createData()
```

Now, run the script. It’s likely to take a little more time with each individual post waiting for an image to be uploaded. You may prefer to modify the script to upload 5–10 images first and then randomly pick from them.

**npm**

```shell
npx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

If run multiple times, this script may begin to populate your dataset with many unused images. Fortunately, there is a [script for removing images without references](https://www.sanity.io/schemas/delete-unused-assets-2ef651b5).

## Turning HTML into Portable Text

Turning HTML into Portable Text is simplified by [@sanity/block-tools](https://www.npmjs.com/package/@sanity/block-tools). For the final variation of the fake demo content creation script, you‘ll create an HTML string of paragraphs, convert it to Portable Text, and add it to each new fake post.

As the script runs in a Node environment, you’ll need [JSDOM](https://www.npmjs.com/package/jsdom) to turn these HTML strings into DOM elements.

Install both the block tools and JSDOM packages into your Studio:

**npm**

```shell
npm install --save-dev @sanity/block-tools jsdom @types/jsdom
```

**pnpm**

```shell
pnpm add --save-dev @sanity/block-tools jsdom @types/jsdom
```

**yarn**

```shell
yarn add --dev @sanity/block-tools jsdom @types/jsdom
```

**bun**

```shell
bun add --dev @sanity/block-tools jsdom @types/jsdom
```

Block Tools uses a compiled Studio schema definition to correctly convert different HTML elements into Portable Text objects. It can be as simple as the `body` field in the `post` document type – or support many custom styles, definitions, and blocks. See the [block schema type definition](https://www.sanity.io/docs/studio/block-type) for a complete list of options.

There’s a new `createFakeBlockContent` function at the top of your import script now, which takes the `blockContent` schema above and uses it to inform the conversion of 2–5 HTML paragraphs of text into Portable Text.

Here’s the script updated with some placeholder Portable Text:

```typescript
// ./scripts/createData.ts

// Create 2 batches of 10 posts with references to one of 5 categories

import {faker} from '@faker-js/faker'
import {htmlToBlocks} from '@sanity/block-tools'
import {Schema} from '@sanity/schema'
import {JSDOM} from 'jsdom'
import pLimit from 'p-limit'
import type {FieldDefinition, SanityDocumentLike} from 'sanity'
import {getCliClient} from 'sanity/cli'

import {schemaTypes} from '../schemas'

const client = getCliClient()
const POST_COUNT = 10
const CATEGORY_COUNT = 5
const BATCHES_COUNT = 2
const args = process.argv.slice(2)
const batchesArg = args.find((arg) => arg.startsWith('batches='))?.split('=')[1]
const batches = batchesArg ? parseInt(batchesArg) : BATCHES_COUNT
const limit = pLimit(1)

const defaultSchema = Schema.compile({types: schemaTypes})
const blockContentSchema = defaultSchema
  .get('post')
  .fields.find((field: FieldDefinition) => field.name === 'body').type

// Create 2-5 paragraphs of fake block content
function createFakeBlockContent() {
  const html = Array.from({length: faker.number.int({min: 2, max: 5})})
    .map(() => `<p>${faker.lorem.paragraph({min: 2, max: 5})}</p>`)
    .join(``)
  return htmlToBlocks(html, blockContentSchema, {
    parseHtml: (html) => new JSDOM(html).window.document,
  })
}

async function createData() {
  console.log(`Create new data with...`)
  console.log(`Project ID: ${client.config().projectId}`)
  console.log(`Dataset: ${client.config().dataset}`)

  console.log(`Deleting previously faked posts and categories...`)
  await client.delete({query: `*[_type in ["post", "category"] && fake == true]`})

  const categories: SanityDocumentLike[] = []

  for (let categoryI = 0; categoryI < CATEGORY_COUNT; categoryI++) {
    categories.push({
      _type: 'category',
      _id: faker.string.uuid(),
      title: faker.company.catchPhraseAdjective(),
      fake: true,
    })
  }

  const categoriesTransaction = client.transaction()

  for (let categoryI = 0; categoryI < categories.length; categoryI++) {
    categoriesTransaction.create(categories[categoryI])
  }

  const categoriesBatch = limit(async () => {
    return categoriesTransaction
      .commit()
      .then(() => {
        console.log(`Created ${CATEGORY_COUNT} categories`)
      })
      .catch((err) => {
        console.error(err)
      })
  })

  console.log(`Preparing ${batches} batches of ${POST_COUNT} posts...`)

  const postsBatches = Array.from({length: batches}).map((_, batchIndex) => {
    limit(async () => {
      const posts: SanityDocumentLike[] = []

      for (let postI = 0; postI < POST_COUNT; postI++) {
        const imageUrl = faker.image.urlPicsumPhotos({width: 800, height: 600})
        const imageBuffer = await fetch(imageUrl).then((res) => res.arrayBuffer())
        const imageAsset = await client.assets.upload('image', Buffer.from(imageBuffer))

        posts.push({
          _type: 'post',
          _id: faker.string.uuid(),
          title: faker.company.catchPhrase(),
          category: {
            _type: 'reference',
            _ref: categories[Math.floor(Math.random() * CATEGORY_COUNT)]._id,
          },
          image: {
            _type: 'image',
            asset: {
              _type: 'reference',
              _ref: imageAsset._id,
            },
          },
          body: createFakeBlockContent(),
          fake: true,
        })
      }

      const postTransaction = client.transaction()

      for (let postI = 0; postI < posts.length; postI++) {
        postTransaction.create(posts[postI])
      }

      return postTransaction
        .commit()
        .then(() => {
          console.log(`Post batch ${batchIndex + 1} Complete`)

          if (limit.pendingCount === 0) {
            console.log(`All batches complete!`)
          }
        })
        .catch((err) => {
          console.error(err)
        })
    })
  })

  await Promise.all([categoriesBatch, ...postsBatches])
}

createData()
```

Run the script again.

**npm**

```shell
npx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**pnpm**

```shell
pnpm dlx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**yarn**

```shell
yarn dlx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

**bun**

```shell
bunx sanity@latest exec scripts/createData.ts --with-user-token -- batches=3
```

You should now have 30 posts, each with an image, some paragraph text, a title, and a reference to one of five category documents.

## Wrap-up

You could now extend the `createData` script to add random booleans, numbers, and more. The concepts you’ve learned here can also extend to being able to [import existing data](https://www.sanity.io/docs/content-lake/importing-data) or create new Sanity content on demand or automatically with a deployed version of a similar script.



# How to implement Multi-tenancy with Sanity

> [!NOTE]
> This developer guide was contributed by Simeon Griggs (Principal Educator).

With Sanity, you’re in complete control of building an infrastructure for distributed teams to author content within customized boundaries. Individual authors may cross – or be limited to – teams, brands, environments, or markets. Each set of content can be cross-referenced so that queries are resolved from sources of truth rather than individual silos that grow stale over time.

## What is multi-tenancy?

Depending on who you ask, multi-tenancy can be interpreted differently.

For this guide, we’ve used it to describe an implementation required when the need to author structured content goes beyond a single team, stored in distinct locations, and edited by multiple authors with differing roles.

This gets more complex when members of these teams share some responsibilities across data sources, or those sources need to create relationships between that data.

### Our multi-tenancy example for this guide

This guide will use an imagined, rapidly growing travel company as an example.

- They started with a website and app for articles about *hotels* operating in a single market.
- As the company grows, it needs content created for individual websites and apps for each market it expands into, handled by local teams of authors.
- While the content *structure* stays the same, these markets *author* distinct content and so have different localization needs.
- Authors will have different lines of responsibility within these markets.
- As they expand into new business areas like advertising *flights*, the need to structure and author content for other domains is apparent.
- When authoring content for multiple domains with some commonalities, a single source of truth to reference them becomes valuable.

This setup can be configured in Sanity; however, before showing how it’s helpful to outline some of the names used to configure a successful build.

### Goals

Through this guide you’ll work through achieving the following outcomes in a Sanity implementation:

- One market’s authors need to create unique content from all others. Load different schema configurations based on the current dataset.
- Hide or lock individual fields for members based on their role, market, or the current Studio workspace environment.
- Scope members’ permissions to specific document types, environments, or markets. Create a member role for authors that can only Publish “article” documents for the Norway team.
- Two content teams that require Development and Production environments operate in individual markets – Norway and the USA. Create unique data storage for these teams.
- Each market team requires a unique space to create content with the same structure. Some members will need visibility of all content, and so must be able to navigate between them.

## Platform overview

To understand how best to divide work among teams, you might first like to orient yourself with the names we give to different parts of the Sanity platform. 

See the [Platform terminology page](https://www.sanity.io/docs/platform-management/platform-terminology) in our documentation.

Now you're familiar with organizations, projects, datasets and members – let's proceed.

## Let’s get started!

The following guide will walk through each step of a successful multi-tenant setup. You may do this in an existing project or create a new one, [following the documentation on creating a new one](https://www.sanity.io/docs/getting-started).

You may consider running multiple projects for your implementation. This creates a complete separation of members and project configuration between your teams. Also, it prevents those configurations and the content inside datasets from colliding. However, it prevents referenced content, and any common settings must be manually duplicated.

For simplicity in this guide, you’ll use one project.

In a one-project, multi-team setup, be aware of administrator-level members’ power. They will have access to all project-level settings, such as tokens and webhooks. All members tasked only with content creation should have their permissions scoped to remove access to these settings. This is covered later in the guide.

> [!TIP]
> From this point forward, the guide implements some features only available on specific plans and a volume of datasets which will require setting up billing for your organization. The following cannot be completed on the free plan without incurring overages. See “Alternatives” at the end of this guide. Or request [a product demo for more information](https://www.sanity.io/contact/sales?ref=multi-tenant-guide).

## Dataset configuration

**Goal: Two content teams that require Development and Production environments operate in individual markets – Norway and the USA. Create unique data storage for these teams.**

Our growing travel company currently authors content related to hotels and operates in two markets: USA and Norway.

*A list of four private datasets as shown in Manage. Two for each "market", each with their own "environment".*

Create a dataset for each team, market, and environment. These indicators are written directly into the dataset names.

- `hotels_us_production`
- `hotels_us_development`
- `hotels_no_production`
- `hotels_no_development`

The consistent naming convention here of `team_market_environment` is essential, as you’ll see in the code snippets further in this guide.

Note: All datasets are created equal! There’s no functional difference between any two datasets in a project.

Datasets can be created in the [project management interface](https://www.sanity.io/manage) or using the [Sanity CLI](https://www.sanity.io/docs/cli-reference/cli-config).

By default, datasets have *public* visibility. Anyone – member or guest – can query the dataset for *published* documents.

Since you’ll be restricting access with member roles, ensure each dataset is *private*. This will require authentication to query the dataset and make it possible to hide specific published documents from specific roles.

Note that uploaded *assets* in the Content Lake are always public but can only be downloaded via an obfuscated, uniquely generated URL using a hashed filename. [Downloading original image assets requires authentication](https://www.sanity.io/docs/apis-and-sdks/image-urls).

> [!TIP]
> When working with datasets as environments, it’s great to set up a deployment workflow early so that you can automatically validate schema and studio changes. [Our guide on “Multi-environment deployments](https://www.sanity.io/guides/multi-environment-deployments)” demonstrates how.

### Migrating data between datasets

Some teams find value in authoring content in a staging environment and migrating it into production. While not generally recommended, this provides more explicit boundaries between publicly available and “staged” content.

Before adopting this workflow, consider

- Content migrated between datasets cannot be automatically kept in sync. Cross-dataset references allow you to link content as references and are covered later in this guide.
- Migrated content typically arrives in a published state; this makes implementing approval workflows and scheduled publishing difficult.

> [!TIP]
> Migrating data is not required to preview changes before publishing in production. Sanity provides tooling to render as-you-type live previews in your front end. See [documentation for previews](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing) for more details, including [implementation guides for Next.js and Remix](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing).

1. [The Cross Dataset Duplicator plugin](https://www.sanity.io/plugins/cross-dataset-duplicator) provides a user interface in the Studio for authors to perform content and asset migrations from a single document up to the results of a query.
2. Sanity’s import and export tooling allows developers to migrate complete datasets. You can learn more about data migration [in the documentation](https://www.sanity.io/docs/content-lake/schema-and-content-migrations).

## Studio configuration

**Goal: Each market team requires a unique space to create content with the same structure. Some members will need visibility of all content, and so must be able to navigate between them.**

Sanity Studio is an application that interfaces with APIs, projects, and datasets like any other application can. It can take multiple “Workspace” configurations in its `sanity.config.ts` file. [Read more about workspaces in the docs](https://www.sanity.io/docs/studio/workspaces).

By default, a new Studio contains just one workspace. Update your configuration file to an array of objects like the one below to create a workspace for each team and conditionally load the correct environment’s dataset.

```typescript
// ./sanity.config.ts

import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemas'

const isProduction = process.env.NODE_ENV === 'production'

export const config = defineConfig([
  {
    name: 'hotels-us',
    title: 'Hotels USA',
    basePath: '/us',
    projectId: 'YOUR_PROJECT_ID',
    dataset: isProduction
      ? 'hotels_us_production' 
      : 'hotels_us_development',

    plugins: [structureTool(), visionTool()],
    schema: {
      types: schemaTypes,
    },
  },
  {
    name: 'hotels-no',
    title: 'Hotels Norway',
    basePath: '/no',
    projectId: 'YOUR_PROJECT_ID',
    dataset: isProduction
      ? 'hotels_no_production' 
      : 'hotels_no_development',

    plugins: [structureTool(), visionTool()],
    schema: {
      types: schemaTypes,
    },
  }
])
```

Your Studio should now display a drop-down menu in the top left corner to switch between workspaces.

*This “workspaces” menu has been additionally configured with a custom icon component and a dynamic subtitle key.*

You can configure each workspace uniquely. For example, a schema type or plugin loaded in one workspace might not be required in another.

[Deploy the Studio](https://www.sanity.io/docs/studio/deployment) now to see it targeting the development datasets locally and where deployed targeting production.

This guide will not dictate which document schema types to use. However, ensure at least an “article” type schema so it’s possible to implement member roles later.

## Schema configuration

**Goal: One market’s authors need to create unique content from all others. Load different schema configurations based on the current dataset.**

Now that multiple workspaces use the same schema, individual teams in different markets create content with the same structure because they use the same Studio. This will require organizational alignment to maintain the integrity of the implementation. Consider that if one team adds a new schema or updates a field definition – both teams will receive those updates.

Currently, the `schema` key in our `sanity.config.ts` file is a static array. Changing that to a function allows you to intercept all registered schema and modify the result using the context of this workspace and member.

In the code snippet below, all markets other than the “US” use a common schema. The “US” team has specific schema types that need registering and are spread into the array.

Notice how you can target the team, market, and environment based on the name of the workspace’s dataset.

```typescript
// ./sanity.config.ts

schema: {
  types: (prev, context) => {
    // Expected dataset name structure is "hotels_us_production"
    const [team, market, environment] = context.dataset.split('_')

    // Return defaults for all markets other than the "us"
    if (market !== 'us') {
      return [...prev, ...schemaTypes]
    }

    // Spread "us" specific schema into this workspace
    return [...prev, ...usSchemaTypes, ...schemaTypes]
  },
},
```

Since the `context` parameter will always contain this workspace’s targeted dataset, this code could be extracted into a helper function and re-used in each of the workspace configurations.

Other parts of the configuration share access to the workspace’s configuration and can filter what is loaded, such as `tools` and `document.actions`.

The `plugins` key does not have the same `context` parameter but may still be loaded differently in each workspace. For example:

```typescript
// ./sanity.config.ts

const pluginsGlobal = [structureTool(), visionTool()]
const pluginsNo = [dashboardTool()]

export default defineConfig([
  // ...other workspaces
  {
    name: 'hotels-no',
    // ...other settings

    plugins: [...pluginsGlobal, ...pluginsNo],
  },
])
```

### Customize schema fields

**Goal: Hide or lock individual fields for members based on their role, market, or the current Studio workspace environment.**

With schema shared across teams and environments, individual fields in schema types may require unique validation and hidden and read-only settings.

In this example, a `slug` field is only required in production datasets.

You can access the current dataset name from the Sanity client through the `context` parameter. Once you have the dataset name, you have this workspace’s team, market, and environment, thanks to the naming convention. Below is an example of [a field validation rule](https://www.sanity.io/docs/studio/validation) only applied when a content creator is in a production environment:

```typescript
defineField({
  name: 'slug',
  type: 'slug',
  validation: (rule) =>
  rule.custom((value, context) => {
    // Valid if the slug field has a value
    if (value) {
    return true
    }
    	
    // The dataset is extracted from the client from this "context"
    const {dataset} = context.getClient({apiVersion: '2021-03-25'}).config()
    	
    // Expected dataset name structure is "hotels_us_production"
    const [team, market, environment] = dataset?.split('_') || []
    	
    return environment === 'production' 
      ? 'A slug is required to generate a page on this website' 
      : true
  }),
})

```

Both `hidden` and `readOnly` properties on fields can also be customized with a function in a similar way.

## Custom access controls

**Goal: Scope members’ permissions to specific document types, environments, or markets. Create a member role for authors that can only Publish “article” documents for the Norway team.**

Now that you have datasets to separate teams, you’ll need member roles and resources to gate their access to only the content they are responsible for.

Permissions are scoped by the value of a field in a document and can be duplicated across datasets for easy reuse.

They are applied by both:

1. Describing a set of documents with a content resource
2. Setting the access permissions for those documents on a dataset to a member role

[Read more about access control in the docs](https://www.sanity.io/docs/user-guides/roles).

### Content resources

Before creating a role, you’ll need to create a resource to define a set of content to which permissions will apply. Inside **Access** > **Resources**, create a new content resource called “Article Documents” with a GROQ filter:

```groq
_type == "article"
```

> [!TIP]
> GROQ is a query language for JSON that is used in Sanity to query documents, configure webhooks, and define content resources. [Find out more in the documentation](https://www.sanity.io/docs/content-lake/how-queries-work).

*Content resources allow you to describe a subset of documents to apply different permissions to for each member role.*

### Member roles

Now inside **Access** > **Roles**, create a new “Norway Article Author” role.

*This role will now need to be configured with specific content resources*

Note that permissions are “additive.” So if a member with two roles – one that only allows “read” and another that allows “create” – will be able to “create.”

Now, apply permissions to documents based on the *dataset*. Configured like the example below, on just these two datasets, a member with this role can:

- Read all documents
- Update and create all image and file assets
- On Production, update and create any document where `_type == “article”`
- On Development, publish any document where `_type == “article”`

*A summary of the permissions applied to content resources on specific datasets*

You may wish to be more granular with these member roles and create one for each market.

Create a member account, only assign this “Norway Article Author” role, and look through the studio in the local development and deployed production environments.

*With this role, you cannot view, create or publish documents in any environment in the US team workspace. In the Norway team workspace, you can only view and create “article” type documents in the “production” environment. In the “development” environment, you can also publish. All other types are hidden.*

### Multiple roles

Members can stack roles to gain more permissions. The built-in role of “Viewer” is helpful to apply to a member to get read-only permissions across all datasets for documents and assets.

Assign this author the “Viewer” role and see how they can now browse through all other document types in all workspaces.

*Some members will benefit from having multiple roles to access various content resources*

### Dataset tags

Each dataset can be given a “tag” to simplify spreading permissions across multiple datasets. Instead of applying permissions to individual datasets, you might apply them to a tag, and all matching datasets will receive those permissions.

Create two tags by going to **Datasets** > **Tags** [in your project’s settings](https://www.sanity.io/manage) and then apply them to the four datasets. The colors used here are purely for visual effect and have no functional difference.

You could now apply a content resource to all production datasets differently than all development datasets – without configuring them individually.

*With the datasets tagged, it’s now simpler to group permission resources together on a member role.*

## Shared Content

**Goal: As new teams are added to the platform, create a source of truth that all teams can reference.**

References in Sanity bind documents together. For example, an `article` document can reference an `author` document.

A query for that `article` can *follow* the reference to retrieve the `author` document’s content.

Likewise, a query for an `author` document can return all `article` documents that reference it.

Typically these documents reference documents in the same dataset. In a multi-tenant implementation, there’s excellent value in teams being able to reference one another’s content – or both teams referencing the same content.

Imagine our fledgling travel company is planning to launch an app and website for flights. Repeating the steps above, you would create new datasets, workspaces, member roles, and resources for those content creation teams.

When teams create content for the same organization, some shared content is best created in – and referenced from – a single source of truth. This is where [cross-dataset references](https://www.sanity.io/docs/studio/cross-dataset-reference-type) are helpful.

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

*Now both the flight and hotel teams can reference airport documents from a single source of truth. Structured content is more trustworthy!*

In [your project settings](https://www.sanity.io/manage), create a pair of private datasets for this shared, globally relevant content for each environment:

- `global_development`
- `global_production`

In your Studio configuration, add a new workspace for this global content:

```typescript
// ./sanity.config.ts

// Import a second set of schema types for this new workspace
import {globalSchemaTypes, schemaTypes} from './schemas'

// ... all other imports

export default defineConfig([
  // ... all other workspaces
  {
    name: 'global',
    title: 'Global',
    basePath: '/global',
    projectId: 'YOUR_PROJECT_ID',
    dataset: process.env.NODE_ENV === 'production' ? 'global_production' : 'global_development',
    plugins: [structureTool(), visionTool()],
    schema: {
      types: globalSchemaTypes,
    },
  },
])
```

Notice how this workspace imports a different set of schema types. You will need to create this additional array of document schema. For this example, the global workspace has just one document schema `airport`, with a `code` and `image` fields.

*In this example, the “Global” workspace contains a unique schema from all other markets and may only be visible to specific members.*

Now in the schema types used by the hotel and flight teams, add a new field for a cross-dataset reference that targets this new global dataset.

```typescript
// In one of the document schema files for the hotel and flight teams

defineField({
  name: 'arrive',
  description: 'The closest airport to the hotel',
  type: 'crossDatasetReference',
  dataset: process.env.NODE_ENV === 'production' ? 'global_production' : 'global_development',
  studioUrl: ({type, id}) => `/global/structure/intent/edit/id=${id};type=${type}/`,
  to: [
    {
      type: 'airport',
      preview: {
        select: {title: 'code', media: 'image'},
        prepare: ({title, media}) => ({title, subtitle: 'Airport'}),
      },
    },
  ],
}),
```

You can create references to the “global” dataset when creating documents in any team or market-specific workspace.

Members with permission to edit these global documents will ensure that all downstream consumers have the most up-to-date version.

*The top reference field targets a document in the same dataset. The bottom is a cross-dataset reference that exists in the global dataset.*

## Alternative multi-tenancy implementations with Sanity

This is not the only way to create a multi-tenant setup in Sanity! The platform's flexibility allows you to divert from this guide in whichever way you feel more accurately suits your goals.

### Per-team projects

If both your content authoring teams and frontend developer teams are entirely separate, it may be worth separating your content across projects. In such instances, referencing content across datasets and easily duplicating project-level configuration may be less valuable. More transparent, complete lines of separation between authors and datasets could be more useful.

### One dataset, multiple teams

Another example is to author multiple markets of content within the same Dataset.

This approach requires a more explicit configuration of member roles, Studio schema, and how you query content with GROQ.

To make this work, every document must have a field like `market`, and your Studio should contain a workspace for each unique market. Within each workspace, document lists and reference fields must be filtered down to just this document's markets.

Using [initial values and initial value templates](https://www.sanity.io/docs/studio/initial-value-templates), you can ensure every new document begins with the correct market field value. It’s also helpful to add filters to reference fields so that authors do not accidentally create references between markets.

It is possible to have unintended naming collisions or schema differences when multiple teams work inside one dataset. So extra care is required to maintain content integrity and frontend queries between teams.

## Conclusion

However you choose to configure a Sanity implementation to spread work across teams, the ability to configuration of permissions and the authoring experience is entirely in your control.

As your teams and content needs grow, the multi-tenancy model you have created should be able to expand with it without the need to rebuild your setup completely.

[Try out Sanity today with a new free project](https://www.sanity.io/docs/getting-started-with-sanity), [contact sales for more details, or request a product demo](https://www.sanity.io/contact/sales?ref=multi-tenant-guide).



# GROQ-Powered Webhooks – Intro to Filters

> [!NOTE]
> This developer guide was contributed by Knut Melvær (Head of Developer Community and Education) and Martin Jacobsen (Technical Writer at Sanity.io).

GROQ-powered webhooks give you precise control over the circumstances and conditions under which your webhooks will trigger. In this article, we'll take a closer look at the possibilities offered by GROQ filters.

### Triggering webhooks with precision

The two GROQ super-powers webhooks have are **filters **and **projections**. The latter enables you to construct your request's payload to your exact needs and is discussed in an [article of its own](https://www.sanity.io/docs/developer-guides/projections-in-groq-powered-webhooks). In this article, we'll be discussing the former. Filters let you define the circumstances and conditions under which your webhook should trigger with immaculate precision.

If you want to follow along, go ahead and set up a new webhook in the project management console. You can use a service like [webhook.site](https://webhook.site), or [Beeceptor](https://beeceptor.com/) to debug and test your webhook.

> [!WARNING]
> This article assumes you are comfortable with the basic concept of webhooks, and how to create one in your Sanity project. If you're not quite up to speed, have a look at the [webhook docs](https://www.sanity.io/docs/content-lake/webhooks) and come back when you're ready to do some filtering!

### Event types

Before dedicating our attention exclusively to GROQ-filters, let's briefly look at the set of checkboxes labeled "Trigger on", found immediately before the filter input field.

![Interface for selecting event types](https://cdn.sanity.io/images/3do82whm/next/ce42de2c3d7740e38d23c92254206c241b612669-486x250.png)

Webhooks can be triggered when a document is **created**, **updated**, **deleted**, or any combination of these.

- **Create** - triggers on the creation of a new document.
- **Update** - triggers on every change to a document once created.
- **Delete** - triggers on the deletion of a document

Between these, you'll be able to react to all major interactions with the documents relevant to you – the selection of which we'll be spending the rest of this article getting increasingly particular about.

> [!WARNING]
> By default, your webhooks will not trigger on draft-events. I.e. They will only trigger when changes to the document are published and not for every single occurrence while you edit. Triggering on draft-events can be enabled, but be careful or you may end up causing huge amounts of traffic to your endpoint!

## Filters in GROQ

This is where, using GROQ, you define the criteria by which a document event should trigger your webhook. If you are already familiar with GROQ, the filter is the bit that you'll often see inside the square brackets at the start of your query, often preceded by an asterisk.

```groq
*[ /*Filters go here!*/ ]
```

A typical case of using a filter in a query might look something like this:



```groq
// Returns every document with a type of post 
*[_type == "post"]
```

When creating filters for our webhooks we skip the asterisk and square brackets and just type the filtering conditions right into the input field. Translating the scenario from above, our webhook filter would look like this:

![Shows the filter input field with the code discussed immediately above](https://cdn.sanity.io/images/3do82whm/next/373e370a5e1d4a310af9d573333286b7df419b8e-538x161.png)

### GROQ Fundamentals

This minimal example is already a quite powerful feature – enabling you to react to changes to specific types of content – but let's get a bit more creative! Let's expand our filter to check for the value of a boolean field named `featured` in the document, and only trigger if it's set to `true`, and just to make it interesting let's add another document type called `article` as well.

```groq
// Trigger on events for posts and articles with featured set to true
_type in ["post", "article"] && featured == true
```

Just as in normal GROQ-queries you can use logical operators to filter on a range of different field types, and you can combine any number of these in order to get exactly the result you want.

```groq
// Trigger on events for movies that are quite popular and relatively recent
_type == "movie" && popularity > 15 && releaseDate > "2016-01-01"

// Trigger on events for articles tagged with a carefree attitude
_type == "article" && "yolo" in tags

// Trigger on events for posts about extraterrestrials 
_type == "post" && body[].children[].text match "alien"
```

Following references to filter on content in the referenced document also works as you'd expect:

```groq
// Trigger on events for posts by a specific author
_type == "post" && author->name == "Sinjoro Ajnulo"
```

### Using Functions

You can also use GROQ functions, such as `dateTime()`, `references()`, or `defined()`.

```groq
// Trigger on events for posts published after 2016-01-01
_type == "post" && dateTime(_createdAt) > dateTime("2016-01-01T00:00:00Z")

// Trigger on events for books with a value set for the author field
_type == "book" && defined(author)

// Trigger on events for books referencing a specific author
_type == "book" && references("sinjoro-ajnulo")
```

Namespaced functions are also available, such as `pt::text()` which gives you a plain text version of any Portable Text rich text field, or the `sanity::`-prefixed functions which are helpful for querying about the environment from which the webhook was triggered.

```groq
// Trigger on events for posts that mention a certain term in the body
_type == "post" && pt::text(body) match "Sinjoro"

// Trigger if the projectId matches the expected value
sanity::projectId() == "<projectId>"

// Trigger if the change occurred in one of the specified datasets
sanity::dataset() in ["test", "staging"]
```

### Querying "what changed?" with Delta-GROQ

Delta-GROQ is an extension to the GROQ language designed specifically to enable you to reason about changes made to a document. Currently, the following delta-functions are available:

- `before()` – returns the matching documents as they were before the change.
- `after()` – returns the matching documents after the change.
- `delta::changedAny()` – returns true if specified field values have changed.
- `delta::changedOnly()` – which returns true if *only* specified field values have changed.

The aptly named functions `before()`and `after()` let you compare the document in its entirety before and after the change event was executed. You may also use dot notation to check the value of any single field. E.g. `before().title == after().title`.

> [!WARNING]
> While you can use projection on the results of `before()` and `after()` in other contexts – like this `before(){ title, description }` – this won't work in filters, as objects are not checked for deep equality and will always return true.

```groq
// Trigger if the price has gone down
_type == "product" && before().price > after().price

// Trigger if title changes to no longer including the string 'DRAFT'
before().title match "DRAFT*" && !(after().title match "DRAFT*")
```

The namespaced functions `delta::changedAny()` and `delta::changedOnly()` are helpful when you want to run the webhook only when certain fields have been updated, or indeed if only certain fields have been updated.

```groq
// Trigger if one or more of the specified fields have been updated
_type == "product" && delta::changedAny((name, price, stock))

// Do not trigger if featured is the only field updated
_type == "product" && !delta::changedOnly(featured)
```

> [!WARNING]
> Notice the double parentheses when more than one field is used in `delta::changedAny()` or `delta::changedOnly()`.

### In conclusion

In this article we've demonstrated some more or less plausible examples of how using GROQ filters enables unmatched granularity in specifying the circumstances and conditions under which your webhooks should be triggered. While we've touched upon a number of different methods and techniques, we haven't even revealed the tip of the iceberg representing the capabilities of GROQ. For inspiration, check out the [GROQ Query Cheat Sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet)!





# GROQ-Powered Webhooks – Intro to Projections

> [!NOTE]
> This developer guide was contributed by Knut Melvær (Head of Developer Community and Education) and Martin Jacobsen (Technical Writer at Sanity.io).

GROQ-powered webhooks in Sanity enable you to shape the payload of your outgoing requests to your exact specifications. In this article, we'll take a closer look at projections and how they can help you communicate clearly with whatever endpoint your webhook is talking to.

### Sending eloquent webhook payloads with projections

The two GROQ super-powers webhooks have are **filters **and **projections**. The former lets you get real nitty-gritty about when your webhooks should trigger, and is discussed in an [article of its own](https://www.sanity.io/docs/developer-guides/filters-in-groq-powered-webhooks). In this article, we'll be discussing the latter. 

> [!TIP]
> [GROQ](https://groq.dev/) is Sanity's open-source query language. Check out the [docs](https://www.sanity.io/docs/overview-groq), or watch a [video tutorial](https://www.youtube.com/watch?v=wgjFf2M4OdQ&list=PLRzQpWc3zNPkoeiKdZzz0zOqZzvy9ItxT) to get started! When you're good to go, come back and check out the rest of this article!

### Have it your way

GROQ projections let you build a JSON data structure using the document which triggered the webhook, and its values – from before and after the change – as building blocks. In your projections, you have access to the document returned from the filter in its entirety including both the original and updated values of every field. With the additional capability to join references multiple levels deep, you have the tools you need to design your outgoing requests to your specifications.

For those already familiar with GROQ; the projection is the bit that usually comes after the filter.

```groq
*[/* Filter goes here */] {
  // Projection goes here
  title,
  tags,
}
```

When we're configuring webhooks, the filter goes into a box of its own, and we are left with only the stuff in curly braces. An example of a GROQ projection with a simple join might look something like this:

```groq
{ 
  // returns the values of fields 'title' and 'description'
  // and follows the reference to another document of type
  // 'author' and assigns its 'name' value to 'authorName'
  title,
  description,
  "authorName": author->name 
}

```

The projection above would result in the JSON being sent as the payload of your webhook request being shaped something like this:

```json
{
  "authorName": "Sinjoro Ajnulo",
  "description": "Lorem ipsum dolor sit amet",
  "title": "Hello, World!"
}
```

You can use string concatenation to shape your values into the format you need.

```groq
{
  // creates a string value assigned to 'status'
  "status": "The price of " + name + " was updated to " + price + "!"
}

```

Projecting array values works as expected. As does employing the ellipsis operator to retrieve all fields.

```groq
{
  // retrieves all fields
  ...,
  // returns an array of asset urls i.e 
  // ["http://cdn.sanity.io/…", "http://cdn.sanity.io/…"]
  "imageUrls": images[].asset->url
}
```

> [!WARNING]
> Gotcha! Projections in webhooks do not support sub-queries. In other words, you can’t access other documents in your dataset unless they are referenced from the document in question. For example, the following webhook projection will not work:
> `{ "relatedProducts": *[^.category._ref in categories] }`

### Delta-GROQ

Projections also support the `before()` and `after()` functions. These are part of the extension to the GROQ language called Delta-GROQ which lets you reason about changes made to documents. You might set a filter to trigger whenever the `price` field is updated and add the following projection (in this example, `price` is presumed to be a number):

```groq
{ 
  _id,
  "status": 
    "Price of " 
      + name 
      + " was changed from " 
      + string(before().price)
      + " to " 
      + string(after().price)
      // ⬆Tip: GROQ happily ignores line-breaks and comments, so feel
      // free to make your filters and projections a bit more readable
}
```

The returned values of the `before()` and `after()`-functions can take projections as well! In fact they contain the document in its entirety in its pre-updated and updated versions, respectively. 

```groq
{
  "beforeValues": before(){ name, price, description },
  "afterValues": after(){ name, price, description },
}
```

If you plan to receive webhooks from multiple projects and datasets, you can use the `sanity::`-namespace in GROQ to include information about the origins of the request:

```groq
{ 
  _id, 
  _type, 
  "projectId": sanity::projectId(), 
  "dataset": sanity::dataset() 
}
```

### In conclusion

In this article we've looked at how GROQ-projections allow the precise construction of webhook payloads. This enables you to think of *any *API endpoint as a potential recipient for your webhooks, as you can shape the request to fit the format of the recipient. Things that previously would have needed some middleman cloud function to reformat your data to fit the need of the service in question can now be done entirely with projections. For more inspiration on GROQ, have a look [over here](https://www.sanity.io/docs/overview-groq)!



# Presenting Portable Text

When you query your Sanity project’s API your rich text content is returned as Portable Text. If you are accustomed to traditional or other [headless CMSes ](https://www.sanity.io/headless-cms)you are probably used to dealing with HTML or Markdown out of the box. Portable Text is designed to be used in pretty much any format or markup where you want to render rich text content. 

You render Portable Text by serializing the arrays that contain your content into the format you need it. There is tooling for generic markup and programming languages and for popular frameworks, that makes it easier to serialize Portable Text and lets you decide how custom content types should be handled.

## Serialization tooling

We have helpers for different languages and platforms.

> [!TIP]
> Protip
> You may notice some mentions of *block text*, including in the tool names. This was the nomenclature we used before open sourcing and publishing the specification for Portable Text. You can explore the specification on [www.portabletext.org](https://www.portabletext.org).

- [Portable Text to HTML](https://github.com/portabletext/to-html)
- [Portable Text to React](https://github.com/portabletext/react-portabletext)
- [Portable Text to React Native](https://github.com/portabletext/react-native-portabletext)
- [Portable Text to React PDF](https://github.com/portabletext/react-pdf-portabletext)
- [Portable Text to Vue](https://github.com/portabletext/vue-portabletext)
- [Portable Text to Svelte](https://github.com/portabletext/svelte-portabletext/)
- [Portable Text to Astro](https://github.com/theisel/astro-portabletext)
- [Portable Text to Hyperscript](https://github.com/sanity-io/block-content-to-hyperscript) (no longer maintained as of September 2025; see maintained alternatives above)
- [Portable Text to Markdown](https://github.com/sanity-io/block-content-to-markdown) (no longer maintained as of December 2025; see maintained alternatives above)
- [Portable Text in .NET](https://github.com/oslofjord/sanity-linq#9-rendering-block-content)
- [Portable Text in Python](https://github.com/otovo/python-portabletext-html)
- [Portable Text in PHP](https://github.com/sanity-io/sanity-php#rendering-block-content)

Need to serialize to something not listed here in a language we don't cover? Create an issue on the repo for [Portable Text](https://www.portabletext.org), or [join us on Slack](https://slack.sanity.io) and let us know.

## Plain text serialization

Serializing Portable Text to plain text can be useful when you need it previews or similar. It also helps demystify what goes into serializing Portable Text. Here's a function written in JavaScript that takes a Portable Text array as an argument, and returns it as paragraphs in plain text:

```javascript
function toPlainText(blocks = []) {
  return blocks
    // loop through each block
    .map(block => {
      // if it's not a text block with children, 
      // return nothing
      if (block._type !== 'block' || !block.children) {
        return ''
      }
      // loop through the children spans, and join the
      // text strings
      return block.children.map(child => child.text).join('')
    })
    // join the paragraphs leaving split by two linebreaks
    .join('\n\n')
}

```

## Rendering Portable Text in React

A common use case is to render rich text content from Sanity in the popular web framework [React](https://reactjs.org/). We have made tooling that deals with the defaults out of the box, and lets you add components for controlling how custom content type should be rendered in the front end. Let's look at an example of how to set it up:

```jsx
import React from 'react'
import * as ReactDOM from 'react-dom'
import {PortableText} from '@portabletext/react'
import {createClient} from '@sanity/client'

const client = sanityClient({
  projectId: '<your project id>',
  dataset: '<your dataset>',
  apiVersion: '2022-05-05',
  useCdn: true
})

const components = {
  types: {
    code: (props) => {
      const {language, code} = props.value
      return (
        <pre data-language={language}>
          <code>{code}</code>
        </pre>
      )
    }
  }
}

client.fetch('*[_type == "article"][0]')
  .then(article => {
    ReactDOM.render(
      <PortableText value={article.body} components={components} />,
      document.getElementById('root')
    )
  })

```

Additional details are available [in our documentation](https://www.sanity.io/docs/studio/block-content) or on the [@portabletext/react README](https://github.com/portabletext/react-portabletext).

> [!NOTE]
> Portable Text to React
> In 2022, the [@sanity/block-content-to-react](https://github.com/sanity-io/block-content-to-react) package was deprecated in favour of [@portabletext/react](https://github.com/portabletext/react-portabletext). The example above uses components and values from the new package instead of serializers and blocks, but we offer a [migration guide](https://github.com/portabletext/react-portabletext/blob/main/MIGRATING.md) and will continue to answer questions about `@sanity/block-content-to-react` in the [Slack community](https://slack.sanity.io).

## Join references

If you have references to other documents such as internal links, files or images in your Portable Text, you typically want to [join the data](https://www.sanity.io/docs/content-lake/how-queries-work) from those documents into your Portable Text. Here's how:

Say you have a Portable Text with internal links to other articles using [mark annotations](https://www.sanity.io/docs/studio/portable-text-editor-configuration) like so:

```json
// portableText.js
export default {
  name: 'portableText',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block',
      marks: {
        annotations: [
          {
            name: 'internalLink',
            type: 'object',
            title: 'Internal link',
            fields: [
              {
                name: 'reference',
                type: 'reference',
                title: 'Reference',
                to: [
                  { type: 'article' },
                  // other types you may want to link to
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}
```

In order to get the slug of the linked article you need to use [GROQ](https://www.sanity.io/docs/groq-reference) to query your document and use the [join syntax](https://www.sanity.io/docs/content-lake/query-cheat-sheet) to fetch the referenced article like so: 

```groq
*[_type == "post"]{
  ...,
  body[]{
    ...,
    markDefs[]{
      ...,
      _type == "internalLink" => {
        "slug": @.reference->slug
      }
    }
  }
}
```

[Read the full guide on including links in Portable Text](https://www.sanity.io/guides/portable-text-internal-and-external-links).

## Deserialization

If you need to convert existing markup to Portable Text you can use the JavaScript library [Sanity Block Tools](https://github.com/portabletext/editor/tree/main/packages/block-tools). You can use it both in a browser and in a node.js environment. It also lets you make custom rules to deserialize parts of your HTML into custom content types etc.

Complete example of deserialization of HTML into Portable Text blocks in a browser environment:

```javascript
import Schema from '@sanity/schema'
import blockTools from '@portabletext/block-tools'


// Start with compiling a schema we can work against
const defaultSchema = Schema.compile({
  name: 'myBlog',
  types: [
    {
      type: 'object',
      name: 'blogPost',
      fields: [
        {
          title: 'Title',
          type: 'string',
          name: 'title'
        },
        {
          title: 'Body',
          name: 'body',
          type: 'array',
          of: [{type: 'block'}]
        }
      ]
    }
  ]
})

// The compiled schema type for the content type that holds the block array
const blockContentType = defaultSchema.get('blogPost')
  .fields.find(field => field.name === 'body').type


// Convert HTML to block array
const blocks = blockTools.htmlToBlocks(
  '<html><body><h1>Hello world!</h1><body></html>',
  blockContentType
)
// Outputs
//
//  {
//    _type: 'block',
//    style: 'h1'
//    children: [
//      {
//        _type: 'span'
//        text: 'Hello world!'
//      }
//    ]
//  }


// Get the feature-set of a blockContentType
const features = blockTools.getBlockContentFeatures(blockContentType)
```





# Add Inline blocks for the Portable Text Editor

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

There are many cases where we want to define specific inline content depending on a user’s country, device, or journey stage. There are ways to do this with variables in PHP and other frameworks. **But inline blocks in Portable Text make more possible than the usual variables**! 

Did you know that you can, for example, dynamically load product prices depending on a user’s location, add any special offers that may apply to them, and add an icon that links to local vendors?

Here is how you can do it!

> [!WARNING]
> This uses V3 but can easily be implemented in V2 by using the fields etc. in the old way to define [schemas](https://www.sanity.io/v2-docs/content-modelling).

## Adding custom blocks to Portable Text

You might be familiar with how to add custom blocks for [the Portable Text Editor](https://www.sanity.io/docs/studio/block-content) by combining the `type: 'block'` with other object types like `image`. 

```javascript
import { defineType } from 'sanity'

export default defineType({
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    },
    {
      type: 'image'
    }
  ]
})
```

## Inline custom blocks - embed content directly into text

But did you know you can also implement [inline blocks](https://www.sanity.io/docs/studio/block-type) by **adding the of property** and an array of object types to the block object field definition? 

Let’s say you wanted to embed a reference to an author in running text. Then, the schema definition would look something like this:

```typescript
import { defineType } from 'sanity'

export default defineType({ 
  name: 'blockContent', 
  type: 'array', 
  of: [ 
    { type: 'block', 
      of: [ 
        {name: 'authorReference', 
        type: 'reference', 
        to: [{type: 'author'}]
        } 
      ] 
    } 
  ] 
})
```

### How will this look in the Portable Text Editor?

![Screenshot of a Portable Text Editor in dark mode with a "Reference to author" button in the toolbar](https://cdn.sanity.io/images/3do82whm/next/78efe1bbdc988d357a8ffba940f652c6b5dabf40-1162x600.png)
*How the inline reference to author will look like in the toolbar of the Portable Text Editor*



![Screenshot showing, that adding an inline block, usually will open a modal, very similar to the way annotations work](https://cdn.sanity.io/images/3do82whm/next/c2c3ab81281157198956af4861b4854be5e4d71d-1240x528.png)
*When you add an inline block, usually this will open a modal, very similar to annotations.*

![Screenshot of Inline reference to author Saskia Bobinska inside the Portable Text Editor](https://cdn.sanity.io/images/3do82whm/next/8cf5ce1a58f29fa2e82cbdf72ab2a71aa7ae4b94-1186x610.png)
*Inline reference to author Saskia Bobinska inside the Portable Text Editor*

### How does the Portable Text output look like? 

As you can see in the JSON below, `authorReference` is on the same as the rest of the text. This is very different from [annotations](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor) and their output. 

```json
"content": [
    {
      "_key": "f6c1d654f9f4",
      "_type": "block",
      "children": [
        {
          "_key": "887fd31a6817",
          "_type": "span",
          "marks": [],
          "text": "This is how an inline reference to an author, "
        },
        {
          "_key": "39d95d603c1a",
          "_type": "authorReference",
          "_ref": "9b8382ae-69f7-4161-a0e2-e8a86b15d616"
        },
        {
          "_key": "0db93e64f0ed",
          "_type": "span",
          "marks": [],
          "text": ", would look like."
        }
      ],
      "markDefs": [],
      "style": "normal"
    }
  ]
```

### Resolving author reference in GROQ queries

Now that we have the author reference embedded inline with the text we need to be able to resolve the reference so we can [serialise the Portable Text](https://www.sanity.io/docs/developer-guides/presenting-block-text) output in our front-end. 

In order to do so, we have to make sure, we resolve `authorReference` in our query:

```groq
*[_type == 'post']{
  ..., 
  // get the content array 
  content[]{
    // if the type is block...
    _type == 'block' => {
    ..., 
    // get the childrens array, and... 
    children[]{
      ...,
      // if a childrens type is our author reference,...
      _type == 'authorReference' => {
      ...,
      // create a new key value pair named "authorName" with the value name value of the referenced author document
      "authorName": @->.name
      }
      }
    }
  }
}
```

Output: 

```json
...,
{
  "_key": "9d95d603c1a",
  "_ref": "9b8382ae-69f7-4161-a0e2-e8a86b15d616",
  "_type": "authorReference",
  "authorName": "Saskia Bobinska"
},
...
```



# Beginners guide to Portable Text

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

Portable Text is a JSON-based rich text specification for modern content editing platforms. PT is an agnostic abstraction of rich text that can be serialized into pretty much any markup language.

This will allow you to reuse your content across any front-end you need.

What does that mean?

Instead of combining the presentation and content layers, PT allows you
to handle the content separate from the markup/how it’s rendered. Since the presentation is decoupled, you can also add custom (block) types and conditional rules directly into your data/content without needing to separate them out or create specific renderers for them in the editor. 

Additionally, you can use the exact same text across all platforms and formats ( website | meta tags | native app | email newsletter | social media post | ... ).

## The difference between formatting and styles

In any text, there are informational layers which are relevant to the overall
structure of the content, giving information about the role of a specific
block, paragraph or span.
Those are also used in HTML to create [semantically relevant elements](https://www.boia.org/blog/accessibility-tips-using-the-div-and-span-elements),
instead of only working with divs and spans.
Other layers are more visual and carry little meaning by themselves but can be
configured based on the role of some entity in the content itself.

### Styles

Styles are generally some sort of role or type of block.
These often correspond to standardised elements in HTML, such as `h1`, `p`, `quote`, `code`, etc.
Those styles represent **what a block is** and can have rules on how they look (formatting).

### Formatting

Formatting will take the form of how the overall content is visually presented (presentational layer).
It determines **how something looks** while not having any or little information about what it **is**.



## What does PT consist of?

- Blocks- Styles
- Lists
- Marks- Annotations
- Decorators


- [Inline blocks](https://www.sanity.io/docs/developer-guides/add-inline-blocks-to-portable-text-editor)


- Custom blocks

### Blocks

Portable Text is broken into blocks of content; to be more exact, it is an array of objects.

The default `block` type can have different `marks` which define additional information-layers to the text in question.

Marks are extra information that can be applied to your text.

- **Decorator:** when that extra information can be expressed as a simple text string.
- **Annotation:** when the extra information is more complex and can be described as an object with keys and values. 

### Data Structure

![Screenshot with annotations on data structure. ](https://cdn.sanity.io/images/3do82whm/next/fb5f7524a363bf42c9a155da182c6f80124cfbc8-2098x1176.png)
*data structure of Portable Text*

What does everything mean

- **children** - the sections within our Portable Text block (array of spans)
- **_type** - can be either block, span or your custom blocks
- **text** - This is the text. The content is broken up into spans that you can customize further.
- **marks** - There is where we add some metadata about a span of text. Decorators will appear here, while annotations will be references by key and have more information in the `markDefs` array
- **markDefs** - You can mark text with more complex data, expressed as an object with keys and values. It can be whatever you want.

### Custom blocks

All custom blocks will have the same data structure as your object.

The resulting data will consist of a `_type` and `_key`, as well as all key-value pairs you define in the schema (fields).

## Presenting Portable Text

You can find more information [in the docs](https://www.sanity.io/docs/developer-guides/presenting-block-text).

### What is a serialiser?

A serializer defines how the blocks of content from the JSON array are stacked together to make a readable format that is adjusted to the needs and presentational layer.

Default serializers can be found for multiple formats.

[These libraries](https://www.sanity.io/docs/developer-guides/presenting-block-text) come with default serializers that translate common data structures into elements (depending on the framework).

### React Example

```typescript
import {PortableText} from '@portabletext/react'

<PortableText
  value={[/* array of portable text blocks */]}
  components={ /* specific render instructions */ }
/>
```

> [!TIP]
> Check out my extensive guide on customising Portable Text up to serialising everything to React 
> https://www.sanity.io/guides/ultimate-guide-for-customising-portable-text-from-schema-to-react-component

## Validations in Portable Text

In Portble Text (PT), you have the option to add validations on the array level (the whole text), the block, and even the span level, which is brilliant for these use cases, where we need to disallow certain combinations of marks and styles, the order of heading styles, for example, or even certain strings or characters.

### Validating the order of blocks

One of the most interesting things to validate is the [order of headings](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements). A break in the convention can lead to lower accessibility and SEO scores, a broken table of contents, and more.

Validations are the perfect tool to ensure your editors adhere to the nesting rules and where certain headings can appear.

**Example: H2 has to be the first heading**

Let's assume you have a document type which only allows `H2` as the first heading in your Portable Text (because the `H1` will be populated via a `title` field, for example).

```typescript
export const validateH2IsFirst = (
  Rule: ArrayRule<PortableTextBlock[] | unknown[]>,
) =>
  Rule.custom((value, context) => {
    const { path } = context
    if (path && value) {
      // get all headings
      const headings = value.filter(
        (block: PortableTextBlock) =>
          block._type == 'block' &&
          (block.style as PortableTextTextBlock['style'])?.startsWith('h'),
      ) as PortableTextTextBlock[]

      if (headings.length && headings[0].style !== 'h2')
        return {
          message: 'First heading should be h2.',
          path: [{ _key: headings[0]._key }],
        }
      return true
    }

    return true
  })
```



**Example: Sequence and nesting of headings**

You can even go so far as to check how headings are nested and which sequence they appear in.

Have a look at [this example](https://github.com/bobinska-dev/page-builder-example/blob/efc024c4fbaa028411111ef7baccb9b1660334dd/sanity/schemas/validations/portableTextValidations.ts#L101-L139).

### Validating block children

Portable Text validations can even go as far as the individual text spans within a block or their marks.

> [!TIP]
> Child validations can also be used to define forbidden strings and characters, remove unwanted spaces, or enforce other rules from your style guide.

In [this example](https://github.com/bobinska-dev/page-builder-example/blob/efc024c4fbaa028411111ef7baccb9b1660334dd/sanity/schemas/validations/portableTextValidations.ts#L151-L202), we check whether the block is all bold and warn the editors when

1. Headings are bold -> Since we don't want any extra font weight to be applied to our headings.
2. A block (that is not a heading) is all bold -> if a whole block is bold, the editors should consider making it a heading.



## Conclusion

If you customise your Portable Text fields to our specific needs and even add `preview`, `block`, `annotation` and other components using the [Component API](https://www.sanity.io/docs/studio/intro-to-custom-studio-components), you and your editors will have a wonderful experience when writing (or working with) Portable Text.



# How to add custom YouTube blocks to Portable Text

> [!NOTE]
> This developer guide was contributed by Knut Melvær (Head of Developer Community and Education).

This guide will take you through adding a custom YouTube block to the Portable Text Editor and show you how to render it in some common front end frameworks like React and Vue.

There are times when you want to embed a video in your block content. Usually, you would paste the HTML embed code into the editor and move along. With Portable Text, however, you want to ensure that your content is structured and that you don't embed too many assumptions about the presentation. Maybe you want to use this video in a native app or a specialized component in your front end(s). 

In this tutorial, you will learn how to add a custom YouTube block into your schema and the Portable Text Editor, add a custom preview component, and serialize it in the front end. 

## Add a YouTube schema type

The main thing you are interested in for a YouTube embed is its ID. The ID is included in the video URL, so if you have this URL: `https://www.youtube.com/watch?v=asENbq3s1m8&feature=youtu.be`, the ID is `asENbq3s1m8`. 

Now, you could ask our content creators to find this ID and only put that into Sanity, but that seems a bit too much to ask since it isn't always easy since YouTube URLs can contain a lot of parameters. You can assume you can work with a URL and find the ID programmatically wherever you want to show the video.

You will start by adding a simple schema type. An object type called `youtube`, with a field called `url`. You can extend this later if you want to specify a time stamp, a title, or other things.

**schemaTypes/youTubeType/index.ts**

```typescript
import {defineType, defineField} from 'sanity'
import {PlayIcon} from '@sanity/icons/Play'
import {YouTubePreview} from './YouTubePreview'

export const youtube = defineType({
  name: 'youtube',
  type: 'object',
  title: 'YouTube Embed',
  icon: PlayIcon,
  fields: [
    defineField({
      name: 'url',
      type: 'url',
      title: 'YouTube video URL',
    }),
  ]
})
```

The next step is to import the file into the `schema/index.ts` to make it available as a `type: 'youtube'`. Your setup might be slightly different, so the point here is that it should end up in the array of schema types that you specify in `sanity.config.ts`. 

**schemaTypes/index.ts**

```typescript
// ...all other imports
import {youTubeType} from './youTubeType'

export const schemaTypes = [
  // ...all other schema types
  youTubeType
]

```

Now, you can add this field as a `type` in any array field which also includes a `block` type to render the Portable Text editor, like the example below.

**schemaTypes/blockContentType.ts**

```typescript
import {defineType, defineArrayMember} from 'sanity'

export const blockContentType = defineType({
  name: 'blockContent',
  type: 'array',
  title: 'Body',
  of: [
    defineArrayMember({
      type: 'block'
    }),
    defineArrayMember({
      type: 'youTube'
    })
  ]
})
```

The new YouTube field should appear in the Portable Text Editor’s toolbar, where you can paste in a YouTube URL. However, getting an actual preview of the video you want to embed would be nice. So, let’s add that.

![The Portable Text editor showing the YouTube Embed object](https://cdn.sanity.io/images/3do82whm/next/a6fc1201f2623728381f187e368e756c8b06dcd4-1202x296.png)
*The Portable Text editor showing the YouTube Embed object*

## Add a block preview

You may be familiar with how to configure previews with Sanity. First, you `select` the fields you want to get content from. You can assign them to variables like `title`, `subtitle`, and `media` and let the Studio figure out the rest. Or you can return a `prepare` function if you want more control over what goes into the slots.

However, you can also pass a React component and gain full control over what’s rendered. It still gets the variables that you define in the `select` object as props. You will use a pre-built component called [React Player](https://www.npmjs.com/package/react-player) for the YouTube preview. It can take a YouTube URL (and other video streaming services) and automatically render the embedded video player. We will also install Sanity UI and use its components to make sure the UI is nice.

Start by installing the dependencies in your Sanity project folder:

**npm**

```shell
npm install react-player @sanity/ui
```

**pnpm**

```shell
pnpm add react-player @sanity/ui
```

**yarn**

```shell
yarn add react-player @sanity/ui
```

**bun**

```shell
bun add react-player @sanity/ui
```

Then, make a new file called `YouTubePreview.tsx`:

**schemaTypes/youTubeType/YouTubePreview.tsx**

```tsx
import type {PreviewProps} from 'sanity'
import {Flex, Text} from '@sanity/ui'
import ReactPlayer from 'react-player'

export function YouTubePreview(props: PreviewProps) {
  const {title: url} = props

  return (
    <Flex padding={3} align="center" justify="center">
      {typeof url === 'string' 
        ? <ReactPlayer src={url} /> 
        : <Text>Add a YouTube URL</Text>}
    </Flex>
  )
}
```

Go back to the `youTubeType` schema file with your YouTube schema type, and add the preview configuration, as well as the custom component: 

**schemaTypes/youTubeType/index.ts**

```typescript
import {defineType, defineField} from 'sanity'
import {PlayIcon} from '@sanity/icons/Play'
import {YouTubePreview} from './YouTubePreview'

export const youTubeType = defineType({
  name: 'youTube',
  type: 'object',
  title: 'YouTube Embed',
  icon: PlayIcon,
  fields: [
    defineField({
      name: 'url',
      type: 'url',
      title: 'YouTube video URL',
    }),
  ],
  preview: {
    select: {title: 'url'},
  },
  components: {
    preview: YouTubePreview,
  },
})
```

You should now see a preview embed of the YouTube video blocks when they are previewed in the Portable Text editor.

![Portable Text editor with YouTube preview](https://cdn.sanity.io/images/3do82whm/next/df5fb8517406aa762444c131442f465cc11aaf93-1280x1040.png)
*Portable Text editor with YouTube preview*

This also mirrors what you would do in a React front end. And speaking of front ends, let's take a closer look at how this data should be implemented.

## Render the YouTube embed in a front end

When you insert a YouTube embed in the Portable Text Editor, it will be added as a custom block in the Portable Text array. If you are unfamiliar with Portable Text, [you can learn more in this introduction](https://www.sanity.io/docs/developer-guides/presenting-block-text). You can also tag along with this tutorial and follow the code examples if you just want to get a feel for it.

### React

To render custom blocks, as the YouTube embed is, you have to add a serializer to the [Portable Text package for React](https://www.npmjs.com/package/@portabletext/react) (currently called `@portabletext/react`). If you don't have this in your project from before, you'll have to install it by running `npm install @portabletext/react` in your command line.

Next up is installing a component to show the YouTube video. In this case, you can install the same React Player dependency we used for the preview:

**npm**

```shell
npm install react-player
```

**pnpm**

```shell
pnpm add react-player
```

**yarn**

```shell
yarn add react-player
```

**bun**

```shell
bun add react-player
```

And where you want to be able to output this YouTube embed, do the following:

**src/components/Body.tsx**

```tsx
import {PortableText} from '@portabletext/react'
import ReactPlayer from 'react-player'

const components = {
  types: {
    youTube: ({value}) => {
      const {url} = value
      return <ReactPlayer src={url} />
    }
  }
}

export default function Body({blocks}) {
  return (
    <PortableText value={blocks} components={components} />
  )
}
```

This is a minimal example, so you might need to make some adjustments to make it render nicely within your design system. You should probably also extract it into its own component instead of having it inline as here.

### Vue

To render custom blocks, as the YouTube embed is, you have to add a serializer to the [Portable Text package for Vue](https://www.sanity.io/plugins/portabletext-vue). If you don't have this in your project from before, you'll have to install it first. We also need a performant YouTube embed component like [vue-lite-youtube-embed](https://www.npmjs.com/package/vue-lite-youtube-embed):

**npm**

```shell
npm install @portabletext/vue vue-lite-youtube-embed
```

**pnpm**

```shell
pnpm add @portabletext/vue vue-lite-youtube-embed
```

**yarn**

```shell
yarn add @portabletext/vue vue-lite-youtube-embed
```

**bun**

```shell
bun add @portabletext/vue vue-lite-youtube-embed
```

Then create a new `YouTube.vue` file for our component. This component uses vue-lite-youtube-embed which provides a fast, privacy-friendly YouTube embed, and extracts the video ID from the URL:

**YouTube.vue**

```html
<template>
  <youtube :video-id="videoId"></youtube>
</template>
<script>
import { getIdFromUrl } from 'vue-youtube'
export default {
  data() {
    return {
      videoId: getIdFromUrl(this.url)
    };
  },
  props: {
    url: {
      type: String,
      default: () => ""
    }
  }
};
</script>
```

In our component for Portable Text, you can now import `YouTube.vue` and add it to our `components` object, like this:

**Body.vue**

```html
<script setup>
import {PortableText} from '@portabletext/vue'
import YouTube from './YouTube.vue'
defineProps({
  value: {
    type: Array,
    default: () => []
  }
})
const components = {
  types: {
    youTube: YouTube
  }
}
</script>
<template>
  <PortableText :value="value" :components="components" />
</template>
```

That's it! Now, the data under `value` in the custom YouTube block in our Portable Text array will be passed on as props to the `YouTube.vue` component.

Not using React or Vue? No problem!

Head over to [Portable Text on GitHub](https://portabletext.org) for libraries for other popular frameworks like Svelte, Astro, and so on. They all work on the same principles as above while following the conventions of the framework.



# Converting Inline Styles to Sanity Block Decorators

> [!NOTE]
> This developer guide was contributed by KJ O'Brien (Senior Support Engineer).

When migrating rich text content from other CMS platforms into Sanity, you may encounter inline styles like `<span style="font-weight: bold;">`. By default, Sanity's `html-to-blocks` method doesn't handle these styles, leading to loss of formatting in the converted content. This guide walks you through creating custom deserialization rules to properly handle these cases. We'll also cover how to process nested spans, preserve spaces between words, and merge decorators effectively. By the end, you'll have a robust solution for rich text migration without losing valuable formatting.

### **Overview**

When migrating rich text content from another CMS to Sanity, inline styles (e.g., `<span style="font-weight: bold;">`) often need to be translated into decorators like `strong`, `em`, or `underline`. This guide walks you through customizing the `html-to-blocks` serialization to handle such cases, including nested spans with multiple styles.

### **Prerequisites**

- Familiarity with Sanity's [block](https://www.sanity.io/docs/studio/block-type) content structure.
- Installed `@sanity/block-tools` package.

### **The Problem**

By default, the `html-to-blocks` method handles common tags like `<strong>` and `<em>`, but inline styles like `<span style="font-weight: bold;">` are ignored. To convert these spans into appropriate decorators, we need to extend the deserialization rules.

### **A Solution**

**Custom Deserialization Rules**

The following code demonstrates how to handle spans with inline styles, including nested spans:

```javascript
const customRules = [
  {
    deserialize(el, next) {
      if (el.tagName === 'SPAN') {
        const style = el.style
        const marks = []

        // Collect marks from inline styles
        if (style?.fontWeight === 'bold' || style?.fontWeight >= 600) {
          marks.push('strong')
        }
        if (style?.fontStyle === 'italic') {
          marks.push('em')
        }
        if (style?.textDecoration.includes('underline')) {
          marks.push('underline')
        }

        // Initialize an array to hold the final processed spans
        const processedSpans = []

        // Process child nodes recursively
        Array.from(el.childNodes).forEach((node) => {
          if (node.nodeType === 3) {
            // Handle text nodes
            const text = node.nodeValue
            if (text) {
              processedSpans.push({
                _type: 'span',
                text,
                marks,
              })
            }
          } else {
            // Process child elements recursively
            const childNodes = next([node]).map((child) => {
              if (child._type === 'span') {
                return {
                  ...child,
                  marks: [...new Set([...(child.marks || []), ...marks])],
                }
              }
              return child
            })
            processedSpans.push(...childNodes)
          }
        })

        return processedSpans
      }

      return undefined // Pass to the next rule if not a span
    },
  },
]
```

### **Key Features**

1. **Handles Inline Styles:** Detects and converts `font-weight`, `font-style`, and `text-decoration` styles into Sanity decorators.
2. **Supports Nested Spans:** Processes nested spans by merging inherited and child decorators.
3. **Prevents Redundant Marks:** Uses `Set` to ensure each mark is applied only once.

### **How It Works**

- **Marks Collection:** The `style` property of the `<span>` tag is inspected to determine which marks to apply.
- **Child Node Processing:** Text nodes are wrapped into spans, and child elements are recursively processed with the accumulated marks.
- **Nested Styles:** For child elements, existing marks are merged with those inherited from the parent.

### **Testing the Solution**

To ensure your custom rules work as expected, test the following HTML input:

```html
<span style="font-weight: bold;">Want to <span style="font-style: italic;">learn <span style="text-decoration: underline;">a lot</span> more</span></span>
```

This should output:

```json
[
  {
    "_type": "span",
    "text": "Want to ",
    "marks": ["strong"]
  },
  {
    "_type": "span",
    "text": "learn ",
    "marks": ["strong", "em"]
  },
  {
    "_type": "span",
    "text": "a lot",
    "marks": ["strong", "em", "underline"]
  },
  {
    "_type": "span",
    "text": " more",
    "marks": ["strong", "em"]
  }
]

```

### **Conclusion**

This approach allows for seamless migration of rich text content with inline styles into Sanity's block content, enabling you to preserve the original formatting. For additional details, refer to the [@sanity/block-tools documentation](https://www.npmjs.com/package/@sanity/block-tools).



# Add things to Portable Text

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

This guide will lead through the steps you need to level-up your use of block content: setting up a block content schema and adding custom blocks and annotations to it. Then we will configure renderers for the Portable Text Editor in your studio, which will help users see their content enrichment inline. In addition we will also learn how to query the custom image blocks & annotations and set up serialisers so you can render your awesome content in React! 

You might also be interested in this guide on [adding inline blocks to portable text](https://www.sanity.io/docs/developer-guides/add-inline-blocks-to-portable-text-editor).

## Setting up block content and adding custom blocks in your schemas

The first step in this journey will be setting up block content and adding some custom blocks and annotations for internal and external links. External links (url based annotations of type `link`) are now part of the default config of the portable text editor (block content). 

[Look through the Code in TS ](https://github.com/bobinska-dev/sharing-is-caring#ultimate-guide-for-customising-portable-text---from-schema-to-react-component-)

> [!WARNING]
> From now on we will use `PT` for Portable Text and `PTE` for Portable Text Editor. 
> PT and block content can be used exchangeably in most cases, but block content refers more to the schema of an array of blocks, while PT is mostly used to describe the JSON based output created in the PTE. 

## Step 1: Adding an image block with alt text 

```typescript
import { defineType } from 'sanity'

export default defineType({
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    },
    // this is our first custom block which will make it possible to add block images with alt text fields into your portable text
    {
      type: 'image',
        fields: [
          {
            name: 'alt',
            type: 'string',
            title: 'Alternative text',
            description: 'Important for SEO and accessiblity.',
              options: {
                isHighlighted: true,
            },
          },
        ],
    }
  ]
})
```

Our PTE (portable text editor) toolbar will look like this now:

![Screenshot of Portable Text Editor Toolbar with external link annotation and image block icons](https://cdn.sanity.io/images/3do82whm/next/377e07a374382aadb1666664f5b610d46a589bc7-1662x92.png)
*Portable Text Editor Toolbar with external link annotation and image block*

When we add an external link, we are asked to paste in a url. But what if we want to validate for certain url types and more? 
we might need to add some logic to the existing link. And since we want to also link to internal pages, we will set this up in the next step as well.

## Step 2: Adding external and internal links as annotations

So let's add internal links and some more validation to the annotations.

In order to customise the default link annotation, we need to define it in our schema as well as the internal page reference (internalLink). 



### Customising the block content array

```typescript
 // if you want to have a re-usable blockContent type, 
 //you need to create an object and add this as fields. 
 // In our case we are using this block content array directly in our page schema as a field.
 
 defineField({
    name: 'content',
    title: 'Content',
    type: 'array',
    of: [
      {
        type: 'block',
        
        // INLINE BLOCKS
        // to understand what this does, visit: https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor
        of: [
          defineField({
            name: 'authorReference',
            type: 'reference',
            to: [{ type: 'author' }],
          }),
        ],
        
        // Let's add some custom annotations by setting marks.annotations
          marks: {
            annotations: [
            //this is our external link object which we override from the default by defining it
              {
                name: 'link',
                type: 'object',
                title: 'Link',
                fields: [
                  {
                    name: 'href',
                    type: 'url',
                    validation: (Rule) =>
                      Rule.uri({
                        allowRelative: false,
                        scheme: ['http', 'https', 'mailto', 'tel'],
                      }),
                  },
                ],
              },

            // this is our internal link object which is a reference to page documents
              {
                name: 'internalLink',
                type: 'object',
                title: 'Link internal page',
                // we can add the icon which will show in the toolbar by importing an icon from a library or pasting in a react component.
                // we use import { LinkIcon } from '@sanity/icons/Link' in this case
                icon: LinkIcon,
                fields: [
                  {
                    name: 'reference',
                    type: 'reference',
                    title: 'Reference',
                    to: [{ type: 'page' }],
                  },
                ],
              },
            ],
          },
        },
        {
          type: 'image',
          fields: [
            {
              name: 'alt',
              type: 'string',
              title: 'Alternative text',
              description: 'Important for SEO and accessiblity.',
            },
          ],
        },
      ],
    }),
```

> [!TIP]
> You can override default block types by defining them yourself. 
> In addition you can deactivate any functionality by setting it to an empty array: 
> `..., styles: [], decorators:` `[], ...`
> [You can find out more options here](https://www.sanity.io/docs/studio/customizing-the-portable-text-editor) but in general: if you can define it, you can deactivate it. 

This is what our PTE now looks like: we can add internal and external links as well as block images.

![Screenshot of portable text edior, with example text: The blue (underlined) text is an external link, the grey (dotted underlined) text is a reference to another page doc and we can see that the 2 link annotations as well as the block images appear in the toolbar.](https://cdn.sanity.io/images/3do82whm/next/0a82d74b85f5441991ac47b385f9ff2c5b3d675d-1248x574.png)
*The blue (underlined) text is an external link, the grey (dotted underlined) text is a reference to another page doc and we can see that the 2 link annotations as well as the block images appear in the toolbar.*

![Screenshot showing a pte. The edit modal for an external link annotation is opened, which exposes the url field defined in the schema](https://cdn.sanity.io/images/3do82whm/next/f84846cb4c0fbde84872cc3881dccb40c205b36f-1314x644.png)
*external link annotation with the opened edit modal, which exposes the url field defined in the schema*

![Screenshot showing a pte. The edit modal for an internal link annotation is opened, which exposes the reference (to a page doc) field defined in the schema](https://cdn.sanity.io/images/3do82whm/next/868c8ce930771ad5ac751d920f5559929e19b506-1428x660.png)
*Internal link annotation with the opened edit modal, which exposes the reference (to a page) field defined in the schema*

Neat! Let's say you want to make sure users can see which pages are linked to in the PTE directly without having to click on the annotation. We can achieve this by adding a custom renderer for the PTE.  

## Step 3: Adding custom renderer for the PTE

So let's start with the external link annotation. So we have a way to show the `href` when needed but not disrupt the flow of reading. A good way to do this is using a `ToolTip` component from the Sanity UI, which will appear on hover. In addition we will add a LinkIcon in front of the annotated text.

### Creating a LinkRenderer component

I would create a `components` folder in the root of your studio, but you can add this in any other part of your repo.

**components/LinkRenderer.jsx**

```jsx
import { LinkIcon } from '@sanity/icons/Link'
import { Box, Text } from '@sanity/ui'
import { Tooltip } from '@sanity/ui/tooltip'
import styled from 'styled-components'

const LinkRenderer = (props) => {
  // you don't need to pass down the props yourself, Sanity will handle that for you
  
  return (
    // the ToolTip component wraps the annotation 
    <Tooltip
    //we define the content in a Box, so we can add padding, and Text where we pass the href value in if present
      content={
        <Box padding={3}>
          <Text align="center" size={1}>
            {props.value?.href || 'No url found'}
          </Text>
        </Box>
      }
      // then we define the placement and other options
      placement="bottom"
      fallbackPlacements={['right', 'left']}
      portal
    >
    
    {/* InlineAnnotation is a styled span element, which we use to add padding. */}
      <InlineAnnotation>
        <LinkIcon /> 
        {/*  renderDefault() is needed to let the studio handle the functionality of the annotation. */}
        <>{props.renderDefault(props?.children)}</>
        
      </InlineAnnotation>
    </Tooltip>
  )
}

const InlineAnnotation = styled.span`
  padding-left: 0.3em;
  padding-right: 0.2em;
`
export default LinkRenderer

```

Now we can add the `LinkRenderer` as a custom component to the link annotation in our block content array.

### Setting a custom annotation component for the link object 

```typescript
...,
defineField({
  name: 'link',
  type: 'object',
  title: 'Link',
  fields: [
    {
      name: 'href',
      type: 'url',
      title: 'Url',
      validation: (Rule) =>
        Rule.uri({
          allowRelative: false,
          scheme: ['http', 'https', 'mailto', 'tel'],
          }),
    },
  ],
  components: {
    annotation: LinkRenderer,
  },
}),
...

```

### Defining a renderer and a custom annotation component for internal links

Let's do the same for internal links. A caveat here is, that the internal link is a reference so we need to fetch some of the referenced document data for our `ToolTip` component. If we don't do that we will only get the `_id` of the referenced document in `reference._ref`.

In addition we want to setup a listener and make sure we fetch the data a bit delayed, so we can make sure, our data has been able to be stored in the content lake. 

**components/InternalLinkRenderer.jsx**

```jsx
import { LinkIcon } from '@sanity/icons/Link'
import { Stack, Text } from '@sanity/ui'
import { Tooltip } from '@sanity/ui/tooltip'
import { useEffect, useState } from 'react'
import { useClient } from 'sanity'
import styled from 'styled-components'

// This is a basic setTimeout function which we will use later to delay fetching our referenced data
const sleep = (ms) => {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

const InternalLinkRenderer = (props) => {
  // in order to be able to query for data in the studio, you need to setup a client version
  const client = useClient({
    apiVersion: '2026-03-01',
  })
  
  // we will store the data we queried in a state
  const [reference, setReference] = useState({})

  // we need to initialise the subscription
  let subscription
  // then get the data from the referenced document
  useEffect(() => {
    // so let's setup the query and params to fetch the values we need.
    const query = `*[_id == $rev]{title, 'slug': slug.current}[0]`
    const params = { rev: props.value.reference?._ref }
    
    const fetchReference = async (listening = false) => {
      listening && (await sleep(1500)) // here we use the sleep timeout function from the beginning of the file
      
      await client
        .fetch(query, params)
        .then((res) => {
          setReference(res)
        })
        .catch((err) => {
          console.error(err.message)
        })
    }
    
    // since we store our referenced data in a state we need to make sure, we also get changes 
    const listen = () => {
      subscription = client
        .listen(query, params, { visibility: 'query' })
        .subscribe(() => fetchReference(true))
    }
    fetchReference().then(listen)
    
    // and then we need to cleanup after ourselves, so we don't get any memory leaks
    return function cleanup() {
      if (subscription) {
        subscription.unsubscribe()
      }
    }
  }, [])

  return (
    <Tooltip
      content={
        <Stack gap={2} padding={3}>
          <Text align="center" size={1}>
            {reference?.title || 'No title or slug found'}
          </Text>
          <Text align="center" size={1} muted>
            {reference?.slug ? `Slug: /${reference.slug}` : ''}
          </Text>
        </Stack>
      }
      fallbackPlacements={['right', 'left']}
      placement="bottom"
      portal
    >
      <InlineAnnotation>
        <LinkIcon /> <>{props.renderDefault(props.children)}</>
      </InlineAnnotation>
    </Tooltip>
  )
}
const InlineAnnotation = styled.span`
  padding-left: 0.3em;
  padding-right: 0.2em;
`
export default InternalLinkRenderer

```

```typescript
...,
  defineField({
    name: 'internalLink',
    type: 'object',
    title: 'Link internal page',
    icon: LinkIcon,
    components: {
      annotation: InternalLinkRenderer,
    },
    fields: [
      {
        name: 'reference',
        type: 'reference',
        title: 'Reference',
        to: [{ type: 'page' }],
      },
    ],
  }),
...
```

Let's look at the results! 

![Screenshot of the pte with an open tooltip, which displays the data of the referenced document from internalLink](https://cdn.sanity.io/images/3do82whm/next/3be128d043ca36049f5d3dd195143e42a53dfd35-954x512.png)
*Awesome! We got everything setup and the tool-tips appear on hover with the referenced document data*

## Step 4: Setting up the query for PT and the custom block data

Now that we have everything set up we need to make sure we also query the custom block data not included in the PT output. Annotation data is stored in a `markDef` array at the end of each block. 

```json
...,
"_type": "block",
"children": [...],
"markDefs": [
  {
    "_key": "21647abff82b",
    "_type": "link",
    "href": "https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor"
  },
  {
    "_key": "c0465b1db68e",
    "_type": "internalLink",
    "reference": {
      "_ref": "abc0397c-ca33-4fba-97bb-1717e86e7261",
      "_type": "reference"
    }
  }
],
...
```

As you can see `reference` does not include the referenced data, but the `href` value for the external `link` is there.

> [!TIP]
> You can get the data of your portable text editor by using the inspect tool in the document you're working in by pressing `crtl` + `alt` + `i` or the right elipsis `...` menu.

In order to get this referenced data for our front-end we need to setup a GROQ query using [joins](https://www.sanity.io/docs/specifications/groq-joins). But since the reference is inside an array (`markDefs`) inside of an array (`blocks`) we need to use a special GROQ syntax for [dereferencing](https://www.sanity.io/docs/specifications/groq-operators) and accessing certain types in arrays.

A best practice to handle queries like that (which you will need to reuse wherever PT is part of the data) is to define field queries separately and import them into the page queries.

### Query parts and exports 

With this set up you can import the queries in your pages or components, and can easily add changes when needed. 

```groq
// lib/sanity.queries.ts

const bodyField = `
  body[]{
    ...,
    // add your custom blocks here (we don't need to do that for images, because we will get the image url from the @sanity/image-url package)
    
    markDefs[]{ 
        // so here we make sure to enclude all other data points are included
        ..., 
        // then we define that if a child of the markDef array is of the type internalLink, we want to get the referenced doc value of slug and combine that with a / 
        _type == "internalLink" => { "href": "/"+ @.reference-> slug.current },
        },
  }
`
const pageFields = `
  _id,
  title,
  description,
  "slug": slug.current,
  ${bodyField}
`

export const pageQuery = `
*[_type == "page" && slug.current == $slug][0] {
  ${pageFields}
}
`
```

## Step 5: Using the data in the `PortableText` component

Now that we have our data ready we can use them in our front-end. But, we need to define, how the PT data should be rendered. Fortunately there are a couple of packages we can use to [serialise PT](https://www.sanity.io/docs/developer-guides/presenting-block-text). In React based Frameworks, we can use `@portabletext/react` ([Repo](https://github.com/portabletext/react-portabletext))



```jsx
// Body.jsx
import { PortableText } from '@portabletext/react'
import { urlForImage } from 'lib/sanity.image'
import Image from 'next/image'

const Body = (props)=> {
  // we pass the content, width & height of the images into each instance of the Body component 
  // content is our array of blocks
  const { content, imgWidth, imgHeight } = props;
  
  const customBlockComponents = {
  // first we tackle our custom block types
  types: {
    image: ({value}) => {
      // we need to get the image source url, and since @sanity/image-url will give us optimised images for each instance we use it
      const imgUrl = urlForImage(value.assset).height(imgHeight).width(imgWidth).url()
      
      return <Image
            width={imgWidth}
            height={imgHeight}
            alt={value.alt}
            src={imgUrl}
            sizes="100vw"
            priority={false}
          />
    },
  },

  // then we define how the annotations should be rendered
  marks: {
    link: ({children, value}) => {
      const rel = !value.href.startsWith('/') ? 'noreferrer noopener' : undefined
      return (
        <a href={value.href} target='_blank' rel={rel}>
          {children}
        </a>
      )
    },
    internalLink: ({children, value}) => {
      return (
        <a href={value.href}>
          {children}
        </a>
      )
    },
  },
}

return <PortableText
  value={content}
  components={customBlockComponents}
/>
}
export default Body
```

### Getting image source urls image objects

And last but not least: we add the `urlForImage` functionality setup. 

```javascript
// lib/sanity.image.ts

import createImageUrlBuilder from '@sanity/image-url'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET

const imageBuilder = createImageUrlBuilder({ projectId, dataset })

export const urlForImage = (source) =>
  imageBuilder.image(source).auto('format').fit('max')

```



And we're done 🥳

[If you prefer to use TS checkout these snippets ](https://github.com/bobinska-dev/sharing-is-caring#ultimate-guide-for-customising-portable-text---from-schema-to-react-component-)



# Change the height of the PTE

> [!NOTE]
> This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

Portable Text Editors are a fantastic tool! 

Learn how to reduce the height and add a character counter based on the max length set in the field validation. 

![A Portable text editor with a reduced height](https://cdn.sanity.io/images/3do82whm/next/d9e126fe66e9c11eb6449846fdb0024722e3eaad-720x382.png)
*A Portable text editor with a reduced height*

Sometimes, you need a Portable Text Editor but want it to take up less space in your document form. 

In my example, we have a PTE that can only use decorators and annotations, and we need to keep track of the character count. Custom input components make this possible. 

## The schema

```tsx
// schemas/portableText/overview.tsx

import { CharacterCountInputPTE } from '@/sanity/components/inputs/CharacterCount'
import { defineArrayMember, defineType, PortableTextBlock } from 'sanity'

/** ## `overview` Type - reduced Portable Text
 *
 * The height of the input is reduced to 2 lines.
 *
 * @name overview
 * @type {PortableTextBlock[]}
 * @validation {Rule} - Required, max 280 characters
 * @description Used both for the <meta> description tag for SEO, and the personal website subheader.
 *
 * ### Blocks
 * - **Decorators**: `em`, `strong`
 * - **Annotations**: none
 * - **Styles**: none
 * - **Lists**: none
 *
 *
 */
export default defineType({
  name: 'overview',
  description: 'Short and on point – max. 280',
  title: 'Meta & SEO Description',
  type: 'array',
  // You can override the max values from the schema by setting a validation on the field
  validation: (Rule) => Rule.required().max(280),
  components: {
    input: CharacterCountInputPTE,
  },
  of: [
    // Paragraphs
    defineArrayMember({
      lists: [],
      marks: {
        annotations: [],
        decorators: [
          {
            title: 'Italic',
            value: 'em',
          },
          {
            title: 'Strong',
            value: 'strong',
          },
        ],
      },
      styles: [],
      type: 'block',
    }),
  ],
})

```

### The field

```typescript
    // * * * Title * * *
    defineField({
      name: 'title',
      type: 'overview',
      description: 'This will be used as the H2 of the Sections. Short and on point – max. 200',
      // setting a validation on the field will override the validation on the overview schema
      validation: (Rule) => Rule.required().max(200),
    }),

```

## The custom input component

We use the `max` values set on the field schema definition (`validation`) and then use them in the component to show users how many characters they already used.

Then we also wrap `renderDefault`  in a container that we use to change the height of this specific PTE, making sure it's resizable, using `styled-components`.



`initialActive` set to true allows editors to just focus on the PTE and start writing without the need to activate it. 



```tsx
// CharacterCountInputPTE.tsx

import { Stack, Text } from '@sanity/ui'
import { toPlainText } from 'next-sanity'
import { PortableTextInputProps, StringInputProps } from 'sanity'
import styled from 'styled-components'
import { toPlainText } from 'next-sanity'

export function CharacterCountInputPTE(props: PortableTextInputProps) {
  // check if validations exist
  // @ts-ignore
  const validationRules = props.schemaType.validation[0]._rules || []
  const characters = props.value ? toPlainText(props.value).length : 0

  //check if max Character validation exists and get the value
  const max = validationRules
    .filter((rule) => rule.flag === 'max')
    .map((rule) => rule.constraint)[0]
  
  return (
    <Stack gap={3}>
      <Container id={'PTE-height-container'}>
        {props.renderDefault({
          ...props,
          // remove the need to activate the PTE 
          initialActive: true,
        })}
      </Container>
      <Text muted align={'right'} size={1}>
        Characters: {characters}
        {max ? ` / ${max}` : ''}
      </Text>
    </Stack>
  )
}
// add a specific height to the PTE without losing the ability to resize it
const Container = styled.div`
  [data-testid='pt-editor'][data-fullscreen='false'] {
    height: 100px;
  }
`

```

And that's it! 🥳



# Create your own Sanity template

#### Ready to submit your template?
Submit your template for review, then join the #template-creators Slack channel to connect with others and share your work.
[Submit your template](https://community.sanity.tools/intent/create/type=contribution.starter;template=contribution.starter/)

[Sanity templates](https://www.sanity.io/templates) are reusable, pre-configured projects that come with an integrated, customizable front-end. They're a great way to streamline your development process, ensuring consistency across different projects, and reducing the time required to get a new project off the ground.

Creating a template for the Sanity community is a great way to share your expertise with other developers and make starting future Sanity projects a breeze.

This guide shows you how to create and submit a new Sanity template.

## Prerequisites

- A GitHub account. The Sanity template kit is a GitHub template repository, and every submitted template needs a public repository.
- [Node.js](https://www.sanity.io/docs/help/a5f6caba-53c9-4a9f-96ef-1bd1ae8f5c10) 18 or later, and npm. This is the floor for the template kit and `@sanity/template-validator`; the front-end framework you choose may require a newer version.
- A Sanity account and project. See Getting started with Sanity.

> [!NOTE]
> Before you start
> Submitting a template requires a Sanity community profile. The submission form's **Author(s)** field only accepts profiles that already exist, so create yours before you start. In the [Community Studio](https://community.sanity.tools), select **Your profile**, add your handle and short bio, then publish it.

## Step 1: Create a repository from the Sanity template kit

The easiest way to get started creating a Sanity template is by using the official [Sanity template kit](https://github.com/sanity-io/template-kit) as a starting point.

This repo provides all the necessary boilerplate for creating a template; including a generic studio configuration, a folder to generate your front-end framework of choice, and the `@sanity/template-validator` [GitHub action](https://docs.github.com/en/actions/writing-workflows/quickstart) to ensure your template meets Sanity's technical requirements.

Click the **Use this template** button in GitHub to create a new repository from the Sanity template kit.

![The Use this template button on the Sanity template kit repository page in GitHub.](https://cdn.sanity.io/images/3do82whm/next/875c3209d26a5a7563aab9eae7ba1b3dd2347085-910x300.png)

## Step 2: Initialize your front end

After creating your repository from the [Sanity template kit](https://github.com/sanity-io/template-kit), initialize your preferred front end inside a directory called `frontend`.

The following command initializes a new app with Next.js, but you can use any front-end framework to build out your template.

**npm**

```shell
npx create-next-app@latest frontend
```

**pnpm**

```shell
pnpm dlx create-next-app@latest frontend
```

**yarn**

```shell
yarn dlx create-next-app@latest frontend
```

**bun**

```shell
bunx create-next-app@latest frontend
```

> [!TIP]
> Protip
> If you're prompted to initialize a new git repository for your project, say 'No' since you're initializing the front-end inside of an existing git repository.

## Step 3: Build out your template

Now, the fun part! After initializing your project, it's time to build out your template.

Before you start, familiarize yourself with the [Opinionated Guide to Sanity Studio](https://www.sanity.io/docs/developer-guides/an-opinionated-guide-to-sanity-studio).

This guide provides best practices for:

- File organization for schemas and plugins
- Defining reusable and extensible content schemas
- Formatting GROQ queries for readability and performance

For advice on building a front end that works well with Sanity, look at Sanity's official, framework-specific templates for [Next.js](https://www.sanity.io/templates/nextjs-sanity-clean), [Astro](https://www.sanity.io/templates/astro-sanity-clean), [Nuxt](https://www.sanity.io/templates/nuxt-sanity-clean), [Angular](https://www.sanity.io/templates/angular-sanity-clean), [SvelteKit](https://www.sanity.io/templates/sveltekit-sanity-clean), and [Remix](https://www.sanity.io/templates/remix-sanity-clean).

These templates show how to use advanced Sanity features like the [Presentation tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool) and the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) to build powerful experiences into your template like:

- [Visual editing](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing)
- [Drag-and-drop page building](https://www.sanity.io/docs/visual-editing/enabling-drag-and-drop)
- [Real-time content updates](https://www.sanity.io/docs/developer-guides/live-content-guide)

If you need help or inspiration, join the `#template-creators` channel in the [Slack community](https://slack.sanity.io/). Other template creators and the Sanity team can help.

## Step 4: Validate your template

Once you're ready to submit your Sanity template, you can validate it using the `@sanity/template-validator` package.

From the repository root, install dependencies with `npm install`, then run the validator:

**npm**

```shell
npm run validate
```

**pnpm**

```shell
pnpm run validate
```

**yarn**

```shell
yarn run validate
```

**bun**

```shell
bun run validate
```

This script ensures your template complies with Sanity's technical requirements. You can find a full [list of validation rules](https://github.com/sanity-io/template-validator#validation-rules), but in general the script checks for:

- A `package.json` with `sanity`, `next-sanity`, or `@sanity/client` in `dependencies`.
- A `sanity.config.js`, `.ts`, or `.tsx` file and a `sanity.cli.js` or `.ts` file.
- One of `.env.template`, `.env.example`, `.env.local.template`, or `.env.local.example`, containing `SANITY_PROJECT_ID` (or `SANITY_STUDIO_PROJECT_ID`) and `SANITY_DATASET` (or `SANITY_STUDIO_DATASET`).

See the [current list of featured templates](https://www.sanity.io/templates) for examples of well structured template projects.

### Common errors

```sh
Environment template in root package contains invalid environment variable syntax. Please see https://dotenvx.com/docs/env-file for proper formatting.
```

This error is usually due to whitespace before the `=` in your `.env.template`, `.env.example`, `.env.local.template`, or `.env.local.example` file.

```sh
Invalid package.json file in frontend
```

This error means the `package.json` in that package could not be parsed — check for malformed JSON (trailing comma, missing brace). If instead you see `At least one package must include "sanity" as a dependency in package.json`, add a Sanity-related dependency such as `sanity`, `next-sanity`, or `@sanity/client` to `dependencies` (devDependencies are not checked).

## Step 5: Submit your template

Once your template passes the validator, [submit your template for review](https://community.sanity.tools/intent/create/type=contribution.starter;template=contribution.starter/) in the Community Studio.

You'll need to provide:

- A title and description of your template.
- A relative address (slug) for your template's page under sanity.io/templates.
- A link to your template's GitHub repository, which must be public and start with `https://github.com/`. A repository link is optional only if you provide a purchase URL for a commercial template.
- A 1200px x 750px image or screenshot of your template.
- At least one author. Each author must already have a Sanity community profile.
- The application frameworks your template uses, such as Next.js.
- The CSS frameworks your template uses, such as Tailwind CSS.
- At least one use case, such as e-commerce.

Although optional, add a link to a deployed example application that shows your template in action.

A deployed example lets readers preview your template and decide whether it fits their use case.

![The template submission form in the Sanity community Studio, with fields for title, description, repository URL, and a screenshot.](https://cdn.sanity.io/images/3do82whm/next/fab4594a9cb536682f2487bf05886cc4e27eec40-3248x2112.png)

The Sanity team reviews your submission to ensure it meets Sanity's quality standards and provides value to the community. For reference, a good template is:

- **Purposeful**: Clearly communicates the use case, for example an e-commerce store with Sanity and Shopify, or a documentation site with search powered by Algolia.
- **Configurable**: Includes sensible defaults but stays customizable.
- **Documented**: Provides clear setup instructions in a `README.md`. The templates gallery renders your README on your template's listing page, so write it for readers deciding whether to use your template. The Sanity template kit includes one to start from.

If changes are needed, the Sanity team reaches out with feedback on how to improve your template.

## Next steps

Congratulations! Once your template is approved, it is listed in the official [Sanity templates gallery](https://www.sanity.io/templates) in Sanity Exchange. After approval, be sure to:

- **Promote your template**: Share your template with the Sanity community on [LinkedIn](https://www.linkedin.com/company/sanity-io/posts/?feedView=all), [X](https://x.com/sanity_io?lang=en), and [Bluesky](https://bsky.app/profile/sanity.io) and tag us @sanity.
- **Iterate and improve your template**: Address user feedback by checking in on your template in Sanity Exchange. Keep your template updated as dependencies and best practices evolve.
- **Contribute other templates**: Don't stop with just one! Build additional templates or join discussions in the `#template-creators` channel [on Slack](https://slack.sanity.io/).

By sharing your work, you’re empowering the community to create amazing projects with Sanity.



# Community Code of Conduct

## Welcome to the Sanity Community

All participants in the Sanity.io community must comply with the Sanity.io code of conduct. This includes discussions and contributions to GitHub repositories, our Discord community, meetups, events, and other venues hosted by Sanity.io. We’re all on the same team and responsible for maintaining a welcoming community.

## The Community Discord

If you join the [Sanity Discord](https://snty.link/community), you will be welcomed with this message:

This space is community-driven, so feel free to suggest new channels, emojis, integrations, or whatever you think will make this place better and more useful for everyone (#feedback). We welcome everyone to help each other out!

**Read this before asking for help:**

- Keep the noise down by only posting a question in one appropriate channel (we will delete duplicates without notice)
- State your problem/question in the main message, and provide useful context in threads (error messages, steps to reproduce, etc.)
- Make code examples easy to read by using the code block formatting or linking to gists on Github, etc.
- Do not tag Sanity team members in threads or send them Direct Messages, even though you are frustrated and stuck. This is incredibly distracting for the team and undermines the purpose of a community where your question can be helpful to others. The Sanity team is told to ignore unsolicited DMs.
- If you use ChatGPT to help people, check the quality of the answer first. ChatGPT often gets it wrong.

## The short version

Everyone at Sanity is dedicated to providing an inclusive, safe, and harassment-free environment for all participants regardless of age, disability, sexual orientation, gender, gender identity, physical appearance, body size, race, ethnicity, nationality, or religion (or lack thereof). This includes memes, emojis, and GIFs. And remember, we’re not only “guys” – including us at Sanity HQ – (we are people, folks, friends, y’all, etc.).

> [!TIP]
> Protip
> The community Discord server features automatic moderation that triggers on use of words and phrases recognized as non-inclusive for under-represented groups in tech. The purpose of this bot is to mitigate language that prevents people from feeling belonging and to educate how we all can act more inclusively in a space with many different people coming from different backgrounds and experiences.

All attendees, speakers, sponsors, and volunteers must agree with this code of conduct at events and in our community spaces. We expect cooperation from everyone to help ensure a safe environment for everybody.

If someone makes you or anyone else feel unsafe or unwelcome, please report it as soon as possible.

**You can make a report by:**

- Contacting a member of Sanity HQ as denoted in their community chat username
- Emailing: [community@sanity.io](https://www.sanity.iomailto:community@sanity.io)

## The long version

First of all, thank you for reading this! We want you to come to our community and feel that you can be your authentic self.

The Sanity community is made of people with a common agenda, cause, and interests, who collaborate by sharing ideas, information, and resources. It is important that each and every person attending our events and spaces has a positive and rewarding experience and to that end, we are committed to providing a safe, productive, and welcoming environment for all participants, speakers, and staff.

### Expected behavior

We expect everyone to:    

- Follow this code of conduct; it’s important for us!
- Use inclusive language
- Let people finish speaking and give each other space to share their thoughts
- Treat each other with consideration and curiosity, and celebrate that we’re all stoked about technology, structured content, and the Sanity community (we hope!)
- Be mindful that everyone comes with their own struggles and experiences
- Keep all conversations in public channels unless you have explicit consent from the receiver to have direct interaction or direct message (DM)
- Celebrate and cheer for each other!
- Follow health guidelines from local health authorities and the venue (wearing masks, hygiene, etc.)
- Make content that’s accessible, not limited to adding alternative text to slides and images, making transcriptions for audio, and adding closed captioning to videos

### Unacceptable behavior

Harassment is not tolerated. That includes, but is not limited to:      

- Comments that are unwelcomed or offensive relating to gender, gender identity, and expression, sexual orientation, disability, physical appearance, body size, race, age, religion
- Sexual images, GIFs, and memes in public spaces
- Spamming messages or advertisements
- Deliberate intimidation, stalking, or following
- Purposeful misgendering of any person
- Harassing photography or recording
- Disruption of talks or other events
- Inappropriate physical contact
- Invasion of personal space
- Unwelcome sexual attention
- Bring any kind of weapon
- Advocating for or encouraging any of the above behavior
- Portray any of the above behavior in any medium (chat, email, social media, webinars, etc.)
- Make false reports or exploit the code of conduct to exclude people or for purposes of retaliation
- Physical violence or threatening behavior is not tolerated. That includes, but is not limited to, hitting, punching, pushing, kicking, pinching, biting, shouting, and spitting.

### Consequences of unacceptable behavior

Engaging in unacceptable behavior will result in the following: 

- A host contacting you to figure out what happened
- You’ll be given instructions for expected behavior going forward, and you are expected to comply immediately
- Removal from the event or space
- Lose of access to Sanity’s community platforms and future events

### Reporting

If someone makes you or anyone else feel unsafe or unwelcome, please report it as soon as possible.

**You can make a report by:**

- Contacting a member of Sanity HQ as denoted in their community chat username
- Emailing: [community@sanity.io](https://www.sanity.iomailto:community@sanity.io)



### Emergency contact information

For life-threatening emergencies, call 911.



**Mental health and crisis phone numbers:**

##### Emergency contact phone numbers

| San Francisco Crisis Line | 1-415-781-0500 |
| U.S. National Suicide & Veteran Crisis Hotline | 1-800-273-8255 |
| TTY - U.S. National Suicide Line | Use your preferred relay service or dial 711 then 1-800-273-8255 |



**Mental health and crisis chat services:**

[U.S. National Suicide Prevention](https://suicidepreventionlifeline.org/chat/)

[Veteran Crisis Chat](https://www.veteranscrisisline.net/get-help-now/chat/)

[ASL - U.S. National Suicide Chat](https://vibrant.aslnow.io/app/8/10004)







# Migrate plugins to support Content Releases

The introduction of [Content Releases](https://www.sanity.io/docs/studio/content-releases-configuration) into Sanity Studio introduces some new core concepts available through the `sanity` package in Sanity Studio.

## Handle version document IDs

Before Content Releases, a document `_id` was either a published ID or a draft ID. A release adds a third form, the version document: one copy of the document per release. Your plugin can now encounter all three forms:

- Published: no prefix, as in `7f3a1c2e-9b21-4c6d-8a10-5e2f0b7d4c88`.
- Draft: prefixed with `drafts.`, as in `drafts.7f3a1c2e-9b21-4c6d-8a10-5e2f0b7d4c88`.
- Version: prefixed with the release ID, in the form `versions.<releaseId>.<publishedId>`, as in `versions.rSummerDrop.7f3a1c2e-9b21-4c6d-8a10-5e2f0b7d4c88`.

The `sanity` package re-exports helpers that read and build all three forms. Use them instead of checking prefixes yourself:

- `getPublishedId(id)`: returns the published ID, whichever of the three forms you pass it.
- `getVersionId(id, releaseId)`: returns the version ID for a document in the named release. It throws `Version can not be "published" or "drafts"` if you pass either of those names as the release ID.
- `getVersionFromId(id)`: returns the release ID from a version ID, and `undefined` for draft and published IDs.
- `isVersionId(id)`: returns `true` for a version ID.
- `isDraftId(id)`: returns `true` for a draft ID.
- `isPublishedId(id)`: returns `true` when the ID carries neither a `drafts.` nor a `versions.` prefix.

Hand-rolled prefix handling is the most common cause of breakage, because a `drafts.` check reports a version document as published. Replace it with the helpers:

**Before**

```ts
function getIds(documentId: string) {
  // Both of these are wrong for `versions.rSummerDrop.7f3a1c2e`:
  // `replace` leaves the prefix in place, and the document is reported
  // as published because it carries no `drafts.` prefix.
  return {
    publishedId: documentId.replace('drafts.', ''),
    isDraft: documentId.startsWith('drafts.'),
  }
}
```

**After**

```ts
import {getPublishedId, getVersionFromId, isDraftId} from 'sanity'

function getIds(documentId: string) {
  return {
    publishedId: getPublishedId(documentId),
    isDraft: isDraftId(documentId),
    // `undefined` for draft and published IDs
    releaseId: getVersionFromId(documentId),
  }
}
```

Outside the studio, in a script or your front end, use the `@sanity/id-utils` package, which offers the same operations with branded ID types. For the ID and path rules the Content Lake enforces, see [IDs and paths](https://www.sanity.io/docs/content-lake/ids).

## Read the current perspective with `usePerspective`

To read the current perspective, use `usePerspective`. It returns the closest perspective context: the global Studio perspective, or the document-scoped perspective when called inside a document pane that overrides it. The hook throws `usePerspective must be used within a PerspectiveProvider` outside a provider. `usePerspective` is currently in beta; its return shape may change in a minor release. For example:

```ts
import {usePerspective} from 'sanity'

function MyComponent() {
  const {perspectiveStack} = usePerspective()
  // ...
}
```

`usePerspective` returns:

```ts
interface PerspectiveContextValue {
  /* The selected perspective name; either a release or `published` */
  selectedPerspectiveName: 'published' | ReleaseId | undefined
  /**
   * The release id as `r<string>`; undefined if the selected
   * perspective is `published` or `drafts`
   */
  selectedReleaseId: ReleaseId | undefined
  /* The current global release */
  selectedPerspective: TargetPerspective
  /**
   * The stacked perspective ids, ordered chronologically, representing the
   * state of documents at a point in time. Pass it as the client
   * `perspective` param. e.g. ["published"] | ["drafts"] |
   * ["releaseId2", "releaseId1", "drafts"]
   */
  perspectiveStack: PerspectiveStack
  /* The excluded perspectives */
  excludedPerspectives: string[]
  /* The selected bundle: `published`, `drafts`, or a release id */
  bundle: PerspectiveBundle
}
```

Further, you can use a `ReleaseId` to query document versions within a release, as described in [Content Releases API](https://www.sanity.io/docs/content-lake/content-release-document-flow).

## Custom input component plugins

Plugins that make custom input components available through custom input types have particular concerns. Before Content Releases, a document form might have made its inputs read-only while data was loading, being re-synced, or in a transient state. Perspectives now let you view the document form of the published document version. That form is read-only in all cases except liveEdit. In those cases, your plugin must pass the `readOnly` prop available when rendering custom components:

**schemaTypes/productCode.ts**

```ts
import {defineField, defineType, type InputProps} from 'sanity'

function ProductCodeInput(props: InputProps) {
  const {readOnly} = props
  // Spread `readOnly` into your Sanity UI input, or use it to disable your own control
  return props.renderDefault(props)
}

export const productCode = defineType({
  name: 'productCode',
  type: 'object',
  fields: [
    defineField({
      name: 'value',
      type: 'string',
      components: {input: ProductCodeInput},
    }),
  ],
})
```

## Troubleshooting

### Custom input ignores read-only state

A custom input that calls `onChange` while the form is read-only throws `Attempted to patch a read-only document`. The patch never reaches the document, so the edit is discarded. Any local state your input holds still looks changed until the next render, which makes the edit appear to have worked.

What the editor sees depends on where your component calls `onChange`:

- From an event handler: a toast titled **Uncaught error** with the message as its description. React error boundaries don't catch errors in event handlers, and nothing appears in the browser console. Repeat attempts collapse into the same toast.
- From a `useEffect`: the throw happens inside React, so the error boundary replaces the document pane with an error screen.
- During the initial render: a different error, `Attempted to patch the Sanity document during initial render or in an `useInsertionEffect`. Input components should only call `onChange()` in a useEffect or an event handler.`

The form is read-only whenever the selected perspective is `published` and the document type doesn't set `liveEdit`. Nothing warns you during development that your input is ignoring the prop, and the built-in read-only labels come from inputs that honor it, so your component shows none of them. The first signal is the toast after an editor tries to type.

Read `readOnly` from the props your component receives and disable the input while it's `true`. To test the path, switch the Studio perspective to published and try to edit a document whose type doesn't set `liveEdit`.



# Getting started with Sanity

#### Quickstart guides

[Next.js](https://www.sanity.io/docs/next-js-quickstart)
Set up Sanity with a Next.js App Router front end

[Nuxt.js](https://www.sanity.io/docs/nuxt-js-quickstart)
Set up Sanity with a Nuxt.js front end

[Astro](https://www.sanity.io/docs/astro-quickstart)
Set up Sanity with a Astro front end

[React Router (Remix)](https://www.sanity.io/docs/react-router-quickstart)
Set up Sanity with a React Router front end

[Sanity Studio](https://www.sanity.io/docs/sanity-studio-quickstart)
Get started with a standalone Studio

[AI coding agents](https://www.sanity.io/docs/getting-started/ai-coding-agents)
Set up Sanity with an AI coding agent like Claude Code or Cursor

[AI app builders](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart)
Connect Sanity to v0, Bolt, Lovable, and other AI app builders

#### Sanity Learn

[Day One Content Operations](https://www.sanity.io/learn/course/day-one-with-sanity-studio/prerequisites)
Get a top-level understanding of the entire Sanity Content Operating System while building out a multi-application monorepo.

[Mastering Content Operations](https://www.sanity.io/learn/track/sanity-developer-essentials)
The complete track of courses for certification

#### Other ways of getting started

[A very short introduction](https://www.sanity.io/docs/getting-started/the-sanity-content-operating-system-an-introduction)
A short introduction to the Sanity Content Operating System

[Templates](https://www.sanity.io/templates)
Start with official and community contributed templates



# Platform introduction

It provides the structured foundation, automation layer, and agentic context companies need to move faster, work smarter, and power every content experience—from websites to AI agents.

Build a content system that matches how your business operates with three interconnected layers:

- [Content Lake](https://www.sanity.io/docs/content-lake): The content database.
- [AI-first tools](https://www.sanity.io/docs/ai): MCP server, skills, and rules no matter where you build.
- [APIs and SDKs](https://www.sanity.io/docs/apis-and-sdks): Libraries and frameworks to build on top of Sanity.

[The Sanity Dashboard](https://www.sanity.io/docs/dashboard) for running your content operations apps, such as:

- [Studio](https://www.sanity.io/docs/studio): A customizable CMS.
- [Media Library](https://www.sanity.io/docs/media-library): Enterprise asset management.
- [Content Agent](https://www.sanity.io/docs/content-agent): Prompt your content.
- [Canvas](https://www.sanity.io/docs/canvas): AI-powered document editor.
- [Your custom-built apps](https://www.sanity.io/docs/app-sdk): SDK-driven apps for any use case.

Unlike traditional or headless CMSes, Sanity provides a foundation for your entire content lifecycle across all digital channels, with the flexibility to evolve as your needs change.

You can get started with Sanity in minutes. [Go here to explore the different ways](https://www.sanity.io/docs/getting-started).



# What is content operations?

Content operations is the practice of running content as a coordinated system rather than as a series of one-off publishing tasks. It covers how content is modeled, who is allowed to change it, how a change reaches production, where the result is delivered, and how you tell whether it worked.

AI-powered content operations extends that system to AI agents. Agents read, draft, and act on the same structured content your team works in, under the same schema and the same permissions.

Sanity supplies the parts that make this work: a queryable content database, applications for people to work in, an automation layer that reacts to changes, and machine-readable context for agents. One set of content serves websites, apps, and agents, so you don't maintain a separate copy per channel.

With Sanity, you can:

- **Model content around your business** instead of around a page template, and reuse the same fields across every channel.
- **Give writers a tool that fits the work**: a structured editor in [Sanity Studio](https://www.sanity.io/docs/studio), or a freeform AI-assisted draft in [Canvas](https://www.sanity.io/docs/canvas).
- **Automate the repetitive parts of publishing** with [Functions](https://www.sanity.io/docs/functions) that run on Sanity's infrastructure whenever content changes.
- **Let AI agents work on your real content** through [Sanity Context](https://www.sanity.io/docs/ai/sanity-context), a hosted [MCP server](https://www.sanity.io/docs/ai/mcp-server), and [Agent Actions](https://www.sanity.io/docs/agent-actions) for generating and transforming content.
- **Deliver to every channel from one source**: query your content with [APIs and SDKs](https://www.sanity.io/docs/apis-and-sdks) for websites, apps, digital displays, and agents.

#### Start building

[Quickstart: AI coding agents](https://www.sanity.io/docs/getting-started/ai-coding-agents)
Written for AI coding agents: point your agent (Claude Code, Cursor) at this page and it installs the toolkit, gets you authenticated, and scaffolds a running Sanity Studio while guiding you.

[Quickstart: AI app builders](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart)
Connect Sanity to your AI app builders and prompt it to turn your existing Markdown or hardcoded content into editable Sanity content.

[Start building, no signup needed](https://www.sanity.new)
Start building with sanity.new, then claim your project when you're ready.

## Core concepts

Five ideas underpin content operations in Sanity. They are not steps to complete in order, and each one shapes the others: what you learn from delivery can change your governance policies, and agent-assisted creation can change how you model content in the first place.

### Structured content

Sanity stores content as structured data rather than as pages or blocks of markup. You define document types and fields in code, and every field is individually addressable: you can query it on its own, reuse it in another channel, and change it without touching anything else. See [Content modeling](https://www.sanity.io/docs/apis-and-sdks/introduction-to-schemas) in Sanity Studio to define the shape of your content.

Addressability is also what makes structured content measurable. You can attribute performance to a specific field or component in the analytics and experimentation tools you already run, rather than to an opaque page.

### Governance and workflow

Governance decides who can change what; workflow decides how a change reaches production. [Roles and permissions](https://www.sanity.io/docs/content-lake/roles-concepts) control access to projects, datasets, and documents. [Content Releases](https://www.sanity.io/docs/user-guides/content-releases) group changes that span many documents—a localization pass, a product launch, a scheduled campaign—so they publish together as one unit.

Model both around how your organization actually operates, rather than around the limits of a content management system (CMS). This is the layer that decides whether the rest of the system is safe to open up to more people, and to agents.

### Content applications

People work in applications, not in a database. [Sanity Studio](https://www.sanity.io/docs/studio) is a customizable, code-defined editing environment. [Canvas](https://www.sanity.io/docs/canvas) is a freeform document editor for drafting, with AI assistance built in. [Media Library](https://www.sanity.io/docs/media-library) manages assets across projects. When none of those fit, build your own application with the [App SDK](https://www.sanity.io/docs/app-sdk) and run it alongside the rest in the Sanity Dashboard.

### Automation and delivery

[Functions](https://www.sanity.io/docs/functions) run your code on Sanity's infrastructure when content changes, so enrichment, validation, and syndication happen without a person triggering them. [APIs and SDKs](https://www.sanity.io/docs/apis-and-sdks) deliver the result: query your content with GROQ from a website, a mobile app, a digital display, or an agent.

Every channel reads from the same source of truth. Adding one means writing a new query, not maintaining a new copy of the content.

### Agentic context

An agent needs more than API access; it needs to know what your content means. [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) is a hosted Model Context Protocol (MCP) server that gives agents structured, read-only access to a dataset. [Agent Actions](https://www.sanity.io/docs/agent-actions) work in the other direction, generating and transforming content through the API against your existing schema.

Together they let an agent answer questions about your content and make changes to it, without a bespoke integration for every tool you want to connect.



# Projects without an account

`sanity.new` is a set of instructions written for AI coding agents. You give your agent the URL, it fetches the page, and it follows what it finds there to build you a working Sanity project. No account, no login, and nothing for you to configure while it works.

The page serves markdown to agents explaining how they can set up a full Sanity project, with content schema, content, and a Studio, as well as a small Next.js app to render this content. A full end-to-end slice. You can then claim this project in your Sanity account and continue to iterate on your app.

#### Start here

[sanity.new](https://sanity.new)
The landing page for this experience. Includes the agent prompt and the markdown delivered to your agent.

## Requirements

You need a coding agent that can run commands in a terminal, and Node.js 22.12 or newer. You do not need a Sanity account, or any credentials.

If your agent cannot run terminal commands, use the [AI app builders quickstart](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart) instead. That path creates an account as part of connecting.

## What your agent does

1. **Creates the project.** `npx sanity@latest new` calls Sanity's provisioning API without authenticating. It returns a project, a dataset, a token scoped to that project, and a claim link that expires in 72 hours.
2. **Scaffolds the folders.** A Studio in `sanity/` and a Next.js frontend in `web/`. The project token is written to `sanity/.env.local`. The frontend gets `NEXT_PUBLIC_SANITY_PROJECT_ID` and `NEXT_PUBLIC_SANITY_DATASET` in `web/.env.local`, not the token. Run it inside an app you already have and it creates only `sanity/`, leaving your app alone.
3. **Asks what you are building.** Unless it already knows from your conversation, it stops and asks in one sentence what kind of content site you are building. The answer shapes the schema, so a blog and a product catalog come out differently.
4. **Builds an end-to-end slice.** Schema types for two or three document types, real content published to the dataset, and pages in the frontend that query it with GROQ and render it. Narrow and working, rather than broad and half-finished.
5. **Verifies and hands over.** It queries the content back and loads the page that renders it before telling you it is done, and it gives you the claim link.

From there it is an ordinary Sanity project, and your agent builds on it the way it would build on any other.

## Doing it yourself

None of this needs an agent. You can run `npx sanity@latest new` from a terminal to provision a project without an account.

If you want a more bespoke setup, we recommend signing up, or creating the project in an account you already have. That skips the claim step and the limitations covered below, and lets you choose your own template and framework. Provisioning without signing up is most useful for agents.

## Core concepts

### The 72-hour window

An unclaimed project expires 72 hours after it is created. When it expires, the project and its content are deleted and cannot be recovered.

Claiming is free and takes about a minute. You can claim at any point in the window, and claiming early costs you nothing. It does not interrupt your agent or change what it is building. There is no reason to wait until the end.

### The claim link

The claim link is a credential. Anyone who opens it and signs in becomes the owner of the project. Treat it the way you would treat a password: keep it out of shared channels, issue trackers, and version control.

The link works once. After a project is claimed, the link reports that the project is already claimed rather than transferring it again.

If you lose the link, your agent can recover it. Running `sanity projects unclaimed` lists every unclaimed project created on this machine, each with its claim link and expiry. The CLI also prints a reminder before other commands until the project is claimed.

Your agent should give you the claim link early rather than at the end. You can claim the project while it is still working.

### The project token

An unclaimed project has no members, so your agent acts as the project itself rather than as a person. It does that with a project token, written to `sanity/.env.local`.

The token writes, publishes, and reads drafts. Published documents are public, so the frontend does not need it. The token stays valid after you claim the project, so nothing breaks at the moment you claim. Once you have signed in as yourself, you can remove it.

## Limitations before you claim

An unclaimed project is a real project. The content you create is real content, the Studio is a real Studio, and none of it is throwaway. A few things are held back while the project has no owner.

**You cannot deploy a hosted Studio.** `sanity deploy`, which publishes a Studio to a `sanity.studio` address, needs an owner and fails until the project is claimed. Running the Studio locally works throughout, and that is where your agent does its work.

**A local Studio signs in with the token.** There is no account and no browser session yet, so the Studio authenticates from the token rather than from a login. Your agent handles this. CORS is set for two origins: `http://localhost:3333` for the Studio, and `http://localhost:3000` for a frontend app. No other CORS origins are set, so a Studio or app on a different port cannot connect from the browser. After you claim, you can add and remove origins in project settings.

> [!WARNING]
> Keep the project token on the server
> The token can write to your project, so it belongs in server-side environment variables only. Never put it in a variable your framework sends to the browser, which means anything prefixed `NEXT_PUBLIC_`, `PUBLIC_`, `VITE_`, or `SANITY_STUDIO_`. The token remains valid after you claim, but you will not need it to open the Studio. You can sign in with your account instead.

**Image and file uploads are unavailable.** Your agent uses external image URLs while it builds. Once the project is claimed, you can upload real assets.

**You cannot add datasets, tokens, or CORS origins.** An unclaimed project has one dataset and one token. The rest of project settings opens up once the project is yours.

**There is no user account yet.** You act as the project rather than as a person, so `sanity login` is not part of this flow.

When your agent fetches `sanity.new`, it knows about these limitations and how to work around them.

## What happens when you claim

The project transfers to your Sanity account when you open the claim link. Sign in with an account you already have, or create one as part of claiming.

You choose which organization the project lands in. If you do not have one yet, Sanity creates it for you, and you can rename it later in settings. You can move the project to a different organization at any point after claiming.

Claiming also does the following:

- You become an administrator of the project.
- The project moves onto our free growth trial plan.
- Everything your agent built comes with it, including content, schema, and history.
- The project token keeps working.

## After you claim

The project is yours and works as it is. However, you can clean a few things up after claiming.

1. To act as yourself rather than as the project, run `sanity login`.
2. To stop commands in that directory authenticating as the project, remove the `SANITY_AUTH_TOKEN` line from `sanity/.env.local`.

Optionally, you can also do the following:

- Host the Studio at a `sanity.studio` address with `sanity deploy`.
- Upload real images. Uploading works as soon as the project is claimed, from the Studio or with `client.assets.upload()`. To render them, add `@sanity/image-url` to build display URLs from the stored asset references.
- Connect your editor to Sanity directly with `sanity mcp configure`.

## Next steps

- [Quickstart: AI coding agents](https://www.sanity.io/docs/getting-started/ai-coding-agents) for the full setup path with Claude Code, Cursor, and similar agents.
- [Get started with AI](https://www.sanity.io/docs/ai/get-started) for using Sanity docs in your editor, `llms.txt`, and Sanity Learn.



# Coding agents

> [!NOTE]
> This guide is written for your AI coding agent
> Point your coding agent (Claude Code, Cursor, or similar) at this page and ask it to set up Sanity. It reads these steps, builds your project, and walks you through the choices. You don't need to install anything first.
> **Agents: **If you don’t have CLI access, go to the [AI app builder quickstart](https://www.sanity.io/docs/getting-started/ai-app-builder-quickstart) to guide your user. If the user has no Sanity account and doesn’t want to create one yet, go to [sanity.new](https://sanity.new) and follow the instructions there. It creates a real project they claim later, so you can build now. Else, follow the steps below in order. Everything you need is on this page, and `sanity init` will set up Sanity's skills and MCP server for you as it runs.
> To understand what an unclaimed project is before you start, see [Projects created without an account](https://www.sanity.io/docs/getting-started/projects-without-an-account).

Set up Sanity for the user by following each step. Tell the user what you're doing in a line or two per step, ask the questions called out below, and use the command-line flags shown so the only thing that pauses is the sign-in step, which the user handles.

## 1. Ask what the user is building

Ask one question: what kind of content is this for? A blog, a marketing site, a product catalog, or something else? Use the answer to shape the schema and the project name. If the user is unsure, default to a blog.

## 2. Create the Studio (this signs the user in and sets up Sanity's tools)

If the user has no Sanity account and doesn’t want to create one, stop and use [sanity.new](https://sanity.new) instead, because the rest of this page assumes an account. Otherwise, run `init`. If the user isn't logged in, it prompts them to sign in or create a Sanity account in the browser. Let them finish that, since you can't do browser sign-in for them. Init automatically installs Sanity's agent skills and configures the MCP server, and nothing else prompts. Set the project name from what the user is building, and run it from the repository root, not inside a frontend app folder:

**npm**

```shell
npx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

**pnpm**

```shell
pnpm dlx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

**yarn**

```shell
yarn dlx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

**bun**

```shell
bunx sanity@latest init --yes --project-name "<project name>" --dataset-default --template clean --typescript --output-path studio
```

This signs the user in if needed, installs the skills and MCP server, creates a project and a public production dataset, and scaffolds the Studio in `studio/`.

## 3. Define the schema and deploy it

Use the sanity best practices skill. Add schema types in `studio/` for the content the user described, then deploy so the editor and content tools can see them:

**Terminal**

```sh
cd studio && npx sanity@latest schema deploy
```

## 4. Add sample content

Create three to five sample documents so the Studio isn't empty. Use the Sanity MCP `create_documents` tool, or write a short script with `@sanity/client`.

## 5. Start the Studio and hand off

Start the Studio and give the user the local URL:

**Terminal**

```sh
cd studio && npx sanity@latest dev
```

Tell the user the Studio is running at `http://localhost:3333` and that they can start editing. Offer next steps: connect a frontend, or add more content types.

See also: [Get started with AI](https://www.sanity.io/docs/ai/get-started) covers using Sanity docs in your editor, `llms.txt`, and Sanity Learn.



# AI app builders

> [!NOTE]
> Using a local agentic code editor/harness?
> If you use an agentic code harness like Cursor, Claude Code, Kiro, OpenCode, etc, then ask it to read [the agentic coding agent quickstart](https://www.sanity.io/docs/getting-started/ai-coding-agents).

## 1. Connect Sanity

Add Sanity from your platform's connector or integrations list. Search for "Sanity" and sign in. Signing in creates your Sanity account if you don't have one yet.

Platforms that lists the Sanity connector: [v0](https://v0.app), [Bolt](https://bolt.new), [Lovable](https://lovable.dev), and [Replit](https://replit.com).

If Sanity isn't listed, add the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server) manually with this configuration:

**MCP configuration**

```json
{
  "url": "https://mcp.sanity.io",
  "type": "http"
}
```

## 2. Describe your content and prompt the builder

Tell the builder what you're building and what content you already have, like Markdown files or hardcoded text. The prompt above asks it to turn that into Sanity content types, give you a place to edit them, and read them back into your app.

Paste this into your app builder to get started:

**Prompt**

```text
Add Sanity to manage content for this app. Use the Sanity connector if it's available. Turn my existing content (Markdown and hardcoded text) into Sanity content types, give me a place to edit it, and read it back into the app.
```

## 3. What the builder sets up

It creates a Sanity project with a public production dataset, deploys a schema for your content, adds CORS origins for client-side data loading, and gives you a hosted Studio (a `your-name.sanity.studio` URL) to edit in. Your existing content moves into Sanity and becomes the source of truth.

## 4. If your app can't load content

Ask the builder to add your platform's preview domain as a CORS origin: `https://*.vusercontent.net` for v0, `https://*.bolt.host` for Bolt, or `https://*.lovable.app` for Lovable.

## 5. Edit your content

Open the hosted Studio to add and edit content. Your changes show up in your app.

Go to [get started with AI](https://www.sanity.io/docs/ai/get-started) to learn how to set up Sanity with your agentic code editors, and to [Sanity Learn](https://www.sanity.io/learn) for courses on how to become a certified Sanity developer. 

