Sanity Pioneers: Get early access to betas, extra AI credits, and a direct line to the engineering team. Apply now

Level up your validation game!

Validations can do so much more than you think, here are some examples.

By Saskia Bobinska


internal helpers

import {
  ArrayRule,
  BlockRule,
  isPortableTextSpan,
  isPortableTextTextBlock,
  PortableTextBlock,
  PortableTextTextBlock,
} from 'sanity'

/**
 * Reusable Portable Text validators
 *
 * Each export is a small factory `(Rule) => Rule.custom(...)` so individual
 * schemas can compose only the rules they need. Array-level rules accept a
 * Sanity `ArrayRule`; per-block rules accept a `BlockRule`. Multiple rules
 * may be returned from a schema's `validation` callback as an array, e.g.:
 *
 * ```ts
 * validation: (Rule) => [
 *   validateHeadingOrder(Rule),
 *   warnWhenSectionTooLongWithoutHeading(Rule, { maxWords: 250 }),
 * ]
 * ```
 *
 * Severity convention: `validate*` helpers are errors (block publish);
 * `warn*` helpers are warnings (advisory only, do not block publish).
 *
 * Why this module exists: enforcing a few simple structural rules on rich text
 * keeps the rendered output accessible (correct heading hierarchy → screen
 * reader navigation), SEO-friendly (single H1 from document title, no level
 * skipping) and readable (no walls of text without subheadings).
 */

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

type PortableTextArrayRule = ArrayRule<PortableTextBlock[] | unknown[]>

/** Filter a Portable Text array down to just its heading blocks (h1–h6). */
function getHeadings(blocks: unknown[]): PortableTextTextBlock[] {
  return blocks.filter(
    (block): block is PortableTextTextBlock =>
      isPortableTextTextBlock(block) &&
      typeof block.style === 'string' &&
      /^h[1-6]$/.test(block.style),
  )
}

/** Count words in a single Portable Text block by joining all span text. */
function countWordsInBlock(block: PortableTextTextBlock): number {
  const text = block.children
    .filter(isPortableTextSpan)
    .map((span) => span.text)
    .join(' ')
    .trim()
  if (!text) return 0
  return text.split(/\s+/).length
}

/** True if the block represents a discrete reading break (resets section length). */
function isReadingBreak(block: unknown): boolean {
  if (!isPortableTextTextBlock(block)) {
    // Non-block array members (tables, callouts, images, dividers, code) all
    // reset the "wall of text" counter — the eye gets a visual break.
    return true
  }
  // Headings, list items, and blockquotes also break up the flow.
  if (typeof block.style === 'string' && /^h[1-6]$/.test(block.style)) return true
  if (block.listItem) return true
  return false
}

DOM heading validators


// ---------------------------------------------------------------------------
// DOM-heading validators (ArrayRule, error severity)
// ---------------------------------------------------------------------------

/**
 * The first heading in the array must be `h2`.
 *
 * Why: H1 is reserved for the document title, which the frontend renders
 * outside the rich-text body. Body content that starts at H1 (or skips
 * straight to H3+) breaks the document outline and confuses screen readers
 * and search engines.
 *
 * When to use: top-level body fields where the document title is rendered
 * as the page H1 (e.g. `guide.content`, `policy.content`, `newsPost.content`).
 * Do NOT use on nested section blocks whose container already provides the H2.
 *
 * @example
 * defineField({
 *   name: 'content',
 *   type: 'blockContent',
 *   validation: (Rule) => [
 *     ...blockContentArrayValidation(Rule),
 *     validateH2IsFirst(Rule),
 *   ],
 * })
 */
export const validateH2IsFirst = (Rule: PortableTextArrayRule) =>
  Rule.custom<PortableTextBlock[]>((value) => {
    if (!Array.isArray(value)) return true
    const headings = getHeadings(value)
    if (headings.length === 0) return true
    if (headings[0].style === 'h2') return true
    return {
      message: 'First heading should be H2 (the document title is rendered as H1).',
      path: [{ _key: headings[0]._key as string }],
    }
  })

