S

FormBuilder API

The recommended fluent API for building type-safe form configurations.

FormBuilder API

FormBuilder is the recommended way to create form configurations. It provides a fluent, chainable API that validates your config at build time.


Build a Form Step by Step

Let’s build a real contact form progressively. Start simple, then add features one at a time.

1. Minimal Working Form

Every form needs a formId, at least one field, and at least one step:

import { FormBuilder, Form } from '@saastro/forms';

const uiComponents = import.meta.glob('@/components/ui/*.tsx', { eager: true });

const config = FormBuilder.create('contact')
  .addField('name', (f) => f.type('text').label('Name'))
  .addField('email', (f) => f.type('email').label('Email'))
  .addStep('main', ['name', 'email'])
  .build();

<Form config={config} components={uiComponents} />;

That’s a working form. Fields use auto layout and have no validation yet.

2. Add Validation

Chain validation methods on each field:

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('message', (f) => f.type('textarea').label('Message').required().minLength(10))
  .addStep('main', ['name', 'email', 'message'])
  .buttons({ submit: { type: 'submit', label: 'Send Message' } })
  .build();

Now fields are validated before submission. See Validation for all options.

3. Add Layout

Switch to manual layout for control over columns and spacing:

const config = FormBuilder.create('contact')
  .layout('manual')
  .columns(12)
  .gap(4)
  .addField(
    'name',
    (f) => f.type('text').label('Name').required().minLength(2).columns({ default: 12, md: 6 }), // Full width on mobile, half on desktop
  )
  .addField('email', (f) =>
    f.type('email').label('Email').required().email().columns({ default: 12, md: 6 }),
  )
  .addField(
    'message',
    (f) => f.type('textarea').label('Message').required().minLength(10).columns({ default: 12 }), // Always full width
  )
  .addStep('main', ['name', 'email', 'message'])
  .buttons({ submit: { type: 'submit', label: 'Send Message' } })
  .build();

See Layout System for the full grid reference.

4. Add Submission Handling

Handle success, errors, and post-submit behavior:

const config = FormBuilder.create('contact')
  .layout('manual')
  .columns(12)
  .gap(4)
  .addField('name', (f) =>
    f.type('text').label('Name').required().minLength(2).columns({ default: 12, md: 6 }),
  )
  .addField('email', (f) =>
    f.type('email').label('Email').required().email().columns({ default: 12, md: 6 }),
  )
  .addField('message', (f) =>
    f.type('textarea').label('Message').required().minLength(10).columns({ default: 12 }),
  )
  .addStep('main', ['name', 'email', 'message'])
  .buttons({ submit: { type: 'submit', label: 'Send Message' } })
  .onSuccess(async (values) => {
    await fetch('/api/contact', { method: 'POST', body: JSON.stringify(values) });
  })
  .onError((error) => console.error('Failed:', error.message))
  .successMessage((values) => `Thanks ${values.name}! We'll reply to ${values.email}.`)
  .build();

That’s a complete, production-ready contact form. The sections below cover every method in detail.


Creating a Builder

const builder = FormBuilder.create('my-form-id');

The formId is required and must be unique per form. It’s used for localStorage keys, analytics tracking, and plugin identification.

build() vs buildUnsafe()

  • .build() validates the config and throws descriptive errors if anything is missing
  • .buildUnsafe() returns the partial config without validation (useful during development)

.build() checks:

  • formId is set
  • At least 1 field exists
  • At least 1 step exists
  • All fields referenced in steps exist
  • initialStep (if set) references an existing step

Layout Methods

Configure the form’s grid layout. For full details, see Layout System.

FormBuilder.create('form')
  .layout('manual') // 'auto' | 'manual' (default: 'auto')
  .columns(12) // 1-12 grid columns (or max columns in auto mode)
  .gap(4) // Gap between fields (0-24, each unit = 0.25rem)
  .minFieldWidth(280) // Auto mode only: min field width in px
  .layoutClassName('p-4'); // Extra CSS classes on the grid container

