# Internationalization

English, German and French UI with request-scoped SSR translation.

## Catalog ownership [#catalog-ownership]

Web catalogs live in `apps/web/src/locales/{en,de,fr}.ts`. `MessageKey` derives from English; the other catalogs must retain key and interpolation parity. Shared error translations come from `packages/errors/src/locales` and join the i18next resources in `apps/web/src/platform/i18n/intl.ts` (mobile: `apps/mobile/src/platform/i18n/catalog.ts`). Locale files are static objects, with no spreads, so [x18n](https://x18n.dev) can read and edit them. Mobile maintains its own app catalogs with the same supported languages.

Use `useTranslation` in components and hooks. Format dates and numbers with Intl. Do not concatenate translated fragments when the sentence needs different word order.

## Public URLs per language [#public-urls-per-language]

A public page exists once per language, at one path each: `/privacy` is the default language, `/de/privacy` and `/fr/privacy` the others. The configuration is one leaf, `packages/intl/src/locale.config.ts`:

```ts
export const languages = ["en", "de", "fr"] as const;
export const fallbackLanguage: Language = "en"; // served unprefixed
export const localeSlugs = { en: "en", de: "de", fr: "fr" } as const; // the path prefix
export const localizedSegments = {}; // canonical segment → translated segment, per language
export const localizedPublicPaths = [
  "/",
  "/privacy",
  "/terms",
  "/architecture",
  "/index.md",
  "/privacy.md",
  "/terms.md",
  "/posts/*",
  "/u/*",
] as const;
export const rootLocaleRedirect = true; // first human visit to / follows cookie, then Accept-Language
```

A project with Brazilian Portuguese adds `"pt-BR"` to `languages` and `"pt-BR": "br"` to `localeSlugs`: its pages answer at `/br/privacy`, `?lang=pt-BR` and `?lang=pt_br` redirect there, `/pt-BR/privacy` redirects to `/br/privacy`, and `Accept-Language: pt-BR` sends a first visit from `/` to `/br`. Translated segments go through the same leaf: `localizedSegments = { de: { privacy: "datenschutz" } }` makes `/de/datenschutz` the German page while the route file stays `routes/{-$locale}/privacy.tsx`; `localizePath("/privacy", "de")` then returns the translated form for links, hreflang and the sitemap, and the router's `rewrite` pair (`router.tsx`, wired only when a segment is translated) maps between the public URL and the route tree. The template ships the identity mapping.

The pure functions live in `packages/intl/src/locale-paths.ts`: `splitLocalePath` (pathname → language and canonical page), `localizePath` (page and language → public path), `canonicalLocalePath` (the one form to redirect to), `queryLanguage` and `negotiateLanguage` (a `?lang=` tag or an `Accept-Language` header → one of ours, region tags included). The redirects themselves and the crawler check are in `apps/web/src/platform/seo/locale-redirects.ts` ([SEO and discovery](/docs/features/seo) lists them).

## Server rendering [#server-rendering]

`getSSRLanguage` reads the path on a public page (`/de/privacy` is German, `/privacy` is the default language, whatever the cookie says) and the locale cookie everywhere else, then the fallback. `getRequestI18n` clones the i18next instance for that request while sharing loaded catalogs. Mutating one global language during SSR would make concurrent requests affect each other.

The client boots in the same language (`initialLanguageFromBrowser`), keeps its shared instance and updates the document language after a switch. On a public page the language switcher is a list of links to the current page in each language, a full navigation that sets the locale cookie; under the app shell, which has one URL, it switches in place. Developer articles are English only: their article region has `lang="en"`, their chrome is translated, and their sitemap entries have no hreflang alternates.

## Extend [#extend]

Add a message to English, German and French together. Preserve placeholder names and run the locale parity tests and `pnpm i18n:check` (x18n; config in `apps/{web,mobile}/x18n.config.yml`). `pnpm i18n:ui` opens an editor across locales. A new language also needs `locale.config.ts` (the language and its slug), the `OG_LOCALE` map in `platform/seo/seo.ts`, the switcher's label key, formatters and native catalogs updated. Translate the domain consistently: a Todo's translated label can differ from its code identifier.

## Key files [#key-files]

- [`packages/intl/src/i18n.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/i18n.ts)
- [`packages/intl/src/server.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/server.ts)
- [`packages/intl/src/locale.config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/locale.config.ts)
- [`packages/intl/src/locale-paths.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/locale-paths.ts)
- [`apps/web/src/platform/seo/locale-redirects.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/seo/locale-redirects.ts)
- [`apps/web/src/platform/i18n/language-switcher.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/i18n/language-switcher.tsx)
- [`apps/web/src/platform/i18n/intl.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/i18n/intl.ts)
- [`apps/web/src/locales/en.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/locales/en.ts)
- [`apps/mobile/src/platform/i18n/i18n.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/platform/i18n/i18n.ts)
- [`apps/mobile/src/platform/i18n/catalog.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/platform/i18n/catalog.ts)
- [`apps/web/x18n.config.yml`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/x18n.config.yml)