/**
 * The first heading in the array must be `h3`.
 *
 * Why: in nested section blocks the container already renders an `<h2>`,
 * so body headings inside it should start at H3 to keep the outline correct.
 *
 * When to use: rich-text fields embedded inside section objects whose
 * surrounding component owns the H2 (e.g. an FAQ "answer" sitting under a
 * question H2, or a column block under a section H2).
 *
 * @example
 * validation: (Rule) => [
 *   ...blockContentArrayValidation(Rule),
 *   validateH3IsFirst(Rule),
 *   validateNoH2(Rule),
 * ]
 */
export const validateH3IsFirst = (Rule: PortableTextArrayRule) =>
  Rule.custom<PortableTextBlock[]>((value) => {
    if (!Array.isArray(value)) return true
    const headings = getHeadings(value)
    if (headings.length === 0) return true
    if (headings[0].style === 'h3') return true
    return {
      message: 'First heading should be H3 (this section already sits under an H2).',
      path: [{ _key: headings[0]._key as string }],
    }
  })

/**
 * Disallow `h2` headings in the array.
 *
 * Why: nested sections that sit under an H2 must not introduce another H2,
 * which would create a sibling section in the document outline rather than
 * a subsection.
 *
 * When to use: pair with `validateH3IsFirst` on rich-text fields inside
 * section objects.
 */
export const validateNoH2 = (Rule: PortableTextArrayRule) =>
  Rule.custom<PortableTextBlock[]>((value) => {
    if (!Array.isArray(value)) return true
    const h2 = getHeadings(value).find((h) => h.style === 'h2')
    if (!h2) return true
    return {
      message: 'H2 headings are not allowed in this section.',
      path: [{ _key: h2._key as string }],
    }
  })

/**
 * Headings must not skip levels when descending (e.g. H2 → H4 is invalid).
 *
 * Why: skipping heading levels breaks the document outline. Screen readers
 * announce headings by level, and a missing level signals a missing
 * structural step. Ascending jumps (H4 → H2) are fine — they signal
 * starting a new section.
 *
 * When to use: every rich-text field that allows multiple heading levels.
 * Wired globally via `blockContentArrayValidation`.
 */
export const validateHeadingOrder = (Rule: PortableTextArrayRule) =>
  Rule.custom<PortableTextBlock[]>((value) => {
    if (!Array.isArray(value)) return true
    const headings = getHeadings(value)
    if (headings.length < 2) return true

    for (let i = 0; i < headings.length - 1; i++) {
      const current = headings[i]
      const next = headings[i + 1]
      if (!current.style || !next.style) continue

      const currentLevel = parseInt(current.style.replace('h', ''), 10)
      const nextLevel = parseInt(next.style.replace('h', ''), 10)

      if (nextLevel > currentLevel && nextLevel - currentLevel > 1) {
        return {
          message: `Heading ${current.style.toUpperCase()} should not be followed by ${next.style.toUpperCase()}. Use H${currentLevel + 1} instead so the outline doesn't skip a level.`,
          path: [{ _key: next._key as string }],
        }
      }
    }
    return true
  })

Bold-paragraph validators

// ---------------------------------------------------------------------------
// Bold-paragraph validators (BlockRule, warning severity)
// ---------------------------------------------------------------------------

/** True when the block consists of a single span entirely marked as `strong`. */
function isAllBoldSingleSpan(block: PortableTextTextBlock): boolean {
  if (block.children.length !== 1) return false
  const [child] = block.children
  if (!isPortableTextSpan(child)) return false
  if (!child.text || !child.text.trim()) return false
  return Boolean(child.marks?.includes('strong'))
}

