This tutorial builds a real-world booking form that pushes validation well beyond single-field rules. You will use createSchema() to express cross-field constraints, async checks, form-level errors, global error mapping, and server-side reuse - all without any external validation library.
By the end you will have a form that:
- Requires at least one contact method (email or phone)
- Ensures the return date is not before departure
- Checks username availability asynchronously
- Validates password confirmation via
superRefine - Prevents the email handle from appearing in the password
- Rewrites every error message through a global
errorMap - Reuses the exact same schema for server-side validation via
safeParse
Why createSchema() instead of field rules alone
Field-level rules (required, min, pattern, sameAs, …) validate each field in isolation. That covers 80% of forms. But the moment a rule depends on more than one field, you need a higher vantage point.
| Need | Field rules | createSchema() |
|---|---|---|
| "Email is required" | field.email().required() | Not needed |
| "Provide email or phone" | Cannot express | .atLeastOne(['email', 'phone']) |
| "Return date after departure" | Cannot express cleanly across the whole form | .superRefine((v, ctx) => ...) |
| "Password must not contain the email handle" | Cannot express | .superRefine((v, ctx) => …) |
| Async uniqueness check | validate(fn) (sync only) | .refineAsync(fn) |
| Rewrite all error messages at once | One field at a time | .errorMap(fn) |
| Server-side reuse | Mount a React component | .safeParse(values) - no React needed |
createSchema() wraps the same shape object you already know. The wrapped value is still passed straight to useFormBridge, so nothing changes in your component tree.
Step 1 - Start with a plain schema
Begin with the field builders. Each field owns its own local rules. No createSchema() wrapper yet.
| 1 | import { field } from '@runilib/react-formbridge' |
| 2 | |
| 3 | const bookingFields = { |
| 4 | username: field.text('Username').required().trim().min(3).max(20) |
| 5 | .pattern(/^[a-z0-9_]+$/i, 'Letters, numbers, and underscores only.'), |
| 6 | email: field.email('Email').trim().lowercase(), |
| 7 | phone: field.phone('Phone').defaultCountry('FR'), |
| 8 | departure: field.date('Departure date').required(), |
| 9 | returnDate: field.date('Return date').required(), |
| 10 | password: field.password('Password').required().min(8), |
| 11 | confirmPassword: field.password('Confirm password').required(), |
| 12 | terms: field.checkbox('I accept the terms').mustBeTrue('You must accept the terms.'), |
| 13 | } |
| 14 | |
| 15 | // At this point, each field validates itself. |
| 16 | // "email or phone", "return after departure", "passwords match" |
| 17 | // are NOT yet enforced. |
Step 2 - Wrap in createSchema() and add atLeastOne
Wrap the shape in createSchema() and chain your first cross-field rule. atLeastOne ensures the user fills in at least one of the listed fields. The error is form-level (no path), so it surfaces under state.formLevelError.
| 1 | import { createSchema, field } from '@runilib/react-formbridge' |
| 2 | |
| 3 | const bookingSchema = createSchema({ |
| 4 | email: field.email('Email').trim().lowercase(), |
| 5 | phone: field.phone('Phone').defaultCountry('FR'), |
| 6 | // ... other fields ... |
| 7 | }) |
| 8 | .atLeastOne( |
| 9 | ['email', 'phone'], |
| 10 | 'Provide at least an email or a phone number.', |
| 11 | ) |
| 12 | |
| 13 | // If both email and phone are empty → form-level error |
| 14 | // If either one is filled → rule passes |
Step 3 - superRefine for temporal constraints
When a rule depends on two date fields at once, superRefine is the most direct option. Compare the two values and pin the error to returnDate so the user sees it exactly where the fix belongs.
| 1 | import { createSchema, field } from '@runilib/react-formbridge' |
| 2 | |
| 3 | const bookingSchema = createSchema({ |
| 4 | departure: field.date('Departure date').required(), |
| 5 | returnDate: field.date('Return date').required(), |
| 6 | // ... |
| 7 | }) |
| 8 | .superRefine((values, ctx) => { |
| 9 | if (!values.departure || !values.returnDate) return |
| 10 | |
| 11 | const departureTs = new Date(values.departure).getTime() |
| 12 | const returnTs = new Date(values.returnDate).getTime() |
| 13 | |
| 14 | if (!Number.isNaN(departureTs) && !Number.isNaN(returnTs) && returnTs < departureTs) { |
| 15 | ctx.addIssue({ |
| 16 | path: 'returnDate', |
| 17 | code: 'return_before_departure', |
| 18 | message: 'Return date must be on or after departure.', |
| 19 | }) |
| 20 | } |
| 21 | }) |
| 22 | |
| 23 | // If departure = 2026-04-20 and returnDate = 2026-04-18 |
| 24 | // → error on "returnDate": "Return date must be on or after departure." |
| 25 | // If either field is empty, the rule is skipped (required catches that). |
Step 4 - superRefine for richer multi-issue checks
superRefine is the most powerful primitive. You receive the full values object and a ctx with addIssue(). You can raise multiple errors on different fields in a single pass.
This is where you put rules that are too complex for a single boolean predicate: password confirmation, email-in-password detection, conditional business logic that touches three fields, etc.
| 1 | const bookingSchema = createSchema({ /* ... */ }) |
| 2 | .superRefine((values, ctx) => { |
| 3 | // Rule 1: passwords must match |
| 4 | if (values.password !== values.confirmPassword) { |
| 5 | ctx.addIssue({ |
| 6 | path: 'confirmPassword', |
| 7 | code: 'password_mismatch', |
| 8 | message: 'Passwords do not match.', |
| 9 | }) |
| 10 | } |
| 11 | |
| 12 | // Rule 2: password must not contain the email handle |
| 13 | const handle = values.email?.split('@')[0]?.toLowerCase() |
| 14 | if (handle && values.password?.toLowerCase().includes(handle)) { |
| 15 | ctx.addIssue({ |
| 16 | path: 'password', |
| 17 | code: 'password_contains_email', |
| 18 | message: "Don't reuse your email handle inside the password.", |
| 19 | }) |
| 20 | } |
| 21 | |
| 22 | // You can add as many issues as needed. |
| 23 | // Each one lands on its own field via "path". |
| 24 | // Omit "path" to create a form-level error (state.formLevelError). |
| 25 | }) |
Step 5 - refineAsync for server-side checks
refineAsync runs a promise-returning predicate. It fires during safeParseAsync and during form validation when the runtime uses the async pipeline.
Pin the error to a specific field with path so the user sees the feedback exactly where it matters.
| 1 | const bookingSchema = createSchema({ /* ... */ }) |
| 2 | .refineAsync( |
| 3 | async (values) => { |
| 4 | // Skip the check if username is too short to be valid anyway |
| 5 | if (!values.username || values.username.length < 3) return true |
| 6 | |
| 7 | const res = await fetch('/api/check-username?u=' + values.username) |
| 8 | const { available } = await res.json() |
| 9 | return available |
| 10 | }, |
| 11 | { |
| 12 | path: 'username', |
| 13 | code: 'username_taken', |
| 14 | message: 'This username is already taken.', |
| 15 | }, |
| 16 | ) |
| 17 | |
| 18 | // The runtime debounces async checks automatically. |
| 19 | // If the user types fast, only the last value triggers the fetch. |
Step 6 - errorMap for global message rewriting
errorMap receives every issue produced by the schema - field-level and refinement-level alike - and lets you rewrite or translate the message in one place. Return the new string to override, or defaultMessage to keep the original.
This is the FormBridge-native equivalent of Zod's errorMap. Use it for i18n, copy standardization, or to strip technical codes from user-facing messages.
| 1 | const bookingSchema = createSchema({ /* ... */ }) |
| 2 | .errorMap((issue, defaultMessage) => { |
| 3 | switch (issue.code) { |
| 4 | case 'required': |
| 5 | return 'This field is required.' |
| 6 | case 'min': |
| 7 | return `Use at least ${issue.params?.min ?? ''} characters.` |
| 8 | case 'invalid_email': |
| 9 | return 'Enter a valid email address.' |
| 10 | case 'password_mismatch': |
| 11 | return 'The two passwords must be identical.' |
| 12 | case 'username_taken': |
| 13 | return 'Pick a different username - this one is taken.' |
| 14 | default: |
| 15 | return defaultMessage |
| 16 | } |
| 17 | }) |
| 18 | |
| 19 | // Every error now goes through this mapper. |
| 20 | // Great for i18n: swap the switch for a t() lookup. |
Step 7 - Render the form
The wrapped schema is passed to useFormBridge exactly like a plain shape. The only new thing in the component is reading state.formLevelError to show form-level errors produced by atLeastOne.
Step 8 - Reuse the schema on the server
Because createSchema() exposes safeParse and safeParseAsync, you can validate the exact same rules server-side - in a server action, a tRPC handler, an API route, or a test - without mounting React.
The returned errorsByField is drop-in compatible with state.errors, so you can feed server errors straight back into the form via setErrors().
If bookingSchema lives in a module shared between your client form and the server action, define that module with @runilib/react-formbridge/schema and keep useFormBridge imported from the main package only in React files.
| 1 | // bookingSchema.ts |
| 2 | import { createSchema, field, type SchemaValues } from '@runilib/react-formbridge/schema' |
| 3 | |
| 4 | export const bookingSchema = createSchema({ |
| 5 | // same schema as above |
| 6 | }) |
| 7 | |
| 8 | export type BookingValues = SchemaValues<typeof bookingSchema> |
| 9 | |
| 10 | // createBooking.ts |
| 11 | 'use server' |
| 12 | |
| 13 | import { bookingSchema, type BookingValues } from './bookingSchema' |
| 14 | |
| 15 | export async function createBooking(raw: unknown) { |
| 16 | // safeParseAsync runs sync + async refinements |
| 17 | const result = await bookingSchema.safeParseAsync(raw as Partial<BookingValues>) |
| 18 | |
| 19 | if (!result.success) { |
| 20 | // result.errorsByField → { username: "..." } |
| 21 | // result.formLevelErrors → ["Provide at least an email or a phone."] |
| 22 | // result.issues → raw ordered list of every issue |
| 23 | return { |
| 24 | ok: false as const, |
| 25 | errors: result.errorsByField, |
| 26 | formLevelErrors: result.formLevelErrors, |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // result.data is fully typed from the schema shape |
| 31 | await db.bookings.insert(result.data) |
| 32 | return { ok: true as const } |
| 33 | } |
| 34 | |
| 35 | // On the client, wire server errors back: |
| 36 | // const res = await createBooking(values) |
| 37 | // if (!res.ok) setErrors(res.errors) |
Chaining order and execution
Every createSchema() method returns the same wrapped schema, so the chain is fully fluent. The execution order matters:
| Order | What runs | Stops on failure? |
|---|---|---|
| 1 | Field-level rules (required, min, pattern, …) | No - all fields are validated |
| 2 | refine / superRefine (sync) | No - all sync refinements run |
| 3 | refineAsync (async) | No - all async refinements run |
| 4 | errorMap rewrites every collected issue | N/A - post-processing |
All issues are collected into a single ValidationResult. Nothing short-circuits by default - the user sees every problem at once, not one at a time.
The chain itself is declarative. You can declare refinements in any order; FormBridge sorts them by kind internally.
Built-in helpers cheat sheet
createSchema() ships with three high-level helpers built on top of superRefine. Use them before writing a custom refinement - they cover the most common cross-field patterns.
| Helper | What it checks | Error target |
|---|---|---|
atLeastOne(fields, msg?) | At least one of the listed fields is filled | Form-level (state.formLevelError) |
exactlyOne(fields, msg?) | Exactly one of the listed fields is filled | Form-level (state.formLevelError) |
allOrNone(fields, msg?) | Either all or none of the listed fields are filled | Form-level (state.formLevelError) |
For anything more complex, drop to refine (one boolean, one error) or superRefine (multiple issues, full control).
| 1 | import { createSchema, field } from '@runilib/react-formbridge' |
| 2 | |
| 3 | // ── exactlyOne: pick one delivery method ───────────────────────────── |
| 4 | const deliverySchema = createSchema({ |
| 5 | pickup: field.text('Pickup address'), |
| 6 | homeAddr: field.text('Home delivery address'), |
| 7 | lockerCode: field.text('Parcel locker code'), |
| 8 | }).exactlyOne( |
| 9 | ['pickup', 'homeAddr', 'lockerCode'], |
| 10 | 'Pick exactly one delivery method.', |
| 11 | ) |
| 12 | |
| 13 | // ── allOrNone: optional-but-atomic billing section ─────────────────── |
| 14 | const billingSchema = createSchema({ |
| 15 | billingStreet: field.text('Street'), |
| 16 | billingCity: field.text('City'), |
| 17 | billingZip: field.text('ZIP code'), |
| 18 | }).allOrNone( |
| 19 | ['billingStreet', 'billingCity', 'billingZip'], |
| 20 | 'Fill in the full billing address or leave it blank.', |
| 21 | ) |
Bonus - Testing validation without React
Because safeParse / safeParseAsync run outside React, you can unit-test every validation rule without mounting a component. Assert directly on errorsByField and formLevelErrors.
| 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { bookingSchema } from './bookingSchema' |
| 3 | |
| 4 | describe('bookingSchema', () => { |
| 5 | it('requires at least one contact method', () => { |
| 6 | const result = bookingSchema.safeParse({ |
| 7 | username: 'ava_dev', |
| 8 | departure: '2026-05-01', |
| 9 | returnDate: '2026-05-10', |
| 10 | password: 'Str0ngP@ss!', |
| 11 | confirmPassword: 'Str0ngP@ss!', |
| 12 | terms: true, |
| 13 | // email and phone both missing |
| 14 | }) |
| 15 | |
| 16 | expect(result.success).toBe(false) |
| 17 | expect(result.formLevelErrors).toContain( |
| 18 | 'Provide at least an email or a phone number.', |
| 19 | ) |
| 20 | }) |
| 21 | |
| 22 | it('rejects return date before departure', () => { |
| 23 | const result = bookingSchema.safeParse({ |
| 24 | username: 'ava_dev', |
| 25 | email: 'ava@example.com', |
| 26 | departure: '2026-05-10', |
| 27 | returnDate: '2026-05-01', |
| 28 | password: 'Str0ngP@ss!', |
| 29 | confirmPassword: 'Str0ngP@ss!', |
| 30 | terms: true, |
| 31 | }) |
| 32 | |
| 33 | expect(result.success).toBe(false) |
| 34 | expect(result.errorsByField.returnDate).toBe( |
| 35 | 'Return date must be on or after departure.', |
| 36 | ) |
| 37 | }) |
| 38 | |
| 39 | it('catches password mismatch', () => { |
| 40 | const result = bookingSchema.safeParse({ |
| 41 | username: 'ava_dev', |
| 42 | email: 'ava@example.com', |
| 43 | departure: '2026-05-01', |
| 44 | returnDate: '2026-05-10', |
| 45 | password: 'Str0ngP@ss!', |
| 46 | confirmPassword: 'WrongPass!', |
| 47 | terms: true, |
| 48 | }) |
| 49 | |
| 50 | expect(result.success).toBe(false) |
| 51 | expect(result.errorsByField.confirmPassword).toBe( |
| 52 | 'The two passwords must be identical.', |
| 53 | ) |
| 54 | }) |
| 55 | |
| 56 | it('catches email handle in password', () => { |
| 57 | const result = bookingSchema.safeParse({ |
| 58 | username: 'ava_dev', |
| 59 | email: 'ava@example.com', |
| 60 | departure: '2026-05-01', |
| 61 | returnDate: '2026-05-10', |
| 62 | password: 'ava12345!', |
| 63 | confirmPassword: 'ava12345!', |
| 64 | terms: true, |
| 65 | }) |
| 66 | |
| 67 | expect(result.success).toBe(false) |
| 68 | expect(result.errorsByField.password).toContain('email handle') |
| 69 | }) |
| 70 | |
| 71 | it('passes with valid data', () => { |
| 72 | const result = bookingSchema.safeParse({ |
| 73 | username: 'ava_dev', |
| 74 | email: 'ava@example.com', |
| 75 | departure: '2026-05-01', |
| 76 | returnDate: '2026-05-10', |
| 77 | password: 'Str0ngP@ss!', |
| 78 | confirmPassword: 'Str0ngP@ss!', |
| 79 | terms: true, |
| 80 | }) |
| 81 | |
| 82 | expect(result.success).toBe(true) |
| 83 | expect(result.data.username).toBe('ava_dev') |
| 84 | }) |
| 85 | }) |
Where to go next
You now have a form with production-grade validation that runs identically on the client and the server.
| Next step | Why |
|---|---|
| createSchema() API reference | Full signatures, edge cases, and ValidationIssue shape |
| Validation overview | How field-level, schema-level, imperative, and bridge validation work together |
| Conditional logic | visibleWhen, requiredWhen, disabledWhen - rules that change form shape at runtime |
| Draft persistence | Save the form state across refreshes so strong validation never costs the user their input |
| Tutorial: advanced flows | Wizards, dynamic forms, readonly review, and analytics |