Internationalization
Translate the app from one shared @repo/i18n message store consumed by next-intl, with locale-prefixed routing and an auto-generated translation pipeline.
@repo/i18n is the single message store; the Next.js app consumes it through next-intl. Strings live in packages/i18n/translations/{locale}/{scope}.json and the locale registry is the i18nConfig export from @repo/config. i18n is always on - you tune it by editing locales and JSON, never a feature flag.
Where translations live
Two files load per locale: the active scope plus shared.json layered beneath it. You write en only; the translation pipeline generates the rest.
packages/i18n/translations/
en/
web.json # frontend-facing copy (default locale - edit this)
shared.json # keys reused across surfaces
ro/
web.json # auto-generated from en/web.json
shared.json # auto-generated| Concept | Value | Notes |
|---|---|---|
| Locales | en, ro | i18nConfig.locales in packages/config/src/i18n.ts; en is the source of truth |
| Scopes | web | the only selectable scope; shared.json always merges beneath it |
| Default locale | en | also the fallback for missing keys |
| Locale cookie | user-locale | name from i18nConfig.localeCookieName |
| Key style | dotted, generic | e.g. dashboard.welcome_message |
Edit the en/*.json files by hand only. Other locales are generated from en - rerunning translation overwrites manual edits there.
How messages load
Messages resolve server-side per request in apps/web/i18n/request.ts.
| Step | Source | Behavior |
|---|---|---|
| Pick scope | NEXT_PUBLIC_I18N_SCOPE | resolves to a known scope, else falls back to "web" |
| Resolve locale | URL [locale] segment → cookie → routing default | cookie is i18nConfig.localeCookieName |
| Build messages | getMessagesForLocale(locale, scope) | merges shared + scope, layers en under the active locale |
Key missing from en too | apps/web/i18n/message-fallback.ts | renders [missing message: <path>] outside production, the bare path in production (set for server and client alike) |
Routing (apps/web/i18n/routing.ts) uses localePrefix: "as-needed" - default en is un-prefixed, ro lives under /ro/.... With localeDetection: false the URL prefix is the sole source of truth on first visit; the <SuggestLocale> card offers a switch to the visitor's detected locale instead of auto-redirecting, and updateLocale persists the choice to the cookie.
Using translations
Namespace by key prefix, then call t:
import { useTranslations } from "next-intl";
export function Welcome() {
const t = useTranslations("dashboard");
return <h1>{t("welcome_message")}</h1>; // dashboard.welcome_message
}Async server components read messages without a hook via next-intl/server:
import { getTranslations } from "next-intl/server";
export default async function Page() {
const t = await getTranslations("dashboard");
return <h1>{t("welcome_message")}</h1>;
}For links and redirects use the locale-aware Link, redirect, and useRouter from apps/web/lib/navigation.ts (not next/link / next/navigation) - they auto-apply the locale prefix.
Keep messages simple with plain placeholders like {count}. ICU plurals and selects are not supported - the pipeline never emits them, so ICU syntax breaks rendering. Keep keys generic (dashboard.welcome_message, not dashboard.welcome_to_acme) so your brand name flows in from config.siteName.
Translation pipeline
@repo/translate turns your en source into every other locale. A pre-commit hook runs it automatically; you can also run it by hand.
| Aspect | Detail |
|---|---|
| Command | pnpm translate (repo root) - translates JSON locales and packages/content MDX. pnpm translate status previews pending work, --dry-run shows changes without writing, -l <code> targets one locale. |
| Provider | OpenRouter via OPENROUTER_API_KEY (environment variables). Default model z-ai/glm-5.3-flash; override with --model. |
| Trigger | The simple-git-hooks pre-commit hook runs pnpm translate and stages the result on every commit. |
| No key | Skips translation - no error; changed strings fall back to en until you add a key and rerun. |
Incremental by hash. packages/translate/.meta/*.json stores a content hash per source key, so each run only (re)translates added or changed en keys and prunes ones you removed.
Adding or removing a language
locales record in packages/config/src/i18n.ts, e.g. de: { name: "Deutsch", iso: "de-DE" }. To remove, delete its line.pnpm translate (or just commit - the pre-commit hook runs it).pnpm translate does | Detail |
|---|---|
| Regenerates derived files | i18n-locales.mjs + .d.mts (read by next.config.mjs) and packages/ui/src/components/app/flag-icons.generated.ts (the SVG flag map, shared by every app). Never edit these by hand. |
| Adds new locales | Creates the locale's JSON from en (needs OPENROUTER_API_KEY; until a key is translated the en fallback renders). |
| Deletes removed locales | Removes that locale's translations/<code>/ and content/<code>/ directories. |
Each flag is derived from the entry's iso region subtag (de-DE resolves to the German flag). For a script-based tag with no region, add an explicit flag country code: { name: "中文", iso: "zh-Hans", flag: "CN" }.
Drop to a single language and the language switcher hides itself - the navbar and sidebar lay out cleanly without it.
Frequently asked questions
Which files do I edit?
Only packages/i18n/translations/en/*.json. The ro files (and any future locale) are generated from en, so hand edits there are overwritten.
Why is my new key showing in English in another locale?
That is the fallback working. Translated copy lands once translation runs - on commit via the hook, or pnpm translate by hand (both need OPENROUTER_API_KEY).
Can I use plurals like "1 item / 2 items"?
No ICU plurals. Handle pluralization in component logic and pass a finished string, or keep the message to a plain {count}.