How I write custom validation rules in Sanity schemas

Jun 29, 2026 · 6 min read

Sanity custom validation rules in schemas are one of those features that look simple in the docs but get complicated fast in production. Required fields are trivial. The moment you need async uniqueness checks, cross-field conditionals, or messages that actually tell an editor what to fix, the built-in examples stop being enough. Here is exactly how I handle each case.

The baseline: required, regex, and length

Every schema gets at least the basics. Sanity's .validation callback receives a Rule object — you chain methods off it or return a custom function. I always type the schema with defineField so TypeScript catches mismatches early.

// schemas/article.ts
import { defineField, defineType } from 'sanity'
 
export const article = defineType({
  name: 'article',
  title: 'Article',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) =>
        Rule.required()
          .min(10)
          .max(80)
          .error('Title must be between 10 and 80 characters.'),
    }),
    defineField({
      name: 'metaDescription',
      title: 'Meta description',
      type: 'text',
      rows: 3,
      validation: (Rule) =>
        Rule.required()
          .min(120)
          .max(160)
          .warning(
            'Meta descriptions outside 120–160 characters often get rewritten by Google.',
          ),
    }),
    defineField({
      name: 'externalUrl',
      title: 'External URL',
      type: 'url',
      validation: (Rule) =>
        Rule.uri({
          scheme: ['https'],
        }).error('Only HTTPS URLs are allowed.'),
    }),
    defineField({
      name: 'sku',
      title: 'SKU',
      type: 'string',
      validation: (Rule) =>
        Rule.custom((value) => {
          if (!value) return true // not required here
          const skuPattern = /^[A-Z]{2}-\d{4,6}$/
          return skuPattern.test(value)
            ? true
            : 'SKU must match the pattern AA-0000 (two uppercase letters, hyphen, 4-6 digits).'
        }),
    }),
  ],
})

A few things I do consistently: .error() for publish-blocking problems, .warning() for best-practice nudges that don't block. The message text is written for the editor, not for me — "SKU must match the pattern AA-0000" beats "invalid format" every time.

Async checks: uniqueness via GROQ inside custom()

The Rule.custom() callback can return a Promise<true | string>. That opens the door to hitting the Sanity client from inside validation — useful for uniqueness checks that don't belong in a slug field specifically.

// schemas/product.ts
import { defineField, defineType } from 'sanity'
import { groq } from 'next-sanity'
 
export const product = defineType({
  name: 'product',
  title: 'Product',
  type: 'document',
  fields: [
    defineField({
      name: 'barcode',
      title: 'Barcode (EAN-13)',
      type: 'string',
      validation: (Rule) =>
        Rule.custom(async (value, context) => {
          if (!value) return true
 
          // Basic EAN-13 format guard first
          if (!/^\d{13}$/.test(value)) {
            return 'Barcode must be exactly 13 digits.'
          }
 
          const { document, getClient } = context
          const client = getClient({ apiVersion: '2024-01-01' })
 
          // Exclude the current document so edits don't false-positive
          const existingId = await client.fetch<string | null>(
            groq`*[_type == "product" && barcode == $barcode && _id != $id][0]._id`,
            { barcode: value, id: document?._id ?? '' },
          )
 
          return existingId
            ? `Barcode ${value} is already used by another product.`
            : true
        }),
    }),
  ],
})

The context argument is the part most tutorials skip. It carries document (the full in-progress document), getClient (so you don't need to import a separate client), path, and type. I always use getClient from context rather than importing a singleton — it respects the Studio's configured dataset and token without extra wiring.

Note the document?._id ?? '' pattern. On first save the document ID might not exist yet. Passing an empty string means the GROQ filter _id != $id still works correctly and won't accidentally exclude a real match.

Cross-field validation on the document type itself

Single-field rules can't see sibling fields. For rules like "if publishDate is set, author is required" you need to validate at the document level using __experimental_actions or, more practically, Rule.custom on the document type.

// schemas/event.ts
import { defineField, defineType } from 'sanity'
 
export const event = defineType({
  name: 'event',
  title: 'Event',
  type: 'document',
  // Document-level validation runs against the whole document object
  validation: (Rule) =>
    Rule.custom((doc) => {
      if (!doc) return true
 
      const { startDate, endDate, registrationUrl, capacity } = doc as {
        startDate?: string
        endDate?: string
        registrationUrl?: string
        capacity?: number
      }
 
      if (startDate && endDate && endDate <= startDate) {
        return 'End date must be after start date.'
      }
 
      if (registrationUrl && !capacity) {
        return 'Set a capacity when a registration URL is provided.'
      }
 
      return true
    }),
  fields: [
    defineField({ name: 'title', type: 'string', validation: (Rule) => Rule.required() }),
    defineField({ name: 'startDate', type: 'datetime' }),
    defineField({ name: 'endDate', type: 'datetime' }),
    defineField({ name: 'registrationUrl', type: 'url' }),
    defineField({ name: 'capacity', type: 'number', validation: (Rule) => Rule.min(1).integer() }),
  ],
})

Document-level validation errors appear at the top of the Studio form — editors see them before they try to publish. The downside is the message has to describe the problem without highlighting a specific field. I keep these messages concrete: name the fields involved so the editor knows where to scroll.

Making error messages useful

A few rules I follow for validation copy:

  • State what is wrong, not what the field is called. "Title must be between 10 and 80 characters" not "Invalid title".
  • For cross-field errors, name both fields: "End date must be after start date" not "Date error".
  • Use .warning() for non-blocking guidance (SEO tips, recommended word counts). Reserve .error() for things that genuinely break the page or downstream systems.
  • Async uniqueness errors should include the conflicting value so the editor can spot it fast: "Barcode 1234567890123 is already used by another product."

One trap to avoid: returning false from custom() shows no message at all. Always return a string when validation fails — the string is the message.

Combining multiple rules

You can return an array from the validation callback to run several independent checks and surface all failures at once rather than stopping at the first:

validation: (Rule) => [
  Rule.required().error('Title is required.'),
  Rule.max(80).warning('Titles over 80 characters are truncated in search results.'),
]

This is cleaner than chaining when the rules have different severities or different messages.

Custom validation is one of the highest-leverage places to invest in a Sanity schema. Good rules catch bad data before it reaches your frontend queries, reduce back-and-forth with editors, and make the Studio feel considered rather than generic.

Related posts

All posts ↗