Site-wide i18n (Chinese + Arabic) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a user switch the entire site between English, Chinese (zh), and Arabic (ar), rendered correctly on first server paint, with full RTL layout for Arabic.
Architecture: Introduce react-i18next from scratch. A rmh-lang cookie is the source of truth, resolved server-side in the root loader so SSR renders in the chosen language. A per-request i18next instance is used on the server; a singleton on the client. <html lang/dir> is set server-side. Chinese/Arabic catalogs are AI-generated at build time by a committed, re-runnable script and committed to the repo. Strings are extracted route-group by route-group; this plan delivers the full infrastructure plus the nav/chrome group.
Tech Stack: TanStack Start, React 19, Vite, Tailwind v4, Zustand, Vitest (node env), i18next, react-i18next, i18next-parser (dev).
Global Constraints¶
Supported locales are exactly
en,zh,ar.enis the fallback (fallbackLng: "en") and the source of truth.dirisrtlforar,ltrfor everything else.The locale cookie name is exactly
rmh-lang.Tests live under
lib/__tests__/**(the only app path collected byvitest.config.ts) and run in the node environment — no jsdom, no@testing-library. Test pure functions; pass fake element/request objects rather than relying on a real DOM.Run tests with:
pnpm exec vitest run <file>.Path alias
@maps to the repo root (e.g.@/lib/i18n/config).Package manager is pnpm. Add dependencies with
pnpm add/pnpm add -D.Follow the existing
themeStorepattern for the locale store (plain Zustandcreate, no persist middleware).Commit after every task.
Task 1: i18n core config + dirFor¶
Files:
Create:
lib/i18n/config.tsTest:
lib/__tests__/i18n-config.test.ts
Interfaces:
Consumes: nothing.
Produces:
LOCALES = ["en","zh","ar"] as consttype Locale = (typeof LOCALES)[number]DEFAULT_LOCALE: Locale = "en"NAMESPACES = ["common","nav"] as constLOCALE_LABELS: Record<Locale,string>→{ en:"English", zh:"中文", ar:"العربية" }TRANSLATE_TARGETS: Record<Exclude<Locale,"en">,string>→{ zh:"Chinese (Simplified)", ar:"Arabic" }isLocale(value: unknown): value is LocaledirFor(locale: Locale): "ltr" | "rtl"buildInitOptions(locale: Locale, resources: Record<string, any>): import("i18next").InitOptions— returns{ lng: locale, fallbackLng: "en", supportedLngs: [...LOCALES], ns: [...NAMESPACES], defaultNS: "common", resources, interpolation: { escapeValue: false }, returnNull: false }
Step 1: Install dependencies
Run:
pnpm add i18next react-i18next && pnpm add -D i18next-parser
Expected: packages added to package.json, no peer-dependency errors.
Step 2: Write the failing test
Create lib/__tests__/i18n-config.test.ts:
import { describe, it, expect } from "vitest";
import { dirFor, isLocale, DEFAULT_LOCALE, LOCALES, LOCALE_LABELS } from "@/lib/i18n/config";
describe("i18n config", () => {
it("marks Arabic as rtl and others as ltr", () => {
expect(dirFor("ar")).toBe("rtl");
expect(dirFor("en")).toBe("ltr");
expect(dirFor("zh")).toBe("ltr");
});
it("validates locales", () => {
expect(isLocale("en")).toBe(true);
expect(isLocale("fr")).toBe(false);
expect(isLocale(undefined)).toBe(false);
});
it("exposes a label for every supported locale", () => {
for (const l of LOCALES) expect(LOCALE_LABELS[l]).toBeTruthy();
});
it("defaults to English", () => {
expect(DEFAULT_LOCALE).toBe("en");
});
});
Step 3: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/i18n-config.test.ts
Expected: FAIL — cannot resolve @/lib/i18n/config.
Step 4: Write the implementation
Create lib/i18n/config.ts:
import type { InitOptions } from "i18next";
export const LOCALES = ["en", "zh", "ar"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const NAMESPACES = ["common", "nav"] as const;
export type Namespace = (typeof NAMESPACES)[number];
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ar: "العربية",
};
/** Human-readable target names passed to the AI translate helper. */
export const TRANSLATE_TARGETS: Record<Exclude<Locale, "en">, string> = {
zh: "Chinese (Simplified)",
ar: "Arabic",
};
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" && (LOCALES as readonly string[]).includes(value);
}
export function dirFor(locale: Locale): "ltr" | "rtl" {
return locale === "ar" ? "rtl" : "ltr";
}
export function buildInitOptions(
locale: Locale,
resources: Record<string, any>,
): InitOptions {
return {
lng: locale,
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...LOCALES],
ns: [...NAMESPACES],
defaultNS: "common",
resources,
interpolation: { escapeValue: false },
returnNull: false,
};
}
Step 5: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/i18n-config.test.ts
Expected: PASS (4 tests).
Step 6: Commit
git add package.json pnpm-lock.yaml lib/i18n/config.ts lib/__tests__/i18n-config.test.ts
git commit -m "feat(i18n): add react-i18next deps and core locale config"
Task 2: Server locale resolution¶
Files:
Create:
lib/i18n/resolve.tsTest:
lib/__tests__/i18n-resolve.test.ts
Interfaces:
Consumes:
Locale,DEFAULT_LOCALE,isLocale,LOCALESfrom@/lib/i18n/config.Produces:
LOCALE_COOKIE = "rmh-lang"resolveLocale(input: { cookie?: string | null; acceptLanguage?: string | null }): Locale— precedence: validrmh-langcookie value → first supported match inAccept-Language→DEFAULT_LOCALE.parseLocaleCookie(cookieHeader: string | null | undefined): string | null— extracts thermh-langvalue from a rawCookie:header.
Step 1: Write the failing test
Create lib/__tests__/i18n-resolve.test.ts:
import { describe, it, expect } from "vitest";
import { resolveLocale, parseLocaleCookie } from "@/lib/i18n/resolve";
describe("resolveLocale", () => {
it("prefers a valid cookie", () => {
expect(resolveLocale({ cookie: "ar", acceptLanguage: "en-US,en" })).toBe("ar");
});
it("ignores an invalid cookie and falls back to Accept-Language", () => {
expect(resolveLocale({ cookie: "fr", acceptLanguage: "zh-CN,zh;q=0.9,en;q=0.8" })).toBe("zh");
});
it("matches Accept-Language by base language", () => {
expect(resolveLocale({ cookie: null, acceptLanguage: "ar-EG,ar;q=0.9" })).toBe("ar");
});
it("defaults to en when nothing matches", () => {
expect(resolveLocale({ cookie: null, acceptLanguage: "de-DE,de" })).toBe("en");
});
it("defaults to en when no signal at all", () => {
expect(resolveLocale({})).toBe("en");
});
});
describe("parseLocaleCookie", () => {
it("extracts rmh-lang from a cookie header", () => {
expect(parseLocaleCookie("foo=1; rmh-lang=zh; bar=2")).toBe("zh");
});
it("returns null when absent", () => {
expect(parseLocaleCookie("foo=1")).toBe(null);
expect(parseLocaleCookie(null)).toBe(null);
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/i18n-resolve.test.ts
Expected: FAIL — cannot resolve @/lib/i18n/resolve.
Step 3: Write the implementation
Create lib/i18n/resolve.ts:
import { DEFAULT_LOCALE, isLocale, LOCALES, type Locale } from "@/lib/i18n/config";
export const LOCALE_COOKIE = "rmh-lang";
export function parseLocaleCookie(cookieHeader: string | null | undefined): string | null {
if (!cookieHeader) return null;
for (const part of cookieHeader.split(";")) {
const [name, ...rest] = part.trim().split("=");
if (name === LOCALE_COOKIE) return decodeURIComponent(rest.join("="));
}
return null;
}
function matchAcceptLanguage(header: string | null | undefined): Locale | null {
if (!header) return null;
const tags = header
.split(",")
.map((p) => p.split(";")[0].trim().toLowerCase())
.filter(Boolean);
for (const tag of tags) {
const base = tag.split("-")[0];
if (isLocale(base)) return base;
const exact = (LOCALES as readonly string[]).find((l) => l === tag);
if (exact) return exact as Locale;
}
return null;
}
export function resolveLocale(input: {
cookie?: string | null;
acceptLanguage?: string | null;
}): Locale {
if (isLocale(input.cookie)) return input.cookie;
return matchAcceptLanguage(input.acceptLanguage) ?? DEFAULT_LOCALE;
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/i18n-resolve.test.ts
Expected: PASS (7 tests).
Step 5: Commit
git add lib/i18n/resolve.ts lib/__tests__/i18n-resolve.test.ts
git commit -m "feat(i18n): add cookie/Accept-Language locale resolution"
Task 3: Seed message catalogs + parser config + extract script¶
Files:
Create:
locales/en/common.json,locales/en/nav.jsonCreate:
locales/zh/common.json,locales/zh/nav.json,locales/ar/common.json,locales/ar/nav.jsonCreate:
i18next-parser.config.jsModify:
package.json(scripts)
Interfaces:
Produces: catalog files keyed by namespace; English values are real copy,
zh/arstart as copies of English (to be overwritten by Task 5’s translate script). Keys used by later tasks:nav:home,nav:explore,nav:settings,nav:language,common:skipToContent,common:languageEnglish,common:languageChinese,common:languageArabic.Step 1: Create the English catalogs
locales/en/common.json:
{
"skipToContent": "Skip to content",
"languageEnglish": "English",
"languageChinese": "Chinese",
"languageArabic": "Arabic"
}
locales/en/nav.json:
{
"home": "Home",
"explore": "Explore",
"settings": "Settings",
"language": "Language"
}
Step 2: Seed zh/ar catalogs as English copies
Create locales/zh/common.json, locales/ar/common.json with the same content as locales/en/common.json, and locales/zh/nav.json, locales/ar/nav.json with the same content as locales/en/nav.json. (Task 5 overwrites these with translations; copying guarantees valid renders meanwhile.)
Step 3: Create the parser config
i18next-parser.config.js:
/** Extracts t("...") keys from the app into locales/<lng>/<ns>.json. */
export default {
locales: ["en", "zh", "ar"],
defaultNamespace: "common",
namespaceSeparator: ":",
keySeparator: false,
input: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
output: "locales/$LOCALE/$NAMESPACE.json",
sort: true,
keepRemoved: false,
createOldCatalogs: false,
// Do not overwrite existing translated values with the key/default.
resetDefaultValueLocale: "en",
};
Step 4: Add npm scripts
In package.json scripts, add:
"i18n:extract": "i18next 'app/**/*.{ts,tsx}' 'components/**/*.{ts,tsx}'",
"i18n:translate": "pnpm exec tsx scripts/translate-locales.ts"
Step 5: Verify extraction runs without clobbering
Run: pnpm run i18n:extract
Expected: command exits 0; locales/en/*.json still contains the seeded keys (no keys deleted). It is fine if it reports parsed files; there are no t() calls yet beyond what later tasks add.
Step 6: Commit
git add locales i18next-parser.config.js package.json
git commit -m "feat(i18n): seed catalogs, parser config, and i18n scripts"
Task 4: Build-time translation script¶
Files:
Create:
scripts/translate-locales.tsCreate:
lib/i18n/diff.tsTest:
lib/__tests__/i18n-diff.test.ts
Interfaces:
Consumes:
translateText(text: string, target: string): Promise<string>from@/lib/ai/text.server;LOCALES,NAMESPACES,TRANSLATE_TARGETSfrom@/lib/i18n/config.Produces (in
lib/i18n/diff.ts):type Catalog = Record<string, string>keysToTranslate(args: { source: Catalog; sources: Catalog; target: Catalog }): string[]— returns keys where the target value is missing OR the recorded English source (sources[key]) differs from the current Englishsource[key]. This is the pure logic the script uses; it lets humans hand-edit a translated value and keep it unless the English changes.
Step 1: Write the failing test
Create lib/__tests__/i18n-diff.test.ts:
import { describe, it, expect } from "vitest";
import { keysToTranslate } from "@/lib/i18n/diff";
describe("keysToTranslate", () => {
it("includes keys missing from target", () => {
const out = keysToTranslate({ source: { a: "A", b: "B" }, sources: {}, target: { a: "X" } });
expect(out).toEqual(["b"]);
});
it("includes keys whose English source changed", () => {
const out = keysToTranslate({
source: { a: "A2" },
sources: { a: "A1" },
target: { a: "translated" },
});
expect(out).toEqual(["a"]);
});
it("skips keys already translated from the same English source", () => {
const out = keysToTranslate({
source: { a: "A" },
sources: { a: "A" },
target: { a: "translated" },
});
expect(out).toEqual([]);
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/i18n-diff.test.ts
Expected: FAIL — cannot resolve @/lib/i18n/diff.
Step 3: Implement the diff helper
Create lib/i18n/diff.ts:
export type Catalog = Record<string, string>;
/**
* Keys needing (re)translation: missing in target, or whose recorded English
* source no longer matches the current English source. Human-edited target
* values survive unless their English changes.
*/
export function keysToTranslate(args: {
source: Catalog;
sources: Catalog;
target: Catalog;
}): string[] {
const { source, sources, target } = args;
return Object.keys(source).filter(
(key) => !(key in target) || sources[key] !== source[key],
);
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/i18n-diff.test.ts
Expected: PASS (3 tests).
Step 5: Write the translation script
Create scripts/translate-locales.ts:
/**
* Generate zh/ar catalogs from the English source of truth.
* Idempotent: only (re)translates missing or English-changed keys, and records
* the English source it translated from in locales/<lng>/.sources.<ns>.json so
* human edits survive. Requires the AI provider env vars used by translateText.
*
* Usage: pnpm run i18n:translate
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { translateText } from "@/lib/ai/text.server";
import { LOCALES, NAMESPACES, TRANSLATE_TARGETS } from "@/lib/i18n/config";
import { keysToTranslate, type Catalog } from "@/lib/i18n/diff";
const ROOT = join(process.cwd(), "locales");
function read(path: string): Catalog {
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
}
function write(path: string, data: Catalog) {
writeFileSync(path, JSON.stringify(sortKeys(data), null, 2) + "\n");
}
function sortKeys(data: Catalog): Catalog {
return Object.fromEntries(Object.keys(data).sort().map((k) => [k, data[k]]));
}
async function run() {
for (const ns of NAMESPACES) {
const source = read(join(ROOT, "en", `${ns}.json`));
for (const locale of LOCALES) {
if (locale === "en") continue;
const target = read(join(ROOT, locale, `${ns}.json`));
const sourcesPath = join(ROOT, locale, `.sources.${ns}.json`);
const sources = read(sourcesPath);
const todo = keysToTranslate({ source, sources, target });
if (todo.length === 0) {
console.log(`[i18n] ${locale}/${ns}: up to date`);
continue;
}
console.log(`[i18n] ${locale}/${ns}: translating ${todo.length} key(s)`);
for (const key of todo) {
try {
target[key] = await translateText(source[key], TRANSLATE_TARGETS[locale as "zh" | "ar"]);
sources[key] = source[key];
} catch (err) {
console.warn(`[i18n] skip "${key}": ${(err as Error).message}`);
}
}
write(join(ROOT, locale, `${ns}.json`), target);
write(sourcesPath, sources);
}
}
}
run().then(() => console.log("[i18n] done")).catch((e) => {
console.error(e);
process.exit(1);
});
Step 6: Generate the real translations
Run: pnpm run i18n:translate
Expected: logs translating keys for zh and ar; locales/zh/*.json and locales/ar/*.json now contain non-English text; .sources.*.json files written. (Requires AI env vars; if unavailable in this environment, note it and leave the seeded English copies — the integrity test in Task 9 still passes.)
Step 7: Commit
git add lib/i18n/diff.ts lib/__tests__/i18n-diff.test.ts scripts/translate-locales.ts locales
git commit -m "feat(i18n): add build-time AI translation script and generate zh/ar"
Task 5: Client/server i18n instances + React provider¶
Files:
Create:
lib/i18n/resources.tsCreate:
lib/i18n/instances.tsCreate:
lib/i18n/dom.tsCreate:
components/i18n/AppI18nProvider.tsxTest:
lib/__tests__/i18n-dom.test.ts
Interfaces:
Consumes:
buildInitOptions,Locale,dirForfrom config; catalogs fromlocales/.Produces:
RESOURCES(inresources.ts) —{ en: { common, nav }, zh: {...}, ar: {...} }built from static JSON imports.getServerI18n(locale: Locale)— returns a new initialized instance per call (SSR isolation).clientI18n+ensureClientLocale(locale: Locale)(ininstances.ts) — singleton, initialized once;ensureClientLocalecallschangeLanguageif needed.applyHtmlLangDir(locale: Locale, el: { lang: string; setAttribute(n: string, v: string): void }): void(indom.ts) — setsel.langanddirattribute. Pure/testable with a fake element.AppI18nProvider({ locale, children })— wraps children in<I18nextProvider>using the server instance during SSR and the client singleton in the browser.
Step 1: Write the failing test
Create lib/__tests__/i18n-dom.test.ts:
import { describe, it, expect } from "vitest";
import { applyHtmlLangDir } from "@/lib/i18n/dom";
function fakeEl() {
const attrs: Record<string, string> = {};
return {
lang: "",
setAttribute(n: string, v: string) { attrs[n] = v; },
attrs,
};
}
describe("applyHtmlLangDir", () => {
it("sets lang and rtl dir for Arabic", () => {
const el = fakeEl();
applyHtmlLangDir("ar", el);
expect(el.lang).toBe("ar");
expect(el.attrs.dir).toBe("rtl");
});
it("sets ltr dir for Chinese", () => {
const el = fakeEl();
applyHtmlLangDir("zh", el);
expect(el.lang).toBe("zh");
expect(el.attrs.dir).toBe("ltr");
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/i18n-dom.test.ts
Expected: FAIL — cannot resolve @/lib/i18n/dom.
Step 3: Implement
dom.ts
Create lib/i18n/dom.ts:
import { dirFor, type Locale } from "@/lib/i18n/config";
export function applyHtmlLangDir(
locale: Locale,
el: { lang: string; setAttribute(name: string, value: string): void },
): void {
el.lang = locale;
el.setAttribute("dir", dirFor(locale));
}
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/i18n-dom.test.ts
Expected: PASS (2 tests).
Step 5: Implement resources + instances + provider
Create lib/i18n/resources.ts:
import enCommon from "@/locales/en/common.json";
import enNav from "@/locales/en/nav.json";
import zhCommon from "@/locales/zh/common.json";
import zhNav from "@/locales/zh/nav.json";
import arCommon from "@/locales/ar/common.json";
import arNav from "@/locales/ar/nav.json";
export const RESOURCES = {
en: { common: enCommon, nav: enNav },
zh: { common: zhCommon, nav: zhNav },
ar: { common: arCommon, nav: arNav },
} as const;
Create lib/i18n/instances.ts:
import i18next, { type i18n } from "i18next";
import { initReactI18next } from "react-i18next";
import { buildInitOptions, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/config";
import { RESOURCES } from "@/lib/i18n/resources";
/** Fresh instance per server request — never share mutable lng across requests. */
export function getServerI18n(locale: Locale): i18n {
const instance = i18next.createInstance();
instance.use(initReactI18next).init(buildInitOptions(locale, RESOURCES));
return instance;
}
/** Singleton client instance, initialized once. */
export const clientI18n: i18n = i18next.createInstance();
let clientReady = false;
export function ensureClientLocale(locale: Locale): i18n {
if (!clientReady) {
clientI18n.use(initReactI18next).init(buildInitOptions(locale, RESOURCES));
clientReady = true;
} else if (clientI18n.language !== locale) {
void clientI18n.changeLanguage(locale);
}
return clientI18n;
}
export { DEFAULT_LOCALE };
Create components/i18n/AppI18nProvider.tsx:
import { type ReactNode, useMemo } from "react";
import { I18nextProvider } from "react-i18next";
import type { Locale } from "@/lib/i18n/config";
import { getServerI18n, ensureClientLocale } from "@/lib/i18n/instances";
/**
* Provides an i18next instance to the tree. On the server a fresh per-request
* instance is created (useMemo runs once per render = per request); on the
* client the singleton is reused and switched to `locale`.
*/
export function AppI18nProvider({ locale, children }: { locale: Locale; children: ReactNode }) {
const instance = useMemo(
() => (typeof window === "undefined" ? getServerI18n(locale) : ensureClientLocale(locale)),
[locale],
);
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
}
Step 6: Verify it type-checks and tests still pass
Run: pnpm exec vitest run lib/__tests__/i18n-dom.test.ts && pnpm run lint
Expected: tests PASS; lint reports no errors for the new files. (JSON imports resolve via the @ alias; if lint flags an unused export, remove it.)
Step 7: Commit
git add lib/i18n/resources.ts lib/i18n/instances.ts lib/i18n/dom.ts components/i18n/AppI18nProvider.tsx lib/__tests__/i18n-dom.test.ts
git commit -m "feat(i18n): add server/client instances and React provider"
Task 6: Wire locale through the root loader, document, and Providers¶
Files:
Modify:
app/routes/__root.tsxModify:
components/Providers.tsx
Interfaces:
Consumes:
resolveLocale,parseLocaleCookiefrom@/lib/i18n/resolve;dirFor,type Localefrom config;AppI18nProvider.Produces: the root loader returns
{ user, locale };RootDocumentrenders<html lang dir>;Providersaccepts alocaleprop and wraps its tree inAppI18nProvider.Step 1: Resolve locale in the root loader
In app/routes/__root.tsx, change getInitialUser so the loader also returns the locale. Add a server fn (or extend the existing one) — replace the loader wiring:
Replace loader: () => getInitialUser(), with a loader that resolves both. Add near getInitialUser:
import { resolveLocale, parseLocaleCookie } from "@/lib/i18n/resolve";
const getInitialLocale = createServerFn({ method: "GET" }).handler(async () => {
const request = getRequest();
const cookie = parseLocaleCookie(request.headers.get("cookie"));
return resolveLocale({ cookie, acceptLanguage: request.headers.get("accept-language") });
});
And change the Route loader:
loader: async () => ({
user: await getInitialUser(),
locale: await getInitialLocale(),
}),
Step 2: Apply lang/dir on
<html>and add the inline guard
In __root.tsx, add an inline guard script constant next to themeScript:
const localeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )rmh-lang=([^;]+)/);var l=m?decodeURIComponent(m[1]):"en";if(["en","zh","ar"].indexOf(l)<0)l="en";document.documentElement.lang=l;document.documentElement.setAttribute("dir",l==="ar"?"rtl":"ltr")}catch(e){}})()`;
Add { children: localeScript } to the non-Discord scripts array in head.
Update RootDocument to take the locale from loader data and the new RootComponent wiring. Change RootDocument’s signature and <html>:
import { dirFor, type Locale } from "@/lib/i18n/config";
function RootDocument({ children }: { children: ReactNode }) {
const data = Route.useLoaderData();
const locale = (data?.locale ?? "en") as Locale;
return (
<html lang={locale} dir={dirFor(locale)} suppressHydrationWarning>
{/* head + body unchanged */}
(Keep the existing <head>/<body> contents and the bodyThemeScript.)
Robustness note:
RootDocumentis registered asshellComponent. IfRoute.useLoaderData()is not available in the shell in this TanStack Start version (verify in Step 5), it’s acceptable to leave<html lang="en" dir="ltr">as the server default and rely on thelocaleScriptguard (which runs in<head>before the body paints) to correctlang/dirpre-hydration. SSR-translated text does not depend on this — it comes fromAppI18nProviderinRootComponent, which always has loader data.
Step 3: Pass locale into Providers
In RootComponent, read the loader data shape (now { user, locale }):
const { user: initialUser, locale } = Route.useLoaderData();
// ...
return (
<Providers initialUser={initialUser} locale={(locale ?? "en") as Locale}>
<Outlet />
</Providers>
);
Step 4: Wrap Providers tree in AppI18nProvider
In components/Providers.tsx, import and use the provider. Add to the Providers props type a locale: Locale field, import:
import { AppI18nProvider } from "@/components/i18n/AppI18nProvider";
import type { Locale } from "@/lib/i18n/config";
Wrap the outermost returned JSX (inside QueryClientProvider is fine, but AppI18nProvider should enclose the app UI) so all consumers can call useTranslation. Place <AppI18nProvider locale={locale}> immediately inside the top-level provider and close it at the end.
Step 5: Verify build/dev renders with correct lang/dir
Run: pnpm run lint
Expected: no errors.
Manual check: start dev (pnpm dev), load the site. With no cookie, <html> shows lang matching your browser and dir="ltr". Setting document.cookie="rmh-lang=ar" and reloading yields <html lang="ar" dir="rtl"> with no English flash on first paint.
Step 6: Commit
git add app/routes/__root.tsx components/Providers.tsx
git commit -m "feat(i18n): resolve locale server-side and provide it to the app"
Task 7: Locale store + language switcher UI¶
Files:
Create:
stores/localeStore.tsCreate:
components/site/LanguageSwitcher.tsxModify:
components/feed/LeftSidebar.tsxModify:
components/feed/MobileNav.tsxTest:
lib/__tests__/locale-store.test.ts
Interfaces:
Consumes:
Locale,LOCALES,LOCALE_LABELS,dirForfrom config;LOCALE_COOKIEfrom resolve;ensureClientLocalefrom instances;applyHtmlLangDirfrom dom.Produces:
writeLocaleCookie(locale: Locale, doc?: { cookie: string }): void— writesrmh-lang=<locale>; path=/; max-age=31536000; samesite=lax. Accepts an injectable doc-like object for testing.useLocaleStore— Zustand store{ locale: Locale; setLocale(locale: Locale): void }.setLocalewrites the cookie, callsensureClientLocale, appliesapplyHtmlLangDirtodocument.documentElement, and updates state. All DOM/i18n side effects guarded bytypeof window !== "undefined".LanguageSwitcher— globe button opening a 3-option menu usingLOCALE_LABELS.
Step 1: Write the failing test
Create lib/__tests__/locale-store.test.ts:
import { describe, it, expect } from "vitest";
import { writeLocaleCookie } from "@/stores/localeStore";
describe("writeLocaleCookie", () => {
it("writes the rmh-lang cookie for the given locale", () => {
const doc = { cookie: "" };
writeLocaleCookie("ar", doc);
expect(doc.cookie).toContain("rmh-lang=ar");
expect(doc.cookie).toContain("path=/");
});
});
Step 2: Run test to verify it fails
Run: pnpm exec vitest run lib/__tests__/locale-store.test.ts
Expected: FAIL — cannot resolve @/stores/localeStore.
Step 3: Implement the store
Create stores/localeStore.ts:
import { create } from "zustand";
import { DEFAULT_LOCALE, type Locale } from "@/lib/i18n/config";
import { LOCALE_COOKIE } from "@/lib/i18n/resolve";
import { ensureClientLocale } from "@/lib/i18n/instances";
import { applyHtmlLangDir } from "@/lib/i18n/dom";
const ONE_YEAR = 60 * 60 * 24 * 365;
export function writeLocaleCookie(locale: Locale, doc: { cookie: string } = document) {
doc.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${ONE_YEAR}; samesite=lax`;
}
interface LocaleStore {
locale: Locale;
setLocale: (locale: Locale) => void;
}
export const useLocaleStore = create<LocaleStore>((set) => ({
locale: DEFAULT_LOCALE,
setLocale: (locale) => {
if (typeof window !== "undefined") {
writeLocaleCookie(locale);
ensureClientLocale(locale);
applyHtmlLangDir(locale, document.documentElement);
}
set({ locale });
},
}));
Step 4: Run test to verify it passes
Run: pnpm exec vitest run lib/__tests__/locale-store.test.ts
Expected: PASS (1 test).
Step 5: Build the LanguageSwitcher
Create components/site/LanguageSwitcher.tsx:
import { useTranslation } from "react-i18next";
import { Globe } from "lucide-react";
import { LOCALES, LOCALE_LABELS, type Locale } from "@/lib/i18n/config";
import { useLocaleStore } from "@/stores/localeStore";
/** Compact language picker (globe + 3 options). */
export function LanguageSwitcher() {
const { t } = useTranslation("nav");
const locale = useLocaleStore((s) => s.locale);
const setLocale = useLocaleStore((s) => s.setLocale);
return (
<label className="flex items-center gap-2 text-sm" aria-label={t("language")}>
<Globe className="h-4 w-4 shrink-0" aria-hidden />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent outline-none cursor-pointer"
>
{LOCALES.map((l) => (
<option key={l} value={l}>{LOCALE_LABELS[l]}</option>
))}
</select>
</label>
);
}
(If lucide-react is not the icon set in use, match whatever LeftSidebar.tsx already imports for icons.)
Step 6: Mount it in the sidebar and mobile nav
In components/feed/LeftSidebar.tsx, import LanguageSwitcher and render it near the bottom of the nav (e.g. beside or below the existing theme/style control). In components/feed/MobileNav.tsx, import and render it in the nav’s overflow/menu area. Read each file first and follow its existing layout/spacing classes; insert <LanguageSwitcher /> where the theme switcher or settings link lives.
Step 7: Initialize the store from the resolved locale
So the switcher reflects the server-resolved locale on load, set the store’s initial value after hydration. In components/Providers.tsx, inside the existing client effect area, add an effect:
import { useLocaleStore } from "@/stores/localeStore";
// inside Providers component body:
useEffect(() => {
useLocaleStore.setState({ locale });
}, [locale]);
(This syncs the store to the SSR-resolved locale without re-writing the cookie.)
Step 8: Verify
Run: pnpm exec vitest run lib/__tests__/locale-store.test.ts && pnpm run lint
Expected: test PASS, lint clean.
Manual: in dev, the switcher appears in the sidebar and mobile nav; choosing 中文/العربية changes the visible nav strings live and (for Arabic) flips <html dir="rtl">; reloading preserves the choice via cookie.
Step 9: Commit
git add stores/localeStore.ts components/site/LanguageSwitcher.tsx components/feed/LeftSidebar.tsx components/feed/MobileNav.tsx components/Providers.tsx lib/__tests__/locale-store.test.ts
git commit -m "feat(i18n): add locale store and language switcher in nav"
Task 8: Language control on the settings page¶
Files:
Modify:
app/routes/strategies/profile/settings.tsx
Interfaces:
Consumes:
useLocaleStore,LOCALES,LOCALE_LABELS.Step 1: Read the settings page
Open app/routes/strategies/profile/settings.tsx and locate the existing timezone <select> block to mirror its label/markup conventions.
Step 2: Add the language select
Beside the timezone control, add a language field mirroring the timezone block’s structure:
import { useLocaleStore } from "@/stores/localeStore";
import { LOCALES, LOCALE_LABELS, type Locale } from "@/lib/i18n/config";
import { useTranslation } from "react-i18next";
// ...
const { t } = useTranslation("nav");
const locale = useLocaleStore((s) => s.locale);
const setLocale = useLocaleStore((s) => s.setLocale);
// in JSX, mirroring the timezone field's wrapper/label classes:
<label>
<span>{t("language")}</span>
<select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}>
{LOCALES.map((l) => <option key={l} value={l}>{LOCALE_LABELS[l]}</option>)}
</select>
</label>
Use the same wrapper/label classNames the timezone control uses so it visually matches.
Step 3: Verify
Run: pnpm run lint
Expected: clean.
Manual: the settings page shows a Language select; changing it updates the site language and stays in sync with the nav switcher (shared store).
Step 4: Commit
git add app/routes/strategies/profile/settings.tsx
git commit -m "feat(i18n): add language selector to profile settings"
Task 9: Catalog integrity test¶
Files:
Test:
lib/__tests__/i18n-catalogs.test.ts
Interfaces:
Consumes:
LOCALES,NAMESPACESfrom config; catalog JSON files.Step 1: Write the test
Create lib/__tests__/i18n-catalogs.test.ts:
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { LOCALES, NAMESPACES } from "@/lib/i18n/config";
function load(locale: string, ns: string): Record<string, string> {
return JSON.parse(readFileSync(join(process.cwd(), "locales", locale, `${ns}.json`), "utf8"));
}
describe("catalog integrity", () => {
for (const ns of NAMESPACES) {
const en = Object.keys(load("en", ns)).sort();
for (const locale of LOCALES) {
if (locale === "en") continue;
it(`${locale}/${ns} has exactly the English key set`, () => {
expect(Object.keys(load(locale, ns)).sort()).toEqual(en);
});
}
}
});
Step 2: Run the test
Run: pnpm exec vitest run lib/__tests__/i18n-catalogs.test.ts
Expected: PASS — every zh/ar namespace has the same keys as en. (If it fails, run pnpm run i18n:extract && pnpm run i18n:translate to resync, then re-run.)
Step 3: Commit
git add lib/__tests__/i18n-catalogs.test.ts
git commit -m "test(i18n): assert zh/ar catalogs match the English key set"
Task 10: RTL base styles + chrome audit¶
Files:
Modify:
app/globals.cssModify:
app/routes/_site.tsx(extract the “Skip to content” string)
Interfaces:
Consumes:
useTranslationfrom react-i18next.Step 1: Extract the first chrome string as a pattern example
In app/routes/_site.tsx, replace the hardcoded Skip to content text with a translated call. At the top of the component add const { t } = useTranslation("common"); (import useTranslation from react-i18next) and replace the literal with {t("skipToContent")}. The key already exists in locales/en/common.json from Task 3.
Step 2: Add base RTL overrides
Append an RTL section to app/globals.css:
/* ----- RTL support (Arabic) ----- */
[dir="rtl"] body { text-align: right; }
/* Flip directional chevron/arrow icons that imply forward/back. */
[dir="rtl"] .rtl-flip { transform: scaleX(-1); }
For the left sidebar and mobile nav, prefer Tailwind logical utilities when editing them (replace pl-*→ps-*, pr-*→pe-*, left-*→start-*, right-*→end-*, ml-*→ms-*, mr-*→me-*, text-left→text-start). Add the rtl-flip class to any back/forward chevron icons in LeftSidebar.tsx/MobileNav.tsx.
Step 3: Verify RTL chrome
Run: pnpm run lint
Expected: clean.
Manual: switch to العربية. The sidebar/nav mirror to the right side, text is right-aligned, directional chevrons point the correct way, and no chrome element is clipped or overlapping. Switch back to English/中文 and confirm the layout is unchanged (ltr).
Step 4: Re-extract and resync catalogs
Run: pnpm run i18n:extract && pnpm run i18n:translate
Expected: any newly added t() keys appear in en and get translated into zh/ar. Re-run pnpm exec vitest run lib/__tests__/i18n-catalogs.test.ts → PASS.
Step 5: Commit
git add app/globals.css app/routes/_site.tsx locales
git commit -m "feat(i18n): add RTL base styles and extract first chrome string"
Task 11: Document the repeatable extraction pattern (for follow-up PRs)¶
Files:
Create:
docs/superpowers/i18n-extraction-guide.md
This task captures the repeatable loop so each subsequent route-group PR (feed, games, library, news, profile, …) is mechanical. No code; it documents the established pattern from Tasks 3–10.
Step 1: Write the guide
Create docs/superpowers/i18n-extraction-guide.md documenting the per-route-group loop:
Pick one route group (e.g.
components/feed/**+ its routes).For each component: add
const { t } = useTranslation("<namespace>"), replace hardcoded JSX strings witht("key"), using interpolation (t("greeting", { name })) for dynamic text. Add the namespace toNAMESPACESinlib/i18n/config.tsand toRESOURCES/imports inlib/i18n/resources.tswhen introducing a new one.Run
pnpm run i18n:extractto populatelocales/en/<ns>.json.Fill real English copy where the parser used the key as a placeholder.
Run
pnpm run i18n:translateto generatezh/ar.Audit RTL for the group (logical Tailwind utilities +
[dir=rtl]overrides +rtl-flipon directional icons).Run
pnpm exec vitest run lib/__tests__/i18n-catalogs.test.ts(and lint) → must pass.Commit; open a PR scoped to that group.
Include the note: a new namespace must be registered in both config.ts (NAMESPACES) and resources.ts (import + RESOURCES entry) or it will not load.
Step 2: Commit
git add docs/superpowers/i18n-extraction-guide.md
git commit -m "docs(i18n): document the per-route-group extraction loop"
Definition of Done (this plan / PR 1)¶
All Vitest suites under
lib/__tests__/i18n-*.test.tsandlib/__tests__/locale-store.test.tspass.The site can be switched between English, 中文, and العربية from both the nav switcher and the settings page.
First paint renders in the cookie/Accept-Language–resolved language with no English flash;
<html lang/dir>is correct server-side.Arabic renders RTL across the nav/chrome without layout breakage.
zh/arcatalogs are committed and key-complete withen.The extraction guide exists so remaining route groups can be localized incrementally in follow-up PRs.