GenerateSaaS

Internationalization

Internationalize your Nuxt app with @repo/i18n and @nuxtjs/i18n v10. One shared JSON message set drives locale-prefixed routing, English fallback, and SEO.

@repo/i18n is the single message store the Nuxt app reads, wired through @nuxtjs/i18n v10 by the @repo/i18n/module. The locale registry is the i18nConfig export from @repo/config. There is no enable flag - i18n underpins every rendered string, so you tune it by editing locales and JSON, never by toggling it off.

What you get

  • One shared message set: edit JSON once, every surface picks it up.
  • Locale-prefixed routing: en is un-prefixed; ro lives under /ro/....
  • English fallback: a missing ro key renders the en value, never a raw key.
  • Automatic SEO: <html lang>, canonical, hreflang, and og:locale emitted per page.
  • Full prerender parity: every route is statically generated for each locale.

Where messages live

Translations are plain JSON, organized by locale then scope:

packages/i18n/translations/
  en/            # source of truth
    web.json     # the "web" scope (app UI)
    shared.json  # keys common to every surface
  ro/            # other locales - see "Maintaining translations"
    web.json
    shared.json
ConceptValueNotes
Localesen, roi18nConfig.locales in packages/config/src/i18n.ts
Default localeeni18nConfig.defaultLocale - also the fallback locale
Locale cookieuser-localei18nConfig.localeCookieName; persisted by the loader plugin on switch
Scope"web"The only scope - scopes = ["web"] in packages/i18n/index.ts
Merge fngetMessagesForLocale(locale, "web")Layers shared + scope, then en beneath the active locale

Translation pipeline

en is the source of truth. @repo/translate turns it into every other locale - a pre-commit hook runs automatically, and you can 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 ro (and any locale) stays in sync with en without re-translating everything.

Edit en/*.json by hand only. Other locales are generated from en - rerunning translation overwrites manual edits there.

Using translations

Pick the API by context - templates vs. <script setup>.

NeedUseWhere
Render a string$t('key')Templates (global helper, no import)
t() from scriptuseI18n()<script setup>
Active localelocale from useI18n()<script setup>
Switch localesetLocale() from useI18n()<script setup>
Build a route pathlocalePath()Templates + script

In templates, call the global helper directly - no destructure:

<template>
  <h1>{{ $t('dashboard.welcome_message') }}</h1>
  <NuxtLink :to="localePath('/pricing')">{{ $t('nav.pricing') }}</NuxtLink>
</template>

In <script setup>, reach for useI18n() only when you need locale, setLocale, or t():

<script setup lang="ts">
const { t, locale, setLocale } = useI18n() // auto-imported via @nuxtjs/i18n
const heading = t('dashboard.welcome_message')
</script>

Always build paths with localePath() - a hardcoded path like /pricing skips the locale prefix and breaks every non-default locale (/ro/...).

Routing & SEO

The module sets a few @nuxtjs/i18n options for you:

  • strategy: "prefix_except_default" - en stays un-prefixed; other locales get a /ro/ prefix.
  • detectBrowserLanguage: false - the URL prefix is the source of truth; the cookie is persistence, not auto-redirect.
  • experimental.strictSeo: true - auto-emits <html lang>, canonical, hreflang alternates, and og:locale per page, so you do not call useLocaleHead yourself.

Authoring keys

  • Keep keys generic. Use dashboard.welcome_message, not dashboard.welcome_to_acme, so your brand name flows in from config.siteName instead of being baked into a key.
  • No ICU plurals. Use simple {count} interpolation; vue-i18n has no native ICU, so plural/select syntax breaks rendering.
  • Match the scope. App UI goes in web.json; cross-surface strings go in shared.json.
// packages/i18n/translations/en/web.json
{
  "dashboard": {
    "welcome_message": "Welcome back",
    "items_count": "You have {count} items" // βœ“ plain interpolation, no ICU
  }
}

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 filesThe locale list and app/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 translations/<code>/web.json + shared.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

Why is my new key only showing in English? That is the fallback working - the key exists in en/web.json but not yet in ro/web.json. Run translation (the commit hook or pnpm translate) to fill it in.

How do I regenerate translations? Run pnpm translate, or just commit - the pre-commit hook runs it. It needs OPENROUTER_API_KEY; without a key, translation is skipped. See Translation pipeline.

Why does my {count} plural come out wrong? ICU plural/select is intentionally unsupported. Handle pluralization in component logic and pass a finished string, or keep the message to a plain {count}.

Do I ever need useLocaleHead? No. experimental.strictSeo: true emits <html lang>, canonical, hreflang (including x-default), and og:locale for every page automatically.

On this page