Adding Fields

addField()

Add a single field using the FieldBuilder fluent API:

.addField('email', (f) =>
  f.type('email')
    .label('Email Address')
    .placeholder('you@example.com')
    .required()
    .email()
    .columns({ default: 12, md: 6 })
)

The callback receives a FieldBuilder and must return it. See the FieldBuilder Reference below.

addFields()

Add multiple fields from a raw config object:

.addFields({
  name: { type: 'text', label: 'Name', schema: { required: true } },
  email: { type: 'email', label: 'Email', schema: { required: true, format: 'email' } },
})

Adding Steps

Every form needs at least one step. Each step references field names.

// Single-step form
.addStep('main', ['name', 'email', 'message'])

// Multi-step with navigation
.addStep('personal', ['name', 'email'], { defaultNext: 'preferences' })
.addStep('preferences', ['theme', 'language'], { defaultNext: 'confirm' })
.addStep('confirm', ['terms'])
.initialStep('personal')

Conditional Routing

Steps can route to different next steps based on field values:

.addStep('plan-select', ['plan'], {
  defaultNext: 'basic-setup',
  next: [
    {
      conditions: [{ field: 'plan', operator: 'equals', value: 'premium' }],
      operator: 'AND',
      target: 'premium-setup',
    },
  ],
})

For full details, see Multi-Step Forms.


Callbacks

.onSuccess((values) => {
  // Called after successful submission
  console.log('Submitted:', values);
})

.onError((error, values) => {
  // Called when submission fails
  console.error('Failed:', error.message);
})

.onStepChange((stepId) => {
  // Called when navigating between steps
  console.log('Now on step:', stepId);
})

Messages

Display messages after submission:

// Static messages
.successMessage('Thanks! We received your message.')
.errorMessage('Something went wrong. Please try again.')

// Dynamic messages based on submitted values
.successMessage((values) => `Thanks ${values.name}! We'll email ${values.email}.`)
.errorMessage((error, values) => `Failed for ${values.email}: ${error.message}`)

Buttons

Configure submit, next, and back buttons. For full reference, see Buttons.

.buttons({
  align: 'between',
  submit: { type: 'submit', label: 'Send', variant: 'default' },
  next: { type: 'next', label: 'Continue' },
  back: { type: 'back', label: 'Back', variant: 'outline' },
})

Submit Actions

The modular submit system supports multiple endpoints, webhooks, and custom handlers. For full reference, see Submit & Actions.

.submitAction('save-lead', {
  type: 'http',
  name: 'Save Lead',
  endpoint: { url: '/api/leads', method: 'POST' },
  body: { format: 'json' },
}, 'onSubmit')

.submitAction('notify-crm', {
  type: 'webhook',
  name: 'CRM Webhook',
  url: 'https://hooks.example.com/lead',
}, 'onSubmit', { continueOnError: true, order: 2 })

.submitExecution({ mode: 'sequential', stopOnFirstError: true })

submitAction() Signature

.submitAction(
  id: string,            // Unique action identifier
  action: SubmitAction,  // { type: 'http' | 'webhook' | 'email' | 'custom', ... }
  trigger: string | SubmitTrigger,  // 'onSubmit' or { type: 'onSubmit', ... }
  options?: {
    condition?: SubmitActionCondition,
    order?: number,
    continueOnError?: boolean,
    disabled?: boolean,
    fieldMapping?: FieldMappingConfig,
  }
)

Redirect

Redirect after successful submission:

// Static URL
.redirect('/thank-you')

// Dynamic URL based on submitted values
.redirect((values) => `/thanks?email=${values.email}`)

The redirect executes after onSuccess and plugin hooks. When set, the success message UI is not shown.


Submit Confirmation

Show a warning when optional fields are left empty. Requires a second click to confirm.

