Submit & Actions
@saastro/forms gives you three ways to handle a submission:
- Callbacks —
onSuccess/onErrorfor simple handling - Submit Actions — declarative system for HTTP, webhooks, emails, custom handlers, and field mapping
- Hosted backend —
<HubForm>submits to the hosted endpoint atsubmit.saastro.iowith zero configuration
The Submission Pipeline
When the user clicks the submit button on the last step (and validation passes), the form runs this pipeline:
form values (react-hook-form)
→ applyFieldTransforms() field-level transforms (trim, lowercase, ...)
→ pluginManager.transformValues() plugins — captcha tokens are appended here
→ onBeforeSubmit plugin hook validation only; throwing aborts the submit
→ dispatch (first match wins):
1. submitActions with an onSubmit trigger → executeSubmitActions()
2. else config.submit { type: 'custom' } → your onSubmit(values)
3. else config.submit (legacy default) → defaultSubmit()
4. else → nothing is sent
→ onAfterSubmit plugin hook
→ success UI · onSuccess(values) · optional redirect
Key behaviors to know:
- Submit actions replace the legacy
submitconfig. If at least one enabled action has anonSubmittrigger,config.submitis never used. - A failed action fails the whole submission — unless it opts out. If
executeSubmitActionsreports a failure, the first failed action withoutcontinueOnError: truethrows and the form shows its error state. Failures of actions markedcontinueOnError: trueare recorded in the result but don’t abort the submit.stopOnFirstErroradditionally controls whether remaining sequential actions still run after a failure. - No submit config at all is a silent success. With no
submitActionsand nosubmit, nothing is sent — the form flips straight to the success state and callsonSuccess(values). Useful if you handle the data yourself inonSuccess, but remember nothing has left the browser. - Redirect —
config.redirect(a URL string or(values) => string) navigates viawindow.location.hrefafteronSuccess; the success message never gets a chance to show. - Error path — errors are normalized to
Error, passed to the pluginonErrorhook andconfig.onError(error, values), shown as a toast, and rendered in the form’s error panel.
Default Messages
The built-in defaults are English: '✅ Thank you!' for success and '❌ Something went wrong while submitting the form.' for error (the loading label is 'Submitting...'). Override them with successMessage / errorMessage (below), per-locale via the i18n guide, or globally with setDefaultMessages (Spanish apps: setDefaultMessages(es)).
Simple Callbacks
The simplest way to react to a submission using FormBuilder:
import { Form, FormBuilder } from '@saastro/forms';
const config = FormBuilder.create('contact')
.addField('email', (f) => f.type('email').label('Email').required().email())
.addField('message', (f) => f.type('textarea').label('Message').required())
.addStep('main', ['email', 'message'])
.onSuccess(async (values) => {
console.log('Form values:', values);
// Send to your API
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(values),
});
})
.onError((error, values) => {
console.error('Error:', error, values);
})
.build();
function ContactForm() {
return <Form config={config} />;
}
Note:
onSuccessruns after the form has already switched to its success state. If the request you make insideonSuccessfails, the form still shows success. To surface failures in the form UI, send the data with a submit action (or a legacysubmitconfig withtype: 'custom') instead, and keeponSuccessfor side effects like analytics.
Success/Error Messages
const config = FormBuilder.create('contact')
.addField('email', (f) => f.type('email').label('Email').required().email())
.addStep('main', ['email'])
.successMessage("Thanks! We'll be in touch.")
.errorMessage('Something went wrong. Please try again.')
.onSuccess((values) => {
console.log('Success:', values);
})
.onError((error, values) => {
console.error('Error:', error, values);
})
.build();
Dynamic Messages
.successMessage((values) =>
`Thanks ${values.name}! We'll email you at ${values.email}.`
)
.errorMessage((error, values) =>
`Failed to submit for ${values.email}: ${error.message}`
)
Submit Actions System
For more complex scenarios, use the declarative Submit Actions system by configuring submitActions directly in the config (or via FormBuilder.submitAction()):
Action Types
| Type | Use Case |
|---|---|
http | REST API calls with full configuration |
webhook | Send data to external services (Zapier, Make, etc.) |
email | Send notification emails via provider APIs |
custom | Run custom async functions |
integration | Declarative reference executed server-side by a hosted backend — see below |
HTTP Action
Send form data to a REST API:
import type { FormConfig, HttpSubmitAction } from '@saastro/forms';
const httpAction: HttpSubmitAction = {
type: 'http',
name: 'submit-to-api',
endpoint: {
url: 'https://api.example.com/contacts',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
body: {
format: 'json',
includeFields: ['name', 'email', 'message'],
},
auth: {
type: 'bearer',
token: 'your-api-token',
},
retry: {
enabled: true,
maxAttempts: 3,
delayMs: 1000,
},
timeout: 30000, // ms — this is also the default
};
// Use in FormConfig
const config: FormConfig = {
formId: 'contact',
fields: {
/* ... */
},
steps: {
/* ... */
},
submitActions: {
'api-submit': {
id: 'api-submit',
action: httpAction,
trigger: { type: 'onSubmit' },
},
},
submitExecution: {
mode: 'sequential',
stopOnFirstError: true,
},
};
HTTP Body Formats
// JSON (default)
body: {
format: 'json',
}
// Form Data (multipart)
body: {
format: 'form-data',
}
// URL Encoded
body: {
format: 'url-encoded',
}
// Custom template with placeholders
body: {
format: 'json',
template: JSON.stringify({
contact: {
fullName: '{{name}}',
emailAddress: '{{email}}',
inquiry: '{{message}}'
}
}),
}
HTTP Authentication
// Bearer Token
auth: {
type: 'bearer',
token: 'your-jwt-token',
}
// Basic Auth
auth: {
type: 'basic',
username: 'user',
password: 'pass',
}
// API Key
auth: {
type: 'api-key',
token: 'your-api-key',
headerName: 'X-API-Key', // default
}
Webhook Action
Send data to webhook endpoints (Zapier, Make, n8n, etc.):
import type { WebhookSubmitAction } from '@saastro/forms';
const webhookAction: WebhookSubmitAction = {
type: 'webhook',
name: 'zapier-webhook',
url: 'https://hooks.zapier.com/hooks/catch/123456/abcdef/',
headers: {
'X-Custom-Header': 'value',
},
payloadTemplate: JSON.stringify({
event: 'form_submission',
data: {
name: '{{name}}',
email: '{{email}}',
},
}),
secret: 'your-webhook-secret', // Optional HMAC signature
};
Webhooks always POST JSON. When secret is set, the payload is signed with HMAC-SHA256 and the signature is sent in the X-Webhook-Signature: sha256=<hex> header so the receiver can verify authenticity.
Email Action
Send email notifications on form submission:
import type { EmailSubmitAction } from '@saastro/forms';
const emailAction: EmailSubmitAction = {
type: 'email',
name: 'admin-notification',
provider: 'resend', // 'smtp' | 'sendgrid' | 'resend' | 'mailgun'
apiKey: 'your-api-key',
to: '[email protected]',
cc: ['[email protected]'],
from: '[email protected]',
replyTo: '{{email}}',
subject: 'New contact from {{name}}',
template: 'default',
};
// With custom HTML template
const customEmailAction: EmailSubmitAction = {
type: 'email',
name: 'welcome-email',
provider: 'sendgrid',
apiKey: process.env.SENDGRID_API_KEY,
to: '{{email}}',
from: '[email protected]',
subject: 'Welcome, {{name}}!',
template: 'custom',
customTemplate: `
<h1>Welcome, {{name}}!</h1>
<p>Thanks for signing up.</p>
<p>Your registered email: {{email}}</p>
`,
};
Default endpoints per provider (used when endpoint is not set):
| Provider | Default endpoint |
|---|---|
smtp | /api/send-email (your own server route) |
sendgrid | https://api.sendgrid.com/v3/mail/send |
resend | https://api.resend.com/emails |
mailgun | endpoint required (the URL embeds your domain) — the action throws without it |
Careful with API keys in the browser. Email actions call the provider’s HTTP API directly from the client, so any
apiKeyyou configure ships in your JavaScript bundle. For production, preferprovider: 'smtp'pointing at your own server endpoint, or send the data to your backend with an HTTP/webhook action and email from there.
Custom Action
Run any async function:
import type { CustomSubmitAction } from '@saastro/forms';
const customAction: CustomSubmitAction = {
type: 'custom',
name: 'analytics-track',
handler: async (values) => {
await analytics.track('form_submitted', {
formId: 'contact',
email: values.email,
});
return { success: true };
},
};
Integration Action (Server-Side Only)
integration actions are declarative references to integrations resolved server-side by a hosted backend (such as the hosted submit service used by <HubForm>). The browser runtime does not execute them: if an integration action reaches the client dispatcher, it is recorded as a failed result (Unknown action type: integration).
interface IntegrationSubmitAction {
type: 'integration';
name: string;
integrationRef: string; // server-side integration id
fieldMapping?: Record<string, string>;
}
Don’t add integration actions to forms that submit from the browser — they only make sense in schemas processed by a server that knows how to dispatch them.
Triggers
Actions can be triggered at different points:
| Trigger | When It Fires |
|---|---|
onSubmit | When the form is submitted (last step) |
onStepEnter | When entering a step (stepId; any step if omitted) |
onStepExit | When leaving a step (stepId; any step if omitted) |
onFieldChange | When a field value changes (fieldName; any field if omitted — debounceMs defaults to 300) |
onFieldBlur | When a field loses focus (fieldName; any field if omitted) |
onDelay | After a period of user inactivity (delayMs, default 30000) |
manual | Only when called programmatically — see Advanced |
Examples
// On form submit (default)
trigger: { type: 'onSubmit' }
// When entering step 2
trigger: { type: 'onStepEnter', stepId: 'step2' }
// When email field changes (with debounce)
trigger: { type: 'onFieldChange', fieldName: 'email', debounceMs: 500 }
// After 2 seconds of inactivity (autosave)
trigger: { type: 'onDelay', delayMs: 2000 }
Inside a rendered form, manual actions can be fired with the triggerManual function returned by useSubmitActionTriggers.
Conditional Execution
Only run an action when certain conditions are met:
const premiumAction: SubmitActionNode = {
id: 'premium-webhook',
action: webhookAction,
trigger: { type: 'onSubmit' },
condition: {
field: 'plan',
operator: 'equals',
value: 'premium',
},
};
Conditions are evaluated against the original form field names — before any fieldMapping is applied. An action skipped by its condition counts as successful.
Condition Operators
| Operator | Description |
|---|---|
equals | Exact match (===) |
notEquals | Not equal |
contains | String contains (both values must be strings) |
notContains | String doesn’t contain |
greaterThan | Number comparison |
lessThan | Number comparison |
greaterThanOrEqual | Number greater than or equal |
lessThanOrEqual | Number less than or equal |
isTrue | Value is true (also accepts 'true', 1) |
isFalse | Value is false (also accepts 'false', 0) |
isEmpty | undefined, null, '', or empty array |
isNotEmpty | Field has a non-empty value |
Execution Modes
When you have multiple actions, control how they execute:
submitExecution: {
mode: 'sequential', // or 'parallel' (default: 'sequential')
stopOnFirstError: true, // sequential only (default: false)
}
Sequential
Actions run one after another, sorted by their order (default 0). Use when order matters. With stopOnFirstError: true, a failed action stops the remaining ones — unless that action has continueOnError: true.
Parallel
Actions run simultaneously. Faster, but no guaranteed order and stopOnFirstError has no effect.
Note: regardless of mode, a failing action fails the whole submission and the form shows its error state — unless that action sets
continueOnError: true, in which case its failure is recorded in the result but the submit proceeds (see the pipeline).
SubmitExecutionConfig.globalTimeoutcaps the entire action pipeline: if it elapses, the submit fails with a clear timeout error. Combine it with the per-actiontimeouton HTTP actions for finer control.
Field-Level Transforms
Field-level transforms normalize a field’s value before submission, regardless of which submit action consumes it. They run as the very first step in the submission pipeline:
form values (react-hook-form)
→ applyFieldTransforms() ← field-level (this section)
→ pluginManager.transformValues() ← form-level (plugins)
→ onBeforeSubmit hook ← plugin validation
→ per action: applyFieldMapping() ← action-level (field mapping)
→ API call
Use field-level transforms for normalizations that should always apply — trimming whitespace, lowercasing emails, formatting dates. Use action-level fieldMapping for API-specific renaming and transforms.
Using with FieldBuilder
const config = FormBuilder.create('contact')
.addField('email', (f) =>
f.type('email').label('Email').required().email().transform('trim', 'lowercase'),
)
.addField('phone', (f) => f.type('tel').label('Phone').required().transform('trim'))
.addField('slug', (f) =>
f
.type('text')
.label('Slug')
.required()
.transform((v) => String(v).replace(/\s+/g, '-').toLowerCase()),
)
.addStep('main', ['email', 'phone', 'slug'])
.build();
Transform Formats
// Single built-in transform
.transform('trim')
// Multiple built-in transforms (chained left-to-right)
.transform('trim', 'lowercase')
// Custom function
.transform((value) => String(value).replace(/[^0-9]/g, ''))
// Mixed built-in + function (composed into a single function)
.transform('trim', (v) => String(v).toUpperCase())
Raw Config
You can also set transform directly on field config objects:
fields: {
email: {
type: 'email',
label: 'Email',
schema: { required: true, format: 'email' },
transform: ['trim', 'lowercase'],
},
phone: {
type: 'tel',
label: 'Phone',
schema: { required: true },
transform: 'trim',
},
}
Three Levels of Transforms
| Level | Where | When to Use |
|---|---|---|
| Field | field.transform('trim') | Universal normalizations (trim, lowercase, dateISO) |
| Form | Plugin transformValues() | Cross-field logic (compute fullName from first+last) |
| Action | fieldMapping.fields.x.transform | API-specific formatting (rename + transform per API) |
Field Mapping
Field mapping transforms form values before they’re sent to a submit action. This is useful when your API expects different field names or value formats than what the form uses.
Each submit action can have its own fieldMapping — so a single form can send data to multiple APIs with different schemas.
Simple Rename
The simplest format maps form field names to API field names:
const action: SubmitActionNode = {
id: 'crm-sync',
action: {
type: 'http',
name: 'submit-to-crm',
endpoint: { url: '/api/leads', method: 'POST' },
body: { format: 'json' },
},
trigger: { type: 'onSubmit' },
// Simple rename: form field → API field
fieldMapping: {
firstName: 'first_name',
lastName: 'last_name',
phone: 'phone_number',
email: 'email_address',
},
};
With the simple format, unmapped fields pass through unchanged.
Advanced Field Mapping
For more control, use the advanced FieldMapping format with transforms, injection, exclusion, and passthrough control:
import type { FieldMapping } from '@saastro/forms';
const mapping: FieldMapping = {
// Rename + transform fields
fields: {
firstName: 'first_name', // simple rename
birthDate: { to: 'birth_date', transform: 'dateYMD' }, // rename + transform
acceptsTerms: { to: 'accepts_terms', transform: 'booleanString' },
},
// Inject static or computed values
inject: {
campaign_id: 'spring-launch', // static value
timestamp: { $resolver: 'timestamp' }, // ISO timestamp
hostname: { $resolver: 'hostname' }, // current hostname
utm_source: { $resolver: 'urlParam', param: 'utm_source', fallback: 'organic' },
},
// Exclude fields from the payload
exclude: ['internal_notes', 'debug_flag'],
// If false, only mapped fields are sent (default: true)
passthrough: true,
};
const action: SubmitActionNode = {
id: 'api-submit',
action: httpAction,
trigger: { type: 'onSubmit' },
fieldMapping: mapping,
};
Built-in Transforms
Apply value transforms per-field before sending:
| Transform | Input | Output | Description |
|---|---|---|---|
toString | 42 | "42" | Convert to string |
toNumber | "42" | 42 | Convert to number |
toBoolean | "yes" | true | Convert to boolean |
booleanString | true | "true" | Boolean to "true"/"false" |
dateISO | Date | "2026-02-19T..." | ISO 8601 string |
dateYMD | Date | "2026-02-19" | YYYY-MM-DD format |
dateDMY | Date | "19/02/2026" | DD/MM/YYYY format |
dateTimestamp | Date | 1771459200000 | Unix timestamp (ms) |
trim | " hello " | "hello" | Trim whitespace |
lowercase | "Hello" | "hello" | Lowercase string |
uppercase | "Hello" | "HELLO" | Uppercase string |
emptyToNull | "" / undefined | null | Empty values to null |
fields: {
email: { to: 'email_address', transform: 'trim' },
birthDate: { to: 'dob', transform: 'dateYMD' },
hasInsurance: { to: 'insured', transform: 'booleanString' },
phone: { to: 'phone_number', transform: 'emptyToNull' },
}
You can also use a custom transform function:
fields: {
fullName: {
to: 'name',
transform: (value) => String(value).split(' ')[0], // first name only
},
}
Resolvers
Inject dynamic computed values that aren’t form fields. There are 8 resolver variants:
| Resolver | Output | Description |
|---|---|---|
timestamp | "2026-02-19T14:30:00.000Z" | Current ISO timestamp |
hostname | "example.com" | Current window.location.hostname |
pageUrl | "https://example.com/pricing" | Full page URL (window.location.href) |
referrer | "https://google.com/" | Referring page (document.referrer) |
userAgent | "Mozilla/5.0 ..." | Browser user agent string |
ip | "203.0.113.7" | Client IP fetched from endpoint (default https://api.ipify.org?format=json); fallback on failure |
urlParam | URL parameter value | Query-string parameter (param), with optional fallback |
custom | Any value | Run a custom function (fn) — the only resolver that isn’t JSON-serializable |
inject: {
// Static value
source: 'landing-page',
// Current timestamp
submitted_at: { $resolver: 'timestamp' },
// Current hostname and full page URL
site: { $resolver: 'hostname' },
page: { $resolver: 'pageUrl' },
// Read ?utm_source= from URL, fallback to "organic"
utm_source: { $resolver: 'urlParam', param: 'utm_source', fallback: 'organic' },
// Client IP — fetched from api.ipify.org unless you set `endpoint`
ip_address: { $resolver: 'ip', fallback: 'unknown' },
// Custom function
session_id: { $resolver: 'custom', fn: () => getSessionId() },
}
The same resolvers (minus custom) power hidden fields, which capture values like UTM parameters as part of the form itself.
Using Field Mapping with FormBuilder
The submitAction() builder method accepts fieldMapping as an option:
const config = FormBuilder.create('lead')
.addField('firstName', (f) => f.type('text').label('First name').required())
.addField('email', (f) => f.type('email').label('Email').required().email())
.addField('birthDate', (f) => f.type('date').label('Date of birth').optional())
.addStep('main', ['firstName', 'email', 'birthDate'])
.submitAction(
'api',
{
type: 'http',
name: 'submit-lead',
endpoint: { url: '/api/leads', method: 'POST' },
body: { format: 'json' },
},
'onSubmit',
{
fieldMapping: {
fields: {
firstName: 'first_name',
birthDate: { to: 'birth_date', transform: 'dateYMD' },
},
inject: {
campaign_id: 'spring-launch',
timestamp: { $resolver: 'timestamp' },
},
},
},
)
.build();
Utility Functions
The applyFieldMapping function is exported for use in custom plugins or handlers. It is async (inject resolvers run in parallel), so remember to await it:
import { applyFieldMapping } from '@saastro/forms';
const raw = { firstName: 'Ana', birthDate: new Date('2000-01-15'), accepts: true };
const mapped = await applyFieldMapping(raw, {
fields: {
firstName: 'first_name',
birthDate: { to: 'birth_date', transform: 'dateYMD' },
accepts: { to: 'accepts_terms', transform: 'booleanString' },
},
inject: { source: 'web' },
});
// { first_name: "Ana", birth_date: "2000-01-15", accepts_terms: "true", source: "web" }
For synchronous dry-runs (e.g. debug previews) use applyFieldMappingSync — async resolvers return placeholder strings ('[fetching IP...]', '[custom resolver]') instead of real values.
Legacy: config.submit and defaultSubmit
Before submit actions existed, forms were configured with a single submit config. It still works — it’s the fallback when no onSubmit-triggered actions are defined — but it’s deprecated for new code.
type: 'custom'
Bring your own submit function. Throwing inside it surfaces in the form’s error UI:
const config = FormBuilder.create('contact')
.addField('email', (f) => f.type('email').label('Email').required().email())
.addStep('main', ['email'])
.submit({
type: 'custom',
onSubmit: async (values) => {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Request failed');
},
})
.build();
type: 'default' / 'default-no-recaptcha'
The exported defaultSubmit({ values, config }) function POSTs a fixed JSON shape to your endpoint:
submit: {
type: 'default-no-recaptcha',
endpoint: { url: 'https://api.example.com/forms', clientId: 'my-site' },
}
{
"clientId": "my-site",
"formId": "contact",
"data": { "email": "[email protected]" }
}
clientIddefaults to'test'when not set.- Your endpoint must accept exactly this JSON shape — this path is only useful if you build a compatible receiver.
- With
type: 'default'andrecaptcha: { siteKey, action }, the submit waits forwindow.grecaptcha(up to 10s) and includes arecaptchaToken. This path no longer loads the reCAPTCHA script itself — registerrecaptchaPlugin(recommended) or add the script tag yourself; without it, the submit fails after the timeout with a clear error. - On failure it rethrows, so the form’s error state renders and the standard error toast fires.
For new code, use submit actions or type: 'custom' instead.
Advanced: Executing Submit Actions Programmatically
Everything the form runs internally is exported, so you can execute actions outside the form lifecycle — from a plugin, an event handler, or your own submit flow.
Orchestrator Functions
| Function | Description |
|---|---|
executeSubmitActions(actions, values, config) | Runs an array of SubmitActionNodes sorted by order, sequentially or in parallel per config.submitExecution. Returns a SubmitActionsResult. |
executeSubmitActionsByTrigger(config, triggerType, values, triggerValue?) | Filters config.submitActions by trigger and executes the matches. Returns an empty all-successful result when nothing matches. |
getActionsByTrigger(config, triggerType, triggerValue?) | Returns the enabled SubmitActionNodes matching a trigger type; triggerValue is a step id or field name for step/field triggers. |
Executor Functions
| Function | Description |
|---|---|
executeSubmitAction(action, values) | Executes a single SubmitAction, dispatching by type. Throws for unknown types (including integration). |
executeHttpAction(action, values) | HTTP executor — auth headers, body formats, AbortController timeout (default 30000 ms), optional retry. |
executeWebhookAction(action, values) | Webhook executor — POST JSON or payloadTemplate, optional HMAC-SHA256 X-Webhook-Signature header. |
executeEmailAction(action, values) | Email executor — provider-specific payload and endpoint. |
executeCustomAction(action, values) | Calls action.handler(values). |
Field Mapping Helpers
| Function | Description |
|---|---|
applyFieldMapping(values, mapping) | Async. Rename + transform + passthrough + exclude + inject (resolvers run in parallel). |
applyFieldMappingSync(values, mapping) | Sync variant for dry-runs; async resolvers return placeholder strings. |
applyFieldTransforms(fields, values) | Applies field-level transform configs over a values object. |
applyTransform(value, transform) | Applies a single transform (built-in name or custom function). |
applyBuiltinTransform(value, transform) | Applies one of the 12 built-in transforms by name. |
resolveValue(resolver) | Async. Resolves a FieldResolver to its value. |
resolveValueSync(resolver) | Sync variant with placeholders for ip / custom. |
isAdvancedMapping(mapping) | Type guard: advanced FieldMapping vs simple rename record. |
Worked Example
import {
executeSubmitActions,
type FormConfig,
type SubmitActionNode,
} from '@saastro/forms';
const actions: SubmitActionNode[] = [
{
id: 'crm',
order: 1,
action: {
type: 'http',
name: 'crm-sync',
endpoint: { url: 'https://api.example.com/leads', method: 'POST' },
body: { format: 'json' },
},
trigger: { type: 'manual' },
fieldMapping: { firstName: 'first_name' },
},
{
id: 'log',
order: 2,
action: {
type: 'custom',
name: 'log-submission',
handler: async (values) => {
console.log('sent', values);
},
},
trigger: { type: 'manual' },
continueOnError: true,
},
];
// executeSubmitActions only reads `submitExecution` from the config
const config: FormConfig = {
formId: 'lead',
fields: {},
steps: {},
submitExecution: { mode: 'sequential', stopOnFirstError: true },
};
const result = await executeSubmitActions(
actions,
{ firstName: 'Ada', email: '[email protected]' },
config,
);
console.log(result.allSuccessful, result.successCount, result.failureCount);
for (const r of result.results) {
console.log(`${r.actionId}: ${r.success ? 'ok' : r.error?.message} (${r.durationMs}ms)`);
}
Per-action errors are caught and recorded in the corresponding SubmitActionResult — executeSubmitActions itself doesn’t throw on action failures; inspect allSuccessful / results.
Hosted Backend
If you don’t want to run any backend, <HubForm> renders a remotely-configured form and submits it (including file uploads and captcha tokens) to the hosted endpoint at https://submit.saastro.io/v1. The lower-level createHubFormSubmit() helper produces a legacy CustomSubmitConfig you can plug into any FormConfig.
Complete Example
import type { FormConfig, SubmitActionNode } from '@saastro/forms';
const submitActions: Record<string, SubmitActionNode> = {
'api-submit': {
id: 'api-submit',
order: 1,
action: {
type: 'http',
name: 'submit-to-api',
endpoint: { url: '/api/leads', method: 'POST' },
body: { format: 'json' },
},
trigger: { type: 'onSubmit' },
fieldMapping: {
fields: {
name: 'full_name',
email: { to: 'email_address', transform: 'lowercase' },
},
inject: {
source: 'website',
submitted_at: { $resolver: 'timestamp' },
},
},
continueOnError: false,
},
'admin-email': {
id: 'admin-email',
order: 2,
action: {
type: 'email',
name: 'notify-admin',
provider: 'smtp',
endpoint: '/api/send-email', // your server route — keeps API keys off the client
to: '[email protected]',
from: '[email protected]',
subject: 'New lead: {{name}}',
template: 'default',
},
trigger: { type: 'onSubmit' },
continueOnError: true,
},
'zapier-sync': {
id: 'zapier-sync',
order: 3,
action: {
type: 'webhook',
name: 'sync-to-crm',
url: 'https://hooks.zapier.com/...',
},
trigger: { type: 'onSubmit' },
fieldMapping: {
name: 'first_name',
company: 'company_name',
},
condition: {
field: 'company',
operator: 'isNotEmpty',
value: null,
},
},
};
const config: FormConfig = {
formId: 'lead-form',
fields: {
name: { type: 'text', label: 'Name', schema: { required: true } },
email: { type: 'email', label: 'Email', schema: { required: true, format: 'email' } },
company: { type: 'text', label: 'Company', schema: { required: false } },
},
steps: {
main: { id: 'main', fields: ['name', 'email', 'company'] },
},
submitActions,
submitExecution: {
mode: 'sequential',
stopOnFirstError: true,
},
};
API Reference
SubmitAction
type SubmitActionType = 'http' | 'webhook' | 'email' | 'custom' | 'integration';
type SubmitAction =
| HttpSubmitAction
| WebhookSubmitAction
| EmailSubmitAction
| CustomSubmitAction
| IntegrationSubmitAction; // server-side only — never executed in the browser
SubmitActionNode
interface SubmitActionNode {
id: string;
action: SubmitAction;
trigger: SubmitTrigger;
condition?: SubmitActionCondition;
fieldMapping?: FieldMappingConfig;
order?: number; // default: 0
continueOnError?: boolean;
disabled?: boolean;
}
FieldMapping
// Simple format: rename only
type SimpleMapping = Record<string, string>;
// Advanced format
interface FieldMapping {
fields?: Record<string, string | FieldMapEntry>;
inject?: Record<string, unknown>;
exclude?: string[];
passthrough?: boolean; // default: true
}
interface FieldMapEntry {
to: string;
transform?: BuiltinTransform | ((value: unknown) => unknown);
}
// Union of both formats
type FieldMappingConfig = Record<string, string> | FieldMapping;
BuiltinTransform
type BuiltinTransform =
| 'toString'
| 'toNumber'
| 'toBoolean'
| 'booleanString'
| 'dateISO'
| 'dateYMD'
| 'dateDMY'
| 'dateTimestamp'
| 'trim'
| 'lowercase'
| 'uppercase'
| 'emptyToNull';
FieldResolver
type FieldResolver =
| { $resolver: 'timestamp' }
| { $resolver: 'hostname' }
| { $resolver: 'urlParam'; param: string; fallback?: string }
| { $resolver: 'ip'; endpoint?: string; fallback?: string }
| { $resolver: 'pageUrl' }
| { $resolver: 'referrer' }
| { $resolver: 'userAgent' }
| { $resolver: 'custom'; fn: () => unknown };
// JSON-serializable subset (everything except 'custom') — used by hidden fields
type SerializableFieldResolver = Exclude<FieldResolver, { $resolver: 'custom' }>;
SubmitTrigger
interface SubmitTrigger {
type:
| 'onSubmit'
| 'onStepEnter'
| 'onStepExit'
| 'onFieldChange'
| 'onFieldBlur'
| 'onDelay'
| 'manual';
stepId?: string;
fieldName?: string;
delayMs?: number; // onDelay — default: 30000
debounceMs?: number; // onFieldChange — default: 300
}
SubmitExecutionConfig
interface SubmitExecutionConfig {
mode: 'sequential' | 'parallel'; // default: 'sequential'
stopOnFirstError?: boolean; // default: false (sequential only)
globalTimeout?: number; // ms cap for the WHOLE pipeline — submit fails on expiry
}
SubmitActionResult / SubmitActionsResult
interface SubmitActionResult {
actionId: string;
actionName: string;
success: boolean;
data?: unknown;
error?: Error;
durationMs: number;
startedAt: Date;
completedAt: Date;
}
interface SubmitActionsResult {
results: SubmitActionResult[];
allSuccessful: boolean;
successCount: number;
failureCount: number;
totalDurationMs: number;
}