rilaykit

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.

npm version

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:

CriteriaReact Hook FormRilayKit
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.

Read the detailed comparison


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.

Learn more

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.

Learn more

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"
  })
)

Learn more

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

Learn more


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 rilaykit

For 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


On this page