# FormBuilder API

URL: https://docs.forms.saastro.io/docs/formbuilder
> 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:

```tsx

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

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

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

That's a working form. With no layout configured, fields render in a single column, and there are no validation constraints yet.

> **Every field needs a schema.** `FieldBuilder` requires a schema for every type except `'hidden'` — without one, `.build()` throws `FieldBuilder: field "name" requires a schema`. Calling any validation method satisfies this; `.optional()` is the minimal choice (it stores a ruleset with `required: false` and no other constraints).

### 2. Add Validation

Chain validation methods on each field:

```tsx
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](/docs/validation) for all options.

### 3. Add Layout

Switch to manual layout for control over columns and spacing:

```tsx
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](/docs/layout) for the full grid reference.

### 4. Add Submission Handling

Handle success, errors, and post-submit behavior:

```tsx
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

```tsx
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](/docs/layout).

```tsx
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-12, each unit = 0.25rem; default: 4)
  .minFieldWidth(280) // Auto mode only: min field width in px (default: 240)
  .layoutClassName('p-4'); // Stored in config; not yet applied to the grid by <Form>
```

---

## Adding Fields

### addField()

Add a single field using the FieldBuilder fluent API:

```tsx
.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](#fieldbuilder-reference) below.

### addFields()

Add multiple fields from a raw config object:

```tsx
.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.

```tsx
// 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:

```tsx
.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](/docs/multi-step).

---

## Callbacks

```tsx
.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:

```tsx
// 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](/docs/buttons).

```tsx
.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](/docs/submit).

```tsx
.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

```tsx
.submitAction(
  id: string,            // Unique action identifier
  action: SubmitAction,  // { type: 'http' | 'webhook' | 'email' | 'custom' | 'integration', ... }
  trigger: SubmitTriggerType | SubmitTrigger,  // e.g. 'onSubmit', or { type: 'onSubmit', ... } — 7 trigger types, see Submit & Actions
  options?: {
    condition?: SubmitActionCondition,
    order?: number,
    continueOnError?: boolean,
    disabled?: boolean,
    fieldMapping?: FieldMappingConfig,
  }
)
```

> `'integration'` actions are declarative only: they reference a server-side integration and are meant to be executed by a hosted backend such as the [Saastro submit service](/docs/hub-form). The in-browser executor does not implement them — an `'integration'` action triggered in the browser fails with `Unknown action type: integration` — so only add them to configs that a hosted backend executes. The other four types run client-side.

---

## Redirect

Redirect after successful submission:

```tsx
// 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.

```tsx
.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' | 'steps' | 'both'
  showOnce: true,       // Only warn once per field
})
```

### SubmitConfirmationConfig

| Property                        | Type                                  | Description                         |
| ------------------------------- | ------------------------------------- | ----------------------------------- |
| `optionalFields`                | `{ name: string; message: string }[]` | Fields to check                     |
| `buttonBehavior.warningText`    | `string`                              | Button text during warning state    |
| `buttonBehavior.warningVariant` | `ButtonConfig['variant']`             | Button variant during warning       |
| `buttonBehavior.warningEffect`  | `ButtonConfig['effect']`              | Button effect during warning        |
| `buttonBehavior.resetDelay`     | `number`                              | Ms before reverting (default: 3000) |
| `applyOn`                       | `'submit' \| 'steps' \| 'both'`       | When to check: submit button, next buttons, or both |
| `showOnce`                      | `boolean`                             | Only warn once per field            |

---

## Plugins

Attach a `PluginManager` for lifecycle hooks, custom fields, and validators. For full details, see [Plugins](/docs/plugins).

```tsx

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

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

### DataBowl Shorthand

DataBowl is a lead-distribution platform — skip this section if you don't use it. `.useDatabowl()` is a convenience method that creates a PluginManager (if you haven't attached one) and registers the [DataBowl plugin](/docs/plugins#databowlplugin):

```tsx
FormBuilder.create('lead')
  .addField('name', (f) => f.type('text').label('Name').required())
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['name', 'email'])
  .useDatabowl({
    token: 'YOUR_DATABOWL_TOKEN',
    endpoint: '/api/send-lead', // Recommended: your server-side proxy (default: DataBowl API)
    fieldMapping: { name: 'first_name', email: 'email_address' },
    staticFields: { source: 'landing-page' },
  })
  .redirect('/thank-you')
  .build();
```

> **Token exposure:** the plugin runs in the browser, so any `token` you pass is visible to visitors. By default it posts directly to `https://www.databowl.com/api/v4/leads` with that token as a Bearer header. For production, set `endpoint` to a route on your own server that holds the real token and forwards the lead to DataBowl.

---

## FieldBuilder Reference

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

