Plugins

Extend forms with lifecycle hooks, custom fields, validators, and config/value transformers.

Plugins

The plugin system lets you extend @saastro/forms without modifying its source. Plugins can hook into the form lifecycle, register custom field types, add validators, and transform configs or values before submission.


Using Plugins

Create a PluginManager, register plugins, and pass it to your form:

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

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

const config = FormBuilder.create('my-form')
  .usePlugins(pm)
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .build();

The <Form> component reads the plugin manager from the config — there is no pluginManager prop. If you build config objects by hand instead of using FormBuilder, set the pluginManager property on the config:

import type { FormConfig } from '@saastro/forms';

const config: FormConfig = {
  formId: 'my-form',
  pluginManager: pm,
  fields: {
    /* ... */
  },
  steps: {
    /* ... */
  },
};

Lifecycle Hooks

Plugins can implement any combination of 6 lifecycle hooks:

HookWhen It FiresArguments
onFormInitForm component mounts(config: FormConfig)
onFieldChangeAny field value changes(fieldName, value, allValues)
onStepChangeStep navigation occurs(stepId, values)
onBeforeSubmitBefore submission (can be async)(values)
onAfterSubmitAfter successful submission(values, response)
onErrorWhen an error occurs(error, values?)

Hooks from all registered plugins execute in registration order. An async onBeforeSubmit that rejects cancels the submission; errors thrown inside other lifecycle hooks (sync or async) are caught and logged per plugin and don’t stop the form or the other plugins.

Notes for plugin authors:

  • Lifecycle hooks are invoked with this bound to your plugin object, and onFormInit receives the real FormConfig (so config.formId works). Closure state via a factory function (like the built-in autosavePlugin) is still the recommended pattern — it survives serialization and is easier to test.
import { definePlugin } from '@saastro/forms';

const loggingPlugin = definePlugin({
  name: 'logging',
  version: '1.0.0',
  onFormInit() {
    console.log('Form initialized');
  },
  onFieldChange(fieldName, value) {
    console.log(`Field "${fieldName}" changed to:`, value);
  },
  onBeforeSubmit(values) {
    console.log('About to submit:', values);
  },
  onAfterSubmit(values, response) {
    console.log('Submitted successfully:', response);
  },
  onError(error) {
    console.error('Form error:', error.message);
  },
});

Config & Value Transformers

transformConfig

Runs before the form schema and defaults are built. Use it to inject fields, modify schemas, or add submit actions.

const prefixPlugin = definePlugin({
  name: 'prefix',
  version: '1.0.0',
  transformConfig(config) {
    // Inject an extra field into every form
    return {
      ...config,
      fields: {
        ...config.fields,
        _source: {
          type: 'html' as const,
          label: '',
          content: '',
          schema: { required: false },
        },
      },
    };
  },
});

transformValues

Runs before submit actions execute. Use it to add computed values, clean data, or merge extra fields.

const timestampPlugin = definePlugin({
  name: 'timestamp',
  version: '1.0.0',
  transformValues(values) {
    return {
      ...values,
      submittedAt: new Date().toISOString(),
      userAgent: navigator.userAgent,
    };
  },
});

Transformers from multiple plugins are chained in registration order. Each plugin receives the output of the previous one.


Custom Field Types

Plugins can register new field types with custom React renderers:

import { definePlugin, type CustomFieldProps } from '@saastro/forms';

const SignatureField = ({ name, colSpanItem }: CustomFieldProps) => (
  <div className={colSpanItem}>
    <canvas id={`sig-${name}`} style={{ border: '1px solid #ccc' }} />
  </div>
);

const signaturePlugin = definePlugin({
  name: 'signature',
  version: '1.0.0',
  registerFields() {
    return {
      signature: SignatureField,
    };
  },
});

Once registered, use the custom type like any built-in type — FieldBuilder.type() accepts custom strings directly, and .prop() sets bespoke config props:

.addField('sig', (f) =>
  f.type('signature').label('Your Signature').prop('penColor', '#1a1a1a'),
)

Custom field components receive the full renderer context: name, fieldConfig, control, colSpanItem, plus the DI components registry, pre-evaluated shouldDisable/shouldReadOnly, and the renderLabel/renderFieldIcon helpers — ready to compose with the exported FieldWrapper.

See the Custom Fields guide for a complete walkthrough (stepper field example, validation, defaults, conditional logic).


Custom Validators

Register named validators that can be referenced by any field via .customValidators():

const validationPlugin = definePlugin({
  name: 'custom-validators',
  version: '1.0.0',
  validators: {
    uniqueEmail: async (value, context) => {
      const res = await fetch(`/api/check-email?email=${value}`);
      const { exists } = await res.json();
      return exists ? 'Email already registered' : true;
    },
    corporateOnly: (value, context) => {
      const email = String(value);
      const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com'];
      const domain = email.split('@')[1];
      return freeProviders.includes(domain) ? 'Please use your corporate email' : true;
    },
  },
});