/**
 * Warn when a paragraph is a single all-bold span — suggest making it a heading.
 *
 * Why: editors often use bold-on-a-line as a visual heading. That looks like
 * a heading but renders as a `<p><strong>`, which screen readers don't
 * announce as a heading and search engines don't treat as document
 * structure.
 *
 * When to use: per-block validation on any `block` array member that allows
 * heading styles. Wired globally on `richBlockMember`.
 */
export const warnWhenBlockIsAllBold = (Rule: BlockRule) =>
  Rule.warning().custom((value) => {
    if (!isPortableTextTextBlock(value)) return true
    const isHeading = typeof value.style === 'string' && /^h[1-6]$/.test(value.style)
    if (isHeading) return true
    if (!isAllBoldSingleSpan(value)) return true
    return 'This paragraph is entirely bold. Did you mean to make it a heading?'
  })

/**
 * Warn when a heading's only span is also marked `strong` — suggest removing the bold.
 *
 * Why: headings are already visually emphasised by their style. Adding
 * `<strong>` on top is redundant, makes downstream styling harder, and
 * tends to break custom heading typography.
 *
 * When to use: per-block validation on any `block` array member that allows
 * the `strong` decorator on heading styles.
 */
export const warnWhenHeadingIsBold = (Rule: BlockRule) =>
  Rule.warning().custom((value) => {
    if (!isPortableTextTextBlock(value)) return true
    const isHeading = typeof value.style === 'string' && /^h[1-6]$/.test(value.style)
    if (!isHeading) return true
    if (!isAllBoldSingleSpan(value)) return true
    return 'Headings should not be styled as bold — the heading style already provides emphasis.'
  })

/**
 * Convenience wrapper that runs both bold-paragraph checks.
 *
 * Combines `warnWhenBlockIsAllBold` (paragraph → suggest heading) and
 * `warnWhenHeadingIsBold` (heading → suggest removing strong) into a single
 * factory. Use this on shared block members so both concerns are covered
 * everywhere with one entry.
 *
 * @example
 * defineArrayMember({
 *   type: 'block',
 *   validation: (Rule) => [warnWhenHeadingOrBlockIsAllBold(Rule)],
 * })
 */
export const warnWhenHeadingOrBlockIsAllBold = (Rule: BlockRule) =>
  Rule.warning().custom((value) => {
    if (!isPortableTextTextBlock(value)) return true
    if (!isAllBoldSingleSpan(value)) return true

    const isHeading = typeof value.style === 'string' && /^h[1-6]$/.test(value.style)
    return isHeading
      ? 'Headings should not be styled as bold — the heading style already provides emphasis.'
      : 'This paragraph is entirely bold. Did you mean to make it a heading?'
  })

Readability validators


// ---------------------------------------------------------------------------
// Readability validators (warning severity)
// ---------------------------------------------------------------------------

interface SectionLengthOptions {
  /**
   * Maximum number of words allowed in a contiguous run of paragraphs without
   * a heading, list, table, callout or other structural break before a
   * warning is shown.
   *
   * Default: 300 words (≈ a typical page of body text). Tune downward for
   * scannable surfaces (FAQs, marketing pages) and upward for long-form
   * content (essays, policy docs) if appropriate.
   */
  maxWords?: number
}

/**
 * Warn when a contiguous stretch of paragraphs runs longer than `maxWords`
 * without a heading or other structural break.
 *
 * Why: long unbroken text is hard to scan. Readers (especially on mobile)
 * skim by headings; AI answer surfaces lift content by section. Adding an
 * H3 every few paragraphs improves both human readability and AEO.
 *
 * Counting rules:
 * - Counts words across spans of `block` items with style `normal` /
 *   `blockquote` / `code` (anything that isn't a heading or list item).
 * - Word count uses trimmed span text split on `\s+`.
 * - The counter resets on any heading style (h1–h6), list item, or
 *   non-block array member (table, callout, image, divider, code block) —
 *   each provides a visual break.
 *
 * Severity is warning, so this never blocks publish; it just nudges the
 * editor to add structure.
 *
 * @param Rule the `ArrayRule` from the field's `validation` callback
 * @param options optional thresholds (see {@link SectionLengthOptions})
 *
 * @example
 * validation: (Rule) => [
 *   warnWhenSectionTooLongWithoutHeading(Rule),
 * ]
 *
 * @example tighter threshold for an FAQ answer
 * validation: (Rule) => [
 *   warnWhenSectionTooLongWithoutHeading(Rule, { maxWords: 150 }),
 * ]
 */
