GenerateSaaS

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 web scope merged over shared (a layer beneath every scope, not a selectable scope itself).
  • Auto-generated locales: you write en only; 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
ConceptValueNotes
Localesen, roen is the source of truth; ro is generated
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. See Translation pipeline.

How messages load

Messages resolve server-side per request in apps/web-next/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

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

Add the key to the relevant en scope file - packages/i18n/translations/en/web.json for frontend copy.
Use a generic, dotted key (dashboard.welcome_message, not dashboard.welcome_to_acme).
Reference it with useTranslations() (or getTranslations() in async server components).
Commit - the pre-commit hook runs 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.

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 google/gemini-2.5-flash-lite; 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. 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.

To add a language, add an entry to i18nConfig.locales, e.g. de: { name: "Deutsch", iso: "de-DE" }. To remove one, delete its line.
Run pnpm translate (or just commit - the pre-commit hook runs it).

pnpm translate reconciles everything else for you:

It doesDetail
Regenerates derived filesi18n-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 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 (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.

On this page