Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Internationalization

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

Markdown

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

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:

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 lists them).

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

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

Edit on GitHubDocumentation is in English.