Private packages

@repo/i18n

Internationalization on Paraglide JS with per-user locales and localized email.

@repo/i18n localizes the whole product on Paraglide JS. Messages live in messages/{locale}/{web,marketing,mail,common}.json, split by surface and merged into one namespace at compile time (the plugin's pathPattern accepts an array for exactly this); the compiler emits tree-shakable typed functions, and components call them like any other import. No extraction step, no Babel, and the same call works in .tsx, .tsrx, and server code. The starter ships en, es, and fr.

Writing copy

Add the English text to the right surface file under messages/en/, translate it under es/ and fr/, and call the generated function:

import { m } from '@repo/i18n/messages';

function SignOutButton() {
  return <button aria-label={m.sign_out()}>{m.sign_out()}</button>;
}
  • Parameters are typed from the message: m.people_with_access_to({ organizationName }) stops compiling if the placeholder set drifts.
  • Plurals use selectors in the message file, picked per locale with Intl.PluralRules.
  • Outside a request scope (queues, Durable Objects, email), pass the locale per call: m.welcome_to_brand({ brandName }, { locale }). For a render that stays in one locale, bind it once with localized(locale) from @repo/i18n/localized and call t.welcome_to_brand({ brandName }) — the mail templates use this.
  • Sentences containing links are split into a text message plus a link-text message; for rich text inside one message, @inlang/paraglide-js-react offers ParaglideMessage with typed markup.

Missing translations compile to the English text, so partially translated locales degrade gracefully, and a unit test keeps every locale's merged key set identical (and each key in exactly one surface file, because the plugin merges them in pathPattern order and a later file would silently win). The compiled output is regenerated on install (prepare) and hot-reloaded in dev by paraglide() in the web app's Vite config.

Locale resolution

Paraglide's strategy chain (cookie, then preferredLanguage, then baseLocale) resolves the locale, and paraglideMiddleware pins it per request in AsyncLocalStorage, so concurrent requests on the Worker never leak locales into each other. <html lang> reads getLocale().

A signed-in user's stored user.locale sits on top: the Language card persists the choice through Better Auth updateUser and setLocale pins the cookie and reloads. Root's getSession re-pins the cookie on every load, and the server entry injects the stored preference when a signed-in request arrives without the cookie, so a new device renders in the account's language immediately.

The marketing site

The marketing pages are localized from the same catalog (messages/{locale}/marketing.json), and the footer carries a language selector. Because root resolves the session on marketing routes too, that re-pin applies here as well, which decides how the selector has to behave: MarketingFooter takes an onLocaleChange callback rather than calling setLocale itself. The app's _marketing layout writes the choice to the account first when the visitor is signed in, otherwise the stored preference would overwrite a cookie-only choice on the very next request. Anonymous visitors just get the cookie.

Blog posts and changelog entries stay in the language they were authored in: they are markdown in apps/web/content, not messages.

Localized email

@repo/mail accepts a locale per send; subjects and templates thread it into every message call:

await mailer.sendResetPassword({
  to: user.email,
  name: user.name,
  resetLink: url,
  locale: user.locale,
});

Auth flows, the welcome job, and the notification digest all pass the recipient's stored locale; invitees without accounts get the default.

Adding a locale

Add the tag to project.inlang/settings.json, create messages/{tag}/{web,marketing,mail,common}.json, and translate them; the key-parity test fails until their merged keys match en's. The compiler regenerates locales, the Locale type, and every message function. The language picker labels itself via Intl.DisplayNames.

Translating with AI

The message files are flat JSON keyed by message id, so nothing is locked in:

  • Languine and the Lingo.dev CLI both translate JSON directly with lockfiles for incremental runs (each needs its own account).
  • Fink, inlang's web editor, works on the project via project.inlang.
  • Translating values by hand (or by prompting your favorite model) works the same; keep placeholders and plural selectors intact.

On this page