Validation
Built-in validators with custom messages, plus the customValidators escape hatch for plugin checks.
Every edit the author makes runs the page through the validator pipeline.
Errors land in the Errors panel in the right rail with a count badge —
both severity: error and severity: warning entries surface there so
the author can see what's wrong before they Publish. The validators are
advisory today: they don't hard-block Save or Publish, but they're the
fastest signal that something's off with the page.
This page covers:
- The built-in validators — every type-aware check that ships in the box.
- Per-rule custom error messages so the panel reads as you'd phrase it, not the generic default.
- The
customValidatorsescape hatch for checks the built-ins don't cover. - How visibility (
visibleIf) interacts with validation.
Built-in validators
Each built-in attaches to a field via a direct knob — no array wrapping, no plugin registration. The pipeline applies them in a fixed order; the panel groups errors by type.
| Validator | Applies to | Knob | Custom message |
|---|---|---|---|
required | every field | required | requiredMessage |
minLength | text, paragraph, link | minLength | minLengthMessage |
maxLength | text, paragraph, link | maxLength | maxLengthMessage |
regex | text, paragraph, link | regex | regexMessage |
min | number, range | min | (default) |
max | number, range | max | (default) |
minItems | array, multichoice | minItems | minItemsMessage |
maxItems | array, multichoice | maxItems | maxItemsMessage |
uniqueOn | array | uniqueOn | uniqueOnMessage |
shape | every field | (automatic) | (default) |
required
The most common check. Empty values (empty string, empty array, null,
undefined, missing key) are rejected.
- key: title
type: text
label: Title
required: true
requiredMessage: 'Give the page a title so authors can find it later.'required on an array means "at least one item." Use minItems: N when you
need exactly N or more.
required on a group means "the group must have any value at all" — usually
not what you want; require specific child fields instead.
minLength / maxLength
Hard bounds in characters. The editor's counter turns red outside the hard bounds.
- key: description
type: paragraph
label: Description
minLength: 50
maxLength: 160
minLengthMessage: "Aim for at least 50 chars so search snippets aren't truncated."For a soft sweet-spot recommendation (green counter inside the range,
amber outside, without surfacing a panel error) use recommendedLength
instead:
- key: description
type: paragraph
recommendedLength: { min: 50, max: 160 }regex
JavaScript-flavoured regex source (no surrounding slashes). The validator
skips empty values — those are required's domain.
- key: slug
type: text
label: Slug
regex: '^[a-z0-9-]+$'
regexMessage: 'Lowercase letters, digits, and dashes only.'For multiple regex checks with different messages, use
customValidators — the direct regex: knob allows
only one.
min / max
Numeric bounds for number and range. Decimals work — the comparison is
JS-numeric.
- key: seats
type: number
min: 1
max: 100minItems / maxItems
Item-count bounds for array and multichoice. The "Add item" affordance
disables at maxItems, so the cap is visible up front.
- key: blocks
type: array
minItems: 1
maxItems: 10
minItemsMessage: 'Add at least one block — the page needs content.'uniqueOn
Per-item uniqueness on an array. Path can be a relative dotted path within the item.
- key: faqs
type: array
label: FAQs
uniqueOn: slug
uniqueOnMessage: 'Two FAQs share the same slug — pick distinct slugs.'
items:
- type: group
key: faq
label: FAQ
fields:
- key: slug
type: text
label: Slug
- key: question
type: text
label: QuestionPath supports descent: uniqueOn: meta.id checks each item's meta.id value.
Items missing the path are skipped (so half-filled drafts don't trigger
false positives).
Per-rule custom messages
Every direct validator with a *Message variant lets you replace the default
error text. This is the lowest-effort way to make the Errors panel read in
the user's voice instead of "Heading is required" / "Description must be at
least 50 characters" generics.
- key: heading
type: text
required: true
minLength: 5
maxLength: 80
requiredMessage: "Every block needs a heading — it's the first thing users see."
minLengthMessage: 'Stretch it a bit — under 5 chars reads as a placeholder.'
maxLengthMessage: 'Keep it tight — over 80 chars wraps awkwardly on mobile.'Custom messages render verbatim. Skip the field name (the panel already shows it).
customValidators
The escape hatch for checks the built-ins don't cover. Each entry is one validator plus its parameters.
- key: slug
type: text
label: Slug
customValidators:
- type: regex
pattern: '^[a-z0-9-]+$'
message: 'Lowercase letters, digits, and dashes only.'
- type: expression
field: $parent.title
op: nonEmpty
message: 'Set a title first so we can derive the slug.'Built-in type: values
type | Params | Effect |
|---|---|---|
regex | pattern, optional flags | Like field-level regex: but lets you stack multiple with per-rule messages. |
expression | field, op, value | Evaluate a Condition as a validity check (same path syntax as visibleIf). |
Why an array escape hatch (and not more direct fields)
Direct fields stay strongly-typed: minLength is only valid on string-shaped
field types; the schema editor + the meta-schema enforce that. customValidators
deliberately gives up the per-type narrowing in exchange for extensibility —
new validator type:s can land without touching the meta-schema.
The pattern: reach for customValidators when:
- You need multiple of the same check kind on one field with different messages (e.g. two regex patterns: "lowercase" and "no spaces", each with its own error text).
- You need a check that depends on another field's value — a "must be filled in once X is set" cross-field rule.
- The check is plugin-style and might not exist for every site (a remote validator that calls out to your service, a project-specific custom rule).
Use the direct knobs for everything else — they're type-safe and don't
need a type: discriminator.
Visibility interaction
Errors are filtered through the visibility set: a field that's hidden by
visibleIf doesn't surface errors for itself or its descendants. The
filter happens in one place (validatePage in the validation pipeline) so
individual validators stay ignorant of visibility — a future validator
added to the registry automatically inherits the behaviour.
Why this matters: without the filter, a required field hidden by
visibleIf would surface a confusing "is required" error in the Errors
panel even though the author can't see the field to fill it in. The
current behaviour matches user intuition: "if I can't see it, I can't
be expected to fill it in."
The whenHidden: drop option on a field complements this — when the
visibility flips false, the stored value is purged on Publish (rather
than preserved). Use it when carrying a stale value forward would
itself be a data quality problem (e.g. a credit-card field hidden when
payment method is invoice).
Validation order
The pipeline runs each validator in registration order, collects errors into one flat array, then applies the visibility filter. The order matters for the panel grouping (which renders errors grouped by type) but not for correctness — every validator runs against every field regardless of what previous validators emitted.
shape runs alongside the others and flags type mismatches (the schema
declares type: number but the value on disk is a string, etc). These
typically point at content that was authored against an older schema
shape and surfaced after a rename.