export const warnWhenSectionTooLongWithoutHeading = (
  Rule: PortableTextArrayRule,
  options: SectionLengthOptions = {},
) => {
  const { maxWords = 300 } = options
  return Rule.warning().custom<PortableTextBlock[]>((value) => {
    if (!Array.isArray(value)) return true

    let runWords = 0
    let runStartKey: string | undefined
    let lastBlockKey: string | undefined

    for (const block of value) {
      if (isReadingBreak(block)) {
        runWords = 0
        runStartKey = undefined
        lastBlockKey = undefined
        continue
      }
      if (!isPortableTextTextBlock(block)) continue

      const words = countWordsInBlock(block)
      if (words === 0) continue

      runWords += words
      runStartKey = runStartKey ?? (block._key as string | undefined)
      lastBlockKey = block._key as string | undefined

      if (runWords > maxWords) {
        return {
          message: `This section runs about ${runWords} words without a subheading. Long unbroken text is hard to scan — consider adding an H3 to break it up.`,
          path: lastBlockKey ? [{ _key: lastBlockKey }] : [],
        }
      }
    }
    return true
  })
}

interface ParagraphLengthOptions {
  /**
   * Maximum number of words allowed in a single paragraph before a warning
   * is shown.
   *
   * Default: 120 words (≈ 4–6 lines of body text on a typical reading
   * surface). Tune as needed per consumer.
   */
  maxWords?: number
}

/**
 * Warn when a single paragraph exceeds `maxWords`.
 *
 * Why: long paragraphs slow readers down and discourage scanning. Splitting
 * a 200-word paragraph into two 100-word paragraphs measurably improves
 * comprehension and dwell time.
 *
 * Counting rules:
 * - Applies only to `normal` and `blockquote` styles — headings, list items
 *   and code blocks have different length expectations.
 * - Word count uses trimmed span text split on `\s+`.
 *
 * Severity is warning, so this never blocks publish.
 *
 * @param Rule the `BlockRule` from the array member's `validation` callback
 * @param options optional thresholds (see {@link ParagraphLengthOptions})
 *
 * @example
 * defineArrayMember({
 *   type: 'block',
 *   validation: (Rule) => [warnWhenParagraphTooLong(Rule)],
 * })
 */
export const warnWhenParagraphTooLong = (Rule: BlockRule, options: ParagraphLengthOptions = {}) => {
  const { maxWords = 120 } = options
  return Rule.warning().custom((value) => {
    if (!isPortableTextTextBlock(value)) return true
    const style = value.style ?? 'normal'
    if (style !== 'normal' && style !== 'blockquote') return true

    const words = countWordsInBlock(value)
    if (words <= maxWords) return true
    return `This paragraph is about ${words} words. Long paragraphs are hard to scan — consider splitting it.`
  })
}

Most editors will not have a deep understanding of DOM structure, your underlying schemas or how things will render.

While this is a fact, we do not need to leave it like this: validations are a great way to let editors know when a DOM rule is broken, readability will be impaired, or even if you just want to make sure, they get a feeling for how your content is rendered (no headings with bold for example).

Pro tip: use the table of contents plugin to help them navigate long arrays and portable text in the Studio!

Contributor

Saskia Bobinska

Senior Support Engineer @Sanity

Germany

Visit Saskia Bobinska's profile