Recipes
Complete, working examples that combine several features at once. Each recipe is self-contained — copy it, adjust the field names and URLs, and it runs.
- Multi-step lead form with conditional routing and a webhook
- Using @saastro/forms without Vite (Next.js)
Multi-step lead form with conditional routing and a webhook
A lead-qualification wizard that combines three features:
- Multi-step routing — the second step depends on what the visitor is interested in (Multi-Step guide)
- Conditional field visibility — a follow-up field appears only when “Other” is selected (Conditional Logic guide)
- Submit actions — a webhook captures every lead, and a second, conditional webhook fires only for demo requests (Submit & Actions guide)
import { Form, FormBuilder } from '@saastro/forms';
// Vite zero-config component discovery — for Next.js and other
// non-Vite bundlers, see the next recipe on this page.
const uiComponents = import.meta.glob('@/components/ui/*.tsx', { eager: true });
const config = FormBuilder.create('lead-qualifier')
// Manual grid: every field declares its own span — fields
// without .columns() default to a single column (1 of 12)
.layout('manual')
.columns(12)
// Step 1 — who they are
.addField('name', (f) =>
f.type('text').label('Full Name').required().minLength(2).columns({ default: 12, md: 6 }),
)
.addField('email', (f) =>
f.type('email').label('Work Email').required().email().columns({ default: 12, md: 6 }),
)
.addField('interest', (f) =>
f
.type('button-radio')
.label('What are you looking for?')
.required()
.options([
{ value: 'demo', label: 'Book a demo' },
{ value: 'pricing', label: 'Pricing information' },
])
.columns({ default: 12 }),
)
// Step 2a — demo route
.addField('teamSize', (f) =>
f
.type('select')
.label('Team Size')
.required()
.options([
{ value: '1-10', label: '1-10' },
{ value: '11-50', label: '11-50' },
{ value: '51+', label: '51+' },
])
.columns({ default: 12, md: 6 }),
)
.addField('useCase', (f) =>
f
.type('select')
.label('Primary Use Case')
.required()
.options([
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'other', label: 'Other' },
])
.columns({ default: 12, md: 6 }),
)
.addField('otherUseCase', (f) =>
f
.type('text')
.label('Tell us more')
.optional()
// Hidden unless "Other" is selected. Keep conditionally hidden
// fields optional — hidden fields are still validated and submitted.
.hidden({
conditions: [{ field: 'useCase', operator: 'notEquals', value: 'other' }],
operator: 'AND',
})
.columns({ default: 12 }),
)
// Step 2b — pricing route
.addField('budget', (f) =>
f
.type('select')
.label('Monthly Budget')
.required()
.options([
{ value: '<500', label: 'Under $500' },
{ value: '500-2000', label: '$500 – $2,000' },
{ value: '>2000', label: 'Over $2,000' },
])
.columns({ default: 12 }),
)
// Step 3 — wrap-up
.addField('message', (f) =>
f.type('textarea').label('Anything else?').optional().rows(4).columns({ default: 12 }),
)
// Steps + conditional routing: first matching condition wins,
// otherwise defaultNext is used
.addStep('about-you', ['name', 'email', 'interest'], {
defaultNext: 'pricing-details',
next: [
{
conditions: [{ field: 'interest', operator: 'equals', value: 'demo' }],
operator: 'AND',
target: 'demo-details',
},
],
})
.addStep('demo-details', ['teamSize', 'useCase', 'otherUseCase'], { defaultNext: 'finish' })
.addStep('pricing-details', ['budget'], { defaultNext: 'finish' })
.addStep('finish', ['message'])
.initialStep('about-you')
// Override the default button labels (Submit / Next → / ← Back)
.buttons({
align: 'between',
submit: { type: 'submit', label: 'Send' },
next: { type: 'next', label: 'Continue' },
back: { type: 'back', label: 'Back', variant: 'outline' },
})
// Webhook 1 — captures every lead (default JSON payload of all values)
.submitAction(
'crm-webhook',
{
type: 'webhook',
name: 'CRM Webhook',
url: 'https://hooks.example.com/catch/lead',
secret: 'your-webhook-secret', // optional HMAC-SHA256 → X-Webhook-Signature header
},
'onSubmit',
)
// Webhook 2 — fires only for demo requests, after the first one
.submitAction(
'notify-sales',
{
type: 'webhook',
name: 'Notify Sales',
url: 'https://hooks.example.com/catch/demo-request',
payloadTemplate: JSON.stringify({
event: 'demo_requested',
name: '{{name}}',
email: '{{email}}',
teamSize: '{{teamSize}}',
}),
},
'onSubmit',
{
condition: { field: 'interest', operator: 'equals', value: 'demo' },
order: 2,
},
)
.submitExecution({ mode: 'sequential', stopOnFirstError: true })
// Custom success/error messages (defaults: '✅ Thank you!' / a generic error)
.successMessage("Thanks! We'll be in touch within one business day.")
.errorMessage('Something went wrong — please try again.')
.build();
export function LeadForm() {
return <Form config={config} components={uiComponents} />;
}
The flow:
about-you → demo-details → finish (if interest = "demo")
about-you → pricing-details → finish (otherwise)
How it works
- Routing — each step’s
nextconditions are evaluated in order against the current values; the first match wins, otherwisedefaultNextapplies. The Back button follows the visited-step history, so a user who took the demo route goes back through the demo route. - Conditional visibility —
otherUseCaserenders only whenuseCaseis'other'. A conditionally hidden field stays registered: it is still validated when its step advances and its value is still submitted, which is why it’s marked.optional(). See Conditional Logic. - Submit actions — because at least one action has an
onSubmittrigger, these actions are the entire submission. They run sequentially byorder(default0). Thenotify-salesaction’sconditionis checked at submit time; when it isn’t met the action is skipped and counts as successful. - Failures — if any executed action fails, the submission fails: the form shows its error state with your
errorMessage.stopOnFirstErroronly controls whether the remaining actions still run. Details in Submit & Actions. - Custom copy — button labels and the success/error messages have built-in English defaults (
Submit,✅ Thank you!, …); the recipe overrides them to match its own tone. For multi-language forms, use a locale overlay — or flip all package defaults at once withsetDefaultMessages— see the i18n guide.
Browser caveats: webhook actions run client-side, so the endpoint must accept cross-origin requests (Zapier/Make/n8n catch hooks do), and any
secretyou configure ships in your JavaScript bundle — treat it as an integrity check, not as a credential.{{placeholders}}inpayloadTemplatematch word characters only (letters, digits, underscore), so use field names liketeamSize, notteam-size.
Using @saastro/forms without Vite (Next.js)
The zero-config examples in these docs discover components with import.meta.glob, which is a Vite-only feature. On Next.js (or webpack, Rspack, esbuild, …) you pass the components explicitly via the components prop instead. Everything else works the same.
1. Install
npm install @saastro/forms react-hook-form zod date-fns react-day-picker
Then add the shadcn/ui primitives this recipe needs (run npx shadcn@latest init first if you haven’t):
npx shadcn@latest add input textarea select button label field form
2. The form — a Client Component
<Form /> is interactive React (hooks, context, react-hook-form), so in the App Router the file that renders it must be a Client Component — start it with 'use client'. Build the config in the same module: a FormConfig can contain functions (handlers, dynamic messages, custom schemas), which cannot be passed as props across the server → client boundary.
// components/contact-form.tsx
'use client';
import { Form, FormBuilder } from '@saastro/forms';
// Explicit imports replace import.meta.glob
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { FormControl, FormField } from '@/components/ui/form';
const components = {
// Always required
Button,
Field,
FieldLabel,
FieldDescription,
FieldError,
// text / email / textarea fields
Input,
Textarea,
Label,
FormField,
FormControl,
// select field
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
};
const config = FormBuilder.create('contact')
.addField('name', (f) => f.type('text').label('Name').required().minLength(2))
.addField('email', (f) => f.type('email').label('Email').required().email())
.addField('topic', (f) =>
f
.type('select')
.label('Topic')
.required()
.options([
{ value: 'sales', label: 'Sales' },
{ value: 'support', label: 'Support' },
]),
)
.addField('message', (f) => f.type('textarea').label('Message').required().rows(5))
.addStep('main', ['name', 'email', 'topic', 'message'])
.buttons({ submit: { type: 'submit', label: 'Send' } })
.submitAction(
'save',
{
type: 'http',
name: 'Save contact',
endpoint: { url: '/api/contact', method: 'POST' },
body: { format: 'json' },
},
'onSubmit',
)
.successMessage("Thanks! We'll get back to you soon.")
.errorMessage('Something went wrong — please try again.')
.build();
export function ContactForm() {
return <Form config={config} components={components} />;
}
3. Use it from a page
The page itself can stay a Server Component — it just renders the client component:
// app/contact/page.tsx
import { ContactForm } from '@/components/contact-form';
export default function ContactPage() {
return (
<main className="mx-auto max-w-xl py-12">
<h1 className="mb-8 text-2xl font-semibold">Contact us</h1>
<ContactForm />
</main>
);
}
And a minimal route handler for the HTTP submit action:
// app/api/contact/route.ts
export async function POST(req: Request) {
const data = await req.json();
// store the submission, forward it to your CRM, send an email, ...
console.log('contact submission', data);
return Response.json({ ok: true });
}
Notes
-
Which components do I need? It depends on your field types. If one is missing, the form renders an inline warning naming it. You can also check programmatically:
import { getRequiredComponents, getInstallCommand } from '@saastro/forms'; const required = getRequiredComponents(config); // ['Input', 'Button', 'Field', ...] const cmd = getInstallCommand(required); // 'npx shadcn@latest add ...'The full per-field-type table is in the Installation guide.
-
Pages Router — works the same; there are no Server Components, so just render
<ContactForm />from any page (the'use client'directive is simply ignored there). -
Many forms, one app — instead of repeating the
componentsobject per form, define it once in a shared module (e.g.lib/form-components.ts) and import it wherever you render a<Form>. The available registry slots and provider-based alternatives are covered in Component System. -
ESM only —
@saastro/formsships as ESM with no CommonJS build. Next.js handles ESM packages natively; notranspilePackagesentry is needed. -
Tailwind CSS 4 — add
@source "node_modules/@saastro/forms/dist/**/*.js";to your main CSS file so Tailwind picks up the classes used by the package. See Styling.