GenerateSaaS

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
ConceptValueNotes
Localesen, roi18nConfig.locales in packages/config/src/i18n.ts; en is the source of truth
Scopeswebthe only selectable scope; shared.json always merges beneath it
Default localeenalso the fallback for missing keys
Locale cookieuser-localename from i18nConfig.localeCookieName
Key styledotted, generice.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.

StepSourceBehavior
Pick scopeNEXT_PUBLIC_I18N_SCOPEresolves to a known scope, else falls back to "web"
Resolve localeURL [locale] segment → cookie → routing defaultcookie is i18nConfig.localeCookieName
Build messagesgetMessagesForLocale(locale, scope)merges shared + scope, layers en under the active locale
Key missing from en tooapps/web/i18n/message-fallback.tsrenders [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.

AspectDetail
Commandpnpm 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.
ProviderOpenRouter via OPENROUTER_API_KEY (environment variables). Default model z-ai/glm-5.3-flash; override with --model.
TriggerThe simple-git-hooks pre-commit hook runs pnpm translate and stages the result on every commit.
No keySkips 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.
The agent translates each task into its output file.
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

To add, put an entry in the locales record in packages/config/src/i18n.ts, e.g. de: { name: "Deutsch", iso: "de-DE" }. To remove, delete its line.
Run pnpm translate (or just commit - the pre-commit hook runs it).
pnpm translate doesDetail
Regenerates derived filesi18n-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 localesCreates the locale's JSON from en (needs OPENROUTER_API_KEY; until a key is translated the en fallback renders).
Deletes removed localesRemoves 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).

On this page