Internationalization
Every user-facing string in a form — labels, placeholders, button text, step titles, success and error messages — lives in the FormConfig. That gives you two ways to localize:
- Single language: write the config in your language. Nothing else needed.
- Multiple languages: write the config in one base language and add per-locale translation overlays via
FormConfig.i18n, then pick the active language withFormConfig.locale.
One thing to know up front: the library’s built-in fallback strings are in English. If you don’t set a button label, the submit button says Submit. This page covers those defaults first — including how a Spanish (or any non-English) app flips them globally — then the multi-language system.
The built-in English defaults
When a string is missing from your config, the runtime falls back to a built-in English default:
| String | Default | Override via |
|---|---|---|
| Submit button label | Submit | buttons.submit.label |
| Next button label | Next → | buttons.next.label |
| Back button label | ← Back | buttons.back.label |
| Success message | ✅ Thank you! | successMessage |
| Error message | ❌ Something went wrong while submitting the form. | errorMessage |
select placeholder | Select an option... | placeholder on the field |
email placeholder | Enter an email... | placeholder on the field |
tel placeholder | Enter a phone number... | placeholder on the field |
| Other input placeholders | Enter a value... | placeholder on the field |
date picker trigger placeholder | Pick a date | placeholder on the field |
Anything you set in the config wins over these defaults:
import { FormBuilder } from '@saastro/forms';
const config = FormBuilder.create('contact')
.addField('email', (f) =>
f.type('email').label('Email').placeholder('[email protected]').required(),
)
.addStep('main', ['email'])
.buttons({
submit: { type: 'submit', label: 'Send' },
next: { type: 'next', label: 'Next' },
back: { type: 'back', label: 'Back' },
})
.successMessage('Thanks! We got your message.')
.errorMessage('Something went wrong. Please try again.')
.build();
Flipping ALL package defaults at once
Instead of overriding string by string, swap the package-level defaults globally with setDefaultMessages and a complete locale from @saastro/forms/locales — once, in your app entry point. A Spanish app does:
import { setDefaultMessages } from '@saastro/forms';
import { es } from '@saastro/forms/locales';
setDefaultMessages(es); // every fallback string is now Spanish
This covers everything in the table above plus toasts, the Submitting... state, step-navigation labels, and aria-labels. Partial overrides merge over the current defaults:
setDefaultMessages({ buttonSubmit: 'Send it', successDefault: '🎉 Done!' });
Per-form config and i18n overlays still take precedence over these defaults. The catalog also covers the validation fallbacks (“This field is required”, “You must accept this”, the file max-size error) — the bundled es locale translates those too.
Call setDefaultMessages once at your app entry point, before rendering. The package’s step components react to later calls, but memoized field placeholders only pick up changes on remount. To stay in sync in your own components, use the reactive hook:
import { useMessages } from '@saastro/forms';
function SubmitHint() {
const messages = useMessages(); // re-renders on setDefaultMessages()
return <p>Press "{messages.buttonSubmit}" to finish</p>;
}
Upgrading from a version where the package defaults were Spanish? They are English now — Spanish apps add the
setDefaultMessages(es)call above (or translate per form withlocale: 'es'and an i18n overlay) to keep the previous strings.
Replacing the error toasts
Validation and submit errors surface through sonner toasts by default. Swap in your own notification system globally:
import { setErrorNotifier } from '@saastro/forms';
setErrorNotifier((message) => myToasts.error(message));
Date formatting: setDateLocale
The date and daterange popover triggers format the selected date with date-fns (PPP → “June 5th, 2026”), and an in-progress range reads From <date>. Date formatting defaults to English; switch it globally with a date-fns locale:
import { setDateLocale } from '@saastro/forms';
import { es } from 'date-fns/locale';
setDateLocale(es); // PPP now renders "5 de junio de 2026"
setDateLocale is the sibling of setDefaultMessages: the messages cover the surrounding strings (the From <date> prefix, the Pick a date placeholder), while the date locale covers the formatted date itself — a fully Spanish date UI needs both calls. The calendar grid is your injected Calendar component, so its month and weekday names follow whatever locale your component uses — see Components.
Multi-language forms: locale + i18n
Two optional FormConfig properties drive translations:
| Property | Type | Purpose |
|---|---|---|
i18n | FormI18n | The translation catalog: which locales exist and a translation overlay per locale. |
locale | string | The active language. When set and i18n.translations[locale] exists, the overlay is merged before rendering. |
FormI18n looks like this:
interface FormI18n {
/** Language the base config strings are written in. */
defaultLocale?: string;
/** Available languages, e.g. ['en', 'es', 'fr']. */
locales?: string[];
/** One translation overlay per locale. */
translations?: Record<string, LocaleOverlay>;
}
The system is additive and optional: a config without i18n works exactly as before, and a locale with no matching overlay is a no-op.
The LocaleOverlay shape
An overlay contains only the strings you want to translate. Anything not specified falls back to the base config:
interface LocaleOverlay {
/** Keyed by field name (the key in config.fields). */
fields?: Record<
string,
{
label?: string;
placeholder?: string;
helperText?: string;
description?: string;
/** Translated option labels, matched to the base options by `value`. */
options?: Array<{ value: string | number; label: string }>;
}
>;
buttons?: {
submit?: { label?: string };
next?: { label?: string };
back?: { label?: string };
};
/** Keyed by step id (the key in config.steps). */
steps?: Record<string, { title?: string; description?: string }>;
messages?: { success?: string; error?: string };
}
Complete example
A form written in English with a Spanish translation, switching language via a prop:
import { Form } from '@saastro/forms';
import type { FormConfig } from '@saastro/forms';
const uiComponents = import.meta.glob('@/components/ui/*.tsx', { eager: true });
const config: FormConfig = {
formId: 'contact',
fields: {
name: {
type: 'text',
label: 'Name',
placeholder: 'Your name',
schema: { required: true },
},
role: {
type: 'select',
label: 'Role',
options: [
{ label: 'Developer', value: 'dev' },
{ label: 'Project manager', value: 'pm' },
],
schema: { required: true },
},
},
steps: {
main: { title: 'Contact', fields: ['name', 'role'] },
},
buttons: { submit: { type: 'submit', label: 'Send' } },
successMessage: 'Thanks!',
i18n: {
defaultLocale: 'en',
locales: ['en', 'es'],
translations: {
es: {
fields: {
name: { label: 'Nombre', placeholder: 'Tu nombre' },
role: {
label: 'Rol',
options: [
{ value: 'dev', label: 'Desarrollador' },
{ value: 'pm', label: 'Jefe de proyecto' },
],
},
},
buttons: { submit: { label: 'Enviar' } },
steps: { main: { title: 'Contacto' } },
messages: { success: '¡Gracias!' },
},
},
},
};
export function ContactForm({ locale }: { locale?: string }) {
// locale === 'es' renders the Spanish overlay; anything else renders the base English.
return <Form config={{ ...config, locale }} components={uiComponents} />;
}
<Form> applies the overlay automatically: when config.locale is set, the localized config flows to every renderer, button, step header, and message — no extra wiring.
Merge semantics
The overlay merge is shallow per string and falls back gracefully:
- Fields —
label,placeholder,helperText, anddescriptionare replaced when present. Untranslated properties keep the base value. - Options — matched to the base options by
value. Base order and extra option properties (icons, etc.) are preserved; options missing from the overlay keep their base label. - Buttons — only
labelis translatable. If the base config doesn’t define a button, the overlay creates its entry. - Steps —
titleanddescription, keyed by step id. - Messages —
messages.success/messages.errorreplacesuccessMessage/errorMessageonly when those are strings (or unset). Function-form messages are never overridden.
One subtlety with placeholders: if neither the base config nor the overlay sets a placeholder for a field, the built-in English default placeholder still shows — translations overlay your strings, not the library fallbacks. Set placeholders in the base config (or swap the package defaults with setDefaultMessages) to avoid this.
applyLocale
The merge function is a public export, so you can localize a config outside the <Form> component — server-side rendering, previews, generating a plain-text summary, etc.:
import { applyLocale } from '@saastro/forms';
const localized = applyLocale(config, 'es'); // FormConfig
- Pure — returns a new config and never mutates the input.
- No-op safe — a missing locale, or a locale with no overlay in
i18n.translations, returns the original config reference unchanged. applyLocale(config, config.locale)is exactly what<Form>does internally (after plugin config transforms).
Hosted forms
<HubForm> accepts a locale prop and forwards it to the fetched config, so a hosted form schema that carries i18n.translations renders in the requested language:
<HubForm siteId="my-site" formSlug="contact" locale="es" />
See HubForm.
See also
- Buttons — full button configuration (variants, icons, alignment).
- Submit — success/error messages, redirects, and the submit pipeline.
- Multi-Step Forms — step titles and descriptions shown in navigation.