Skip to main content
Engineering7 min readMarch 3, 2026

i18n in Nuxt: Adding Multi-Language Support Without the Pain

A complete guide to internationalization in Nuxt with @nuxtjs/i18n — locale routing, translation files, lazy-loading locales, RTL support, and SEO for multi-language sites.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Internationalization gets a reputation for being painful to add after the fact. That reputation is earned — retrofitting an application with translation support is genuinely tedious. But building it in from the start with the right tools is not that difficult, and the module ecosystem for Nuxt makes it more approachable than most frameworks.

This guide covers everything you need for a production-quality multi-language Nuxt application: routing, translation files, locale detection, SEO, and the common edge cases.

Installing @nuxtjs/i18n

npx nuxi module add i18n

Basic configuration:

// nuxt.config.ts
i18n: {
  strategy: 'prefix_except_default',
  defaultLocale: 'en',
  locales: [
    { code: 'en', language: 'en-US', name: 'English', dir: 'ltr', file: 'en.json' },
    { code: 'es', language: 'es-ES', name: 'Español', dir: 'ltr', file: 'es.json' },
    { code: 'ar', language: 'ar-SA', name: 'العربية', dir: 'rtl', file: 'ar.json' },
    { code: 'de', language: 'de-DE', name: 'Deutsch', dir: 'ltr', file: 'de.json' },
  ],
  lazy: true,
  langDir: 'locales',
  detectBrowserLanguage: {
    useCookie: true,
    cookieKey: 'i18n_redirected',
    alwaysRedirect: false,
    fallbackLocale: 'en',
  },
},

The strategy: 'prefix_except_default' setting gives you URLs like /products for English and /es/products for Spanish, /ar/products for Arabic. The default locale has no prefix, which is the most common and most SEO-friendly approach.

Translation Files

Create your translation files in the locales/ directory:

// locales/en.json
{
  "nav": {
    "home": "Home",
    "products": "Products",
    "about": "About",
    "contact": "Contact"
  },
  "hero": {
    "title": "Build Better Software",
    "subtitle": "Strategic systems architecture for complex problems.",
    "cta": "Work with me"
  },
  "errors": {
    "notFound": "Page not found",
    "notFoundMessage": "The page you are looking for does not exist.",
    "backHome": "Go back home"
  }
}
// locales/es.json
{
  "nav": {
    "home": "Inicio",
    "products": "Productos",
    "about": "Sobre nosotros",
    "contact": "Contacto"
  },
  "hero": {
    "title": "Construye Mejor Software",
    "subtitle": "Arquitectura de sistemas estratégica para problemas complejos.",
    "cta": "Trabajar conmigo"
  },
  "errors": {
    "notFound": "Página no encontrada",
    "notFoundMessage": "La página que busca no existe.",
    "backHome": "Volver al inicio"
  }
}

The nested structure keeps translations organized. Be consistent with nesting depth across your locale files — mismatched structures are a common source of bugs.

Using Translations in Components

The $t function and useI18n composable are your primary tools:

<script setup lang="ts">
const { t, locale, locales, setLocale } = useI18n()

// Locale-aware formatting
const { n, d } = useI18n()

const formattedPrice = n(29.99, 'currency', locale.value)
const formattedDate = d(new Date(), 'long', locale.value)
</script>

<template>
  <nav>
    <NuxtLink :to="localePath('/')">{{ t('nav.home') }}</NuxtLink>
    <NuxtLink :to="localePath('/products')">{{ t('nav.products') }}</NuxtLink>
  </nav>

  <h1>{{ t('hero.title') }}</h1>
</template>

The localePath composable generates locale-aware paths. localePath('/products') returns /products when English is active and /es/products when Spanish is active.

Locale-Aware Dates, Numbers, and Currencies

Use the built-in formatters rather than manual formatting. They are locale-aware and consistent:

// nuxt.config.ts
i18n: {
  numberFormats: {
    en: {
      currency: {
        style: 'currency',
        currency: 'USD',
        notation: 'standard',
      },
      decimal: {
        style: 'decimal',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      },
    },
    es: {
      currency: {
        style: 'currency',
        currency: 'EUR',
      },
    },
  },
  datetimeFormats: {
    en: {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
    },
    es: {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
    },
  },
},

Pluralization

Translation strings often need to vary based on count. Use the built-in plural handling:

// locales/en.json
{
  "cart": {
    "items": "No items | One item | {count} items"
  }
}
<template>
  <span>{{ $tc('cart.items', cartCount, { count: cartCount }) }}</span>