.submitConfirmation({
  optionalFields: [
    { name: 'phone', message: 'Without a phone number we cannot call you back' },
    { name: 'company', message: 'Adding your company helps us prioritize' },
  ],
  buttonBehavior: {
    warningText: 'Continue without filling?',
    warningVariant: 'outline',
    resetDelay: 4000,  // ms before reverting to normal button
  },
  applyOn: 'submit',   // 'submit' | 'navigation' | 'both'
  showOnce: true,       // Only warn once per field
})

SubmitConfirmationConfig

PropertyTypeDescription
optionalFields{ name: string; message: string }[]Fields to check
buttonBehavior.warningTextstringButton text during warning state
buttonBehavior.warningVariantButtonConfig['variant']Button variant during warning
buttonBehavior.warningEffectButtonConfig['effect']Button effect during warning
buttonBehavior.resetDelaynumberMs before reverting (default: 3000)
applyOn'submit' | 'navigation' | 'both'When to check
showOncebooleanOnly warn once per field

Plugins

Attach a PluginManager for lifecycle hooks, custom fields, and validators. For full details, see Plugins.

import { PluginManager, analyticsPlugin, localStoragePlugin } from '@saastro/forms';

const pm = new PluginManager();
pm.register(analyticsPlugin);
pm.register(localStoragePlugin);

FormBuilder.create('my-form').usePlugins(pm);
// ...

DataBowl Shorthand

.useDatabowl() is a convenience method that creates a PluginManager and registers the DataBowl plugin:

FormBuilder.create('lead')
  .addField('nombre', (f) => f.type('text').label('Nombre').required())
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['nombre', 'email'])
  .useDatabowl({
    token: import.meta.env.DATABOWL_TOKEN,
    fieldMapping: { nombre: 'first_name', email: 'email_address' },
    staticFields: { source: 'landing-page' },
  })
  .redirect('/gracias')
  .build();

FieldBuilder Reference

The FieldBuilder is used inside .addField() callbacks. All methods return this for chaining.

Configuration

MethodDescription
.type(type)Field type ('text', 'email', 'select', etc.)
.label(text)Field label text
.placeholder(text)Placeholder text
.value(val)Default value
.helperText(text)Help text below the field
.tooltip(text)Tooltip text
.icon(element, props?)Icon element (React node)
.options(opts)Options for select, radio, checkbox-group, etc.
.buttonCardOptions(opts)Options for button-card fields
.rows(n)Number of rows (textarea)
.maxLength(n)HTML maxLength attribute (textarea)
.range(min, max, step?)Slider range
.sliderVariant(v)Slider variant: 'default', 'range', 'multi'
.sliderOrientation(o)Slider orientation: 'horizontal', 'vertical'
.thumbCount(n)Number of slider thumbs (multi variant)
.showLabels(bool)Show slider min/max labels
.showValue(bool)Show slider current value
.valueFormat(fmt)Slider value format ('{value}%', '${value}')

Type-Specific

MethodDescription
.min(n)Slider minimum value
.max(n)Slider maximum value
.step(n)Slider step size
.dateType(style)Date picker style: 'simple' or 'popover'
.showTime(bool)Enable time picker on date fields
.datePresets(presets)Quick-select date presets ({ label, value }[])
.prefix(text)Input-group prefix text
.suffix(text)Input-group suffix text
.searchPlaceholder(text)Combobox/command search placeholder
.emptyText(text)Combobox/command empty state text
.multiple(bool)Button-card multi-select mode
.otpLength(n)OTP digit count (default: 6)
.defaultValue(val)Field default value
.description(text)Field description (metadata text)
.autocomplete(attr)HTML autocomplete attribute
.optionsClassName(cls)CSS classes for options grid layout
.resolver(config)Hidden field resolver (IP, URL param, timestamp)
.accept(types)Accepted file types (e.g. "image/*", ".pdf")
.maxSize(bytes)Max file size in bytes
.itemFields(fields)Sub-field definitions for repeater
.minItems(n)Minimum items for repeater
.maxItems(n)Maximum items for repeater
.addLabel(text)Repeater “add” button label
.removeLabel(text)Repeater “remove” button label