> **Computed fields have no builder method.** To derive a field's value from other fields, set `computed: { dependsOn, compute }` in raw field config (e.g. via `.addFields()`). See [Computed Fields](/docs/use-computed-fields).

### Configuration

| Method                     | Description                                        |
| -------------------------- | -------------------------------------------------- |
| `.type(type)`              | Field type (`'text'`, `'email'`, `'select'`, etc.) |
| `.label(text)`             | Field label text                                   |
| `.placeholder(text)`       | Placeholder text                                   |
| `.value(val)`              | Initial 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.    |
| `.loadOptions(loader)`     | Async options loader (select/combobox/native-select), called once on mount |
| `.optionsSource(src)`      | Serializable async options source (URL) — survives the JSON config |
| `.serverSearch(on?)`       | Combobox: re-run the loader (debounced) on type instead of client-filtering |
| `.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

| Method                     | Description                                      |
| -------------------------- | ------------------------------------------------ |
| `.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)`          | Time picker on date fields — declared but not yet implemented (no effect) |
| `.datePresets(presets)`    | Quick-select date presets (`{ label, value }[]`, value = days from today) — declared but not yet implemented (no effect) |
| `.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                    |
| `.cardLayout(layout)`      | Button-card card layout: `'horizontal'` (default) or `'vertical'` (icon above text, ideal for logos) |
| `.prop(key, value)`        | Sets an arbitrary config prop — escape hatch for custom field types registered via plugins (see [Custom Fields](/docs/custom-fields)) |
| `.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 — validated client-side on submit; still validate server-side for security |
| `.itemFields(fields)`      | Sub-field definitions for repeater               |
| `.minItems(n)`             | Minimum items for repeater — enforced on submit since 0.15 |
| `.maxItems(n)`             | Maximum items for repeater — enforced on submit since 0.15 |
| `.addLabel(text)`          | Repeater "add" button label                      |
| `.removeLabel(text)`       | Repeater "remove" button label                   |
| `.reorderable(enabled?)`   | Show move up/down buttons on repeater items (opt-in) |
| `.minItemsMessage(msg)`    | Custom error for the repeater minItems violation |
| `.maxItemsMessage(msg)`    | Custom error for the repeater maxItems violation |

### Transform

| Method                      | Description                                                 |
| --------------------------- | ----------------------------------------------------------- |
| `.transform(...transforms)` | Value transform(s) before submission (built-in names or fn) |

```tsx
.transform('trim')                    // Single built-in
.transform('trim', 'lowercase')       // Chain multiple built-ins
.transform((v) => String(v).trim())   // Custom function
```

See: [Field-Level Transforms](/docs/submit#field-level-transforms)

### Validation

| Method                          | Description                        |
| ------------------------------- | ---------------------------------- |
| `.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

| Method             | Description                                          |
| ------------------ | ---------------------------------------------------- |
| `.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 |

Per-field `.columns()` and `.order()` only take effect in `'manual'` layout mode — the `'auto'` grid ignores them. See [Layout System](/docs/layout).

### State

| Method                 | Description                                                   |
| ---------------------- | ------------------------------------------------------------- |
| `.hidden(condition)`   | Hide field (boolean, function, responsive, or ConditionGroup) |
| `.disabled(condition)` | Disable field                                                 |
| `.readOnly(condition)` | Read-only field                                               |

---

## FormBuilder Methods Summary

| Method                                      | Description                              |
| ------------------------------------------- | ---------------------------------------- |
| `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 (0-12)                          |
| `.minFieldWidth(px)`                        | Auto mode min width                      |
| `.layoutClassName(cls)`                     | Extra grid CSS classes (stored; not yet applied by `<Form>`) |
| `.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                     |
| `.crossFieldRules(rules)` / `.crossFieldRule(rule)` | Serializable cross-field validation ([Validation](/docs/validation#cross-field-validation)) |
| `.formSchema(zod)`                          | Whole-form Zod schema — TSX-only escape hatch |
| `.validation(timing)`                       | Validation timing (`mode` / `revalidateMode` / `delayError`, see [Validation](/docs/validation#configuring-the-timing)) |
| `.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:

```tsx

// 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 form-level validation
const partial = FormBuilder.create('form')
  .addField('email', (f) => f.type('email').label('Email').optional())
  .buildUnsafe(); // ✅ Partial<FormConfig> — no step required

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

Note that `.addField()` always runs the field's own `.build()`, so the per-field schema requirement still applies inside a `buildUnsafe()` chain — only the form-level checks are skipped. To skip field validation too, build the field separately with `new FieldBuilder(name)...buildUnsafe()` and pass it via `.addFields()` — note that `.addFields()` is typed to take complete `FieldConfig` objects, so the `Partial<FieldConfig>` needs a type assertion (`as FieldConfig`).

> **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.
