11 KiB
Phase 34: i18n Foundation - Research
Researched: 2026-04-13 Status: Complete
Library Evaluation
react-i18next vs Lingui
| Criterion | react-i18next | Lingui |
|---|---|---|
| React 19 support | Yes (v15+) | Yes (v5+) |
| Hook-based API | useTranslation() |
useLingui() |
| Lazy loading | Built-in (react-i18next/icu) backend, dynamic imports |
Catalog-based lazy loading via @lingui/loader |
| TypeScript support | Strong with i18next typed resources |
Strong with compiled catalogs |
| Bundle size | ~10kb (i18next core + react-i18next) | ~5kb (runtime only) |
| Vite plugin | i18next-resources-for-ts or manual |
@lingui/vite-plugin |
| Extraction tooling | i18next-parser (CLI) |
@lingui/cli extract (built-in) |
| JSON file format | Native JSON key-value | PO files or JSON catalogs |
| Bun/Hono server-side | i18next works standalone (no React dependency) |
@lingui/core works standalone |
| Community/ecosystem | Larger ecosystem, more plugins | Growing, more opinionated |
| Namespace support | Built-in first-class | Via message IDs with prefixes |
| Interpolation | {{name}} syntax |
{name} syntax with ICU |
Recommendation: react-i18next
Reasons:
- Namespace support is first-class — CONTEXT.md decision D-08 requires namespaces by feature area. react-i18next has this built-in; Lingui requires manual ID prefixing.
- JSON translation files — Decision D-06 specifies JSON files in
src/client/locales/. react-i18next uses plain JSON natively. Lingui prefers PO files or its own catalog format. - Server-side reuse —
i18nextcore (no React) can be used directly in Hono routes and MCP tool descriptions. Same translation files, same API. - Larger ecosystem — More documentation, Stack Overflow answers, and community plugins for future needs (Crowdin/Lokalise integration mentioned in D-06).
- Lazy loading —
i18next-http-backendor dynamic imports work cleanly with Vite code splitting.
Required Packages
i18next # Core translation engine
react-i18next # React bindings (useTranslation hook)
i18next-browser-languagedetector # Auto-detect browser locale (D-10)
No additional Vite plugins needed — JSON imports work natively.
Architecture Design
Client-Side Setup
src/client/
├── locales/
│ ├── en/
│ │ ├── common.json # Shared: buttons, labels, navigation
│ │ ├── collection.json # Collection page strings
│ │ ├── threads.json # Research threads strings
│ │ ├── setups.json # Setups strings
│ │ ├── onboarding.json # Onboarding flow strings
│ │ └── settings.json # Settings page strings
│ └── de/
│ ├── common.json
│ ├── collection.json
│ ├── threads.json
│ ├── setups.json
│ ├── onboarding.json
│ └── settings.json
├── lib/
│ └── i18n.ts # i18next initialization
Namespace strategy (D-08):
common— buttons ("Save", "Cancel", "Delete"), nav items, shared labels, error messages, empty statescollection— collection page, item forms, item cardsthreads— thread list, thread detail, candidate formssetups— setup list, setup detail, impact previewonboarding— welcome, hobby picker, item browser, review, done screenssettings— settings page labels, API keys section, import/export
i18n Initialization (src/client/lib/i18n.ts)
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
// Eager-load both locales (small app, 2 languages)
import enCommon from "../locales/en/common.json";
import enCollection from "../locales/en/collection.json";
import enThreads from "../locales/en/threads.json";
import enSetups from "../locales/en/setups.json";
import enOnboarding from "../locales/en/onboarding.json";
import enSettings from "../locales/en/settings.json";
import deCommon from "../locales/de/common.json";
import deCollection from "../locales/de/collection.json";
import deThreads from "../locales/de/threads.json";
import deSetups from "../locales/de/setups.json";
import deOnboarding from "../locales/de/onboarding.json";
import deSettings from "../locales/de/settings.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
common: enCommon,
collection: enCollection,
threads: enThreads,
setups: enSetups,
onboarding: enOnboarding,
settings: enSettings,
},
de: {
common: deCommon,
collection: deCollection,
threads: deThreads,
setups: deSetups,
onboarding: deOnboarding,
settings: deSettings,
},
},
fallbackLng: "en",
defaultNS: "common",
interpolation: {
escapeValue: false, // React handles XSS
},
detection: {
order: ["localStorage", "navigator"],
lookupLocalStorage: "gearbox-language",
caches: ["localStorage"],
},
});
export default i18n;
Note on lazy loading: With only 2 languages and ~200 strings, eager loading all namespaces is simpler and avoids loading spinners. Total JSON payload is <20KB gzipped. Lazy loading can be added later when more languages are added.
Integration with useFormatters()
Decision D-04 specifies i18n integrates with the existing formatters hook. The formatters currently use manual string concatenation. With i18n, number and date formatting should use Intl.NumberFormat and Intl.DateTimeFormat for locale-aware output.
Approach: Extend useFormatters() to accept locale from i18n context and pass it to formatWeight() and formatPrice(). The format functions gain a locale parameter:
// formatPrice now uses Intl.NumberFormat for locale-aware number display
export function formatPrice(cents: number | null, currency: Currency, locale: string): string {
if (cents == null) return "--";
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: currency === "JPY" ? 0 : 2,
}).format(cents / 100);
}
This replaces the manual symbol lookup with Intl.NumberFormat which handles symbol placement, decimal separators, and grouping per locale (e.g., German: 1.234,56 € vs English: $1,234.56).
Language Setting Storage
Following the useWeightUnit() and useCurrency() pattern:
// src/client/hooks/useLanguage.ts
export function useLanguage(): string {
const { data } = useSetting("language");
return data && VALID_LANGUAGES.includes(data) ? data : "en";
}
Key difference from weight/currency: Language changes need to call i18n.changeLanguage() in addition to persisting via useSetting(). A useEffect in the root layout (or the useLanguage hook) syncs the i18n instance when the setting changes.
Server-Side Translation
MCP tool descriptions and API error messages need server-side translation. Since Hono runs on Bun (not browser), use i18next core directly:
// src/server/lib/i18n.ts
import i18next from "i18next";
import en from "../../client/locales/en/common.json";
import de from "../../client/locales/de/common.json";
const serverI18n = i18next.createInstance();
serverI18n.init({
lng: "en", // Default server language
resources: { en: { common: en }, de: { common: de } },
defaultNS: "common",
});
export function t(key: string, lng?: string): string {
return serverI18n.t(key, { lng });
}
MCP tool descriptions: These are registered once at server start and consumed by AI clients. They should remain in English — AI models work best with English tool descriptions. Server-side i18n applies to API error messages returned to the browser, not MCP tool descriptions.
String Key Convention
Nested keys with dot notation:
{
"nav": {
"collection": "Collection",
"setups": "Setups",
"discover": "Discover",
"settings": "Settings"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create"
},
"empty": {
"noItems": "No items yet",
"noThreads": "No research threads yet"
}
}
Access pattern: t("nav.collection"), t("actions.save").
Language Picker UX
Reuse the pill-toggle pattern from weight unit and currency in settings:
const LANGUAGES = [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
];
Place in the settings page above weight unit, since language is the most fundamental preference.
String Extraction Strategy
Given ~200 strings across ~50 components, extraction should be done systematically by feature area matching the namespace structure:
- Common — TopNav, BottomTabBar, FabMenu, ConfirmDialog, AuthPromptModal, empty states
- Collection — CollectionView, ItemCard, ItemForm, CategoryPicker, CategoryHeader, WeightSummaryCard
- Threads — ThreadCard, ThreadTabs, CandidateCard, CandidateForm, ComparisonTable, CreateThreadModal
- Setups — SetupsView, SetupCard, SetupImpactSelector, ShareModal
- Onboarding — OnboardingWelcome, OnboardingHobbyPicker, OnboardingItemBrowser, OnboardingReview, OnboardingDone
- Settings — SettingsPage (weight, currency, language, API keys, import/export)
Validation Architecture
Test Strategy
- Unit tests — i18n initialization loads both locales without error
- Unit tests —
useLanguage()hook returns correct language from settings - Unit tests —
formatPrice()with locale produces correct output for en and de - Unit tests —
formatWeight()with locale produces correct output for en and de - Integration test — Language change via settings API persists and takes effect
- E2E test — Switch language in settings, verify UI text changes to German
Completeness Checks
- Every
en/*.jsonkey has a correspondingde/*.jsonkey (no missing translations) - No hardcoded English strings remain in components that have been extracted
i18n.tsregisters all namespaces for both languages- Language picker appears in settings and persists selection
Risk Assessment
| Risk | Impact | Mitigation |
|---|---|---|
| React 19 compatibility with react-i18next | Medium | react-i18next v15+ supports React 19. Pin compatible version. |
| Bundle size increase | Low | i18next + react-i18next is ~10KB gzipped. JSON files add <10KB per language. |
| String extraction misses some strings | Low | Incremental approach — extract by namespace area, verify each area. |
| German translation quality | Low | AI-generated is acceptable per D-14. User corrects organically. |
| Formatter locale breaking existing tests | Medium | Update test helpers to pass locale. Existing tests keep "en" default. |
Dependencies
- Phase 33 (Currency System): Language detection should follow the same browser auto-detection pattern. The
useCurrencyhook pattern is the model foruseLanguage. Phase 33 may add market auto-detection; language auto-detection is independent but similar. - No schema changes needed: Language preference stored in existing
settingstable viauseSetting("language").