Real-World Examples
Production-ready patterns for SaaS onboarding, KYC verification, and dynamic pricing — complete implementations with RilayKit.
Real-World Examples
Three production scenarios end to end — forms, workflow wiring, conditional logic, validation, persistence, and analytics. All three share one instance:
import { ril } from 'rilaykit';
import { Checkbox, Input, Select, Textarea } from '@/components';
export const rilay = ril.create()
.component('input', { renderer: Input })
.component('select', { renderer: Select })
.component('textarea', { renderer: Textarea })
.component('checkbox', { renderer: Checkbox });SaaS Onboarding Flow
Four steps — account, company, team (skippable), billing — with progress saved to localStorage and funnel analytics.
Define the forms
import { email, minLength, required, when } from 'rilaykit';
import { rilay } from '@/lib/rilay';
export const accountForm = rilay.form('onboarding-account')
.add({
id: 'name',
type: 'input',
props: { label: 'Full Name', placeholder: 'Jane Doe' },
validation: { validate: [required()] },
})
.add({
id: 'email',
type: 'input',
props: { label: 'Email Address', type: 'email' },
validation: { validate: [required(), email()] },
})
.add({
id: 'password',
type: 'input',
props: { label: 'Password', type: 'password' },
validation: { validate: [required(), minLength(8)] },
});
export const companyForm = rilay.form('onboarding-company')
.add({
id: 'companyName',
type: 'input',
props: { label: 'Company Name' },
validation: { validate: [required()] },
})
.add({
id: 'companySize',
type: 'select',
props: {
label: 'Company Size',
options: [
{ value: '1-10', label: '1-10 employees' },
{ value: '11-50', label: '11-50 employees' },
{ value: '51+', label: '51+ employees' },
],
},
validation: { validate: [required()] },
});
export const teamForm = rilay.form('onboarding-team')
.add({
id: 'teamName',
type: 'input',
props: { label: 'Team Name', placeholder: 'Engineering' },
validation: { validate: [required()] },
})
.add({
id: 'inviteEmails',
type: 'textarea',
props: { label: 'Invite Team Members', placeholder: 'alice@acme.com, bob@acme.com' },
});
export const billingForm = rilay.form('onboarding-billing')
.add({
id: 'plan',
type: 'select',
props: {
label: 'Plan',
options: [
{ value: 'free', label: 'Free' },
{ value: 'pro', label: 'Pro' },
{ value: 'enterprise', label: 'Enterprise' },
],
},
validation: { validate: [required()] },
})
.add({
id: 'cardNumber',
type: 'input',
props: { label: 'Card Number' },
validation: { validate: [required('Card number is required for paid plans')] },
conditions: {
visible: when('plan').notEquals('free'),
},
});Build the workflow with persistence and analytics
import { LocalStorageAdapter } from 'rilaykit';
import { rilay } from '@/lib/rilay';
import { accountForm, billingForm, companyForm, teamForm } from './onboarding-forms';
export const onboarding = rilay.flow('saas-onboarding', 'SaaS Onboarding')
.step({ id: 'account', title: 'Account Setup', formConfig: accountForm })
.step({ id: 'company', title: 'Company Details', formConfig: companyForm })
.step({ id: 'team', title: 'Team', formConfig: teamForm, allowSkip: true })
.step({ id: 'billing', title: 'Billing', formConfig: billingForm })
.configure({
persistence: {
adapter: new LocalStorageAdapter(),
options: { autoPersist: true, debounceMs: 500, storageKey: 'onboarding-progress' },
},
analytics: {
onStepComplete: (stepId, duration) =>
analytics.track('onboarding_step_complete', { step: stepId, durationMs: duration }),
onWorkflowComplete: (id, totalTime, data) =>
analytics.track('onboarding_complete', { totalTimeMs: totalTime, plan: data.billing?.plan }),
onStepSkip: (stepId, reason) =>
analytics.track('onboarding_step_skipped', { step: stepId, reason }),
},
});Render the page
<Flow> accepts the builder directly — no .build() needed. onComplete(data, meta) receives the answered steps plus meta (visitedSteps, skippedSteps, passedSteps).
import { Flow } from 'rilaykit/react';
import { onboarding } from '../config/onboarding-workflow';
export function OnboardingPage() {
return (
<Flow
of={onboarding}
onComplete={async (data, meta) => {
await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, skippedSteps: meta.skippedSteps }),
});
}}
className="max-w-2xl mx-auto py-12"
>
<Flow.Progress />
<Flow.Body />
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<Flow.Back />
<div className="flex gap-3">
<Flow.Skip />
<Flow.Next />
</div>
</div>
</Flow>
);
}cardNumber is visible — and validated — only while the plan is not free: hidden fields are excluded from validation and from the submitted data. A skipped step (here team) is simply absent from data.
KYC Identity Verification
Three steps: personal details with a nationality-dependent field, document upload with an async backend check, and a legal acknowledgment.
import { async as asyncValidator, custom, required, when } from 'rilaykit';
import { rilay } from '@/lib/rilay';
const verifyDocumentNumber = asyncValidator(async (value: string) => {
const response = await fetch('/api/verify-document', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ number: value }),
});
const { valid } = await response.json();
return valid;
}, 'Invalid document number');
export const personalInfoForm = rilay.form('kyc-personal')
.add({
id: 'fullName',
type: 'input',
props: { label: 'Full Legal Name' },
validation: { validate: [required()] },
})
.add({
id: 'nationality',
type: 'select',
props: {
label: 'Nationality',
options: [
{ value: 'US', label: 'United States' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'FR', label: 'France' },
],
},
validation: { validate: [required()] },
})
.add({
id: 'ssn',
type: 'input',
props: { label: 'Social Security Number', placeholder: 'XXX-XX-XXXX' },
validation: { validate: [required('SSN is required for US nationals')] },
conditions: {
visible: when('nationality').equals('US'),
},
});
export const documentForm = rilay.form('kyc-document')
.add({
id: 'documentType',
type: 'select',
props: {
label: 'Document Type',
options: [
{ value: 'passport', label: 'Passport' },
{ value: 'drivers-license', label: "Driver's License" },
{ value: 'national-id', label: 'National ID' },
],
},
validation: { validate: [required()] },
})
.add({
id: 'documentNumber',
type: 'input',
props: { label: 'Document Number' },
validation: { validate: [required(), verifyDocumentNumber], debounceMs: 500 },
})
.setValidation({ mode: 'onBlur' });
export const reviewForm = rilay.form('kyc-review')
.add({
id: 'termsAccepted',
type: 'checkbox',
props: { label: 'I accept the terms and conditions' },
validation: {
validate: [custom((value) => value === true, 'You must accept the terms')],
},
});async(fn, message) wraps a promise-returning check into a Standard Schema validator. debounceMs throttles it while the user types; blur and submit always validate immediately. .setValidation({ mode: 'onBlur' }) defers each field's first validation to blur — the default reValidateMode: 'onChange' then clears errors live.
import { LocalStorageAdapter } from 'rilaykit';
import { rilay } from '@/lib/rilay';
import { documentForm, personalInfoForm, reviewForm } from './kyc-forms';
export const kycWorkflow = rilay.flow('kyc-verification', 'Identity Verification')
.step({ id: 'personal', title: 'Personal Information', formConfig: personalInfoForm })
.step({ id: 'document', title: 'Document Upload', formConfig: documentForm })
.step({ id: 'review', title: 'Review', formConfig: reviewForm })
.configure({
persistence: {
adapter: new LocalStorageAdapter({ maxAge: 24 * 60 * 60 * 1000 }),
options: { autoPersist: true, storageKey: 'kyc-progress' },
},
analytics: {
onError: (error, context) =>
analytics.track('kyc_error', { error: error.message, step: context.currentStepIndex }),
},
});The maxAge option expires persisted progress after 24 hours. onError fires for every workflow error path (step transitions, submission, persistence failures) — a validation error blocking Next is not an error path.
import { Flow } from 'rilaykit/react';
import { kycWorkflow } from '../config/kyc-workflow';
export function KycPage() {
return (
<Flow
of={kycWorkflow}
onComplete={async (data) => {
await fetch('/api/kyc/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}}
className="max-w-xl mx-auto py-12"
>
<Flow.Progress />
<Flow.Body />
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<Flow.Back />
<Flow.Next />
</div>
</Flow>
);
}The ssn field is shown only when nationality is US. Its validation is skipped entirely for other nationalities.
Dynamic Pricing Calculator
A single-page form (no workflow) whose fields adapt to the selected service type — the common shape for quote generators and booking forms.
import { custom, required, when } from 'rilaykit';
import { rilay } from '@/lib/rilay';
export const pricingForm = rilay.form('pricing-calculator')
.add({
id: 'serviceType',
type: 'select',
props: {
label: 'Service Type',
options: [
{ value: 'consulting', label: 'Consulting' },
{ value: 'development', label: 'Development' },
{ value: 'design', label: 'Design' },
],
},
validation: { validate: [required()] },
})
.add({
id: 'teamSize',
type: 'input',
props: { label: 'Team Size', type: 'number' },
validation: {
validate: [
required('Team size is required for development projects'),
custom((value) => {
const size = Number(value);
return Number.isFinite(size) && size >= 1 && size <= 50;
}, 'Team size must be between 1 and 50'),
],
},
conditions: {
visible: when('serviceType').equals('development'),
},
})
.add({
id: 'designRevisions',
type: 'input',
props: { label: 'Design Revisions', type: 'number' },
validation: {
validate: [
required('Number of revisions is required for design projects'),
custom((value) => {
const revisions = Number(value);
return Number.isFinite(revisions) && revisions >= 1 && revisions <= 10;
}, 'Revisions must be between 1 and 10'),
],
},
conditions: {
visible: when('serviceType').equals('design'),
},
});import { Form } from 'rilaykit/react';
import { pricingForm } from '../config/pricing-form';
export function PricingPage() {
function handleSubmit(data: Record<string, unknown>) {
// Send to backend for quote generation
}
return (
<div className="max-w-lg mx-auto py-12">
<h1 className="text-2xl font-bold mb-6">Get a Quote</h1>
<Form of={pricingForm} onSubmit={handleSubmit}>
<div className="space-y-4">
<Form.Field id="serviceType" />
<Form.Field id="teamSize" />
<Form.Field id="designRevisions" />
<Form.Submit className="w-full">Request Quote</Form.Submit>
</div>
</Form>
</div>
);
}teamSize appears only for development, designRevisions only for design — each with its own validation, active only while visible. For simpler starting points, see the Examples page.