---
title: Internationalization
description: Configure the editor's UI language with built-in or custom locale support.
---

# Internationalization

The editor UI supports locale switching. Every label, tooltip, placeholder and message is driven by a translation key — no hardcoded strings.

Two languages are in play, and they are not the same thing. `init({ locale })` sets the language of the **editor's own interface**. A template's `settings.locale` — the "Content language" field in Template Settings — sets the language of the **email being written**, and becomes `<html lang>` in the rendered output. A German-speaking marketer writing an English campaign sets the first to `de` and the second to `en`.

Writing direction is a third, independent setting. `settings.direction` (`"ltr"` or `"rtl"`) is the canvas `dir` and the rendered `<mjml dir>`. When unset, it follows the content language (`ar`, `he`, `fa`, `ur`, … → RTL). It does not follow `init({ locale })` or the host page. The "Right-to-left" toggle in Template Settings writes an explicit value; after that, locale no longer drives it.

## Setting the locale

Pass the `locale` option to `init()`:

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  locale: 'de',
});
```

## Built-in locales

| Code | Language |
|---|---|
| `en` | English (default) |
| `de` | German |
| `pt-BR` | Portuguese (Brazil) |
| `es` | Spanish |
| `ca` | Catalan |
| `fr` | French |
| `nl` | Dutch |

## Locale resolution

The editor normalizes locale codes by stripping region suffixes:

| Input | Resolved |
|---|---|
| `'en'` | `en` |
| `'en-US'` | `en` |
| `'en-GB'` | `en` |
| `'de-AT'` | `de` |
| `'fr-BE'` | `fr` |
| `'it'` | `en` (unsupported, falls back to English) |

If the resolved locale is not supported, the editor falls back to English and warns once on the console, naming the locales that would have worked. The fallback is silent only for the cloud chunk, which ships fewer locales on purpose (see below).

## Async loading

Locale files are loaded asynchronously using dynamic `import()`. Only the active locale is bundled into the client — the other locale files are not included in your build. This means switching locales at runtime requires re-initializing the editor:

```ts
async function switchLocale(newLocale: string) {
  editor.unmount();
  editor = await init({
    container: '#editor',
    locale: newLocale,
  });
}
```

## How translations work

Translations are nested objects organized by UI section:

```ts
{
  blocks: {
    paragraph: 'Paragraph',
    image: 'Image',
    button: 'Button',
    // ...
  },
  toolbar: {
    duplicate: 'Duplicate',
    delete: 'Delete',
    // ...
  },
  blockSettings: {
    spacing: 'Spacing',
    padding: 'Padding',
    // ...
  },
  templateSettings: {
    layout: 'Layout',
    // ...
  },
}
```

Some strings support placeholder interpolation using `{placeholder}` syntax:

```ts
{
  header: {
    templatesUsed: '{used}/{max} templates used',
  },
}
```

## Default block text

New blocks start with placeholder text, and it follows the locale — a Button
dragged in under `locale: 'de'` reads "Hier klicken", not "Click Here".

Which locale depends on who the text is for:

| Default | Follows | Why |
|---|---|---|
| Title, Paragraph, Button text | `init({ locale })` | Author-facing prompts. They exist to be overwritten, so they match the interface around them. |
| Video `alt`, Countdown unit labels and expired message | the template's `settings.locale` | This text ships in the delivered email, so it follows the email's language — not the language of whoever is editing. |

Override any of it with [`blockDefaults`](/guide/defaults), which wins over both:

```ts
const editor = await init({
  container: '#editor',
  locale: 'de',
  blockDefaults: {
    // Your own wording, in any language you like.
    button: { text: 'Jetzt kaufen' },
    paragraph: { content: '<p>Text hier eingeben</p>' },
  },
});
```

The merge is deep, so overriding one field leaves the rest localized —
`button: { backgroundColor: '#ff6600' }` keeps the translated label.

::: tip
An unset locale behaves exactly as before this existed: the English defaults are
byte-identical to the factory values.
:::

## Dates

Relative labels ("5m ago") come from translation keys. Absolute dates and times —
version history entries, saved-block tooltips, the template's write-time line —
are built by `Intl` from `init({ locale })`, so they read in the same language as
the chrome beside them rather than in the browser's language.

The canvas also carries the template's `settings.locale` as a `lang` attribute,
so the browser's spellchecker and hyphenation judge the copy by the rules of the
language it is actually written in. It binds `dir` from `settings.direction`
(resolved as above) so an RTL host page cannot leak into an LTR email, and an
LTR host cannot keep Arabic copy LTR.

## Contributing a new locale

To add a new language:

1. Copy `packages/editor/src/i18n/locales/en.ts` to a new file named after your locale code (e.g. `<locale>.ts`)
2. Translate all string values, keeping the same key structure, and annotate the object with `typeof en` (see the example below)
3. Run `pnpm run typecheck` — the `typeof en` annotation makes missing, extra, or misnested keys fail at compile time
4. Run `pnpm run test` to verify placeholder parity — tests check that every `{placeholder}` token in the English strings also appears in your translations

There is no registration step. The supported-locale list is derived from the files in `locales/` at build time, so dropping in `<locale>.ts` is all it takes — `init({ locale: '<locale>' })` picks it up automatically.

Example structure for a new locale:

```ts
// packages/editor/src/i18n/locales/<locale>.ts
import type en from './en';

const translations: typeof en = {
  blocks: {
    paragraph: '…',
    image: '…',
    button: '…',
    section: '…',
    divider: '…',
    spacer: '…',
    // ... all keys from en.ts
  },
  toolbar: {
    duplicate: '…',
    delete: '…',
    // ...
  },
  // ... all sections from en.ts
};

export default translations;
```

### Cloud strings are optional

Translations are split into two chunks. The OSS chunk (`locales/*.ts`) covers everything in the open-source editor. A separate cloud chunk (`locales/cloud/*.ts`) covers features only available via `initCloud()` — AI, collaboration, template scoring, the lint save gate, and plan limits. You don't need to translate the cloud chunk: if `locales/cloud/<locale>.ts` doesn't exist, cloud features fall back to English while the rest of the editor renders in your language.

Submit a pull request with your translation file. Contributions for any language are welcome.
