Files
theater-ziefen-website/.agents/skills/sanity-best-practices/references/schema.md
T
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

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 defineType for the root export.
  • ALWAYS use defineField for fields.
  • ALWAYS use defineArrayMember for 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 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:

// ✅ 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.list with "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

  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.
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'
  })
})