All skills
sanity-io avatar

/sanity-best-practices

@fc8116b official
by Sanitysanity-io/agent-toolkit187 stars
30

Sanity development best practices for schema design, GROQ queries, TypeGen, Visual Editing, images, Portable Text, Studio structure, localization, migrations, Sanity Functions, webhooks, Blueprints, and framework integrations such as Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and the App SDK. Use this skill whenever working with Sanity schemas, defineType or defineField, GROQ or defineQuery, content modeling, Presentation or preview setups, Sanity-powered frontend integrations, event-driven content automation, documentEventHandler, defineDocumentFunction, defineMediaLibraryAssetFunction, @sanity/functions, @sanity/blueprints, sanity.blueprint.ts, event-driven content automation, or when reviewing and fixing a Sanity codebase.

Use this Skill: https://skilld.dev/gh/sanity-io/agent-toolkit/sanity-best-practices

This session only. Nothing lands on disk.

referencesschema.md

≈3.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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
  • Document creation and IDs
  • 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/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' }] })
      ]
    })
  ]
})

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.

// ✅ 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

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. Document Creation and IDs

Sanity document _id values are implementation identifiers, not a content modeling tool.

  • Prefer generated IDs: Let Sanity assign _id values for ordinary content documents. Avoid deterministic UUIDs, slug-derived IDs, and IDs copied from legacy systems.
  • Use relationships, not ID conventions: Connect documents with reference fields and set _ref from an actual lookup or from the _id returned after creating the related document.
  • Store source identity as content: For imports, put legacy IDs, external IDs, or stable slugs in explicit fields such as legacyId, externalId, or slug, then query by those fields when you need to find or upsert content.
  • Keep explicit IDs rare: Directly setting _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}`},
})

7. 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 migrations run rename-oldTitle-to-newTitle

# Execute when ready
sanity migrations run rename-oldTitle-to-newTitle --no-dry-run

Phase 3: Remove — Once oldTitle is undefined for all documents, delete the field definition.

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

Source: SKILL.md on GitHub

2 warnings14d5 checks · Risk SAFE
  • Gen Agent Trust Hub14d

    This skill provides comprehensive Sanity.io development best practices, covering schema design, GROQ queries, and integration with major frontend frameworks. It promotes secure development habits, such as proper management of API tokens and environment variables.

  • Socket14d

    1 alert: gptAnomaly

  • Snyk14d

    Risk: LOW · No issues

  • Runlayer6mo

    8/24 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at fc8116b. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 months ago
  • Next.js
  • Nuxt
  • sanity
  • groq
  • schema
  • typegen
  • portable-text
  • visual-editing
  • astro
  • remix
  • sveltekit
  • migrations
  • localization
  • cms

README badge

README badge for sanity-io/agent-toolkit/sanity-best-practices

Provides guidelines and reference materials for Sanity schema design, GROQ queries, TypeGen, Visual Editing, Portable Text, Studio structure, localization, migrations, Sanity Functions, and framework integrations including Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, and Hydrogen. Use this skill when setting up Sanity projects, designing content models, writing queries, implementing live preview, or integrating Sanity with a frontend framework.

Generated from the current SKILL.md.

Does this skill cover framework integrations like Next.js, Nuxt, and Astro?
Yes. The skill includes integration guides for Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and standalone Studio patterns.
What does this skill cover for GROQ queries?
The skill provides GROQ query patterns, type safety approaches, and performance optimization guidelines, with reference materials for detailed examples.
Does this skill include guidance on Sanity Functions and event automation?
Yes. The skill covers Sanity Functions for automating content workflows, including documentEventHandler and defineDocumentFunction patterns.
Can I use this skill for schema design and content modeling?
Yes. The skill provides schema design best practices, field definitions, validation patterns, and content modeling guidance for different use cases.
Does this cover Visual Editing and live preview setup?
Yes. The skill includes guidance on the Presentation Tool, Stega, overlays, and live preview configuration for frontend frameworks.

Generated from the current SKILL.md. These answers refresh after source changes.