i18n
The apps/ui uses react-i18next for translation. Keys are type-safe (typos fail the build) and hardcoded strings in JSX are a lint error, so a forgotten translation never ships.
How it works
Section titled “How it works”Single common namespace by default. Keep it simple; split into more namespaces only when one grows past around 200 keys.
Locales are detected from the browser, falling back to the first in VITE_LOCALES. This is friendly for international visitors and predictable for tests. English and German are included by default. Pick any two locales you need and replace them. Catalog files are plain JSON, trivial to diff, translate, and review.
No Suspense for translations. This avoids a flash of fallback UI during i18n init. Hardcoded JSX strings are rejected by lint, so forgotten translations cannot ship; the linter catches the JSX literal directly.
How it’s wired
Section titled “How it’s wired”flowchart LR
detect["LanguageDetector<br/>browser language"]
catalogs["src/lib/i18n/locales/<br/>en/common.json · de/common.json"]
i18n["i18next + react-i18next"]
hook["useTranslation('common')<br/>const { t } = ..."]
component["component renders<br/>{t('auth.signIn')}"]
detect --> i18n
catalogs --> i18n
i18n --> hook
hook --> component
Translation flow: the LanguageDetector reads the browser’s preferred language;
JSON catalogs under src/lib/i18n/locales/ feed i18next; the
useTranslation(‘common’) hook returns t; components
call t(‘key’) with statically-checked keys.
Using it
Section titled “Using it”import { useTranslation } from "react-i18next";
const SignInButton = () => { const { t } = useTranslation(); return <button>{t("auth.signIn")}</button>;};A missing key in any locale that’s listed in VITE_LOCALES is a build-time concern, not a runtime one; the type generation step would flag a key that exists in en but not in de (or vice versa).
Adding a locale
Section titled “Adding a locale”- Drop
<lang>/common.jsonundersrc/lib/i18n/locales/<lang>/. - Add
<lang>toVITE_LOCALES(comma-separated). - Update the import in
src/lib/i18n/config.tsto register the catalog.
The language detector picks it up automatically; users on browsers in that locale start seeing it.
Adding a key
Section titled “Adding a key”- Add
"my.new.key": "English copy"toen/common.json. - Add the translation to every other locale’s
common.json(an_TODO_placeholder works as a build-tolerant intermediate). - Use it:
t("my.new.key").
For interpolation, react-i18next docs cover the syntax. For pluralization, the _one / _other suffix convention.
Patterns to avoid
Section titled “Patterns to avoid”Fragment concatenation: t("the") + " " + t("button"). Word order is language-specific; build full sentences as keys.
Sentence-as-ID: t("Click here to continue"). Hard to refactor when copy changes; use semantic keys like t("checkout.continue").
Hardcoded aria-label or placeholder: these are content, not technical strings. The lint rule applies to all of them.
Lint coverage
Section titled “Lint coverage”The UI app’s lint config bans hardcoded JSX strings on user-facing text. Numbers, technical identifiers, and data-* attributes are exempt. Static t("…") keys are also checked against the English catalog by @boring-stack-pkg/eslint-plugin-i18n-keys so a typo cannot ship. See Architecture rules and Lint as the contract.
Source
Section titled “Source”src/lib/i18n/; config, locales, and the catalog JSON.
Related
Section titled “Related”- Architecture rules; the component anatomy that hosts
t("…")calls. - UI template overview; where the i18n layer sits in the SPA shell.
- Lint as the contract; the
eslint-plugin-i18n-keysrule that catches typos at build. - Testing; how view-object tests stay locale-stable.