ADR-0031 Internationalization
Company standard app i18n — Paraglide JS, compiled to typed message functions, with the locale in the URL and resolved per request on the server.
- Decision
- Paraglide JSBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”Apps must be able to ship in more than one language; Portuguese (pt-PT) and English are the usual pair.
- We render on the server and stream (ADR-0002): the locale must be known per request, and the server and the browser must agree, or hydration breaks
- One process serves many users (ADR-0012): a locale kept in a module-level variable leaks between requests
- TypeScript everywhere: a missing message or a missing parameter should fail the build, not show a key to a user
- Text is also produced outside React: emails (ADR-0029) and jobs (ADR-0028) have no request to read a locale from
What is open: the i18n library, where the locale comes from, and how messages are written.
Options considered
Section titled “Options considered”1. Paraglide JS
Section titled “1. Paraglide JS”A compiler: message files become typed, tree-shakable functions.
Pros
m.greeting({ name })is a function call — a missing message or parameter is a type error, and unused messages are not shipped- A Vite plugin, and a TanStack Start example in its own repository: server middleware plus TanStack Router’s
rewrite - The server middleware keeps the request’s locale in
AsyncLocalStorage, so concurrent requests do not mix - A message can be forced to a locale per call — what emails and jobs need
Cons
- Version 3.0 shipped on 2026-11-03; most examples online still show v2
- Its own message format, not ICU MessageFormat
- A smaller ecosystem than i18next
2. i18next with react-i18next
Section titled “2. i18next with react-i18next”Pros: the largest ecosystem; tooling and translators know it. Cons: messages are looked up by string key at runtime; typed keys need extra setup; a per-request instance on the server is ours to wire; whole namespaces ship to the browser unless we split them.
3. FormatJS (react-intl)
Section titled “3. FormatJS (react-intl)”Pros: ICU MessageFormat, a standard translators know; mature. Cons: string IDs; a runtime parser or an extra compile step; more boilerplate per message.
Decision
Section titled “Decision”Paraglide JS is the company standard for app internationalization.
Concretely:
- Package:
@inlang/paraglide-js3.x, pinned to an exact version (3.2.2 at time of writing) while the major is this new; upgrade deliberately - Vite plugin:
paraglideVitePlugin({ project: "./project.inlang", outdir: "./src/paraglide", strategy: ["url", "cookie", "preferredLanguage", "baseLocale"] }).src/paraglide/is generated output: never edited, and not one of the layer folders of ADR-0026 - Locales are per product, declared in
project.inlang/settings.json. Messages live inmessages/{locale}.json, one file per locale - The URL carries the locale. Route files stay canonical (
/about); TanStack Router’srewritemaps public URLs withdeLocalizeUrlandlocalizeUrl - Server:
src/server.tswraps the Start handler withparaglideMiddleware(request, () => handler(request)). Pass the original request — the middleware’s rewritten one causes a redirect loop when the router also rewrites - Document: the root route renders
<html lang={getLocale()} dir={getTextDirection()}> - Switching language uses
setLocale()with its default full-page navigation, so the URL, the server render, and the runtime stay in step - In code: import
mfrom the generatedsrc/paraglide/messages.jsand callm.some_message({ … }). No string keys, no string concatenation; plurals and other variants arematchentries in the message file - Outside a request (emails, jobs) pass the locale explicitly:
m.some_message({ … }, { locale }) - Dates, numbers, and currency are formatted with
IntlusinggetLocale()— not Paraglide’s job - Not used:
experimentalPerLocaleBuildand other experimental options
The deciding factors: messages that are type-checked and tree-shaken, a request-scoped locale on the server, a documented TanStack Start setup, and per-call locales for mail and jobs. We accept a major version that is days old and a smaller ecosystem.
Consequences
Section titled “Consequences”Easier:
- A missing translation key or parameter fails
bun run check, not production - Only the messages a page uses reach the browser
- Emails and jobs render in the recipient’s language with the same message files
Harder:
- Version 3 is new: expect fixes, and examples that no longer apply
- URL rules need care:
/api/*(ADR-0036) and the health endpoints must not be redirected to a locale prefix - Translators work in JSON files or inlang’s tools, not in an ICU-based platform
Given up: ICU MessageFormat and i18next’s ecosystem. Accepted — compile-time safety and smaller bundles matter more to us than tooling breadth.

