New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed.
Start here →
TL;DR: Wrap a Zod safeParse call inside a FormEngine custom validator function and return
result.error.issues[0].message on failure. Register it on the validators prop of FormViewer,
then reference it in the form JSON with "type": "custom".
Last reviewed: April 2026.
FormEngine’s built-in validators handle most cases — required, email, minLength, regex, and
the rest. But when you need reusable validation logic expressed as a TypeScript schema, Zod is a
natural fit. This tutorial shows how to bridge Zod schemas into FormEngine’s custom validator
interface.
Prerequisites: FormEngine Core installed, Zod installed (npm install zod). Familiar with basic FormEngine validation JSON.
FormEngine Core is open-source on GitHub.
Custom validators are registered on the FormViewer component via the validators prop. Each
validator is a function that receives the current field value and returns true (valid) or an error
string.
const validators: FormViewerProps['validators'] = {
string: {
myValidator: {
validate: (value, store, args, formData) => {
// return true = valid, return string = error message
return true
}
}
}
}
The validator is then referenced in the form JSON with "type": "custom":
{
"key": "someField",
"schema": {
"validations": [
{ "key": "myValidator", "type": "custom" }
]
}
}
Zod fits naturally here: parse the value with a Zod schema, and convert Zod’s error output to a string.
1. Single-field Zod validation
A common need: a password field with several rules that are easier to express as a Zod schema than as individual built-in validators.
const passwordSchema = z
.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[0-9]/, 'Must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Must contain at least one special character')
const validators: FormViewerProps['validators'] = {
string: {
strongPassword: {
validate: (value) => {
if (!value) return true // let "required" handle the empty case
const result = passwordSchema.safeParse(value)
if (result.success) return true
return result.error.issues[0].message
}
}
}
}
function SignupForm({ schema }: { schema: string }) {
return (
<FormViewer
view={view}
form={schema}
validators={validators}
/>
)
}
Register this validator in the form JSON:
{
"key": "password",
"type": "MuiTextField",
"props": {
"label": { "value": "Password" },
"type": { "value": "password" }
},
"schema": {
"validations": [
{ "key": "required", "args": { "message": "Password is required" } },
{ "key": "strongPassword", "type": "custom" }
]
}
}
safeParse returns all issues, but we take only issues[0].message to show one error at a time — the same behavior as built-in validators.
2. Cross-field validation: password confirmation
Zod’s .refine() and the formData argument in the validator function enable cross-field checks — validate one field against another.
const validators: FormViewerProps['validators'] = {
string: {
matchesPassword: {
validate: (value, _store, _args, formData) => {
const password = formData?.data?.password as string | undefined
if (!value || !password) return true
return value === password ? true : 'Passwords do not match'
}
}
}
}
The form JSON for the confirm field:
{
"key": "confirmPassword",
"type": "MuiTextField",
"props": {
"label": { "value": "Confirm password" },
"type": { "value": "password" }
},
"schema": {
"validations": [
{ "key": "required", "args": { "message": "Please confirm your password" } },
{ "key": "matchesPassword", "type": "custom" }
]
}
}
formData is the full IFormData object — the same one available in renderWhen expressions. You can read any field in the form from it.
3. Parameterized validators with Zod
For validators that need configuration per field — like a min/max range that differs by form — pass args in the JSON and read them in the validator.
const validators: FormViewerProps['validators'] = {
number: {
zodRange: {
validate: (value, _store, args) => {
const min = Number(args?.min ?? 0)
const max = Number(args?.max ?? Infinity)
const schema = z.number().min(min, `Min value is ${min}`).max(max, `Max value is ${max}`)
const result = schema.safeParse(Number(value))
if (result.success) return true
return result.error.issues[0].message
}
}
}
}
In the form JSON, pass args alongside the validator key:
{
"key": "age",
"type": "MuiTextField",
"props": { "label": { "value": "Age" } },
"schema": {
"validations": [
{ "key": "required" },
{
"key": "zodRange",
"type": "custom",
"args": { "min": 18, "max": 120 }
}
]
}
}
4. Async validation with Zod
FormEngine’s custom validator interface is synchronous. For async checks (username availability,
email existence), use the validateAsync key instead of validate:
const validators: FormViewerProps['validators'] = {
string: {
uniqueUsername: {
validateAsync: async (value) => {
if (!value) return true
// Zod can validate format first, before hitting the API
const formatCheck = z.string().min(3).regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, underscores')
const formatResult = formatCheck.safeParse(value)
if (!formatResult.success) return formatResult.error.issues[0].message
// Then do the async check
const response = await fetch(`/api/check-username?username=${encodeURIComponent(value)}`)
const { available } = await response.json()
return available ? true : 'Username is already taken'
}
}
}
}
This pattern uses Zod for the synchronous format check (fast, no network) and only hits the API when the format passes.
5. Reusable validator library
For larger codebases, collect validators in a shared module:
// src/lib/formValidators.ts
const phoneSchema = z
.string()
.regex(/^\+?[\d\s\-().]{7,15}$/, 'Enter a valid phone number')
const slugSchema = z
.string()
.min(2, 'Slug must be at least 2 characters')
.max(60, 'Slug cannot exceed 60 characters')
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens')
.refine(s => !s.startsWith('-') && !s.endsWith('-'), 'Slug cannot start or end with a hyphen')
export const validators: FormViewerProps['validators'] = {
string: {
phone: {
validate: (value) => {
if (!value) return true
const r = phoneSchema.safeParse(value)
return r.success ? true : r.error.issues[0].message
}
},
slug: {
validate: (value) => {
if (!value) return true
const r = slugSchema.safeParse(value)
return r.success ? true : r.error.issues[0].message
}
}
}
}
Pass the combined validators object to every FormViewer instance that needs them:
<FormViewer view={view} form={schema} validators={validators} />
Validator function signature
type ValidateFn = (
value: unknown, // current field value
store: ComponentStore, // component registry
args: Record<string, unknown> | undefined, // args from JSON
formData: IFormData | undefined // full form state
) => boolean | string
Return true for valid. Return a non-empty string to show as the error message. Return false to
trigger a generic error (prefer returning a string).
Troubleshooting
Validator not firing — check the validator key matches exactly between the validators prop and the JSON "key" value. Keys are case-sensitive.
Wrong data type passed to Zod — form field values are strings by default even for number-type
inputs. Use Number(value) or z.coerce.number() when writing number validators.
Zod shows all issues at once — take issues[0].message to match FormEngine’s
one-error-at-a-time display pattern, or join them if you want all messages shown.
Cross-field validator doesn’t re-run when the other field changes — this is expected. FormEngine
re-validates a field when that field changes, not when another field changes. To force
re-validation, use an onChange action on the first field to trigger validation on the second.
Next steps
Last modified on April 16, 2026