Type something to search...
How to Add Validation Rules to Sanity Fields

How to Add Validation Rules to Sanity Fields

To add validation rules to Sanity fields, give a field a validation property: a function that receives a rule object and returns one or more chained rules, such as validation: (rule) => rule.required().min(10).max(80). Studio checks the rules as editors type, shows messages next to the field and blocks publishing while any error remains. You can lower a rule to a warning() or info() so it advises without blocking, write your own logic with rule.custom(), compare against other fields with rule.valueOfField(), and validate a whole document by adding validation to the document type itself.

Validation is how your schema protects content quality. It stops posts going live without a slug, catches prices with too many decimals and nudges editors towards meta descriptions that fit in search results. This guide covers where validation runs, the built-in rules for each type, error levels and custom messages, custom and asynchronous validators, cross-field and document-level rules, conditional validation for hidden fields, and how to validate content outside Studio.

Where Validation Runs

One point is worth understanding before anything else: schema validation runs in Sanity Studio only. When editors work in Studio, rules are enforced and errors block publishing. When you write documents through the HTTP API, @sanity/client or an import script, the Content Lake doesn't check your validation rules.

In practice that means:

  • Studio is your main line of defence for editor-created content.
  • Scripts and integrations must validate their own data before writing.
  • The CLI can check existing documents against your rules in bulk, which is covered at the end of this article.
  • Your frontend should still handle missing or unexpected values gracefully.

The Basic Syntax

// schemaTypes/post.ts
import { defineField, defineType } from 'sanity';

export const post = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (rule) => rule.required().min(10).max(80),
    }),
  ],
});

Rules chain left to right and all must pass. Without required(), an empty field is valid: min(10) only applies once there's a value. That's a common surprise, so add required() whenever a field must be filled.

Built-In Rules by Type

Strings and Text

RuleWhat it checks
required()The field has a value
min(n) / max(n)Minimum and maximum characters
length(n)Exact number of characters
regex(pattern, name?)The value matches a regular expression
uppercase() / lowercase()Letter case
email()A valid email address

A regex with a readable name produces a clearer message:

defineField({
  name: 'productCode',
  type: 'string',
  validation: (rule) =>
    rule.required().regex(/^[A-Z]{3}-\d{4}$/, { name: 'product code (e.g. ABC-1234)' }),
}),

Pass { invert: true } to reject values that match, for example to forbid certain words.

Numbers

RuleWhat it checks
min(n) / max(n)Range, inclusive
greaterThan(n) / lessThan(n)Range, exclusive
positive() / negative()Sign
integer()Whole numbers only
precision(n)Maximum decimal places
defineField({
  name: 'price',
  type: 'number',
  validation: (rule) => rule.required().positive().precision(2),
}),
defineField({
  name: 'rating',
  type: 'number',
  validation: (rule) => rule.integer().min(1).max(5),
}),

URLs

url fields accept uri() with options for schemes and relative links:

defineField({
  name: 'link',
  type: 'url',
  validation: (rule) =>
    rule.uri({ scheme: ['https', 'mailto'], allowRelative: false }),
}),

Arrays

RuleWhat it checks
min(n) / max(n)Number of items
length(n)Exact number of items
unique()No duplicate items
defineField({
  name: 'categories',
  type: 'array',
  of: [{ type: 'reference', to: [{ type: 'category' }] }],
  validation: (rule) => rule.required().min(1).max(3).unique(),
}),

Dates and Datetimes

min() and max() accept date strings:

defineField({
  name: 'eventDate',
  type: 'date',
  validation: (rule) => rule.required().min('2020-01-01'),
}),

For "must be in the future" or "must be after another field", use the approaches in the following sections.

Images and Files

required() on an image is satisfied by an object with only an alt text but no upload. Add assetRequired() to insist on an actual file:

defineField({
  name: 'mainImage',
  type: 'image',
  validation: (rule) => rule.required().assetRequired(),
}),

Error Levels and Custom Messages

Every rule fails as an error by default, which blocks publishing. Change the level, and the message, with error(), warning() or info() at the end of a chain:

defineField({
  name: 'metaDescription',
  type: 'text',
  rows: 2,
  validation: (rule) => [
    rule.required().error('A meta description is required for SEO'),
    rule.max(160).warning('Search engines usually truncate descriptions over 160 characters'),
    rule.min(70).info('Longer descriptions tend to perform better'),
  ],
}),

Returning an array lets you combine different levels on one field. Each chain gets its own level and message. Use errors for things that would break the site, and warnings for editorial advice, so editors aren't blocked by style guidance.

Comparing Fields With valueOfField

rule.valueOfField() reads a sibling field, so you can compare values:

defineField({ name: 'startsAt', type: 'datetime', validation: (rule) => rule.required() }),
defineField({
  name: 'endsAt',
  type: 'datetime',
  validation: (rule) => rule.min(rule.valueOfField('startsAt')).error('The event must end after it starts'),
}),

The same works for numbers, such as a sale price that must be below the regular price with rule.lessThan(rule.valueOfField('price')).

Custom Validators

When built-in rules aren't enough, rule.custom() takes a function that receives the value and a context. Return true when valid, or a string message when not:

defineField({
  name: 'title',
  type: 'string',
  validation: (rule) =>
    rule.custom((value) => {
      if (!value) return true; // let required() handle empty values
      if (value === value.toUpperCase()) return 'Please avoid titles in all capitals';
      return true;
    }).warning(),
}),

Note the first line. Custom validators run even when the field is empty, so either handle undefined yourself or chain rule.optional() before custom().

Using the Context

The second argument provides context about where the field sits:

