Skill 05 · Sanity Best Practices
Subchapter 5.19
references/schema.mdMarkdown12 KBView on GitHub
Use this contents list to jump to the schema design decision you are making.
Model what things are, not what they look like.
bigHeroText, redButton, threeColumnRow, color, fontSizeheroStatement, callToAction, featuresSection, status, roleThe test: “If we redesigned the site, would this field name still make sense?”
threeColumnLayout → ❌ Fails (what if we go to 2 columns?)features → ✅ Passes (features are features regardless of layout)Always use the helper functions from sanity for type safety and autocompletion.
defineType for the root export.defineField for fields.defineArrayMember for items inside arrays.import { defineType, defineField, defineArrayMember } from 'sanity'
import { TagIcon } from '@sanity/icons/Tag'
export const article = defineType({
name: 'article',
title: 'Article',
type: 'document',
icon: TagIcon,
fields: [
defineField({
name: 'title',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'tags',
type: 'array',
of: [
// ALWAYS use defineArrayMember for array items
defineArrayMember({ type: 'reference', to: [{ type: 'tag' }] })
]
})
]
})Export arrays of fields to reuse common patterns (e.g., SEO, standard page headers).
// src/schemaTypes/shared/seoFields.ts
export const seoFields = [
defineField({ name: 'seoTitle', type: 'string', title: 'SEO Title' }),
defineField({ name: 'seoDesc', type: 'text', title: 'SEO Description' })
]
// Usage
defineType({
name: 'page',
fields: [
defineField({ name: 'title', type: 'string' }),
...seoFields // Spread shared fields
]
})Every item in a Sanity array automatically gets a _key property. This is critical for:
key prop)Schema: Sanity auto-generates _key for array items. You don’t define it.
Frontend: Always use _key as React’s key:
// ✅ Correct
{items.map((item) => <Component key={item._key} {...item} />)}
// ❌ Wrong - index keys break Visual Editing
{items.map((item, i) => <Component key={i} {...item} />)}Querying: Always include _key in array projections:
*[_type == "page"][0]{
pageBuilder[]{
_key, // Always include _key in queries
_type,
...
}
}Always assign an icon from @sanity/icons to documents and objects. This improves the Studio UX significantly. Browse all icons at icons.sanity.build (opens in a new tab).
// ✅ Correct — import each icon from its own subpath
import { DocumentTextIcon } from '@sanity/icons/DocumentText'
// ❌ Wrong — root named exports were removed in v5.
// Type-checks clean, then fails at bundle time.
import { DocumentTextIcon } from '@sanity/icons'| Content Type | Icon | Import |
|---|---|---|
| Article, Post | DocumentTextIcon | @sanity/icons/DocumentText |
| Author, Person | UserIcon | @sanity/icons/User |
| Category, Tag | TagIcon | @sanity/icons/Tag |
| Settings | CogIcon | @sanity/icons/Cog |
| Page | DocumentIcon | @sanity/icons/Document |
| Image block | ImageIcon | @sanity/icons/Image |
| Video block | PlayIcon | @sanity/icons/Play |
| FAQ | HelpCircleIcon | @sanity/icons/HelpCircle |
| Link | LinkIcon | @sanity/icons/Link |
Avoid boolean fields for binary states that might expand later.
options.list with “radio” layout.defineField({
name: 'status',
type: 'string',
options: {
list: [
{ title: 'Draft', value: 'draft' },
{ title: 'Published', value: 'published' }
],
layout: 'radio'
}
})Use a radio/boolean field to toggle visibility of other fields (often grouped in fieldsets).
defineField({
name: 'linkType',
type: 'string',
options: { list: ['internal', 'external'], layout: 'radio' }
}),
defineField({
name: 'internalLink',
type: 'reference',
hidden: ({ parent }) => parent?.linkType !== 'internal'
}),
defineField({
name: 'externalUrl',
type: 'url',
hidden: ({ parent }) => parent?.linkType !== 'external'
})A critical modeling decision: when to use reference vs embedding an object.
// ✅ Author is reusable and independently editable
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }]
})// ✅ SEO is document-specific, not shared
defineField({
name: 'seo',
type: 'object',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'description', type: 'text' })
]
})| Scenario | Use |
|---|---|
| Blog post author | reference (reusable) |
| Product category | reference (shared taxonomy) |
| Page SEO fields | object (page-specific) |
| Hero section content | object (page-specific) |
| Team member on About page | reference (might be used elsewhere) |
| Call-to-action button | object (usually page-specific) |
// Reference requires expansion
*[_type == "post"]{ author->{ name, bio } }
// Object is already inline
*[_type == "post"]{ seo { title, description } }Sanity document _id values are implementation identifiers, not a content modeling tool.
_id values for ordinary content documents. Avoid deterministic UUIDs, slug-derived IDs, and IDs copied from legacy systems.reference fields and set _ref from an actual lookup or from the _id returned after creating the related document.legacyId, externalId, or slug, then query by those fields when you need to find or upsert content._id is mainly useful for singleton documents managed through Studio Structure, such as settings or localized singletons like homePage-en.// ✅ Correct - relationship comes from a lookup
import {defineQuery} from 'groq'
const AUTHOR_BY_EXTERNAL_ID_QUERY = defineQuery(`
*[_type == "author" && externalId == $externalId][0]{_id}
`)
const author = await client.fetch(AUTHOR_BY_EXTERNAL_ID_QUERY, {
externalId: post.authorId,
})
if (!author?._id) throw new Error(`Missing author for ${post.authorId}`)
await client.create({
_type: 'post',
title: post.title,
slug: {_type: 'slug', current: post.slug},
legacyId: post.id,
author: {_type: 'reference', _ref: author._id},
})
// ❌ Wrong - IDs encode relationships and source data
await client.createOrReplace({
_id: `post-${post.id}`,
_type: 'post',
author: {_type: 'reference', _ref: `author-${post.authorId}`},
})NEVER delete a field that contains production data. It will cause data loss or Studio crashes. Instead, follow the ReadOnly -> Hidden -> Deprecated lifecycle.
deprecated: Adds a visual warning and reason.readOnly: true: Prevents new edits but keeps data visible.hidden: Hides it from new documents (where value is undefined).initialValue: undefined: Ensures new documents don’t get this field.defineField({
name: 'oldTitle', // The field you want to remove
title: 'Article Title (Deprecated)',
type: 'string',
deprecated: {
reason: 'Use the new "seoTitle" field instead. This will be removed in v2.'
},
readOnly: true,
hidden: ({ value }) => value === undefined,
initialValue: undefined
})Phase 1: Deprecate — Apply the deprecation pattern above. Deploy.
Phase 2: Migrate — Update frontend to use new fields (with coalesce() fallbacks). Create a migration:
// migrations/rename-oldTitle-to-newTitle/index.ts
import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'
export default defineMigration({
title: 'Rename oldTitle to newTitle',
documentTypes: ['article'],
filter: 'defined(oldTitle) && !defined(newTitle)',
migrate: {
document(doc) {
if (!doc.oldTitle || doc.newTitle) return
return [
at('newTitle', setIfMissing(doc.oldTitle)),
at('oldTitle', unset())
]
}
}
})# Dry run first (default)
sanity migrations run rename-oldTitle-to-newTitle
# Execute when ready
sanity migrations run rename-oldTitle-to-newTitle --no-dry-runPhase 3: Remove — Once oldTitle is undefined for all documents, delete the field definition.
Beyond rule.required(), Sanity offers powerful validation options.
// Email validation
defineField({
name: 'email',
type: 'string',
validation: (rule) => rule.email().required()
})
// URL validation (with custom message)
defineField({
name: 'website',
type: 'url',
validation: (rule) => rule.uri({
scheme: ['http', 'https']
}).error('Must be a valid URL starting with http:// or https://')
})
// Length constraints
defineField({
name: 'excerpt',
type: 'text',
validation: (rule) => rule.max(200).warning('Keep it under 200 characters for best SEO')
})
// Regex pattern
defineField({
name: 'slug',
type: 'slug',
validation: (rule) => rule.required().custom((slug) => {
if (!slug?.current) return 'Required'
if (!/^[a-z0-9-]+$/.test(slug.current)) {
return 'Slug must be lowercase with hyphens only'
}
return true
})
})defineField({
name: 'endDate',
type: 'datetime',
validation: (rule) => rule.custom((endDate, context) => {
const startDate = context.document?.startDate
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
return 'End date must be after start date'
}
return true
})
})defineField({
name: 'tags',
type: 'array',
of: [{ type: 'string' }],
validation: (rule) => rule
.min(1).error('Add at least one tag')
.max(10).warning('Too many tags may hurt SEO')
.unique()
})defineField({
name: 'slug',
type: 'slug',
validation: (rule) => rule.required().custom(async (slug, context) => {
if (!slug?.current) return true
const client = context.getClient({ apiVersion: '2026-02-01' })
const id = context.document?._id?.replace(/^drafts\./, '')
const existing = await client.fetch(
`count(*[_type == "post" && slug.current == $slug && _id != $id])`,
{ slug: slug.current, id }
)
return existing === 0 || 'Slug already exists'
})
})