Introduction
RilayKit is a config-driven form and workflow engine for React. When your forms become data - not JSX - you unlock serialization, multi-tenancy, visual builders, and true UI/logic separation.
rilaykit
The schema-first form and workflow engine for React.
Is RilayKit for you?
RilayKit is not a React Hook Form replacement for simple forms. It's a specialized tool for specific use cases.
Start here
Building a login, contact, or settings form? → Use React Hook Form + Zod. It's simpler, battle-tested, and perfect for standard forms.
RilayKit is for you if you're building one of these:
✅ You need RilayKit when:
- Forms are generated dynamically from server config or database schemas
- Multi-tenant SaaS where each client customizes form fields and workflows
- Visual form builder for non-technical users to create forms
- Complex multi-step workflows with cross-step conditional logic (onboarding, KYC, checkout)
- Same logic, multiple UIs — one form schema across web, mobile, or different design systems
- Form versioning & A/B testing — store forms as data, diff them, roll back changes
❌ You don't need RilayKit when:
- Building standard CRUD forms with static field sets
- Forms are defined once and never change dynamically
- Simple wizard flows with no cross-step dependencies
- No need for serialization or form-as-data architecture
The tipping point
Here's when React Hook Form hits its limits and RilayKit shines:
Scenario: Multi-step onboarding where Step 3 shows different fields based on selections from Step 1 and Step 2, and Step 4 is skipped entirely if revenue < $50k.
With React Hook Form
// State machine sprawl
const [step, setStep] = useState(1);
const [userType, setUserType] = useState('');
const [revenue, setRevenue] = useState(0);
// Manual cross-step dependencies
useEffect(() => {
if (userType === 'freelance' && revenue < 50000) {
setStep(5); // skip step 4
}
}, [userType, revenue]);
// 200+ lines of conditional rendering and navigation logic
{step === 3 && userType === 'business' && <CompanyFields />}
{step === 3 && userType === 'freelance' && <FreelanceFields />}With RilayKit
import { ril } from 'rilaykit';
// Create step forms
const basicsForm = rilay.form('basics')
.add({ id: 'userType', type: 'select', props: { options: ['freelance', 'business'] } })
.add({ id: 'revenue', type: 'number', props: { label: 'Annual Revenue' } })
.build();
const businessForm = rilay.form('business-details')
.add({ id: 'company', type: 'input', props: { label: 'Company Name' } })
.build();
const freelanceForm = rilay.form('freelance-details')
.add({ id: 'skills', type: 'multi-select', props: { label: 'Skills' } })
.build();
// Build workflow with conditional steps
// Note: ID and name are optional (auto-generated if omitted)
const onboarding = rilay.flow('onboarding', 'User Onboarding')
.step({
id: 'basics',
title: 'Basic Information',
formConfig: basicsForm
})
.step({
id: 'business',
title: 'Business Details',
formConfig: businessForm,
conditions: {
visible: when('basics.userType').equals('business'),
skippable: when('basics.revenue').lessThan(50000)
}
})
.step({
id: 'freelance',
title: 'Freelance Details',
formConfig: freelanceForm,
conditions: { visible: when('basics.userType').equals('freelance') }
})
.build();
// Serialize to DB, version control, A/B test
const json = onboarding.toJSON();The key difference: RilayKit moves business logic out of your components and into declarative configuration. This configuration is data, so you can store it, diff it, version it, and generate it dynamically.
Quick example
import { ril, Form, FormField } from 'rilaykit';
// 1. Register your components once (shadcn, MUI, custom — your choice)
const rilay = ril.create()
.addComponent('input', { renderer: YourInput })
.addComponent('select', { renderer: YourSelect });
// 2. Define forms as data (not JSX)
const signupForm = rilay.form('signup')
.add({
id: 'email',
type: 'input',
validation: { validate: [required(), email()] }
})
.add({
id: 'plan',
type: 'select',
props: { options: ['free', 'pro', 'enterprise'] }
})
.add({
id: 'company',
type: 'input',
conditions: { visible: when('plan').in(['pro', 'enterprise']) }
})
.build();
// 3. Render anywhere with full type safety
<Form formConfig={signupForm} onSubmit={handleSubmit}>
<FormField fieldId="email" />
<FormField fieldId="plan" />
<FormField fieldId="company" /> {/* auto-hidden unless plan is pro/enterprise */}
</Form>RilayKit is fully headless — it manages state, validation, and logic. You own the components, the markup, and the styling.
React Hook Form vs RilayKit
An honest comparison to help you choose:
| Criteria | React Hook Form | RilayKit |
|---|---|---|
| Learning curve | ✅ Simple, familiar patterns | ⚠️ Steeper (new concepts) |
| Simple forms | ✅ Perfect fit | ❌ Overkill |
| Multi-step with conditions | ⚠️ Requires custom state machine | ✅ Built-in workflow engine |
| Forms as data | ❌ Not possible | ✅ Core feature (.toJSON()) |
| Dynamic form generation | ❌ Challenging | ✅ Designed for it |
| Multiple design systems | ⚠️ Requires refactoring | ✅ Swap renderers, logic stays |
| Ecosystem maturity | ✅ Huge, battle-tested | ⚠️ Young but growing |
| Type safety | ✅ Good (with Zod/TS) | ✅ Excellent (built-in propagation) |
Rule of thumb: If you're not sure you need RilayKit, you probably don't. Start with React Hook Form. Migrate to RilayKit when you hit the limitations above.
What makes RilayKit different
1. Forms are data, not JSX
This is the fundamental shift. Your form definition is a serializable data structure:
const pricingForm = rilay.form('pricing')
.add({ id: 'plan', type: 'select', props: { options: plans } })
.add({ id: 'seats', type: 'number', conditions: { visible: when('plan').equals('enterprise') } })
.build();
// It's data — you can serialize it
const json = pricingForm.toJSON();
await db.forms.save(json);
// Load it later, even from a different server
const loaded = rilay.form().fromJSON(json).build();
// Diff it for version control
const diff = deepDiff(v1.toJSON(), v2.toJSON());Why this matters:
- Multi-tenant SaaS: Each tenant customizes their forms, stored in your DB
- Visual builders: Non-technical users create forms through a UI, you render them
- A/B testing: Switch form versions without deploying code
- Form versioning: Track changes, roll back, audit who changed what
2. Type propagation end-to-end
Register a component once and TypeScript propagates its types everywhere:
import { ril } from 'rilaykit';
// Register with typed props
const rilay = ril.create()
.addComponent('input', { renderer: Input }); // Input has InputProps
// Now TypeScript knows
rilay.form('test')
.add({
type: 'input', // ✅ Autocompletes from registry
props: { label: '' } // ✅ Typed as InputProps
})
.add({
type: 'unknown', // ❌ Compile error — not registered
props: { foo: 'bar' } // ❌ Won't even get here
});No any escape hatches. Your registry becomes your single source of truth for types.
3. Universal validation (Standard Schema)
Any validation library, no adapters:
// Built-in validators
validation: { validate: [required(), email(), minLength(8)] }
// Zod — directly
validation: { validate: z.string().email() }
// Yup — directly
validation: { validate: yup.string().email() }
// Mix them
validation: { validate: [required(), z.string().min(8)] }RilayKit implements the Standard Schema spec, so any compliant library works out of the box.
4. Declarative conditions (no useEffect)
Control field behavior based on other fields without manual state management:
.add({
id: 'company',
type: 'input',
conditions: {
visible: when('accountType').equals('business'),
required: when('plan').in(['pro', 'enterprise']),
disabled: when('revenue').lessThan(10000),
}
})Cross-step conditions work the same way in workflows:
.step('payment', (s) => s
.conditions({
skippable: when('plan.tier', 'eq', 'free'), // from step "plan"
visible: when('details.country', 'in', ['US', 'CA']) // from step "details"
})
)5. Production-ready workflow engine
Multi-step flows with navigation, persistence, analytics, and plugins:
import { localStorageAdapter } from 'rilaykit';
const onboarding = rilay.flow('onboarding', 'User Onboarding')
.step({
id: 'account',
title: 'Account',
formConfig: accountForm
})
.step({
id: 'profile',
title: 'Profile',
formConfig: profileForm
})
.configure({
persistence: {
adapter: localStorageAdapter({ key: 'onboarding-v2' })
},
analytics: {
onStepComplete: (id) => analytics.track('step_done', { step: id })
}
})
.build();
// Render the workflow
<WorkflowProvider workflowConfig={onboarding}>
<WorkflowStepper />
</WorkflowProvider>Not just a wizard. Real features:
- Step skipping based on conditions
- Progress persistence (localStorage, sessionStorage, custom adapter)
- Navigation guards (prevent forward if validation fails)
- Analytics hooks for tracking
- Plugin system for custom behavior
Architecture
Headless & renderer-agnostic
RilayKit generates zero HTML and zero CSS. It's pure logic.
You bring your own components:
- shadcn/ui
- Material UI
- Chakra UI
- Mantine
- Your custom design system
// Works with any component library
const rilay = ril.create()
.addComponent('input', { renderer: ShadcnInput })
.addComponent('select', { renderer: ShadcnSelect });
// Or switch to MUI without changing form logic
const rilay = ril.create()
.addComponent('input', { renderer: MuiTextField })
.addComponent('select', { renderer: MuiSelect });Same form config, different UI. This is why multi-tenant SaaS and white-label products love RilayKit — one form schema, N brands.
One package, everything included
Install rilaykit and you're ready to go — forms, workflows, validation, and conditions all in one:
pnpm add rilaykitFor more granular control, individual packages are also available (@rilaykit/core, @rilaykit/forms, @rilaykit/workflow). See Installation for details.
Real-world use cases
1. Multi-tenant SaaS onboarding
Problem: Each customer wants custom onboarding flows. Hardcoding every variation is unmaintainable.
Solution: Store form configs per tenant in your database, render them dynamically.
// Load tenant-specific config from DB
const tenantConfigJson = await db.workflows.findOne({ tenantId, workflowId: 'onboarding' });
// Recreate workflow from JSON
const onboarding = rilay.flow('onboarding', 'Onboarding')
.fromJSON(tenantConfigJson)
.build();
// Render the same way for all tenants
<WorkflowProvider workflowConfig={onboarding}>
<WorkflowStepper />
</WorkflowProvider>2. Visual form builder
Problem: Non-technical users need to create/edit forms without touching code.
Solution: Build a drag-and-drop form builder that outputs RilayKit JSON.
// Builder UI generates this
const formJson = {
id: 'lead-capture',
rows: [
{ kind: 'fields', fields: [{ id: 'email', type: 'input', componentId: 'input', validation: { ... } }] },
{ kind: 'fields', fields: [{ id: 'source', type: 'select', componentId: 'select', conditions: { ... } }] }
]
};
// Your app consumes it
const leadForm = rilay.form()
.fromJSON(formJson)
.build();3. Complex KYC workflows
Problem: Know Your Customer flows have dozens of conditional branches based on country, business type, revenue, etc.
Solution: Declarative conditions keep it manageable:
.step('tax-info', (s) => s
.conditions({
visible: when('basics.country', 'in', ['US', 'CA']),
skippable: when('basics.revenue', 'lt', 10000)
})
.add({
id: 'ein',
type: 'input',
conditions: {
visible: when('basics.entityType', 'eq', 'corporation'),
required: when('basics.country', 'eq', 'US')
}
})
)Get Started
Quick Start
Working form in 5 minutes. Best for hands-on learners.
Installation
Package installation and system requirements.
Your First Form
Step-by-step tutorial with detailed explanations.
Why RilayKit?
Deep dive into the schema-first approach and when to use it.