Language in the browser

If you scaffolded a front end, this is already wired. Adding a locale to your config is the whole change.

The rest of this page is what it is doing.

The client’s own negotiation chain

i18n overview is the capability, translating in the browser is the walkthrough, and the hooks come from the ui-react components.

Query → account → storage → what the server said → default.

Different from the server’s, because half of each side’s links do not exist on the other: storage is absent from a Worker, and the browser’s language API inside the Workers runtime is a constant.

The account outranks storage — a reader who picked Spanish on their phone should not read French on their laptop because that laptop holds an older choice.

What the server said sits below both and above the default, so a first render agrees with the page the server produced.

Keep both document attributes in step

The language, and the direction.

Getting direction wrong is worse than getting language wrong, because a right-to-left reader gets a layout that is not merely untranslated but unusable.

Catalogs are fetched one locale at a time

Screens are negotiated in the browser and fetched as their own chunk, per locale — so a reader downloads the language they are reading and not the others.

That is deliberately different from mail. The send Worker carries every language in its bundle because it has no request to negotiate from; a browser has one, so it fetches.

The virtual module

What the client knows about a capability comes from a virtual module resolved in memory rather than a generated file on disk.

Opt-in by construction: a capability’s client-safe projection is declared, and a new config field never reaches a browser bundle unless it is explicitly projected. A capability with no browser surface projects nothing, and the front end reads a disabled flag.

So there is no artifact to regenerate, nothing to gitignore, and no moment where the client and the backend disagree because somebody forgot to re-run something.

A screen’s English is baked into the file

Each screen carries its own catalog inside itself, so from the moment it is written those sentences are yours.

A later release adding a line to the kit’s sign-in screen changes the template in the package, never your copy.

And no release will ever add a locale file to your repository. The translations ship inside the i18n package and arrive as an upgrade — no locale directory to review, no merge to take.

Errors translate on the client, from the code

The server never translates an error message. It sends the English, the stable code, and structured parameters.

The client renders its own string for that code, falling back to the English when it has none — and the code is already the translation key, so there is no second identifier to keep in sync.

The one exception

The auth routes are answered by the underlying library in its own shape, before anything of Pithy’s sees the failure. No payload to key on, no parameters to interpolate.

Left alone, those were the last English sentences on an otherwise translated screen — met by the most ordinary mistake there is: mistyping a one-time code.

So the message is substituted server-side for a code there are words for, keeping the English alongside so the operator’s half survives. It reaches a caller the client seam cannot: a mobile app holding a bearer token gets the reader’s language with nothing extra shipped.

Letting somebody choose

Offer only locales you serve. A picker listing a language that falls back to English is a picker that lies.

When a signed-in reader chooses, write it through to their account rather than only to storage — that is what makes the choice follow them.

A plain query link is the cheapest switcher and works for signed-out readers, crawlers and shared links.

If you wrote the client yourself

Negotiate before first paint, or accept a flash of the wrong language.

Read what the server negotiated rather than starting over.

Set both document attributes, from the negotiated locale rather than the raw browser preference.

ESC