Level up your validation game!
Validations can do so much more than you think, here are some examples.
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