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:
| Hook | When It Fires | Arguments |
|---|---|---|
onFormInit | Form component mounts | (config: FormConfig) |
onFieldChange | Any field value changes | (fieldName, value, allValues) |
onStepChange | Step navigation occurs | (stepId, values) |
onBeforeSubmit | Before submission (can be async) | (values) |
onAfterSubmit | After successful submission | (values, response) |
onError | When 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
thisbound to your plugin object, andonFormInitreceives the realFormConfig(soconfig.formIdworks). Closure state via a factory function (like the built-inautosavePlugin) 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
ValidationContextpassed at runtime containsfieldName, butallValuesis currently always an empty object — cross-field validation is not possible from a custom validator — andabortSignalis 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 initializedform_step— step change (withstep_id)form_submit— successful submission (withsuccess: true)form_error— error occurred (witherror_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— Callsgrecaptcha.execute()to get a fresh token and adds it to the form valuescleanup— Removes the injected script (runs when you callpm.unregister('recaptcha-v3')orpm.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
useRecaptchahook (removed in 0.8). The hook only injected the script — this plugin also handles token generation automatically. The legacyconfig.submit.recaptchapath 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:
- The explicit
containerselector, if provided and found. - An element matching
[data-saastro-turnstile]. - 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 basistransformValues— Requests a fresh token on every submit (Turnstile tokens are single-use), waiting up to 20 seconds, and adds it to the form valuescleanup— Removes the widget, the fallback container, and the injected script (runs when you callpm.unregister('turnstile')orpm.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 ahub-submitaction with anonSubmittrigger, powered by the samecreateHubFormSubmitpipeline<HubForm>uses:filefields upload via presigned URLs, and the_captchaToken/_hpconventions 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';
| Method | Description |
|---|---|
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);