</template>

RTL Language Support

For Arabic, Hebrew, and other right-to-left languages, you need more than just translated text — the entire layout direction must flip. Configure the dir attribute per locale and apply it to the HTML element:

// plugins/i18n.ts
export default defineNuxtPlugin(() => {
  const { localeProperties } = useI18n()

  watch(localeProperties, (locale) => {
    document.documentElement.dir = locale.dir ?? 'ltr'
    document.documentElement.lang = locale.language ?? locale.code
  }, { immediate: true })
})

In your Tailwind config, enable RTL support:

// tailwind.config.ts
plugins: [
  require('tailwindcss-rtl'),
]

This adds RTL-aware utility classes: rtl:flex-row-reverse, rtl:mr-auto, rtl:text-right. Use these instead of directional classes (ml-4ms-4 for margin-start which flips in RTL):

<!-- This flips correctly in RTL -->
<div class="flex items-center gap-4">
  <Icon class="me-2" />
  <span>{{ label }}</span>
</div>

SEO for Multi-Language Sites

Multi-language SEO requires hreflang tags on every page. The module generates these automatically:

// nuxt.config.ts
i18n: {
  // Automatically adds hreflang alternate links
  // to every page in the <head>
},

Verify the tags are present in your HTML source. Each page should have an alternate link for each supported locale plus x-default:

<link rel="alternate" hreflang="en" href="https://yourdomain.com/products" />
<link rel="alternate" hreflang="es" href="https://yourdomain.com/es/products" />
<link rel="alternate" hreflang="ar" href="https://yourdomain.com/ar/products" />
<link rel="alternate" hreflang="x-default" href="https://yourdomain.com/products" />

Update your sitemap configuration to include all locale URLs:

// nuxt.config.ts
sitemap: {
  // Include all locale variants in sitemap
  i18n: true,
},

Language Switcher Component

<!-- components/LanguageSwitcher.vue -->
<script setup lang="ts">
const { locale, locales, setLocale } = useI18n()
const switchLocalePath = useSwitchLocalePath()

const availableLocales = computed(() =>
  locales.value.filter(l => l.code !== locale.value)
)
</script>

<template>
  <div class="relative">
    <button
      class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100"
      aria-haspopup="listbox"
      :aria-label="`Current language: ${locale}`"
    >
      <span>{{ locale.toUpperCase() }}</span>
    </button>
    <ul role="listbox">
      <li
        v-for="l in availableLocales"
        :key="l.code"
        role="option"
      >
        <NuxtLink :to="switchLocalePath(l.code)" class="block px-4 py-2 hover:bg-gray-100">
          {{ l.name }}
        </NuxtLink>
      </li>
    </ul>
  </div>
</template>

The switchLocalePath composable generates the equivalent URL in the target locale — if the user is on /es/products/widget, switching to English produces /products/widget.

Lazy Loading Locales

For applications with many supported languages, lazy loading prevents downloading all translations upfront. The configuration we set earlier with lazy: true and individual file references handles this — only the active locale's translation file downloads.

Use split files for large applications:

locales: [
  {
    code: 'en',
    files: ['en/common.json', 'en/products.json', 'en/checkout.json'],
  },
],

Load namespace-specific translations only on the routes that need them, reducing the initial bundle for simpler pages.

Testing Translations

Add a check in your CI to ensure all locale files have the same keys:

// scripts/check-translations.ts
const en = JSON.parse(readFileSync('locales/en.json', 'utf8'))
const es = JSON.parse(readFileSync('locales/es.json', 'utf8'))

function getKeys(obj: object, prefix = ''): string[] {
  return Object.entries(obj).flatMap(([key, value]) =>
    typeof value === 'object'
      ? getKeys(value, `${prefix}${key}.`)
      : [`${prefix}${key}`]
  )
}

const enKeys = new Set(getKeys(en))
const esKeys = new Set(getKeys(es))

const missing = [...enKeys].filter(k => !esKeys.has(k))
if (missing.length) {
  console.error('Missing Spanish translations:', missing)
  process.exit(1)
}

Internationalization is a commitment that requires coordination with translators, design, and QA. The technical implementation is the easy part. The harder part is the process of keeping translations updated as the application evolves. Establish that process early.


Building a multi-language Nuxt application or need help with the i18n architecture? Book a call and let's design it together: calendly.com/jamesrossjr.


Keep Reading