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.
Messages are ICU: plain placeholders like {count}, plus plurals and selects like {count, plural, one {# tool} other {# tools}}. Translation keeps their structure and adds the plural forms each language needs. 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 and document for each locale, so each run only (re)translates added or changed en text and prunes what you removed.
Translating with your coding agent
No OpenRouter key, or rather use your own model? The bundled translate skill runs the same pipeline with your AI agent as the translator: type /translate (add anything you like - one locale, a glossary, which model its subagents use) or ask it to translate the project.
pnpm translate prepare does the bookkeeping pnpm translate does and writes every missing or outdated key and document as a task under packages/translate/.work/, with the same translation rules the API prompt uses.pnpm translate apply checks every translation with the API path's gates (placeholders, plurals, frontmatter shape), writes what passes, records its hash, and lists anything to redo.Both paths share the ledger, so a translation the agent applied is never redone by the hook or by pnpm translate until its source changes.
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), or through your agent with the translate skill.
Can I use plurals like "1 item / 2 items"?
Yes - write an ICU plural, {count, plural, one {# item} other {# items}}, and pass count. Every branch carries the whole phrase and other is required; translation adds the forms each language needs (Romanian's few, for example).
Error Reporting
Send server, browser, desktop and mobile errors to Sentry or PostHog from committed config - off by default, and off loads nothing.
Authentication
Authenticate users with the @repo/auth Better Auth instance - sessions, magic links, rate limits, and the client wired into the Next.js app.