---
id: w5/m3/t002
title: SSR-safe language detection + persistence
worker: worker5
status: done
estimate: 75m
depends_on: [w5/m3/t001]
---

## Objective

Make the language decision happen on the server and survive hydration with **no
React mismatch**. The server picks a language (URL `?lang`/`?locale` → cookie →
`Accept-Language` → default), renders in it, and hands the client the same
language to hydrate with. Client changes persist to both a cookie (for the next
SSR request) and localStorage.

## Context

- Reference chain + rationale (read-only): `~/projects/web-beancount/beancount-dashboard/docs/i18n.md`
  — detection priority `URL → cookie(i18nextLng) → Accept-Language → en`; dual
  storage cookie(1y)+localStorage; `LanguageDetector` deliberately disabled to
  avoid reading localStorage before hydration (React #419 mismatch); language
  injected via `window.__I18N_LANGUAGE__` and applied only after hydration.
- This repo differs: TanStack **Start** SSR. Detect on the server via a server
  fn / request headers rather than a custom `server.ts`. Existing server-side
  patterns to mirror: `src/common/server-fn/session.ts`,
  `src/common/lib/user-agent/server.ts`, `src/common/lib/search-params/server.ts`.
- Initialize the client i18next instance to the server-chosen language (via
  router context or an injected global) so first client render === SSR render.
  Apply any newer localStorage/URL override only after hydration settles.

## Steps

1. Add a server-side detector (`src/i18n/detect-language/server.ts`) reading the
   incoming request's cookie + `Accept-Language` + URL params; return a
   `SupportedLanguage`.
2. Thread the detected language into the root (router context / loader) and use
   it as the i18next init language on both sides.
3. Add `persistLanguage(lang)` (`src/i18n/detect-language/client.ts` or
   `src/i18n/utils.ts`) writing cookie (`i18nextLng`, path=/, ~1y) + localStorage.
4. Ensure the client hydrates in the SSR language, then applies a post-hydration
   override (URL param or stored preference) without a mismatch.
5. Verify: set cookie `i18nextLng=zh` (once zh exists) or force via `?lang`,
   reload, confirm no hydration warning in console and correct initial language.

## Files

- `dashboard/src/i18n/detect-language/{server.ts,client.ts}`
- `dashboard/src/i18n/{init.ts,utils.ts}` (init language wiring)
- `dashboard/src/routes/__root.tsx` (loader/context threading)
- `dashboard/src/common/providers/root-provider.tsx` (init language)

## Acceptance criteria

- [ ] With a `i18nextLng` cookie set, SSR HTML renders in that language and the
      client hydrates with it — **no** hydration-mismatch console error.
- [ ] `?lang=xx` overrides and is persisted to cookie + localStorage.
- [ ] Detection falls back correctly: URL → cookie → Accept-Language → default.
- [ ] `yarn typecheck` / `yarn lint` pass.

## Out of scope

- The switcher UI that calls `persistLanguage` (t003).
- Translating the actual strings (t004).
- Ory locale wiring (t005).
</content>