PropertyContains
documentThe whole document being edited
parentThe closest parent object, useful inside arrays and objects
pathThe path to the field being validated
getClientA function that returns a configured client for queries
hiddenWhether the field is currently hidden

A rule that depends on another field's value:

defineField({
  name: 'externalUrl',
  type: 'url',
  validation: (rule) =>
    rule.custom((value, context) => {
      const doc = context.document as { linkType?: string } | undefined;
      if (doc?.linkType === 'external' && !value) {
        return 'An external link needs a URL';
      }
      return true;
    }),
}),

Asynchronous Validation

Custom validators can be async, which lets you query the dataset. This example makes sure no other product uses the same SKU:

defineField({
  name: 'sku',
  type: 'string',
  validation: (rule) =>
    rule.required().custom(async (value, context) => {
      if (!value) return true;
      const client = context.getClient({ apiVersion: '2025-10-01' });
      const id = context.document?._id.replace(/^drafts\./, '');
      const count = await client.fetch<number>(
        'count(*[_type == "product" && sku == $sku && !(_id in [$draft, $published])])',
        { sku: value, draft: `drafts.${id}`, published: id },
      );
      return count === 0 ? true : 'This SKU is already used by another product';
    }),
}),

Excluding both the draft and published IDs stops the document clashing with itself. Validation isn't debounced, so keep async checks light. For slugs specifically, the slug type already checks uniqueness and offers an isUnique option, explained in how to create custom slugs.

Validating Objects and Array Items

Rules on fields inside a named object travel with it, so a reusable object type like seo validates the same way everywhere it's used. You can also validate the object as a whole and point the error at a specific child with a path:

defineField({
  name: 'faqs',
  type: 'array',
  of: [
    {
      type: 'object',
      name: 'faq',
      fields: [
        { name: 'question', type: 'string' },
        { name: 'answer', type: 'text' },
      ],
    },
  ],
  validation: (rule) =>
    rule.custom((items: { _key: string; question?: string }[] | undefined) => {
      const missing = (items ?? []).filter((item) => !item.question);
      if (missing.length === 0) return true;
      return missing.map((item) => ({
        message: 'Each FAQ needs a question',
        path: [{ _key: item._key }, 'question'],
      }));
    }),
}),

Returning an array of { message, path } objects attaches each error to the right item, which is far more helpful than one generic message on the array.

Document-Level Validation

Some rules involve several fields. Add validation to the document type and the function receives the whole document:

export const post = defineType({
  name: 'post',
  type: 'document',
  fields: [
    defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
    defineField({ name: 'guestAuthor', type: 'string' }),
  ],
  validation: (rule) =>
    rule.custom((doc) => {
      if (doc?.author && doc?.guestAuthor) {
        return { message: 'Choose an author or a guest author, not both', path: ['guestAuthor'] };
      }
      return true;
    }),
});

Including a path shows the message on the relevant field, not just at the top of the document.

Validation for Hidden Fields

Conditional fields hide inputs that don't apply, but rules on hidden fields still run. Use rule.skip() with context.hidden so hidden fields don't block publishing:

defineField({
  name: 'salePrice',
  type: 'number',
  hidden: ({ document }) => !document?.onSale,
  validation: (rule, context) =>
    context?.hidden ? rule.skip() : rule.required().positive(),
}),

skip() discards the rules before it in the chain and makes the field optional.

Validating Content Outside Studio

Because API writes skip schema rules, check existing content periodically, especially after imports or schema changes. The CLI can validate every document in a dataset against your current schema:

npx sanity documents validate

It reports each document with errors or warnings, so you can fix problems in bulk. For scripts that write content, validate data before calling the client, for example with Zod or your own checks, and run the CLI validation afterwards as a safety net.

Tips for Good Validation

  • Write messages for editors: "Add a title of at least 10 characters" beats "Invalid value".
  • Prefer warnings for style: reserve errors for things that would break the site or a query.
  • Mirror frontend assumptions: if a component crashes without an image, make the image required.
  • Don't over-validate: every rule is friction, so keep them meaningful.
  • Pair with descriptions: tell editors the rule up front rather than only after they break it.

FAQ: Validation Rules in Sanity

No. Schema validation only runs in Sanity Studio. Documents created or updated through the HTTP API, client libraries or imports aren't checked, so validate data in your scripts and use the CLI's documents validate command to check existing content.

Rules like min and max only apply when the field has a value. An empty field is considered valid unless you add required() to the chain, for example rule.required().min(10).

End the rule chain with warning(), optionally with a message, such as rule.max(160).warning('Keep it short'). Warnings show in Studio but don't stop editors from publishing. Use info() for softer hints.

Use rule.valueOfField('otherField') with comparison rules like min, max, greaterThan or lessThan. For more complex logic, use rule.custom() and read other values from context.document or context.parent.

Yes. Make the custom validator async, get a client with context.getClient and an API version, and run a GROQ query. Keep these checks light, because validation runs frequently as editors type.

Check context.hidden in the validation function and return rule.skip() when the field is hidden. Otherwise, required rules on hidden fields will still block publishing even though editors can't see them.


Conclusion

Validation in Sanity is a function on the field that returns chained rules. Start with required(), add type-specific rules like max(), precision() or unique(), and use warning() and info() to give advice without blocking editors. When you need more, rule.custom(), valueOfField(), async queries and document-level rules cover almost any business logic.

Remember that rules protect Studio, not the API. Validate data in your scripts, run npx sanity documents validate after imports and schema changes, and keep your frontend tolerant of missing values. Combined with clear descriptions and good content modelling, validation keeps your content clean without getting in your editors' way.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper