# 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/catalogs.server.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 server holds every catalog (`apps/web/src/platform/i18n/catalogs.server.ts`, imported by `server.ts`); the client entry ships none. The browser boots in the language the server rendered: the root route preloads `/locales/{$lang}.json?v=<version>`, a content-hashed, immutable asset, and `client.tsx` loads it before hydrating (`platform/i18n/boot-catalog.ts`), so the first client render and SSR share one catalog and a repeat visit pays nothing for it. The client 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, `switchLanguage` fetches the missing catalog once and 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/wundklare/blob/main/packages/intl/src/i18n.ts)
- [`packages/intl/src/server.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/packages/intl/src/server.ts)
- [`packages/intl/src/locale.config.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/packages/intl/src/locale.config.ts)
- [`packages/intl/src/locale-paths.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/packages/intl/src/locale-paths.ts)
- [`apps/web/src/platform/seo/locale-redirects.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/platform/seo/locale-redirects.ts)
- [`apps/web/src/platform/i18n/language-switcher.tsx`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/platform/i18n/language-switcher.tsx)
- [`apps/web/src/platform/i18n/intl.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/platform/i18n/intl.ts)
- [`apps/web/src/platform/i18n/catalogs.server.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/platform/i18n/catalogs.server.ts)
- [`apps/web/src/platform/i18n/boot-catalog.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/platform/i18n/boot-catalog.ts)
- [`apps/web/src/routes/locales/{$lang}[.]json.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/routes/locales/%7B$lang%7D%5B.%5Djson.ts)
- [`apps/web/src/locales/en.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/locales/en.ts)
- [`apps/mobile/src/platform/i18n/i18n.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/mobile/src/platform/i18n/i18n.ts)
- [`apps/mobile/src/platform/i18n/catalog.ts`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/mobile/src/platform/i18n/catalog.ts)
- [`apps/web/x18n.config.yml`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/x18n.config.yml)

