Server-Driven Forms
Generate fully functional forms from JSON schemas sent by the backend. No frontend redeployment needed.
Server-Driven Forms
compileForm() turns a JSON schema — validation, conditions, effects, repeatables included — into the same FormConfiguration the form builder produces.
Quick Start
import { compileForm } from '@rilaykit/forms';
import type { FormSchema } from '@rilaykit/forms';
import { Form, FormBody, FormSubmit } from '@rilaykit/forms/react';
import { rilay } from '@/lib/rilay'; // your ril catalog
const schema: FormSchema = await fetch('/api/forms/contact').then(r => r.json());
const { formConfig, defaultValues } = compileForm(schema, rilay);
<Form of={formConfig} defaults={defaultValues} onSubmit={handleSubmit}>
<FormBody />
<FormSubmit />
</Form>Schema Format
A FormSchema is plain JSON — no functions:
{
"id": "contact",
"defaultValues": { "country": "FR" },
"validation": { "mode": "onTouched", "reValidateMode": "onChange" },
"fields": [
{
"id": "name",
"type": "text",
"props": { "label": "Full Name" },
"validation": { "rules": "required" }
},
{
"id": "email",
"type": "text",
"props": { "label": "Email" },
"validation": { "rules": ["required", "email"] }
}
]
}Form-level validation accepts mode, reValidateMode, and rules (cross-field descriptors). Field-level validation accepts rules and debounceMs.
Two Layout Modes
Each field gets its own row:
{
"id": "login",
"fields": [
{ "id": "email", "type": "text", "props": { "label": "Email" } },
{ "id": "password", "type": "text", "props": { "label": "Password" } }
]
}Multiple fields per row, mixable with repeatable groups:
{
"id": "address",
"rows": [
{
"kind": "fields",
"fields": [
{ "id": "firstName", "type": "text", "props": { "label": "First" } },
{ "id": "lastName", "type": "text", "props": { "label": "Last" } }
]
}
]
}A schema must have exactly one of fields or rows — not both.
Validation Descriptors
Rules are string shortcuts or parameterized objects — data only.
{
"validation": {
"rules": [
"required",
{ "type": "minLength", "params": { "min": 3 } },
{ "type": "maxLength", "params": { "max": 100 }, "message": "Too long!" }
],
"debounceMs": 300
}
}String shortcuts: "required", "email", "url", "number".
| Type | Params |
|---|---|
minLength | { min: number } |
maxLength | { max: number } |
min | { min: number } |
max | { max: number } |
pattern | { pattern: string } |
debounceMs throttles async validation; blur and submit always validate immediately. Timing (mode, reValidateMode) is form-level — see the schema format above.
Custom Validators via Bindings
Logic that can't be JSON lives in bindings, referenced by key:
import { compileForm } from '@rilaykit/forms';
import type { Bindings } from '@rilaykit/forms';
import { custom } from '@rilaykit/core';
const bindings: Bindings = {
validators: {
postalCode: (params, message) =>
custom((v: string) => /^\d{5}$/.test(v), message ?? 'Invalid postal code'),
},
};
// Schema side:
// { "validation": { "rules": { "type": "postalCode", "message": "Enter a 5-digit ZIP" } } }
const { formConfig } = compileForm(schema, rilay, { bindings });Conditions
Conditions pass through unchanged — ConditionConfig is already JSON-serializable:
{
"id": "companyName",
"type": "text",
"props": { "label": "Company Name" },
"conditions": {
"visible": { "field": "accountType", "operator": "equals", "value": "business" },
"required": { "field": "accountType", "operator": "equals", "value": "business" }
}
}All operators are supported: equals, notEquals, greaterThan, lessThan, contains, in, exists, matches, etc.
Effects
Effects reference handler keys from the bindings — no inline functions. Optional params let one handler serve many fields:
{
"id": "city",
"type": "select",
"props": { "label": "City", "options": [] },
"effects": [
{ "trigger": "change", "watch": "country", "handler": "loadCities" },
{ "trigger": "change", "watch": "country", "handler": "clearField", "params": { "target": "city" } }
]
}const bindings: Bindings = {
effects: {
loadCities: async (newValue, { setValue, setProps }) => {
setValue('city', '');
setProps('city', { options: await fetchCities(newValue as string) });
},
clearField: (newValue, { setValue }, params) => {
setValue(params?.target as string, '');
},
},
};Handlers receive the same FieldEffectContext as programmatic effects (setValue, setProps, getValues, getFieldValue), plus the descriptor's params as a third argument.
Repeatable Groups
Via the rows format:
{
"id": "team-form",
"rows": [
{
"kind": "repeatable",
"repeatable": {
"id": "members",
"min": 1,
"max": 5,
"defaultValue": { "role": "member" },
"rows": [
{
"fields": [
{ "id": "name", "type": "text", "props": { "label": "Name" } },
{ "id": "role", "type": "select", "props": { "label": "Role", "options": [] } }
]
}
]
}
}
]
}Schema Validation
compileForm() validates structure before building and throws SchemaValidationError with detailed issues:
import { compileForm, SchemaValidationError } from '@rilaykit/forms';
try {
const { formConfig } = compileForm(schema, rilay, { bindings });
} catch (error) {
if (error instanceof SchemaValidationError) {
console.log(error.issues);
// [{ path: "fields[0]", message: "Unknown component type: 'foo'", severity: "error" }]
}
}To check without building, validateSchema(schema, rilay, bindings) throws the same error; isFormSchema(value) is a type guard that narrows to FormSchema.
API Reference
compileForm(schema, config, options?)
| Parameter | Type | Description |
|---|---|---|
schema | FormSchema | The JSON schema definition |
config | RilayInstance<C> | Your ril catalog with registered components |
options? | CompileFormOptions | See below |
| Option | Description |
|---|---|
bindings | Custom validators and effect handlers, resolved by key |
validateProps | Check each field's props against its component's propsSchema; violations become issues pathed to the exact prop |
lenient | Streaming tolerance: compile the compilable subset of a partial schema instead of throwing — only for schemas still streaming in |
Returns FormSchemaResult<C>:
interface FormSchemaResult<C> {
readonly formConfig: FormConfiguration<C>;
readonly defaultValues?: Record<string, unknown>;
}fromSchema(schema, config, registry?) still works but is deprecated — it's an alias for compileForm(schema, config, { bindings: registry }). Same for the SchemaRegistry type, renamed Bindings.
Bindings
interface Bindings {
readonly validators?: Record<string, CustomValidatorFactory>;
readonly effects?: Record<string, SchemaEffectHandler>;
}
type CustomValidatorFactory = (
params?: Record<string, unknown>,
message?: string
) => StandardSchema;
type SchemaEffectHandler = (
newValue: unknown,
context: FieldEffectContext,
params?: Record<string, unknown>
) => void | Promise<void>;SchemaValidationError
class SchemaValidationError extends Error {
readonly code = 'SCHEMA_VALIDATION_ERROR';
readonly issues: SchemaIssue[];
}
interface SchemaIssue {
readonly path: string;
readonly message: string;
readonly severity: 'error' | 'warning';
}