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.

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.

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, so each run only (re)translates added or changed en keys and prunes ones you removed.

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).

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}.

On this page