# Internationalization

URL: https://docs.forms.saastro.io/docs/i18n
> Translate forms with locale overlays, switch languages at runtime, and override the built-in English default strings.

# 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:

1. **Single language**: write the config in your language. Nothing else needed.
2. **Multiple languages**: write the config in one base language and add per-locale translation overlays via `FormConfig.i18n`, then pick the active language with `FormConfig.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:

```tsx

const config = FormBuilder.create('contact')
  .addField('email', (f) =>
    f.type('email').label('Email').placeholder('you@example.com').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:

```ts

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:

```ts
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:

```tsx

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 with `locale: 'es'` and an i18n overlay) to keep the previous strings.

### Replacing the error toasts

Validation and submit errors surface through [sonner](https://sonner.emilkowal.ski/) toasts by default. Swap in your own notification system globally:

```ts

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:

```ts

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](/docs/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:

```ts
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:

```ts
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:

```tsx

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!' },
      },
    },
  },
};

  // 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`, and `description` are 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 `label` is translatable. If the base config doesn't define a button, the overlay creates its entry.
- **Steps** — `title` and `description`, keyed by step id.
- **Messages** — `messages.success` / `messages.error` replace `successMessage` / `errorMessage` **only 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.:

```ts

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:

```tsx
<HubForm siteId="my-site" formSlug="contact" locale="es" />
```

See [HubForm](/docs/hub-form).

---

## See also

- [Buttons](/docs/buttons) — full button configuration (variants, icons, alignment).
- [Submit](/docs/submit) — success/error messages, redirects, and the submit pipeline.
- [Multi-Step Forms](/docs/multi-step) — step titles and descriptions shown in navigation.
