Sanity Custom Validation Rules: A Practical Guide

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

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: '2026-03-25' })
 
          // Exclude both the draft and published versions of this document
          const id = (document?._id ?? '').replace(/^drafts\./, '')
          const existingId = await client.fetch<string | null>(
            groq`*[_type == "product" && barcode == $barcode && !(_id in [$draft, $published])][0]._id`,
            { barcode: value, draft: `drafts.${id}`, published: 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 draft/published exclusion. While an editor is working, the in-progress document has a drafts.-prefixed ID, and the published version has the bare ID. A plain _id != $id check would exclude the draft but still match the published copy of the same document — a false positive against itself. Stripping the prefix and excluding both IDs avoids that.

Cross-field validation on the document type itself

Field-level rules can read sibling values via context.document (or compare with Rule.valueOfField()), but when a rule spans several fields it's cleaner to put Rule.custom on the document type itself.

// 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. A plain string message isn't attached to a specific field, so either name the fields involved in the message, or return { message, path: ['endDate'] } to point the error at the field the editor needs to fix.

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() gives the editor no useful explanation. 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.


Need a well-structured Sanity schema?

Schema design is where most Sanity projects either get fast or get painful. If you need a Sanity developer to design or audit your content model, I work on Sanity + Next.js projects — fixed-price scoping, 24-hour response.

Frequently asked questions

How do I write an async validation rule in Sanity, like a uniqueness check?

Return a Promise from `Rule.custom()` — it can await a GROQ query via `context.getClient()`. Always exclude both the draft and published IDs of the current document (`!(_id in [$draft, $published])`) so editing an existing document doesn't false-positive against itself. See the barcode example below for the full pattern.

How do I validate across multiple fields in a Sanity schema, not just one?

A field-level `Rule.custom()` callback can read sibling values through `context.document`, and simple comparisons can use `Rule.valueOfField()`. For rules that involve several fields at once — like requiring an end date after a start date and a capacity when registration is open — put the `validation` callback on the document type itself; it receives the whole document object, and you can return `{ message, path }` to point the error at a specific field.

Why does my Sanity validation rule show no error message?

Most likely you returned `false` instead of a string. Sanity's `Rule.custom()` is documented to return either `true` (valid) or an error message string — the string is what the editor sees. Returning `false` gives the editor no useful explanation. Always return an explicit, specific error string.