Reference validators on fields:

.addField('email', (f) =>
  f.type('email')
    .label('Work Email')
    .required()
    .email()
    .customValidators('uniqueEmail', 'corporateOnly')
)

Validators are chained as Zod superRefine checks and may be async. Return true for valid, a string error message for invalid, or false for invalid with a generic message.

Two current limitations to be aware of:

  • Custom validators only run on fields that also declare validation rules or a schema (the .required().email() calls above count). A field with only .customValidators() is not validated.
  • The ValidationContext passed at runtime contains fieldName, but allValues is currently always an empty object — cross-field validation is not possible from a custom validator — and abortSignal is never provided.

Built-in Plugins

The package ships 8 built-in plugins.

Instances vs Factories: Two of the built-in plugins (localStoragePlugin, analyticsPlugin) are ready-to-use instances — register them directly. The other six (autosavePlugin, databowlPlugin, recaptchaPlugin, turnstilePlugin, hubPlugin, localeDetectorPlugin) are factory functions — call them with parentheses to get a plugin instance (localeDetectorPlugin() accepts zero arguments).

// Instances — register directly (no parentheses)
pm.register(localStoragePlugin);
pm.register(analyticsPlugin);

// Factories — call with config (parentheses required)
pm.register(autosavePlugin({ interval: 30000 }));
pm.register(databowlPlugin({ token: 'your-databowl-token' }));
pm.register(recaptchaPlugin({ siteKey: 'your-recaptcha-site-key' }));
pm.register(turnstilePlugin({ siteKey: 'your-turnstile-site-key' }));

localStoragePlugin

Persists form progress to localStorage under the key form-<formId>. Clears saved data after successful submission.

import { localStoragePlugin } from '@saastro/forms';

pm.register(localStoragePlugin);

Uses transformConfig (restore saved values as field defaults), onFieldChange (save on change), and onAfterSubmit (clear storage).

Note: the plugin is a shared instance. With multiple forms mounted on the same page at once, the last form to initialize wins the storage slot — progress for the others isn’t persisted. One form per page (the normal case) works fully.

analyticsPlugin

Sends Google Analytics events via gtag() for form interactions.

import { analyticsPlugin } from '@saastro/forms';

pm.register(analyticsPlugin);

Tracked events:

  • form_init — form initialized
  • form_step — step change (with step_id)
  • form_submit — successful submission (with success: true)
  • form_error — error occurred (with error_message)

Events are only sent when window.gtag is available; otherwise the plugin is a no-op.

autosavePlugin

Debounced auto-save — POSTs the current form values to an endpoint after a period of inactivity following a field change (not on a fixed timer).

import { autosavePlugin } from '@saastro/forms';

pm.register(
  autosavePlugin({
    interval: 30000, // Debounce interval in ms (default: 60000)
    endpoint: '/api/autosave', // POST endpoint (default: '/api/autosave')
  }),
);

The request body is the current form values as JSON (Content-Type: application/json). Fetch errors are logged to the console; saving never blocks the user.

databowlPlugin

Databowl is a lead management platform; this plugin posts each form submission to its leads API. It injects an HTTP submit action via transformConfig and merges staticFields into the submitted values via transformValues — user-entered values always win: a staticFields key is only injected when the form has no non-empty value for it (a dev warning logs any ignored key).

import { databowlPlugin } from '@saastro/forms';

pm.register(
  databowlPlugin({
    // Keep the token in an env var (process.env, import.meta.env, etc. — depends on your bundler)
    token: 'your-databowl-api-token',
    endpoint: '/api/send-lead', // Optional proxy route (default: 'https://www.databowl.com/api/v4/leads')
    fieldMapping: {
      // your field name → Databowl field name
      firstName: 'first_name',
      email: 'email_address',
      phone: 'phone',
    },
    staticFields: {
      source: 'website',
      campaign: 'spring-launch',
    },
    bodyFormat: 'url-encoded', // default — set 'json' if your proxy expects JSON
    continueOnError: false, // default — a Databowl failure fails the whole submission
  }),
);

Or use the FormBuilder shorthand:

const config = FormBuilder.create('lead')
  .useDatabowl({ token: 'your-databowl-api-token', fieldMapping: { firstName: 'first_name' } })
  .addField('firstName', (f) => f.type('text').label('First name').required())
  .addStep('main', ['firstName'])
  .build();

databowlAction

If you’d rather wire the submit action yourself — for example to combine it with other actions — databowlAction(config) returns a pre-configured HTTP submit action for use with .submitAction() instead of the full plugin. It accepts token, endpoint, and bodyFormat, and defaults to a POST to https://www.databowl.com/api/v4/leads with a url-encoded body, bearer auth, and a 15-second timeout.

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

