9.9 KiB
title, description
| title | description |
|---|---|
| Sanity Schema Best Practices | Rules for defining Sanity Content Models (Schemas), including field definitions, strict typing, and validation patterns. |
Sanity Schema Best Practices
Use this contents list to jump to the schema design decision you are making.
Table of Contents
- Core philosophy: data over presentation
- Strict definition syntax
- Shared fields pattern
- Field patterns
- References vs nested objects
- Safe schema updates
- Validation patterns
1. Core Philosophy: Data > Presentation
Model what things are, not what they look like.
- ❌ Bad:
bigHeroText,redButton,threeColumnRow,color,fontSize - ✅ Good:
heroStatement,callToAction,featuresSection,status,role
The 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)
2. Strict Definition Syntax
Always use the helper functions from sanity for type safety and autocompletion.
- ALWAYS use
defineTypefor the root export. - ALWAYS use
defineFieldfor fields. - ALWAYS use
defineArrayMemberfor items inside arrays.
import { defineType, defineField, defineArrayMember } from 'sanity'
import { TagIcon } from '@sanity/icons'
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' }] })
]
})
]
})
3. Shared Fields Pattern
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
]
})
4. Field Patterns
A. Array Keys (_key)
Every item in a Sanity array automatically gets a _key property. This is critical for:
- React reconciliation (use as
keyprop) - Visual Editing overlays (click-to-edit)
- Portable Text rendering
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,
...
}
}
B. Icons
Always assign an icon from @sanity/icons to documents and objects. This improves the Studio UX significantly. Browse all icons at icons.sanity.build.
| Content Type | Icon |
|---|---|
| Article, Post | DocumentTextIcon |
| Author, Person | UserIcon |
| Category, Tag | TagIcon |
| Settings | CogIcon |
| Page | DocumentIcon |
| Image block | ImageIcon |
| Video block | PlayIcon |
| FAQ | HelpCircleIcon |
| Link | LinkIcon |
C. Boolean vs. List
Avoid boolean fields for binary states that might expand later.
- Prefer:
options.listwith "radio" layout.
defineField({
name: 'status',
type: 'string',
options: {
list: [
{ title: 'Draft', value: 'draft' },
{ title: 'Published', value: 'published' }
],
layout: 'radio'
}
})
D. The "Toggle" Pattern (Conditional Fields)
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'
})
5. References vs Nested Objects
A critical modeling decision: when to use reference vs embedding an object.
Use References When:
- Content is reusable across documents (authors, categories, products)
- Content needs its own editing interface in Studio
- You need to query/filter by the related content independently
- Multiple documents should share the same instance (update once, reflect everywhere)
// ✅ Author is reusable and independently editable
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }]
})
Use Nested Objects When:
- Content is specific to this document (not shared)
- Content doesn't make sense on its own (address, SEO metadata)
- You want simpler editing (all fields in one place)
- You need the data to be copied not linked
// ✅ SEO is document-specific, not shared
defineField({
name: 'seo',
type: 'object',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'description', type: 'text' })
]
})
Quick Decision Matrix
| 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) |
Querying Differences
// Reference requires expansion
*[_type == "post"]{ author->{ name, bio } }
// Object is already inline
*[_type == "post"]{ seo { title, description } }
6. Safe Schema Updates (The Deprecation Pattern)
NEVER delete a field that contains production data. It will cause data loss or Studio crashes. Instead, follow the ReadOnly -> Hidden -> Deprecated lifecycle.
The Pattern
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
})
Migration Workflow
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 migration run rename-oldTitle-to-newTitle
# Execute when ready
sanity migration run rename-oldTitle-to-newTitle --no-dry-run
Phase 3: Remove — Once oldTitle is undefined for all documents, delete the field definition.
7. Validation Patterns
Beyond rule.required(), Sanity offers powerful validation options.
Common Patterns
// 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
})
})
Cross-Field Validation
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
})
})
Array Validation
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()
})
Async Validation (Uniqueness Check)
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'
})
})