Transform

MethodDescription
.transform(...transforms)Value transform(s) before submission (built-in names or fn)
.transform('trim')                    // Single built-in
.transform('trim', 'lowercase')       // Chain multiple built-ins
.transform((v) => String(v).trim())   // Custom function

See: Field-Level Transforms

Validation

MethodDescription
.schema(zodSchema)Direct Zod schema
.required(msg?)Mark as required
.optional()Mark as optional
.email(msg?)Email format validation
.url(msg?)URL format validation
.minLength(n, msg?)Minimum string length
.maxLengthValidation(n, msg?)Maximum string length (validation)
.regex(pattern, msg?)Regex pattern validation
.numberRange(min?, max?)Number range validation
.itemCount(min?, max?)Array item count validation
.mustBeTrue(msg?)Must be checked/true (boolean)
.preset(id)Apply a validation preset
.customValidators(...names)Plugin-registered validators

Layout

MethodDescription
.columns(cols)Responsive columns ({ default: 12, md: 6 })
.order(n)Display order (fixed or responsive)
.layout(config)Full layout config object
.size(size)Field size: 'sm', 'md', 'lg'
.hideLabel()Visually hide the label
.classNames(obj)CSS classes for wrapper, input, label, error, helper

State

MethodDescription
.hidden(condition)Hide field (boolean, function, responsive, or ConditionGroup)
.disabled(condition)Disable field
.readOnly(condition)Read-only field

FormBuilder Methods Summary

MethodDescription
FormBuilder.create(id)Create a new builder
.layout(mode)Set layout mode ('auto' or 'manual')
.columns(n)Grid columns (1-12)
.gap(n)Grid gap
.minFieldWidth(px)Auto mode min width
.layoutClassName(cls)Extra grid CSS classes
.addField(name, fn)Add field via FieldBuilder
.addFields(config)Add multiple raw fields
.addStep(id, fields, opts?)Add a step
.initialStep(id)Set the initial step
.onSuccess(fn)Success callback
.onError(fn)Error callback
.onStepChange(fn)Step change callback
.successMessage(msg)Success message (static or dynamic)
.errorMessage(msg)Error message (static or dynamic)
.buttons(config)Button configuration
.submit(config)Legacy submit config
.submitAction(id, action, trigger, opts?)Add a submit action
.submitExecution(config)Configure action execution
.redirect(url)Post-submit redirect
.submitConfirmation(config)Optional field warning
.usePlugins(pm)Attach a PluginManager
.useDatabowl(config)DataBowl shorthand
.build()Build and validate config
.buildUnsafe()Build without validation

build() vs buildUnsafe()

.build() validates the config before returning it — it checks that at least one field and one step exist, and that all step field references are valid. If validation fails, it throws an error.

.buildUnsafe() skips all validation and returns a Partial<FormConfig>. Use it during development or when building configs incrementally:

// Safe — throws if config is incomplete
const config = FormBuilder.create('form')
  .addField('email', (f) => f.type('email').label('Email').required())
  .addStep('main', ['email'])
  .build(); // ✅ FormConfig

// Unsafe — returns partial config, no validation
const partial = FormBuilder.create('form')
  .addField('email', (f) => f.type('email').label('Email'))
  .buildUnsafe(); // ✅ Partial<FormConfig> — no step required

// FieldBuilder also has buildUnsafe()
const field = new FieldBuilder('email').type('email').buildUnsafe(); // ✅ Partial<FieldConfig> — no schema required

When to use buildUnsafe(): Testing, prototyping, visual builder internals, or when the config will be completed later. In production, always use .build() to catch config errors early.