| name | sanity-keys-and-ids |
| description | How to handle Sanity document `_id`s and array `_key`s correctly across every write surface in this repo. Covers why deterministic IDs (hashed emails, derived strings) are an anti-pattern, the reference-field + GROQ-filter pattern that replaces them, and the per-surface mechanism for `_key` generation: `autoGenerateArrayKeys` for `@sanity/client`, automatic key handling in App SDK hooks (`useEditDocument`, `useApplyDocumentActions`), and automatic key handling in `sanity/migrate`. Use when writing or reviewing any code that creates, upserts, or patches Sanity content -- particularly `client.create*`, `client.patch().commit()`, mutations from `@sanity/sdk-react`, or `defineMigration`. Triggers on `_id`, `_key`, `generateKey`, hashed document IDs, `createIfNotExists`, `append/insert/setIfMissing`, and any helper that builds a Sanity `_id` or `_key` from another value. |
Sanity document IDs and array keys
Two things must be exactly right on every Sanity write:
_id -- the document identifier. Should be auto-generated by Sanity. Never hand-built, never derived from another value.
_key -- the per-array-item identifier required by Sanity for any object array. Generated differently depending on the surface you write through.
This skill is the source of truth for both. The companion rules (sanity-document-ids, sanity-array-mutations) summarise the dos and don'ts; this skill explains why and how each surface works under the hood, with sources.
Part 1 -- Document _ids: never deterministic
The anti-pattern
function generateBookmarksId(email: string): string {
const hash = email
.split('')
.reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
.toString(16)
return `userBookmarks.person.${hash}`
}
await client.createIfNotExists({
_id: generateBookmarksId(person.email),
_type: 'userBookmarks',
person: { _type: 'reference', _ref: person._id, _weak: true },
bookmarks: [],
})
Why this is wrong
- Drafts model. Sanity prefixes draft revisions with
drafts.<id>. A custom _id collides with that scheme as soon as someone enables drafts on the type, and migrations between published/draft (e.g. discardDraft, publish) start mis-routing.
- Identity changes. The moment the source value changes (user updates their email, a Slack handle is renamed, an external system reassigns IDs) you produce a second document for the same logical owner. The "lookup by ID" optimisation silently splits state across two documents.
- Hash collisions. A 32-bit string-hash like the example above (
(acc << 5) - acc + ch | 0) is non-cryptographic and short -- collisions are rare but real. A real collision corrupts another user's data because both writers think they own the same _id.
- Content releases. Releases version document IDs as
versions.<release>.<id>. Custom IDs that look like paths (userBookmarks.person.abc123) interact badly with the release ID parser and content layer routing.
- Permissions and ACLs. Studio roles can scope grants by
_id patterns. Stable, human-meaningful IDs are a security smell -- they leak intent and make targeted ACLs accidentally too broad.
- Dataset operations.
sanity dataset export/import, dataset copies, and content release rebases all assume Sanity-issued IDs. Custom IDs survive, but de-duplication, backfills, and cross-dataset references against them break in non-obvious ways.
- Reference integrity. A reference to a derived ID survives a rename of the source field but no longer points at the meaning (e.g.
_ref: 'userBookmarks.person.<old-hash>' after the email changes).
The HTTP mutation API spells out the contract Sanity expects:
If the _id attribute is missing, then a new, random, unique ID is generated. If the _id attribute is present but ends with ., then it is used as a prefix for a new, random, unique ID. If the _id attribute is present, it is used as-is.
-- HTTP mutations: Creating a new document
The "use it as-is" branch is for Sanity-issued identifiers (e.g. when you re-create a known document during a migration), not for application-derived strings.
The right pattern: reference field + GROQ filter
Model "this document belongs to X" as a reference, and look it up by reference:
defineField({
name: 'person',
type: 'reference',
to: [{ type: 'person' }],
weak: true,
validation: (Rule) => Rule.required(),
})
const USER_BOOKMARKS_QUERY = defineQuery(`
*[_type == "userBookmarks" && person._ref == $personId][0]{
_id, _rev, bookmarks[]{ _ref, _key }
}
`)
await client.create({
_type: 'userBookmarks',
person: { _type: 'reference', _ref: personId, _weak: true },
bookmarks: [],
})
If you need create-or-fetch semantics, do the create + the read separately:
const existing = await client.fetch(USER_BOOKMARKS_QUERY, { personId })
if (existing) return existing
await client.create({
_type: 'userBookmarks',
person: { _type: 'reference', _ref: personId, _weak: true },
bookmarks: [],
})
Use createIfNotExists only when Sanity issued the _id (e.g. you have a real document handle from a list). It is not a way to make a custom-ID upsert "safe" -- under contention you can still get a duplicate via the reference lookup if two writers race. Pair the lookup with a small guard (person._ref uniqueness check, or a Sanity Function that dedupes) when contention matters.
External identifiers belong in dedicated fields
await client.createOrReplace({ _id: `slack-${slackUser.id}`, _type: 'slackTag', ... })
await client.create({ _type: 'slackTag', slackId: slackUser.id, ... })
Use names that describe the source: slackId, slackChannelId, ripplingId, dovetailProjectId. Add a uniqueness check in validation if collisions on that field would corrupt data.
Part 2 -- Array _keys: pick the right surface mechanism
Sanity requires every object item in an array to carry a unique _key. How you produce it depends on which library is doing the write. Always use the surface-appropriate mechanism. Never invent your own.
@sanity/client -- commit({ autoGenerateArrayKeys: true })
The vanilla JS client does not add _keys by default. You must opt in by passing { autoGenerateArrayKeys: true } to .commit() (or to client.create* mutations).
await client
.patch(documentId)
.ifRevisionId(rev)
.setIfMissing({ teamspaces: [] })
.append('teamspaces', [
{
_type: 'reference',
_ref: team._id,
_weak: true,
},
])
.commit({ autoGenerateArrayKeys: true })
Source: Advanced client patterns -- Auto-generated array keys. The same option is honoured by client.create, client.createIfNotExists, client.createOrReplace, and transaction().commit().
If you need a key value before sending the mutation (e.g. you write the new item to local optimistic state and need to identify it in the response), import a real key generator:
import { randomKey } from '@sanity/util/content'
const key = randomKey(12)
const newItem = { _key: key, _type: 'reference', _ref: refId }
setOptimistic((prev) => [...prev, newItem])
await client.patch(id).append('items', [newItem]).commit()
randomKey produces a base-62, cryptographically-random string of the requested length. It is the same generator the Studio uses internally.
App SDK hooks -- automatic, do not pass _key
useEditDocument and useApplyDocumentActions from @sanity/sdk-react route every change through @sanity/sdk's patchOperations module. Every operation that returns a value passes its result through ensureArrayKeysDeep, which walks the result and assigns _key to any object array item that lacks one:
function generateArrayKey(length: number = 12): string {
}
export const ensureArrayKeysDeep = memoize(<R>(input: R): R => {
return input.map((item) => {
if (isKeyedObject(item)) return ensureArrayKeysDeep(item)
const next = ensureArrayKeysDeep(item)
return { ...next, _key: generateArrayKey() }
}) as R
})
This means:
useEditDocument -- pass plain objects in your updater, no _key. The hook handles it.
useApplyDocumentActions(editDocument(handle, { setIfMissing/insert/... })) -- same.
useApplyDocumentActions(createDocument(...)) -- same.
Adding manual _key is harmless (the SDK leaves existing keys alone via isKeyedObject), but it is noise and it lets a bad helper -- like generateKey(id) -- introduce stable-but-collision-prone keys. Keep your code free of it.
There is no autoGenerateArrayKeys option on these hooks. The SDK does it unconditionally.
Source: @sanity/sdk src/document/patchOperations.ts (ensureArrayKeysDeep, generateArrayKey, called from every patch helper used by useEditDocument and useApplyDocumentActions).
@sanity/sdk-react useClient -- you are using @sanity/client
useClient() returns a configured @sanity/client instance. The same rule as the vanilla client applies: you must pass { autoGenerateArrayKeys: true } to .commit(). The SDK's automatic key handling does not apply to direct client calls -- it only applies when you go through SDK hooks (useEditDocument, useApplyDocumentActions).
This is the most common mistake in this repo: mixing useClient().patch(...).commit() (where keys are NOT auto-generated) with the assumption that "the SDK handles keys". It does not, when you bypass the SDK's edit hooks.
const client = useClient({ apiVersion: '2026-02-01' })
await client
.patch(id)
.append('items', [{ _type: 'reference', _ref: refId }])
.commit({ autoGenerateArrayKeys: true })
sanity/migrate -- automatic
The migration helpers (set, setIfMissing, insert, append, prepend, replace) generate _key automatically when you pass plain objects. The runtime applies the same _key insertion the Studio does.
import { defineMigration, at, append } from 'sanity/migrate'
export default defineMigration({
title: 'Add default tags',
documentTypes: ['post'],
migrate: {
document(doc) {
return [at('tags', append([{ _type: 'tag', name: 'default' }]))]
},
},
})
Source: Content migrations -- Mutations and the sanity/migrate source (packages/sanity/src/_internal/cli/threads/runMigrations.ts and @sanity/util randomKey).
Studio (@sanity/ui / patches via document operations) -- automatic
Studio inputs (the form, array inputs, Portable Text, custom inputs that use useFormValue + useDocumentOperation) generate _key automatically through the form state machine. You do not need to think about it for inputs; the only place this comes up is custom document actions that build raw patches:
import { useDocumentOperation } from 'sanity'
const { patch } = useDocumentOperation(id, type)
patch.execute([
{ setIfMissing: { items: [] } },
{ insert: { after: 'items[-1]', items: [{ _type: 'thing' }] } },
])
Document operations route through the same form state machine and do _key insertion for you. Mutations issued outside the operation system (e.g. getClient().patch(id)...) follow the @sanity/client rule above.
Part 3 -- Decision tables
"Where do I put this identifier?"
| You want to... | Use |
|---|
| Identify a document Sanity created | _id (auto-generated) |
| Identify a document by an external system's ID | dedicated field (slackId, ripplingId, ...) |
| Identify a document by its owner | reference field + *[... && owner._ref == $id][0] |
| Identify an array item across renders | _key (per the surface in Part 2) |
| Identify an array item from a derived value | DON'T -- the value will change, the key won't |
"How do I get _key on an array item?"
| Surface | Mechanism | Source |
|---|
@sanity/client .commit() | commit({ autoGenerateArrayKeys: true }) | docs |
client.create* | same { autoGenerateArrayKeys: true } option | docs |
useClient() from @sanity/sdk-react | same as above (it returns @sanity/client) | n/a (it is @sanity/client) |
useEditDocument | automatic via ensureArrayKeysDeep | @sanity/sdk/src/document/patchOperations.ts |
useApplyDocumentActions(editDocument(...)) | automatic via ensureArrayKeysDeep | @sanity/sdk/src/document/patchOperations.ts |
useApplyDocumentActions(createDocument(...)) | automatic via ensureArrayKeysDeep | @sanity/sdk/src/document/patchOperations.ts |
sanity/migrate (append, insert, ...) | automatic | Migrations cheatsheet |
| Studio inputs | automatic via form state | n/a |
Studio custom doc actions (useDocumentOperation) | automatic via form state | Document operations |
| Need a key value before the mutation runs | randomKey(length) from @sanity/util/content | @sanity/util source |
Part 4 -- Anti-patterns to delete on sight
function generateBookmarksId(email: string): string { }
function generateKey(id: string): string {
return id.replace(/^drafts\./, '').replace(/[^a-zA-Z0-9]/g, '_')
}
items.map((item, i) => ({ ...item, _key: String(i) }))
{ _type: 'reference', _ref: id, _key: id }
What to do instead, in order of preference:
- Drop the helper entirely and let the surface mechanism (Part 2) generate the
_key.
- If you genuinely need a key in JS, call
randomKey(12) from @sanity/util/content.
- If a single document needs to reference the same target multiple times intentionally (rare), differentiate them with a stable per-occurrence value (e.g. role + slot), not the target's
_id.
Part 5 -- Practical refactor recipe
When you encounter generateBookmarksId(email) / generateKey(id) style helpers in this repo:
- Remove the deterministic ID helper. Replace
client.createIfNotExists({ _id: generateBookmarksId(...), ... }) with a fetch-then-create:
const existing = await client.fetch(OWNER_QUERY, { personId })
if (!existing) await client.create({ _type, person: { _type: 'reference', _ref: personId, _weak: true }, ... })
- Remove the
_key helper. Strip _key from any item literal you pass to useEditDocument / useApplyDocumentActions. For raw client.patch(...).commit(), add { autoGenerateArrayKeys: true } to the commit call.
- **Update all
unset([\items[_key == "..."]`])selectors** that relied on the old derived-key shape. Selectors should match the _real__key` from the fetched document, not a recomputed value.
- Migrate existing data if older documents in production already have the deterministic
_id or derived _key. Use sanity/migrate (see the sanity-content-migrations skill) to copy them onto Sanity-issued IDs and to rewrite _keys. Schedule the cutover so writers stop emitting old IDs first.
- Add a regression test or grep guard in CI: fail the build if
generateKey(, generateBookmarksId(, or _id: \...${...}`literals appear insdk-apps/**`.
Part 6 -- Where this comes up in this repo today
sdk-apps/home/src/hooks/useBookmarks.ts -- generateBookmarksId(email) and generateKey(item._id). Refactor per Part 5.
sdk-apps/home/src/hooks/useUserSettings.ts -- generateSettingsId(email) and generateKey(team._id). Same.
sdk-apps/home/src/hooks/useRecipeTried.ts -- already correct: uses client.patch(...).commit({ autoGenerateArrayKeys: true }) and no manual key helper. Use this as the reference implementation.
studios/home/structure/personalListItem.tsx -- generatePersonId(email) + userApps.${...} / userBookmarks.${...} / userSettings.${...} derived _ids, plus _key: team._id.replace(/[^a-zA-Z0-9]/g, ''). The whole onboarding transaction needs the Part 5 treatment.
sdk-apps/coach/src/lib/provisionPlanTemplate.ts -- fixed. Previously minted _id: \planTemplate.academy.${academy._id}`/planTemplate.learningPath.${path._id} and index keys (module-${i}, learning-path-0). Now looks the template up by academy._ref/$pathId in learningPaths[]._ref, creates with a Sanity-issued _idand{ autoGenerateArrayKeys: true }`. Use it as the reference implementation for "create-one-per-related-document" (Part 7).
- Hand-authored
planTemplate docs in coach-staging (gtm-recruiter-onboarding, senior-support-eng-onboarding) carry slug-shaped _ids typed in by a human / the People-team MCP authoring flow -- not produced by any code. These need a data migration (recreate with an auto _id, repoint referencing plan.planTemplate._ref, delete the old doc) and the authoring habit corrected per Part 7.
Part 7 -- Content model & authoring rules (incl. plan templates)
These are the rules that the planTemplate work surfaced. They generalise to every document type.
Rule 1 -- Identity lives in fields and slugs, never in _id
_id is an opaque, system-issued handle. Anything a human or another system would recognise -- a name, a slug, a category, an external system's key -- goes in a field:
| Want to express | Put it in |
|---|
| Human-readable handle / URL segment | slug (a slug field), not _id |
| "This template is for the GTM Recruiter role" | title + a role/academy/learningPaths reference |
| "Belongs to / scopes to X" | a reference field, queried by X._ref |
| An external system's identifier | a named field (ripplingId, slackId, ...) |
{ _id: 'gtm-recruiter-onboarding', _type: 'planTemplate', planType: 'onboarding' }
{ _type: 'planTemplate', title: 'GTM Recruiter Onboarding',
slug: { _type: 'slug', current: 'gtm-recruiter-onboarding' }, planType: 'onboarding' }
Rule 2 -- Authoring surfaces choose _ids too. They must not.
The anti-pattern is not limited to client.create* in app code. It also happens when:
- A human types into Studio's "create with custom ID" field.
- The Sanity MCP / a content agent passes
_id to create_documents (this is how the coach-staging slug IDs got there -- see Part 6).
- A seed/import script reuses a slug as the
_id.
In all three, omit _id and let Sanity issue one; capture the readable handle in slug. When generating content via the MCP, never set _id unless you are re-creating a document Sanity already issued (a real migration).
Rule 3 -- "Create one per related document" = look up by reference, not a deterministic _id
The reason deterministic IDs are tempting is idempotency: "only one learning-plan template per academy." Get idempotency from the reference, not from a hand-built _id:
const existingId = await client.fetch<string | null>(
`*[_type == "planTemplate" && academy._ref == $academyId][0]._id`,
{ academyId: academy._id },
)
const templateId =
existingId ??
(await client.create(
{ _type: 'planTemplate', academy: { _type: 'reference', _ref: academy._id }, todos: [...] },
{ autoGenerateArrayKeys: true },
))._id
Trade-off to state in a comment: two writers racing past the lookup can fork a duplicate. For low-contention, recoverable cases (a company-wide template) that is acceptable. When contention matters, add a uniqueness guard on the reference field or dedupe in a Sanity Function -- do not reach back for a deterministic _id.
Rule 4 -- A base62 _id is not a bug, a _key, or a custom ID
Two valid Sanity-issued formats exist, and which one you get depends only on who created the document:
- Studio /
@sanity/uuid -> dashed UUID, e.g. 189bc292-e41b-42a0-91b5-bfaa33a34af2.
@sanity/client create/transaction.create without _id -> the Content Lake issues a ~22-char base62 string, e.g. 8WC74fAmDi3XtMuSfip6dY. It shares the base62 alphabet family with array _keys, so it looks like a key, and a batch created in one transaction often shares a long common prefix. This is normal server behaviour, not a collision bug -- a review of the @sanity/client changelog (7.20.x–7.23.0) shows no ID-generation fix, and the client docstring states "if no _id is given, one will automatically be generated by the database."
Do not "fix", normalise, or migrate these. They are exactly the system-issued IDs you want. The only IDs to fix are the derived/hand-typed ones (Rules 1–3).
References