Frontend Internationalisation (i18n)¶
TaskWolf's frontend is translated with react-i18next. This page documents the conventions established by the i18n pilot (nav chrome, auth, and core account settings) so follow-up cycles migrate the remaining pages the same way.
Where It Lives¶
frontend/src/i18n/
index.ts # i18next init: languages, namespaces, detection
format.ts # Intl-based date/number/relative-time helpers
locales/
en/
common.json
nav.json
auth.json
settings.json
de/
common.json
nav.json
auth.json
settings.json
frontend/src/i18n/index.ts configures the instance:
export const SUPPORTED_LANGUAGES = ['en', 'de'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { common: enCommon, settings: enSettings, nav: enNav, auth: enAuth },
de: { common: deCommon, settings: deSettings, nav: deNav, auth: deAuth },
},
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LANGUAGES],
nonExplicitSupportedLngs: true,
defaultNS: 'common',
ns: ['common', 'settings', 'nav', 'auth'],
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'taskowolf.lang',
caches: ['localStorage'],
},
})
fallbackLng is 'en'; supportedLngs is ['en', 'de']. Language detection checks localStorage['taskowolf.lang'] first, then the browser's navigator language, and always caches the resolved language back to that same localStorage key. A languageChanged listener keeps document.documentElement.lang in sync for accessibility/SEO.
Namespaces¶
One namespace per feature area, each with an en and a de JSON file that are key-identical (same keys, same nesting, translated values):
| Namespace | File | Holds |
|---|---|---|
common (default) |
locales/{en,de}/common.json |
Shared words used across the app: save, cancel, saving, saved, loading, error |
nav |
locales/{en,de}/nav.json |
Sidebar section headers and nav item labels, logout, sidebar expand/collapse |
auth |
locales/{en,de}/auth.json |
Login and register pages |
settings |
locales/{en,de}/settings.json |
Profile, security, account, notification-preferences settings |
common is defaultNS, so an unqualified t('save') resolves there without specifying a namespace.
Keys¶
Keys are semantic and hierarchical — never the English source text:
// locales/en/settings.json
{
"profile": {
"title": "Profile",
"displayName": "Display name",
"updateFailed": "Failed to update profile"
}
}
// locales/de/settings.json
{
"profile": {
"title": "Profil",
"displayName": "Anzeigename",
"updateFailed": "Profil konnte nicht aktualisiert werden"
}
}
Group related strings under a feature-section object (profile, security, account, notifications inside settings; login, register inside auth) so a page's keys read as t('profile.displayName'), t('login.ssoButton'), etc.
Usage¶
import { useTranslation } from 'react-i18next'
export function ProfilePage() {
const { t } = useTranslation('settings')
const { t: tc } = useTranslation('common') // pull in `common` alongside the page's own namespace
return (
<>
<h1>{t('profile.title')}</h1>
<button>{updatePending ? tc('saving') : tc('save')}</button>
</>
)
}
Call useTranslation('<namespace>') once per component for the page's primary namespace; alias a second call (t: tc) when you also need common strings in the same component.
Registering a new namespace — when a migration needs a namespace that doesn't exist yet:
- Create
locales/en/<namespace>.jsonandlocales/de/<namespace>.json, key-identical. - In
frontend/src/i18n/index.ts, import both files and add the namespace toresources.en,resources.de, and thensarray.
Interpolation and Plurals¶
Pass variables, never concatenate translated fragments:
interpolation.escapeValue is false (React already escapes JSX output), so interpolated values render as-is — do not pass raw HTML through an interpolated variable.
For plurals, i18next (v26, CLDR-based) resolves the key by suffix from a count variable — key_one / key_other (English/German both only need those two forms), e.g.:
No page uses plurals yet — apply this convention only when a migrated string genuinely needs to vary by count.
Formatting Dates, Numbers, and Relative Times¶
Never hand-roll Date/Number formatting. Use the helpers in frontend/src/i18n/format.ts, which read the active i18next language and delegate to Intl:
import { formatDate, formatDateTime, formatNumber, formatRelativeTime } from '@/i18n/format'
formatDate(issue.createdAt) // e.g. "Jul 12, 2026" / "12. Juli 2026"
formatDateTime(comment.createdAt) // adds a short time component
formatNumber(project.issueCount) // locale-aware thousands separators
formatRelativeTime(notification.createdAt) // "3 hours ago" / "vor 3 Stunden"
Language Persistence¶
Two layers, so the choice is instant locally and durable across devices:
- localStorage (immediate).
i18n.changeLanguage(lng)writestaskowolf.langand updates<html lang>synchronously — the UI never waits on a network round trip. - Backend (
PATCH /api/v1/me/language, best-effort).useUpdateLanguage()(frontend/src/hooks/useMe.ts) persists the choice server-side so it follows the user to a new device/browser. useLanguageSync()(frontend/src/hooks/useLanguageSync.ts) is called once fromAppLayout. It applies the user's server-stored language once after/meloads, if it differs from the current language (which the detector has already resolved from localStorage if one was set) — guarded by a ref so it only ever runs once per session.
The LanguageSwitcher component (frontend/src/components/LanguageSwitcher.tsx) drives both layers from one <select> and lives in Settings → Profile:
const onChange = (lng: string) => {
void i18n.changeLanguage(lng) // localStorage + <html lang>, immediate
updateLanguage.mutate(lng) // backend persistence, best-effort, non-blocking
}
Supported Languages / Fallback¶
SUPPORTED_LANGUAGES = ['en', 'de'] as const (exported from frontend/src/i18n/index.ts, with the AppLanguage union type derived from it) is the single source of truth for which languages exist — the LanguageSwitcher options and the useLanguageSync allow-list both read from it. fallbackLng is 'en'.
Migration Recipe (Follow-Up Cycles)¶
The pilot covered nav chrome, auth (login/register), and core account settings (profile, security, account, notifications). Every other page is still hardcoded English pending migration. To migrate a page:
- Pick a page (or a cohesive slice of one).
- Read through it and list every user-facing literal string.
- Add to (or create) that feature area's namespace in both
locales/en/<ns>.jsonandlocales/de/<ns>.json— the English value should initially be verbatim the current hardcoded copy, and the German value should be a real translation, not a placeholder. - If it's a new namespace, register it in
frontend/src/i18n/index.ts(see Registering a new namespace above). - Replace the literals with
t('...')calls (useTranslation('<ns>')), using dates/numbers/relative-times viaformat.tsinstead of hand-rolled formatting. - Run
npm run buildinfrontend/to catch missing-import/typecheck errors. - Manually switch languages via the Settings → Profile language switcher and eyeball the page in both
enandde.
Common Pitfalls¶
- Never use the English text as a translation key. Keys are semantic (
profile.displayName), nott('Display name')— an English-as-key approach breaks the moment the English copy is edited. - Never concatenate translated fragments. Build the full sentence in each locale file with
{{variable}}interpolation instead of gluingt()calls together with string concatenation — word order differs between languages. - Don't hand-roll date/number formatting. Use
formatDate/formatDateTime/formatNumber/formatRelativeTimefromfrontend/src/i18n/format.tsso formatting follows the active locale automatically. - Keep
en/deJSON files key-identical. A key present in one locale and missing in the other silently falls back tofallbackLng(en) at runtime instead of failing loudly — diff the two files when reviewing a namespace change. - Don't add a namespace without registering it. A
useTranslation('newns')call with an unregistered namespace renders raw keys instead of translated text.