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:
enis un-prefixed;rolives under/ro/.... - English fallback: a missing
rokey renders theenvalue, never a raw key. - Automatic SEO:
<html lang>, canonical, hreflang, andog:localeemitted 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| Concept | Value | Notes |
|---|---|---|
| Locales | en, ro | i18nConfig.locales in packages/config/src/i18n.ts |
| Default locale | en | i18nConfig.defaultLocale - also the fallback locale |
| Locale cookie | user-locale | i18nConfig.localeCookieName; persisted by the loader plugin on switch |
| Scope | "web" | The only scope - scopes = ["web"] in packages/i18n/index.ts |
| Merge fn | getMessagesForLocale(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.
| 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 google/gemini-2.5-flash-lite; 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. 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>.
| Need | Use | Where |
|---|---|---|
| Render a string | $t('key') | Templates (global helper, no import) |
t() from script | useI18n() | <script setup> |
| Active locale | locale from useI18n() | <script setup> |
| Switch locale | setLocale() from useI18n() | <script setup> |
| Build a route path | localePath() | 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"-enstays 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, andog:localeper page, so you do not calluseLocaleHeadyourself.
Authoring keys
- Keep keys generic. Use
dashboard.welcome_message, notdashboard.welcome_to_acme, so your brand name flows in fromconfig.siteNameinstead of being baked into a key. - No ICU plurals. Use simple
{count}interpolation;vue-i18nhas no native ICU, so plural/select syntax breaks rendering. - Match the scope. App UI goes in
web.json; cross-surface strings go inshared.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.
i18nConfig.locales, e.g. de: { name: "Deutsch", iso: "de-DE" }. To remove one, delete its line.pnpm translate (or just commit - the pre-commit hook runs it).pnpm translate reconciles everything else for you:
| It does | Detail |
|---|---|
| Regenerates derived files | The 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 locales | Creates translations/<code>/web.json + shared.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 (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.