rilaykit
Forms

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".

TypeParams
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?)

ParameterTypeDescription
schemaFormSchemaThe JSON schema definition
configRilayInstance<C>Your ril catalog with registered components
options?CompileFormOptionsSee below
OptionDescription
bindingsCustom validators and effect handlers, resolved by key
validatePropsCheck each field's props against its component's propsSchema; violations become issues pathed to the exact prop
lenientStreaming 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';
}

On this page