const config = FormBuilder.create('lead')
  .addField('firstName', (f) => f.type('text').label('First name').required())
  .addStep('main', ['firstName'])
  .submitAction('databowl', databowlAction({ token: 'your-databowl-api-token' }), 'onSubmit', {
    fieldMapping: { firstName: 'first_name' },
  })
  .build();

recaptchaPlugin

Google reCAPTCHA v3 integration. Injects the script on form init and automatically adds a fresh token to every submission.

import { recaptchaPlugin } from '@saastro/forms';

pm.register(
  recaptchaPlugin({
    siteKey: 'your-recaptcha-site-key',
    action: 'submit', // Action name sent to Google (default: 'submit')
    tokenField: '_recaptchaToken', // Field name in submitted values (default: '_recaptchaToken')
    failMode: 'open', // 'open' (default): proceed tokenless | 'closed': fail the submit
  }),
);

What it does:

  • onFormInit — Injects the reCAPTCHA v3 script into <body> (deduped, skips if already loaded)
  • transformValues — Calls grecaptcha.execute() to get a fresh token and adds it to the form values
  • cleanup — Removes the injected script (runs when you call pm.unregister('recaptcha-v3') or pm.cleanup() — the form does not call it automatically on unmount)

Token failures are non-blocking by default (failMode: 'open'): the plugin logs the error and the submission proceeds without a token — server-side verification is the backstop, and reCAPTCHA v3 is advisory scoring. Set failMode: 'closed' to fail the submit with a clear error instead (turnstile’s default).

Your backend then verifies the token:

// Server-side verification
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: process.env.RECAPTCHA_SECRET_KEY,
    response: values._recaptchaToken,
  }),
});

This plugin replaces the old useRecaptcha hook (removed in 0.8). The hook only injected the script — this plugin also handles token generation automatically. The legacy config.submit.recaptcha path no longer auto-injects the script either: register this plugin (recommended) or add the script tag yourself.

turnstilePlugin

Cloudflare Turnstile integration — the Turnstile counterpart to recaptchaPlugin. Unlike reCAPTCHA v3, Turnstile is widget-based, so the plugin renders the widget into a container it manages, in execution: 'execute' mode (it only challenges at submit time), and attaches a fresh token to every submission.

import { turnstilePlugin } from '@saastro/forms';

pm.register(
  turnstilePlugin({
    siteKey: 'your-turnstile-site-key',
    tokenField: '_captchaToken', // Field name in submitted values (default: '_captchaToken')
    appearance: 'interaction-only', // 'always' | 'execute' | 'interaction-only' (default)
    container: '#turnstile-slot', // Optional CSS selector for the widget container
    failMode: 'closed', // 'closed' (default): no token → submit fails | 'open': proceed tokenless
  }),
);

With the default appearance: 'interaction-only', the widget stays invisible unless Cloudflare decides an interactive challenge is needed — the closest UX to reCAPTCHA v3. The default _captchaToken field name is also what the hosted submit backend expects, so if you use HubForm with a captcha-enabled form, this plugin is wired up automatically.

The widget container is resolved in this order:

  1. The explicit container selector, if provided and found.
  2. An element matching [data-saastro-turnstile].
  3. A fixed, unobtrusive container the plugin creates at the bottom-right of the page, so an interactive challenge can still surface.

What it does:

  • onFormInit — Injects the Turnstile script (deduped, skips if already loaded) and pre-renders the widget on a best-effort basis
  • transformValues — Requests a fresh token on every submit (Turnstile tokens are single-use), waiting up to 20 seconds, and adds it to the form values
  • cleanup — Removes the widget, the fallback container, and the injected script (runs when you call pm.unregister('turnstile') or pm.cleanup() — the form does not call it automatically on unmount)

Unlike recaptchaPlugin, token failures fail closed by default: if no token can be obtained (script blocked, timeout), the submit fails with a clear error — the server would reject a tokenless request anyway, and a local error is actionable. Set failMode: 'open' to restore the old proceed-without-token behavior. Your backend then verifies the token:

// Server-side verification
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: process.env.TURNSTILE_SECRET_KEY,
    response: values._captchaToken,
  }),
});

hubPlugin

Sends every submission to the hosted Saastro Hub backend — for any form, including ones built entirely in code with FormBuilder. You get hosted storage, notifications, and the presigned file-upload pipeline without <HubForm> or the visual builder:

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

const pm = new PluginManager();
pm.register(
  hubPlugin({
    siteId: 'my-site',
    formSlug: 'contact',
    hubUrl: 'https://submit.saastro.io/v1', // default — override for self-hosted
    continueOnError: false, // default: a Hub failure fails the submit
  }),
);

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

