Universal Validation with Standard Schema
Learn how to use validation with any Standard Schema compatible library.
RilayKit validates with any Standard Schema compatible library — Zod (3.24+), Yup (1.7+), Joi (18+), Valibot (1.0+), ArkType (2.0+) — no adapters needed.
Field Validation
Each field's validation object accepts:
| Property | Description |
|---|---|
validate | A Standard Schema validator, or an array of them |
debounceMs | Debounce validation runs (async cost control); blur and submit always validate immediately |
Built-ins and external schemas mix freely in the same array:
import { z } from 'zod';
import { rilay } from '@/lib/rilay';
import { form } from '@rilaykit/forms';
import { required } from '@rilaykit/core';
const registrationForm = form.create(rilay, 'registration')
.add({
id: 'email',
type: 'email',
props: { label: 'Email Address' },
validation: {
validate: [
required('Email is required'), // RilayKit built-in
z.string().email('Invalid email format'), // Zod schema
],
},
})
.add({
id: 'password',
type: 'password',
props: { label: 'Password' },
validation: {
validate: z.string().min(8, 'Password too short'),
},
});Validation Timing
Timing is configured form-wide via .setValidation({ mode, reValidateMode }), mirroring React Hook Form:
| Option | Values | Default | Meaning |
|---|---|---|---|
mode | 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all' | 'onTouched' | When a field validates for the first time |
reValidateMode | 'onChange' | 'onBlur' | 'onSubmit' | 'onChange' | When an errored field re-validates |
const strictForm = form.create(rilay, 'strict')
.add({ id: 'email', type: 'email', validation: { validate: email() } })
.setValidation({ mode: 'onBlur', reValidateMode: 'onChange' });The default 'onTouched' validates a field on its first blur, then live. Submit always validates everything and marks errored fields touched, so touched-gated renderers show submit errors — and they clear live as the user types.
Built-in Validators
All from @rilaykit/core, all Standard Schema compatible:
required(message?),email(message?),url(message?)minLength(min, message?),maxLength(max, message?),pattern(regex, message?)number(message?),min(value, message?),max(value, message?)custom(fn, message?),async(fn, message?),combine(...validators)
Creating Custom Standard Schema Validators
import type { StandardSchemaV1 } from '@standard-schema/spec';
export function containsRilay(message = 'Value must contain "rilay"'): StandardSchemaV1<string> {
return {
'~standard': {
version: 1,
vendor: 'my-app',
validate: (value: unknown) => {
if (typeof value === 'string' && value.includes('rilay')) {
return { value };
}
return { issues: [{ message }] };
},
},
};
}Use it like any other validator: validation: { validate: [required(), containsRilay()] }.
Form-Level Validation
For cross-field rules, pass an object schema (or a custom validator over the form data) to .setValidation:
import { z } from 'zod';
const userSchema = z.object({
password: z.string().min(8, 'Password too short'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
const changePasswordForm = form.create(rilay, 'change-password')
.add({ type: 'password', id: 'password', props: { label: 'New Password' } })
.add({ type: 'password', id: 'confirmPassword', props: { label: 'Confirm Password' } })
.setValidation({ validate: userSchema });Form-level issues are routed by issue.path:
- A path naming a known field attaches the error to that field —
useFieldErrors(id)receives it alongside the field's own errors. - An empty or unmatched path lands in a reserved
__form__bucket, read viauseFormErrors():
import { useFormErrors } from 'rilaykit/react';
function FormErrorBanner() {
const errors = useFormErrors();
if (errors.length === 0) return null;
return (
<div role="alert">
{errors.map(e => <p key={e.message}>{e.message}</p>)}
</div>
);
}Form-level rules re-run on the same mode/reValidateMode cadence as field rules and on repeatable row changes, so cross-field errors appear and clear live — no resubmit needed. isValid counts the whole error map, __form__ included.
Repeatable fields use composite ids (items[k0].price) that never match a schema's dot path (items.0.price): cross-field issues over repeatable rows land in __form__.
Async Validation
Standard Schema supports async validators; pair them with debounceMs to limit API calls:
const emailField = {
id: 'email',
type: 'email',
props: { label: 'Email' },
validation: {
validate: z.string()
.email('Invalid email format')
.refine(async (email) => {
const response = await fetch(`/api/check-email?email=${email}`);
const { isUnique } = await response.json();
return isUnique;
}, 'Email is already taken'),
debounceMs: 500,
},
};