Recipes

Complete, copy-pasteable examples that combine multiple features — multi-step routing with webhooks, and using @saastro/forms without Vite.

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

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 next conditions are evaluated in order against the current values; the first match wins, otherwise defaultNext applies. The Back button follows the visited-step history, so a user who took the demo route goes back through the demo route.
  • Conditional visibilityotherUseCase renders only when useCase is '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 onSubmit trigger, these actions are the entire submission. They run sequentially by order (default 0). The notify-sales action’s condition is 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. stopOnFirstError only 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 with setDefaultMessages — 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 secret you configure ships in your JavaScript bundle — treat it as an integrity check, not as a credential. {{placeholders}} in payloadTemplate match word characters only (letters, digits, underscore), so use field names like teamSize, not team-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 components object 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/forms ships as ESM with no CommonJS build. Next.js handles ESM packages natively; no transpilePackages entry 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.