Building Nuxt apps

Internationalisation

The @nuxtjs/i18n module — routing strategies, lazy-loaded messages, locale detection, SEO tags, formatting with Intl, right-to-left layouts and the hydration trap of a locale that differs between server and client.

Most products that hire senior frontend engineers in Europe ship in more than one language, so i18n is a practical interview topic: how routes work per locale, how you avoid shipping every language to every user, how search engines learn about the alternatives, and how formatting is done without hand-rolled helpers. In Nuxt the answer is @nuxtjs/i18n on top of Vue I18n, plus the Intl APIs the browser already has.

Know

  • @nuxtjs/i18n wraps Vue I18n for Nuxt: it generates localised routes, detects and persists the locale, lazy-loads message files, and exposes useI18n() (t, locale, locales, n, d), useLocalePath(), useSwitchLocalePath(), useLocaleRoute(), <NuxtLinkLocale> and useLocaleHead(). Nuxt 4 projects use the v10 line.
  • Routing strategies: prefix_except_default (default: /about, /de/about), prefix (every locale prefixed, root redirects), prefix_and_default (both forms for the default locale), no_prefix (locale from detection only, no URL signal). The choice is an SEO decision: prefixed URLs give each language a stable, indexable address.
  • Locale definitions carry code, language (a BCP 47 tag such as en-GB, used for hreflang and <html lang>), file or files, dir for RTL, and optional domain for differentDomains.
  • Lazy loading: messages live in i18n/locales/*.json (or .ts for dynamic messages) and only the active locale's file ships, loaded on demand when the user switches. The alternative, bundling every language, is a bundle-size bug that grows with each locale.
  • Detection and persistence: detectBrowserLanguage reads Accept-Language on the server and a cookie (i18n_redirected) afterwards; redirectOn: 'root' limits redirects to /. The cookie is what keeps the server render and the client agreeing on the locale.
  • SEO tags: useLocaleHead({ dir: true, lang: true, seo: true }) produces <html lang dir>, hreflang alternates including x-default, canonical, and og:locale/og:locale:alternate. Wire it in app.vue or the layout once.
  • Formatting is Intl's job. n(value, 'currency') and d(date, 'short') use Intl.NumberFormat and Intl.DateTimeFormat with per-locale formats you define once; Intl.RelativeTimeFormat, Intl.ListFormat, Intl.PluralRules and Intl.DisplayNames cover the rest. Pluralisation in messages uses Vue I18n's | syntax or ICU-style named counts.
  • Right-to-left: set dir on the locale, render it on <html>, and write CSS with logical properties (margin-inline-start, padding-block, inset-inline) so layouts flip without duplicated rules.
  • Workflow: keys are structured (checkout.summary.total), the default locale is the fallback (fallbackLocale), missing keys warn in development (missingWarn), and extraction and translation management happen outside the app (a TMS, or JSON files reviewed in PRs). Content pages translate through per-locale content directories rather than message files.
  • Server side: Nitro routes have no Vue I18n instance; the module offers experimental server-side translation helpers, and otherwise you pass the locale explicitly (from the URL or the cookie) into Intl formatters or your own message lookup.

How it works

Configuration with lazy files, detection and formats:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    strategy: 'prefix_except_default',
    defaultLocale: 'en',
    locales: [
      { code: 'en', language: 'en-GB', name: 'English', file: 'en.json' },
      { code: 'cs', language: 'cs-CZ', name: 'Čeština', file: 'cs.json' },
      { code: 'ar', language: 'ar-EG', name: 'العربية', file: 'ar.json', dir: 'rtl' },
    ],
    detectBrowserLanguage: { useCookie: true, cookieKey: 'i18n_redirected', redirectOn: 'root' },
    baseUrl: 'https://shop.example.com', // absolute hreflang and canonical URLs
  },
})
i18n/i18n.config.ts
export default defineI18nConfig(() => ({
  legacy: false,
  fallbackLocale: 'en',
  numberFormats: {
    'en': { currency: { style: 'currency', currency: 'EUR' } },
    'cs': { currency: { style: 'currency', currency: 'CZK' } },
    'ar': { currency: { style: 'currency', currency: 'EGP' } },
  },
  datetimeFormats: {
    'en': { short: { day: 'numeric', month: 'short', year: 'numeric' } },
    'cs': { short: { day: 'numeric', month: 'numeric', year: 'numeric' } },
    'ar': { short: { day: 'numeric', month: 'long', year: 'numeric' } },
  },
}))

Head tags once, links and formatting in a component:

app/app.vue
<script setup lang="ts">
const head = useLocaleHead({ dir: true, lang: true, seo: true })
useHead(head) // <html lang dir>, hreflang alternates, canonical, og:locale
</script>
app/components/Cart/Total.vue
<script setup lang="ts">
const { t, n, d, locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
defineProps<{ total: number, updatedAt: Date, items: number }>()
</script>

<template>
  <p>{{ t('cart.items', items) }}</p>                <!-- "1 item | {count} items" -->
  <p>{{ t('cart.total', { amount: n(total, 'currency') }) }}</p>
  <p>{{ d(updatedAt, 'short') }}</p>
  <NuxtLinkLocale to="/checkout">{{ t('cart.checkout') }}</NuxtLinkLocale>
  <nav>
    <NuxtLink v-for="l in locales" :key="l.code" :to="switchLocalePath(l.code)" :hreflang="l.language">
      {{ l.name }}
    </NuxtLink>
  </nav>
</template>
i18n/locales/en.json
{
  "cart": {
    "items": "no items | one item | {count} items",
    "total": "Total: {amount}",
    "checkout": "Go to checkout"
  }
}

A formatter that does not depend on Vue, usable in a Nitro route:

shared/utils/format.ts
export function formatMoney(amount: number, locale: string, currency: string) {
  return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)
}
Gotcha· Locale mismatch between server and client

If the server picks the locale from Accept-Language but the client reads a different cookie, or a localStorage preference, the server renders German and the client hydrates English: a hydration mismatch plus a flash of wrong language. The locale has to come from one source available on both sides, which is the URL prefix or the cookie the module manages.

Gotcha· Hand-rolled formatting

total.toFixed(2) + ' €' is wrong in half of Europe (decimal comma, symbol position, thousands separators). Intl.NumberFormat knows the rules for every locale and is built into every browser and Node; use it directly or through n().

Verify before the interview:

The current major of @nuxtjs/i18n for Nuxt 4, the default locale-file directory (i18n/locales), the detectBrowserLanguage defaults and the status of the experimental server-side translation helpers.

docs ↗

Exercise

Exercise
  • Set up two locales with lazy files, open the Network panel, switch locale, and confirm only the second file loads.
  • Add useLocaleHead and view the server HTML: find lang, dir, the hreflang alternates and x-default.
  • Add an RTL locale, switch to it, and fix every layout that broke by replacing physical CSS properties with logical ones.

Be able to say

Be able to say· How would you add internationalisation to a Nuxt app?

"With @nuxtjs/i18n on top of Vue I18n. I choose a prefixed routing strategy so each language has its own indexable URLs, define locales with BCP 47 language tags and per-locale message files that load lazily, and let the module detect the language from Accept-Language on the first visit and persist it in a cookie so the server and the client always agree. useLocaleHead in the app root emits lang, dir, hreflang alternates and canonical tags. Formatting goes through Intl, either via the n and d helpers with formats defined once, or directly in shared utilities so server routes can use them. For right-to-left languages I write CSS with logical properties from the start."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.