379 lines
9.9 KiB
Markdown
379 lines
9.9 KiB
Markdown
---
|
|
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) => <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:
|
|
```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'
|
|
})
|
|
})
|
|
```
|