Level up your Portable Text 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