Submit & Actions
@saastro/forms provides two ways to handle form submission:
- Callbacks - Use
onSuccess/onErrorfor simple handling - Submit Actions - Declarative system for HTTP, webhooks, emails, custom handlers, and field mapping
Simple Callbacks
The simplest way to handle form submission using FormBuilder:
import { Form, FormBuilder } from '@saastro/forms';
const config = FormBuilder.create('contact')
.addField('email', (f) => f.type('email').label('Email').required())
.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} />;
}
Success/Error Messages
const config = FormBuilder.create('contact')
.addField('email', (f) => f.type('email').label('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:
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 |
custom | Run custom async functions |
HTTP Action
Send form data to a REST API:
import { FormBuilder, type SubmitActionNode, type 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,
};
// 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',
}
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
};
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: 'admin@example.com',
cc: ['sales@example.com'],
from: 'noreply@example.com',
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: 'hello@example.com',
subject: 'Welcome, {{name}}!',
template: 'custom',
customTemplate: `
<h1>Welcome, {{name}}!</h1>
<p>Thanks for signing up.</p>
<p>Your registered email: {{email}}</p>
`,
};
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 };
},
};
Triggers
Actions can be triggered at different points:
| Trigger | When It Fires |
|---|---|
onSubmit | When form is submitted |
onStepEnter | When entering a specific step |
onStepExit | When leaving a specific step |
onFieldChange | When a field value changes |
onFieldBlur | When a field loses focus |
onDelay | After X ms of inactivity |
manual | Only when called programmatically |
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 }
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',
},
};
Condition Operators
| Operator | Description |
|---|---|
equals | Exact match |
notEquals | Not equal |
contains | String contains |
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 truthy |
isFalse | Value is falsy |
isEmpty | Field is empty/null/undefined |
isNotEmpty | Field has value |
Execution Modes
When you have multiple actions, control how they execute:
submitExecution: {
mode: 'sequential', // or 'parallel'
stopOnFirstError: true, // Only for sequential
globalTimeout: 60000,
}
Sequential
Actions run one after another. Use when order matters.
Parallel
Actions run simultaneously. Faster but no guaranteed order.
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: {
nombre: 'first_name',
apellido: 'last_name',
telefono: 'phone',
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: {
nombre: 'first_name', // simple rename
fecha_nacimiento: { to: 'birth_date', transform: 'dateYMD' }, // rename + transform
acepta_terminos: { to: 'accepts_terms', transform: 'booleanString' },
},
// Inject static or computed values
inject: {
campaign_id: '10653', // 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' },
birth_date: { to: 'dob', transform: 'dateYMD' },
has_insurance: { 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:
| Resolver | Output | Description |
|---|---|---|
timestamp | "2026-02-19T14:30:00.000Z" | Current ISO timestamp |
hostname | "example.com" | Current window.location.hostname |
urlParam | URL parameter value | Read from query string |
custom | Any value | Run a custom function |
inject: {
// Static value
source: 'landing-page',
// Current timestamp
submitted_at: { $resolver: 'timestamp' },
// Current hostname
site: { $resolver: 'hostname' },
// Read ?utm_source= from URL, fallback to "organic"
utm_source: { $resolver: 'urlParam', param: 'utm_source', fallback: 'organic' },
// Custom function
ip_address: { $resolver: 'custom', fn: () => fetchClientIP() },
}
Using Field Mapping with FormBuilder
The submitAction() builder method accepts fieldMapping as an option:
const config = FormBuilder.create('lead')
.addField('nombre', (f) => f.type('text').label('Nombre'))
.addField('email', (f) => f.type('email').label('Email'))
.addField('fecha', (f) => f.type('date').label('Fecha de nacimiento'))
.addStep('main', ['nombre', 'email', 'fecha'])
.submitAction(
'api',
{
type: 'http',
name: 'submit-lead',
endpoint: { url: '/api/leads', method: 'POST' },
body: { format: 'json' },
},
'onSubmit',
{
fieldMapping: {
fields: {
nombre: 'first_name',
fecha: { to: 'birth_date', transform: 'dateYMD' },
},
inject: {
campaign_id: '10653',
timestamp: { $resolver: 'timestamp' },
},
},
},
)
.build();
Utility Functions
The applyFieldMapping function is exported for use in custom plugins or handlers:
import { applyFieldMapping } from '@saastro/forms';
const raw = { nombre: 'Ana', fecha: new Date('2000-01-15'), acepta: true };
const mapped = applyFieldMapping(raw, {
fields: {
nombre: 'first_name',
fecha: { to: 'birth_date', transform: 'dateYMD' },
acepta: { to: 'accepts', transform: 'booleanString' },
},
inject: { source: 'web' },
});
// Result: { first_name: "Ana", birth_date: "2000-01-15", accepts: "true", source: "web" }
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: 'resend',
apiKey: process.env.RESEND_API_KEY!,
to: 'admin@example.com',
from: 'forms@example.com',
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: {
nombre: 'first_name',
telefono: 'phone_number',
},
condition: {
field: 'company',
operator: 'isNotEmpty',
value: null,
},
},
};
const config: FormConfig = {
formId: 'lead-form',
fields: {
name: { type: 'text', label: 'Name' },
email: { type: 'email', label: 'Email' },
company: { type: 'text', label: 'Company' },
},
steps: {
main: { id: 'main', fields: ['name', 'email', 'company'] },
},
submitActions,
submitExecution: {
mode: 'sequential',
stopOnFirstError: true,
},
};
API Reference
SubmitActionNode
interface SubmitActionNode {
id: string;
action: SubmitAction;
trigger: SubmitTrigger;
condition?: SubmitActionCondition;
fieldMapping?: FieldMappingConfig;
order?: number;
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: 'custom'; fn: () => unknown };
SubmitTrigger
interface SubmitTrigger {
type:
| 'onSubmit'
| 'onStepEnter'
| 'onStepExit'
| 'onFieldChange'
| 'onFieldBlur'
| 'onDelay'
| 'manual';
stepId?: string;
fieldName?: string;
delayMs?: number;
debounceMs?: number;
}
SubmitExecutionConfig
interface SubmitExecutionConfig {
mode: 'sequential' | 'parallel';
stopOnFirstError?: boolean;
globalTimeout?: number;
}