---
title: Sanity Schema Best Practices
description: 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 `defineType` for the root export.
- **ALWAYS** use `defineField` for fields.
- **ALWAYS** use `defineArrayMember` for items inside arrays.
```typescript
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).
```typescript
// 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 `key` prop)
- 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`:
```typescript
// ✅ Correct
{items.map((item) => )}
// ❌ Wrong - index keys break Visual Editing
{items.map((item, i) => )}
```
**Querying:** Always include `_key` in array projections:
```groq
*[_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](https://icons.sanity.build/all).
| 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.list` with "radio" layout.
```typescript
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).
```typescript
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)
```typescript
// ✅ 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
```typescript
// ✅ 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
```groq
// 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
1. **`deprecated`**: Adds a visual warning and reason.
2. **`readOnly: true`**: Prevents new edits but keeps data visible.
3. **`hidden`**: Hides it from *new* documents (where value is undefined).
4. **`initialValue: undefined`**: Ensures new documents don't get this field.
```typescript
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:
```typescript
// 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())
]
}
}
})
```
```bash
# 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
```typescript
// 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
```typescript
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
```typescript
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)
```typescript
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'
})
})
```