Internationalization
Translate the app from one shared @repo/i18n message store consumed by next-intl.
@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; the locale registry is the i18nConfig export from @repo/config (re-exported by @repo/i18n). i18n is always on - tune it by editing locales and JSON, not a feature flag.
What you get
- Locale routing: the URL prefix picks the message set per request; no Accept-Language auto-redirect (
localeDetection: false). - Automatic fallback: missing keys fall back to the default locale,
en. - Scoped messages: the frontend loads the
webscope merged overshared(a layer beneath every scope, not a selectable scope itself). - Auto-generated locales: you write
enonly; the translate pipeline generates the rest.
Where translations live
Two files load per locale: the active scope plus shared.json layered beneath it.
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 | en is the source of truth; ro is generated |
| 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. See Translation pipeline.
How messages load
Messages resolve server-side per request in apps/web-next/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 |
Routing (apps/web-next/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 (not an auto-redirect) offers to switch to a visitor's detected locale, and updateLocale persists the choice to the user-locale cookie.
Using translations in components
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-next/lib/navigation.ts (not next/link / next/navigation) - they auto-apply the locale prefix.
Adding a string
en scope file - packages/i18n/translations/en/web.json for frontend copy.dashboard.welcome_message, not dashboard.welcome_to_acme).useTranslations() (or getTranslations() in async server components).pnpm translate to fill the other locales (needs OPENROUTER_API_KEY).Keep messages simple with plain placeholders like {count}. ICU plurals/selects are not supported - don't add ICU syntax or a custom message compiler; the pipeline never emits it.
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 google/gemini-2.5-flash-lite; 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. Each run only (re)translates added or changed en keys and prunes keys you removed - so every locale stays in sync with en without re-translating everything.
Adding or removing a language
Edit one file - the locales record in packages/config/src/i18n.ts - then run pnpm translate.
i18nConfig.locales, e.g. de: { name: "Deutsch", iso: "de-DE" }. To remove one, delete its line.pnpm translate (or just commit - the pre-commit hook runs it).pnpm translate reconciles everything else for you:
| It does | Detail |
|---|---|
| Regenerates derived files | i18n-locales.mjs + .d.mts (read by next.config.mjs) and apps/web-next/components/shared/flag-icons.generated.ts (the SVG flag map). These are generated from your locales record - never edit them 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 (e.g. zh-Hans), add an explicit flag country code: { name: "中文", iso: "zh-Hans", flag: "CN" }.
Drop to a single language and the language switcher hides itself automatically - there is nothing to switch between, and the surrounding 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 blank in another locale?
Missing keys fall back to en. 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. Use a plain {count} placeholder and keep the wording count-agnostic, since the message format stays simple by design.
How do I keep keys reusable?
Avoid product-specific words. Generic keys (dashboard.welcome_message) survive rebrands and keep the boilerplate portable.