Site-wide Internationalization — English / 中文 / العربية¶
Date: 2026-06-22
Status: Approved (design)
Topic: Add full site i18n with Chinese (zh) and Arabic (ar) language options, including RTL support.
Summary¶
The site (TanStack Start + React 19 + Vite + Tailwind v4) currently has no i18n
infrastructure: all user-facing text is hardcoded English across app/routes/
and components/, <html lang="en"> is static, and there is no RTL support.
This project introduces real i18n from scratch using react-i18next, lets a
user switch the entire site between English, Chinese, and Arabic, and adds
RTL layout for Arabic. Translated text is rendered correctly on the first
server paint (no “English flash”). Chinese/Arabic catalogs are AI-generated at
build time (reusing the repo’s existing translateText helper) and committed to
the repo.
Goals¶
A language switcher that changes the whole site between
en/zh/ar.Server-side rendering already in the selected language (no flash of English).
Full RTL layout for Arabic (
<html dir="rtl">+ mirrored UI).A repeatable pipeline to extract English strings and generate translations.
Non-Goals¶
Localizing user-generated content (posts/comments) — separate AI-translation features already exist for that and are out of scope here.
URL-based locale routing (
/zh,/ar). Locale is cookie-based; URLs unchanged.Professional/human translation sourcing. Catalogs are AI-generated; humans may hand-correct individual strings later by editing the committed catalogs.
Localizing number/date/currency formatting beyond what
i18nextprovides by default (can be a follow-up).
Decisions (from brainstorming)¶
Approach: Proper i18n infrastructure AND translate the whole site (not a machine-translation overlay, not core-UI-only).
Translation source: AI-generated at build time, committed catalogs.
Library:
react-i18next+i18next(mature, framework-agnostic, and its companioni18next-parserauto-extracts the hundreds of existing strings).Persistence: Cookie + Zustand store (mirroring the existing
themeStorepattern), read server-side so SSR renders in the right language.Switcher placement: Left sidebar nav + mobile nav, AND the profile settings page.
First-visit default: Derive locale from the browser
Accept-Languageheader when normh-langcookie is present; otherwise defaulten.
Architecture¶
Locale source of truth¶
A cookie
rmh-langwith valueen | zh | aris the source of truth.On the server, the root loader (
app/routes/__root.tsx) reads the cookie viagetRequest(). If absent, it parsesAccept-Languageand picks the best supported match, elseen. The resolved locale is returned from the loader.A per-request
i18nextinstance is initialized with that locale so all SSR’d components render translated text on first paint.On the client,
i18nextis initialized once with the same locale (read from the cookie) to keep hydration consistent.
<html lang> and <html dir>¶
RootDocumentrenders<html lang={locale} dir={dirFor(locale)}>wheredirFor("ar") === "rtl"and everything else is"ltr". These come from the loader data so they are correct server-side.A tiny inline guard script (sibling to the existing
themeScript) reads thermh-langcookie and setsdocument.documentElement.lang/dirbefore hydration as belt-and-suspenders.suppressHydrationWarningalready present.
SSR i18n instance lifecycle¶
Use
i18next.createInstance()per request (never the shared singleton) to avoid cross-request locale bleed in SSR. Initialize with the resolved locale and the preloaded catalogs, then wrap the tree in<I18nextProvider>(added insidecomponents/Providers.tsx).On the client, a single shared instance initialized at module load with the cookie locale, also provided via
<I18nextProvider>.
Components & Files¶
New¶
lib/i18n/config.ts— supported locales list,defaultLocale,dirFor(locale), namespaces list, and thei18nextinit options factory (shared server/client).lib/i18n/server.ts—resolveLocale(request)(cookie → Accept-Language →en) andcreateServerI18n(locale)returning a per-request instance.lib/i18n/client.ts— the singleton clienti18nextinstance + init.stores/localeStore.ts— Zustand store:{ locale, setLocale }.setLocalewrites thermh-langcookie, callsi18n.changeLanguage, and updatesdocument.documentElement.lang/dirlive (no reload).components/site/LanguageSwitcher.tsx— globe icon + 3 options (English,中文,العربية), used in sidebar/mobile nav.locales/<locale>/<namespace>.json— message catalogs.enis source of truth;zhandarare generated.scripts/translate-locales.ts— readsencatalogs, fills missing/changed keys inzh/arvia the existingtranslateTexthelper, writes catalogs. Idempotent and re-runnable; output committed.i18next-parser.config.js— extraction config (namespaces, key style, locales, input globs forapp/andcomponents/).
Modified¶
app/routes/__root.tsx— loader resolves locale;RootDocumentsetslang/dir; add locale inline guard script.components/Providers.tsx— wrap children in<I18nextProvider>with the correct (server vs client) instance.components/.../LeftSidebar+MobileNav— mountLanguageSwitcher.app/routes/strategies/profile/settings.tsx— add a language<select>beside the existing timezone control.app/globals.css— add[dir="rtl"]overrides for physically-positioned UI.package.json— addi18next,react-i18next,i18next-parser(dev), and scripts:i18n:extract(parser) andi18n:translate(translate-locales).
Message Catalogs¶
Namespaced by feature (e.g.
common,nav,feed,games,library,news,profile,settings) so no single file grows unmanageable. Exact namespace list finalized during extraction.locales/en/*.jsonpopulated byi18next-parserfromt("...")calls.locales/zh/*.jsonandlocales/ar/*.jsongenerated byscripts/translate-locales.tsand committed. The script only translates keys that are missing or whose English source changed (tracked by storing the source English string or a hash alongside generation), so human-corrected strings are not clobbered unless their English changed.
String Extraction (bulk work)¶
Replace hardcoded JSX strings with
t("namespace:key")viauseTranslation().Done route-group by route-group (e.g.
_sitenav/chrome first, then feed, then games, etc.) so each PR is a reviewable chunk rather than one mega-diff.After each group: run
i18n:extractto refreshen, runi18n:translateto fillzh/ar, and audit RTL for that group.
RTL Support (Arabic)¶
Prefer Tailwind v4 logical-property utilities (
ps-*/pe-*,ms-*/me-*,start-*/end-*,text-start/end) when touching components during extraction.Add targeted
[dir="rtl"]overrides inapp/globals.cssfor components that use physical positioning (sidebar, nav, anything withleft/right/translateXor directional icons). Audited per route group.Icons that imply direction (chevrons/arrows for back/forward) flipped under RTL.
Data Flow¶
Request arrives → root loader reads
rmh-langcookie (orAccept-Language) → resolveslocale.Server creates a per-request
i18nextinstance withlocale+ catalogs → renders<html lang dir>and translated markup.Client hydrates with the same locale → no mismatch, no English flash.
User picks a language in
LanguageSwitcher→localeStore.setLocalewrites cookie,i18n.changeLanguage, updateslang/dir→ UI re-renders live.Next request uses the cookie.
Error Handling¶
Missing translation key →
i18nextfalls back to theenvalue (configuredfallbackLng: "en"), so the UI never shows a raw key.Unknown/invalid cookie value → treated as absent; falls back to
Accept-Languagethenen.scripts/translate-locales.tsfailures on a single key are logged and skipped (that key keeps its previous value / falls back toen) rather than aborting the whole run.
Testing¶
Unit:
resolveLocale(cookie precedence, Accept-Language parsing, fallback);dirFor;localeStore.setLocaleside effects (cookie +lang/dir).Catalog integrity: a test asserting
zhandarhave exactly the same key set asenper namespace (no missing/orphan keys).Render: a component renders translated text when locale is
zh; rendering underaryieldsdir="rtl".
Rollout / Scope Reality¶
PR 1 (infra, independently shippable): library install,
lib/i18n/*, stores, Providers wiring,<html lang/dir>,LanguageSwitcher, settings control, extraction/translate scripts + config, and extraction of the_sitenav/chrome group as the first proof. At this point all three languages work for the chrome.Subsequent PRs: one route group per PR — extract strings, regenerate
zh/ar, audit RTL. Repeat until the whole site is covered.