
How to Use Sanity's Mutation API
Sanity's Mutation API is the HTTP endpoint for writing data to the Content Lake. You send a POST to /data/mutate/<dataset> with a list of mutations (create, createOrReplace, createIfNotExists, patch and delete), authenticated with a token that has write access. Every mutation in a request runs as one transaction, so they all succeed or none do. In JavaScript, @sanity/client wraps the same endpoint with methods like client.create(), client.patch(id).set({...}).commit() and client.transaction().
Most content is written by editors in the Studio, but sooner or later code needs to write too: a form that saves submissions, a script that imports products, a migration or a counter that tracks page views. This guide covers tokens, the raw HTTP format, each mutation type, the patch operations, transactions, the JavaScript client, optimistic locking with ifRevisionID, query parameters such as visibility and returnIds, and how to write safely from a website.
Get a Write Token
Reads of public datasets don't need authentication, but every write does. Create a token in sanity.io/manage under API > Tokens and give it the Editor role, or a custom role limited to the document types it should write.
Store it in a server-only environment variable:
SANITY_WRITE_TOKEN="sk..."
Never prefix it with NEXT_PUBLIC_, PUBLIC_ or SANITY_STUDIO_, because those variables are bundled into browser JavaScript. A write token in the browser lets anyone change or delete your content.
The HTTP Endpoint
Mutations go to one endpoint per dataset:
curl -X POST "https://<projectId>.api.sanity.io/v2025-02-19/data/mutate/production?returnIds=true" \
-H "Authorization: Bearer $SANITY_WRITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mutations": [
{
"create": {
"_type": "post",
"title": "Hello from the Mutation API"
}
}
]
}'
The response lists what happened:
{
"transactionId": "Vk4jK8mQ2tR9...",
"results": [{ "id": "8f2c1e3a-...", "operation": "create" }]
}
Writes always go to the live API, never the CDN. For the other endpoints, including the Actions API that publishes and unpublishes drafts, see the Sanity HTTP API.
The Five Mutation Types
| Mutation | What it does | Fails when |
|---|---|---|
create | Creates a new document. Generates an _id if you don't set one | A document with that _id exists |
createOrReplace | Creates the document, or replaces it entirely | Never, for valid input |
createIfNotExists | Creates the document only if the _id is free | Never; it's a no-op if it exists |
patch | Changes specific fields on an existing document | The document doesn't exist |
delete | Deletes a document by ID, or every match of a query | Never; a missing ID is a no-op |
A few practical notes:
createsuits new user content, such as a form submission, where you don't care about the ID.createOrReplacesuits syncing from another system with stable IDs, such asproduct-1234. It overwrites the whole document, so fields you don't send are removed.createIfNotExistssuits seeding singletons likesiteSettings, and pairs well with a patch in the same transaction.- Every document needs a
_type. IDs that start withdrafts.create drafts, and IDs without that prefix are published documents.
{
"mutations": [
{
"createIfNotExists": {
"_id": "siteSettings",
"_type": "siteSettings",
"title": "My Site"
}
},
{
"createOrReplace": {
"_id": "product-1234",
"_type": "product",
"name": "Mug",
"price": 12
}
},
{ "delete": { "id": "post-old" } }
]
}
Patch Operations
A patch changes part of a document without sending the whole thing. Operations in one patch run in a fixed order: set, setIfMissing, unset, inc, dec, then insert.
{
"mutations": [
{
"patch": {
"id": "post-1",
"setIfMissing": { "tags": [] },
"set": { "title": "Updated title", "seo.metaTitle": "Updated title" },
"unset": ["legacyField"],
"inc": { "views": 1 },
"insert": {
"after": "tags[-1]",
"items": ["sanity"]
}
}
}
]
}
What each operation does:
setsets fields to new values. Keys are paths, soseo.metaTitlereaches into an object, andbody[_key=="abc"].texttargets one array item by key.setIfMissingsets a value only if the field is empty. Use it to make sure an array or object exists before inserting into it.unsetremoves fields by path.incanddecadd to or subtract from numbers. They're applied on the server, so two concurrent increments both count.insertadds items to an arraybeforeoraftera position, orreplaces items.tags[-1]is the last item, andtags[0]is the first.diffMatchPatchapplies a text diff to a string, which is how the Studio merges simultaneous typing.
Patch Many Documents With a Query
Instead of an id, a patch or delete can take a GROQ query, with parameters:
{
"mutations": [
{
"patch": {
"query": "*[_type == 'post' && category._ref == $categoryId]",
"params": { "categoryId": "category-news" },
"set": { "archived": true }
}
},
{
"delete": {
"query": "*[_type == 'formSubmission' && _createdAt < $cutoff]",
"params": { "cutoff": "2026-01-01T00:00:00Z" }
}
}
]
}
A delete by query can remove up to 10,000 documents per request. For bigger jobs, process documents in batches. Query mutations are powerful, so test the query in Vision first and consider a dryRun.
Query Parameters
These options go on the URL, or into the client's commit() options:
| Parameter | Default | Effect |
|---|---|---|
returnIds | false | Include the IDs of affected documents |
returnDocuments | false | Include the full documents after the change |
visibility | sync | sync waits until changes are queryable; async returns once committed; deferred skips real-time indexing for bulk imports |
dryRun | false | Validate the transaction without applying it |
autoGenerateArrayKeys | false | Add a _key to array items that lack one |
transactionId | Generated | Set your own unique transaction ID |
tag | None | Label the request in your request logs |
autoGenerateArrayKeys saves a lot of boilerplate. Array items in Sanity need a unique _key, and the Studio shows warnings when it's missing. visibility=async speeds up scripts that don't immediately read their own writes, and deferred is meant for large imports.
Writing With @sanity/client
In JavaScript, use @sanity/client with a token. Keep a separate write client so the token never ends up in code that runs in the browser:
// src/sanity/lib/write-client.ts
import "server-only";
import { createClient } from "@sanity/client";
export const writeClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2025-02-19",
token: process.env.SANITY_WRITE_TOKEN,
useCdn: false,
});
The server-only import makes the build fail if a Client Component imports this file. If you use next-sanity, its createClient export is the same function. The next-sanity guide covers the read client.
Create, Replace and Delete
const submission = await writeClient.create({
_type: "formSubmission",
name: "Ada",
email: "ada@example.com",
message: "Hello",
});
console.log(submission._id);
await writeClient.createOrReplace({
_id: "product-1234",
_type: "product",
name: "Mug",
price: 12,
});
await writeClient.createIfNotExists({
_id: "siteSettings",
_type: "siteSettings",
title: "My Site",
});
await writeClient.delete("post-old");
await writeClient.delete({
query: '*[_type == "formSubmission" && spam == true]',
});
Each method returns the resulting document by default.
The Patch Builder
client.patch(id) returns a builder. Chain operations, then call commit():
await writeClient
.patch("post-1")
.setIfMissing({ tags: [] })
.set({ title: "Updated title" })
.unset(["legacyField"])
.inc({ views: 1 })
.append("tags", ["sanity"])
.commit({ autoGenerateArrayKeys: true });
append(path, items) and prepend(path, items) are shortcuts for insert('after', 'tags[-1]', items) and insert('before', 'tags[0]', items). Use insert('replace', 'gallery[_key=="abc"]', [newItem]) to swap an item. You can also patch by query with writeClient.patch({ query: '*[_type == "post" && !defined(featured)]' }).
Transactions
Group mutations into one atomic request with client.transaction():
const orderId = `order-${crypto.randomUUID()}`;
await writeClient
.transaction()
.create({
_id: orderId,
_type: "order",
product: { _type: "reference", _ref: "product-1234" },
quantity: 1,
})
.patch("product-1234", (p) => p.dec({ stock: 1 }))
.commit({ visibility: "sync" });
If any step fails, nothing is written, so you never create an order without decrementing stock. Setting the _id yourself, as here, also lets you reference a new document from another mutation in the same transaction.
Optimistic Locking With ifRevisionID
Every document has a _rev that changes on each write. If you read a document, change it in code and write it back, someone else may have changed it in between, and your write would silently overwrite theirs. ifRevisionID prevents that: the patch only applies if the document's revision still matches.
const post = await writeClient.getDocument<{ _rev: string; tags?: string[] }>(
"post-1",
);
if (!post) throw new Error("Not found");
const tags = Array.from(new Set([...(post.tags ?? []), "featured"]));
try {
await writeClient
.patch("post-1")
.ifRevisionId(post._rev)
.set({ tags })
.commit();
} catch (error) {
// 409 Conflict: the document changed since we read it. Refetch and try again.
console.error("Revision mismatch", error);
}
In raw HTTP, add "ifRevisionID": "<rev>" to the patch object. Use it for read-modify-write logic. For simple changes, prefer server-side operations such as inc or insert, which don't need locking at all.
Drafts, Publishing and History
Mutations work on raw documents, so be clear about which one you're changing:
- Patching
post-1changes the published document immediately, and the change is live on your site. - Patching
drafts.post-1changes the draft. Editors see it in the Studio, and it goes live when they publish.
To publish or unpublish in the same way the Studio does, use the Actions API rather than copying documents between IDs by hand. Every transaction is recorded, so writes from code appear in a document's history with the token's name, which Sanity document history shows how to inspect.
Writing From a Website Safely
Form handlers, comments and likes all write from a public website. The rule is that the browser never talks to the Mutation API directly. Send data to a server function that validates it and writes with the token:
// src/app/contact/actions.ts
"use server";
import { writeClient } from "@/sanity/lib/write-client";
export async function submitContact(formData: FormData) {
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
if (
!name ||
!email.includes("@") ||
message.length < 10 ||
message.length > 5000
) {
return { ok: false, error: "Please check the form and try again." };
}
await writeClient.create(
{ _type: "formSubmission", name, email, message, status: "new" },
{ visibility: "async" },
);
return { ok: true };
}
The server decides the document type and fields, so a visitor can't use the action to write anything else. Add rate limiting or a CAPTCHA for public forms, and keep in mind the API's limit of 25 write requests per second per IP. Common Sanity mistakes covers leaked tokens and other pitfalls.
Reacting to Your Own Writes
Writes from the Mutation API fire webhooks like any other change. If your webhook rebuilds the site, a script that patches 5,000 documents can trigger a lot of builds, so disable rebuild webhooks during big imports or filter them with delta::changedAny(). The Sanity webhooks guide explains filters, and triggering site rebuilds with Sanity webhooks covers debouncing. For one-off bulk changes, Sanity content migrations are usually a better tool than hand-written mutation scripts.
FAQ: Sanity's Mutation API
create fails if a document with the same _id already exists, while createOrReplace creates it or overwrites it entirely. Use createOrReplace for syncing from another system with stable IDs, and create for new content.
Yes. All mutations in a single request run as one transaction, so they all succeed or none are applied. In JavaScript, client.transaction() groups mutations the same way.
Read the document's _rev, then pass it as ifRevisionID on the patch, or call ifRevisionId() with the client. If the document changed in the meantime, the write fails and you can refetch and retry.
Not safely with a write token, because anyone could read it. Send data to a server function or API route that validates it and writes with a server-only token.
Set autoGenerateArrayKeys to true in the query string or in commit() options, and Sanity adds a unique _key to each new array item. Otherwise, generate keys yourself before writing.
It depends on the document ID. Writing to an ID without the drafts. prefix changes the published document immediately. Writing to drafts. id creates or changes a draft that editors still need to publish.
Conclusion
The Mutation API is a small, predictable surface: five mutation types, a handful of patch operations and a few query parameters. Everything runs in transactions, so related changes land together or not at all. @sanity/client makes the same operations pleasant to use, with a chainable patch builder and client.transaction().
Keep write tokens on the server, validate anything that comes from users, prefer server-side operations like inc and insert over read-modify-write, and reach for ifRevisionID when you can't avoid it. With those habits, writing content from scripts, forms and integrations is as safe as editing in the Studio.