What it does:

  • transformConfig — Injects a hub-submit action with an onSubmit trigger, powered by the same createHubFormSubmit pipeline <HubForm> uses: file fields upload via presigned URLs, and the _captchaToken/_hp conventions apply
  • Composable: it’s a regular submit action, so it coexists with your other actions (webhooks, analytics) and respects submitExecution (mode, stopOnFirstError, globalTimeout)

Combine with turnstilePlugin for captcha-protected hosted submissions.

localeDetectorPlugin

Activates the form’s locale from the browser language (navigator.language) when the config doesn’t set one explicitly and a matching translation overlay exists:

import { localeDetectorPlugin } from '@saastro/forms';

pm.register(localeDetectorPlugin()); // matches config.i18n.translations keys
pm.register(localeDetectorPlugin({ fallback: 'en' })); // when nothing matches
pm.register(localeDetectorPlugin({ supported: ['en', 'es', 'fr'] })); // explicit allowlist

An explicit config.locale always wins. See the Internationalization guide for the overlay system. Note this picks the per-form locale — the package-level default strings are flipped separately with setDefaultMessages.


Creating Your Own Plugin

Use definePlugin() for type safety:

import { definePlugin } from '@saastro/forms';

export const myPlugin = definePlugin({
  name: 'my-plugin',
  version: '1.0.0',
  description: 'Does something useful',

  // Optional: initialize with options
  options: { apiKey: '...' },
  init(options) {
    console.log('Plugin initialized with:', options);
  },

  // Lifecycle hooks (all optional)
  onFormInit(config) {
    /* ... */
  },
  onFieldChange(fieldName, value, allValues) {
    /* ... */
  },
  onStepChange(stepId, values) {
    /* ... */
  },
  onBeforeSubmit(values) {
    /* ... */
  },
  onAfterSubmit(values, response) {
    /* ... */
  },
  onError(error, values) {
    /* ... */
  },

  // Transformers (all optional)
  transformConfig(config) {
    return config;
  },
  transformValues(values) {
    return values;
  },

  // Custom fields (optional)
  registerFields() {
    return { myField: MyFieldComponent };
  },

  // Custom validators (optional)
  validators: {
    myRule: (value, ctx) => (value ? true : 'Required'),
  },

  // Cleanup (optional)
  cleanup() {
    console.log('Plugin cleaned up');
  },
});

API Reference

PluginManager

import { PluginManager, globalPluginManager } from '@saastro/forms';
MethodDescription
register(plugin)Register a plugin (throws if name already taken)
unregister(name)Remove a plugin (calls cleanup)
getPlugin(name)Get a registered plugin
getAllPlugins()Get all registered plugins
getCustomField(type)Get a custom field renderer
hasCustomField(type)Check if a custom field type exists
getValidator(name)Get a custom validator
executeHook(hook, ...args)Execute a lifecycle hook on all plugins
transformConfig(config)Run all config transformers
transformValues(values)Run all value transformers
cleanup()Clean up all plugins and clear registries
getStats()Get plugin system statistics

FormPlugin Interface

interface FormPlugin {
  name: string;
  version: string;
  description?: string;
  options?: Record<string, unknown>;
  init?: (options?: Record<string, unknown>) => void;
  cleanup?: () => void;

  // Lifecycle hooks
  onFormInit?: (config: FormConfig) => void;
  onFieldChange?: (fieldName: string, value: unknown, allValues: Record<string, unknown>) => void;
  onStepChange?: (stepId: string, values: Record<string, unknown>) => void;
  onBeforeSubmit?: (values: Record<string, unknown>) => void | Promise<void>;
  onAfterSubmit?: (values: Record<string, unknown>, response: unknown) => void;
  onError?: (error: Error, values?: Record<string, unknown>) => void;

  // Transformers
  transformConfig?: (config: FormConfig) => FormConfig;
  transformValues?: (
    values: Record<string, unknown>,
  ) => Record<string, unknown> | Promise<Record<string, unknown>>;

  // Extensions
  registerFields?: () => Record<string, FieldRenderer>;
  validators?: Record<
    string,
    (value: unknown, context: ValidationContext) => boolean | string | Promise<boolean | string>
  >;
}

ValidationContext

interface ValidationContext {
  fieldName: string;
  allValues: Record<string, unknown>;
  abortSignal?: AbortSignal;
}

At the current runtime call site, allValues is always an empty object and abortSignal is never set — see Custom Validators.

definePlugin()

import { definePlugin } from '@saastro/forms';

const plugin = definePlugin({ name: '...', version: '...' /* ... */ });

A simple identity function that provides type checking for plugin objects.

globalPluginManager

A shared PluginManager instance exported for convenience. You can use it or create your own instances.

import { globalPluginManager } from '@saastro/forms';

globalPluginManager.register(myPlugin);