> ## Documentation Index
> Fetch the complete documentation index at: https://docs.elasticfunnels.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Internationalization (i18n)

> Translate page copy with a brand-wide dictionary, switch the active language via URL, and render localized strings from templates and backend scripts.

ElasticFunnels gives you a single, brand-wide system for translating page
copy without forking pages or maintaining duplicate variants. You write
your page once, **reference translation keys in templates** (or backend
scripts), **store translations per language** in the admin, and the page
shows the right copy based on the visitor's language.

The system is intentionally narrow: it only translates copy. Layout,
images, links, and behavior stay identical across languages. Use
[Page Variants](./page-variants) when you need a fully different
experience per audience.

## How it works

There are two things to manage:

1. **Languages** — the list of locales your brand supports (e.g. `en`,
   `pt-br`, `de`). One language is your **default**.
2. **Entries** — for each language, a list of `key → translation` rows.
   The same key (e.g. `hero.title`) lives in every language.

Once a language has entries, any template or script that uses that key
under the **resolved** locale for the request will receive the matching
value.

### Translation keys

Keys use **dot notation** so you can group related copy together —
`hero.title`, `checkout.cta`, `faq.refund_policy`.

Use **letters, numbers, underscores, and dots** in keys (e.g. `hero.title`,
`form.email_placeholder`). The admin may allow a wider pattern for stored
keys; sticking to this subset keeps lookups predictable.

Each `key` is unique within a language but is meant to repeat across
languages — that's how the system knows the English `hero.title` and
the German `hero.title` are the same slot.

## Managing languages and translations

Open **Pages → Advanced → Translations** to manage everything in one
place.

### Add a language

1. Click **Add language**.
2. Enter a **locale code** — short BCP-47 tag like `en`, `de`, `pt-br`.
3. Optionally add a **display label** (shown only in the admin).
4. Toggle **Default language** for the language that should be served
   when no other language is selected.
5. Use **Sort order** to control the order in language pickers.

### Add translation entries

1. In the **Entries** section, pick a language from the dropdown.
2. Click **Add entry**.
3. Enter a **Key** (e.g. `hero.title`) and the translated **Value**.
4. Save.

Use the search box to filter by key or value when your dictionary grows.

### Bulk edit

Click **Bulk edit** above the entries table to switch to a
spreadsheet-style editor. Add or change many keys at once, then click
**Save all changes**. The whole batch saves together — if any row
fails validation, nothing is saved.

### Permissions

Access to translations is controlled by the **Translations** module
permissions in your role: `view`, `create`, `update`, `delete`.

## Choosing the active language

ElasticFunnels derives a **locale hint** from the request, then **snaps**
it to a language row configured for the brand (when any exist).

**Locale hint priority** (first match wins):

1. **Wildcard / route params** — `hl` or `locale` from the matched slug
   (e.g. `{hl}` in [wildcard routes](../wildcard-routes)), also merged
   Express `params` when present.
2. **Query string** — `hl`, then `locale`, then `lang`.
3. **Session** — `session.locale`.
4. **Domain** — `locale` / `language` from domain config.
5. **Brand** — `locale` / `language` from brand variables.
6. **`Accept-Language`** — primary two-letter subtag of the first range
   (e.g. `en-US` → `en`).
7. **`en`** — final fallback for the hint.

**Snapping to brand languages** (when the brand has at least one language
in Advanced → Translations):

* If the hint matches a configured **code** exactly (after normalization),
  that language is used.
* Otherwise the brand **default** language is used, or the first language
  by sort order if no default is set.

There is **no** automatic `pt-br` → `pt` alias today: add both codes in
the admin if you need regional variants.

If the brand has **no** languages configured, the hint is still set on
the request for built-in helpers, but **`getTranslations()`** and
dictionary-backed **`t()`** / **`| t`** paths will not load entries from
the database.

### Clean URLs per language

If you want URLs like `/en/about` and `/pt-br/about` to switch the
active language, capture the locale segment in your page slug:

* **Page slug:** `{hl}/about`
* **URLs that match:** `/en/about`, `/pt-br/about`

The `{hl}` placeholder feeds into the route params used in step (1)
above.

## Using translations in templates

There are two sources of copy:

1. **Brand dictionary** (Advanced → Translations) — **`t()`**, **`| t`**,
   and **`getTranslations()`** use the **resolved** language for the request.
