Skip to content

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

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.

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

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.

Pros: ICU MessageFormat, a standard translators know; mature. Cons: string IDs; a runtime parser or an extra compile step; more boilerplate per message.

Paraglide JS is the company standard for app internationalization.

Concretely:

  • Package: @inlang/paraglide-js 3.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 in messages/{locale}.json, one file per locale
  • The URL carries the locale. Route files stay canonical (/about); TanStack Router’s rewrite maps public URLs with deLocalizeUrl and localizeUrl
  • Server: src/server.ts wraps the Start handler with paraglideMiddleware(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 m from the generated src/paraglide/messages.js and call m.some_message({ … }). No string keys, no string concatenation; plurals and other variants are match entries in the message file
  • Outside a request (emails, jobs) pass the locale explicitly: m.some_message({ … }, { locale })
  • Dates, numbers, and currency are formatted with Intl using getLocale() — not Paraglide’s job
  • Not used: experimentalPerLocaleBuild and 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.

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.