Translate in the browser

You need: i18n composed, and a front end — scaffolded or your own.

The scaffolded front end already does this

Language in the browser is the client-side chain, where a locale comes from is the server’s, and errors is the one payload that stays English on purpose.

If you ran pithy ui add react, the provider is wired, the negotiation runs, and the document’s attributes are kept in step. Adding a locale to your config is the whole change.

The rest of this page is what it is doing, and what to do if you wrote the client yourself.

The browser has its own chain

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, for the same reason it does on the server: a reader who picked Spanish on their phone should not read French on their laptop because that laptop’s storage holds an older choice.

What the server said sits below both and above the default, because the server already negotiated from the request — so a first render agrees with the page the server produced.

Keep the document’s attributes in step

Two attributes, both from the negotiated locale: 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. Set both, and set them together.

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 how email works. The send Worker carries every language in its bundle, because it has no request to negotiate from; a browser has one, so it fetches.

Errors translate on the client, from the code

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

The client renders its own string for that code and those parameters, falling back to the English when it has none.

The code is already the translation key, so there is no second identifier to keep in sync — and a locale file for the kit’s errors is, exactly, a file that covers the error codes.

This is why a mobile app gets translated errors without shipping the i18n package at all: the payload carries everything a client needs to translate on its own.

The one exception

Better Auth owns the routes under the auth base path and answers them in its own shape, before anything of Pithy’s sees the failure. There is no payload for a client to key on and 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 auth capability substitutes the message server-side for a code it has words for, and keeps the English alongside — so the operator’s half survives the translation rather than being replaced by it. It also reaches a caller the client seam cannot: a mobile app holding a bearer token gets the reader’s language without shipping anything extra.

Letting somebody choose

Offer only locales you actually 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 to another device.

A plain ?lang= link is the cheapest switcher and it works for signed-out readers, crawlers and shared links.

If you wrote the client yourself

Three things to get right:

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

Read what the server negotiated rather than starting from scratch, so the first client render agrees with the server’s.

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

Check it worked

  • The first paint is in the right language, with no flash
  • A signed-in reader’s choice follows them to a second browser
  • Only the current locale’s catalog is downloaded
  • An error renders translated on the client, and the English is still in the payload
  • A right-to-left locale sets the direction attribute
ESC