2. **Built-in product strings** (`src/locales` on the server) — used when
   **`t()`** or **`| t`** does not find a brand entry for the key; today the shipped
   catalog is **English**; extra locale files extend this over time.

```twig theme={null}
<h1>{{ t('title.greeting') }}</h1>
<h2>{{ 'hero.subtitle' | t }}</h2>
<img src="/hero.png" alt="{{ t('hero.alt') }}" />
<p>{{ dump(locales()) }}</p>
<p>Default: {{ defaultLocale() }}</p>
<p>{{ t('product.savings_percent', { percent: 20 }) }}</p>
<p>{{ 'product.savings_percent' | t:savingsOpts }}</p>
<p>Language: {{ getResolvedLanguage() }}</p>
<p>Active code (same as resolved): {{ locale }}</p>
```

| Function / filter                  | What it does                                                                                                                                                                                                                                                                           |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getTranslations()`                | Shallow copy of the full `key → value` map for the active resolved language. Async.                                                                                                                                                                                                    |
| `getResolvedLanguage()`            | Snapped language **code** from the brand configuration (e.g. `pt-br`). Async.                                                                                                                                                                                                          |
| `t(key, params?)`                  | **Brand entry first** when the key exists for the resolved language; otherwise built-in JSON from `src/locales`. Optional `params` is an **object** for `{{ name }}`-style placeholders **only** for built-in strings. Async.                                                          |
| `expr \| t` and `expr \| t:params` | Same resolution as `t()`; `expr` yields the **key string** (often a quoted literal). Optional **`t:params`** passes an object for built-in **`{{ name }}`** interpolation — `params` must be a variable or safe expression on the page context, same rules as `t(key, params)`. Async. |
| `locales()`                        | Array of configured language codes for the brand (admin sort order). Async.                                                                                                                                                                                                            |
| `defaultLocale()`                  | Default language code, or first by sort order, or `''`. Async.                                                                                                                                                                                                                         |

**`getTranslation(key)`** is still exposed for **brand-only** lookups (no built-in fallback, returns `''` if missing). For strings in pages and scripts, prefer **`t()`** or **`| t`** so product copy can fall through to the shipped catalog when useful.

Use **`{{ locale }}`** for the active resolved code on the page context, or **`getResolvedLanguage()`** inside expressions when you need the snapped code explicitly.

## Using translations in backend scripts

Backend scripts (`<script scope="backend">`) expose the same **brand**
helpers as templates (all async in the sandbox):

```javascript theme={null}
var code = await getResolvedLanguage();
var langs = await getLocales();
var def = await getDefaultLocale();
var hint = await getLocale();
var dict = await getTranslations();
var cta = await t('checkout.cta');
var title = await t('title.greeting');
```

**`t(key, params?)`** tries the **brand** dictionary first, then built-in
locale JSON (same as templates). Use `await`. **`getLocale()`** returns
the same resolved code as `{{ locale }}` (async in the sandbox).

There is **no** pipe-filter syntax in backend scripts — use **`await t('key')`**.

For the full host surface, see
[Backend Scripts → Data Functions](../backend-scripts/data-functions).

## Caching

Translations are served from a fast **in-memory cache** (per app
process). Saving languages or entries from the admin should **bust**
that cache via the worker (`brand_translation` or full `brand` clear) so
the next request reloads from the database.

## Patterns and pitfalls

* **Always set a default language** in Advanced → Translations so
  snapping has a predictable target when the URL hint does not match any
  row.
* **Use stable keys.** Renaming a key in the admin does not update your
  templates. Once `hero.title` exists, keep using it; rename only when
  the meaning of the slot truly changes.
* **Missing entries.** When the brand has no value for a key in the active
  language, **`t()` / `| t`** fall back to built-in strings when a catalog
  entry exists; otherwise prefer a sensible default in markup or a **`default:`**
  filter. **`getTranslation`** (brand-only) returns `''` when missing.
* **Exact language codes.** Add `pt` and `pt-br` separately if you need
  both; the runtime does not auto-collapse regions.

## Related

* [Wildcard routes](../wildcard-routes) — capture an `hl` path segment
  for clean URLs like `/en/about`.
* [Page variables](./page-variables) — non-translation per-locale
  switches (currency formatting, date masks, etc.).
* [Backend scripts → data functions](../backend-scripts/data-functions)
  — backend reference for translation helpers.
