Nuxt.js i18n

Last updated: July 31, 2025Author: Jakub Pomykała

In this guide, you'll learn how to implement internationalization in Nuxt.js using the official @nuxtjs/i18n module and manage your translations with SimpleLocalize. This module provides automatic routing, SEO optimization, and powerful translation features.

Installation

Install the @nuxtjs/i18n module for Nuxt.js:

npm install @nuxtjs/i18n@next

For Nuxt 2, use the stable version:

npm install @nuxtjs/i18n

What you get:

  • Automatic route generation for all locales
  • SEO-optimized URLs with proper hreflang tags
  • Lazy loading of translation files
  • Browser language detection
  • Domain-based or path-based routing strategies

Nuxt Configuration

Configure the i18n module in your nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],

  i18n: {
    locales: [
      {
        code: 'en',
        name: 'English',
        file: 'en.json',
        iso: 'en-US'
      },
      {
        code: 'es',
        name: 'Español',
        file: 'es.json',
        iso: 'es-ES'
      },
      {
        code: 'fr',
        name: 'Français',
        file: 'fr.json',
        iso: 'fr-FR'
      },
      {
        code: 'de',
        name: 'Deutsch',
        file: 'de.json',
        iso: 'de-DE'
      }
    ],
    defaultLocale: 'en',
    strategy: 'prefix_except_default',
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',
      alwaysRedirect: false,
      fallbackLocale: 'en'
    },
    langDir: 'locales/',
    lazy: true,
    baseUrl: 'https://example.com',
    seo: true
  }
})

Configuration options:

  • strategy - URL structure strategy (prefix, prefix_except_default, prefix_and_default)
  • detectBrowserLanguage - Automatic language detection settings
  • lazy - Load translation files on demand for better performance
  • seo - Automatic SEO optimization with hreflang tags

Translation Files Structure

Create translation files in the locales/ directory:

locales/
  en.json
  es.json
  fr.json
  de.json

Example translation files:

// locales/en.json
{
  "nav": {
    "home": "Home",
    "about": "About",
    "services": "Services",
    "contact": "Contact",
    "blog": "Blog"
  },
  "home": {
    "title": "Welcome to Our Website",
    "subtitle": "Building amazing multilingual experiences",
    "cta": "Get Started",
    "features": {
      "title": "Why Choose Us",
      "performance": {
        "title": "High Performance",
        "description": "Optimized for speed and user experience"
      },
      "seo": {
        "title": "SEO Optimized",
        "description": "Built for search engines and discoverability"
      },
      "responsive": {
        "title": "Mobile First",
        "description": "Perfect experience on all devices"
      }
    }
  },
  "contact": {
    "title": "Contact Us",
    "form": {
      "name": "Full Name",
      "email": "Email Address",
      "subject": "Subject",
      "message": "Your Message",
      "submit": "Send Message",
      "sending": "Sending...",
      "success": "Thank you for your message!",
      "error": "Something went wrong. Please try again."
    }
  },
  "meta": {
    "title": "My Nuxt App - Multilingual Website",
    "description": "A powerful multilingual website built with Nuxt.js"
  }
}
// locales/es.json
{
  "nav": {
    "home": "Inicio",
    "about": "Acerca de",
    "services": "Servicios",
    "contact": "Contacto",
    "blog": "Blog"
  },
  "home": {
    "title": "Bienvenido a Nuestro Sitio Web",
    "subtitle": "Creando experiencias multiidioma increíbles",
    "cta": "Comenzar",
    "features": {
      "title": "Por Qué Elegirnos",
      "performance": {
        "title": "Alto Rendimiento",
        "description": "Optimizado para velocidad y experiencia de usuario"
      },
      "seo": {
        "title": "Optimizado para SEO",
        "description": "Construido para motores de búsqueda y descubrimiento"
      },
      "responsive": {
        "title": "Mobile First",
        "description": "Experiencia perfecta en todos los dispositivos"
      }
    }
  },
  "contact": {
    "title": "Contáctanos",
    "form": {
      "name": "Nombre Completo",
      "email": "Dirección de Correo",
      "subject": "Asunto",
      "message": "Tu Mensaje",
      "submit": "Enviar Mensaje",
      "sending": "Enviando...",
      "success": "¡Gracias por tu mensaje!",
      "error": "Algo salió mal. Por favor intenta de nuevo."
    }
  },
  "meta": {
    "title": "Mi App Nuxt - Sitio Web Multiidioma",
    "description": "Un poderoso sitio web multiidioma construido con Nuxt.js"
  }
}

Page Implementation

Basic Page with Translations

<!-- pages/index.vue -->
<template>
  <div>
    <Hero />
    <Features />
    <CallToAction />
  </div>
</template>

<script setup lang="ts">
// Set page meta with translations
definePageMeta({
  layout: 'default'
})

const { t } = useI18n()

// SEO meta tags
useSeoMeta({
  title: () => t('meta.title'),
  description: () => t('meta.description'),
})
</script>

Hero Component

<!-- components/Hero.vue -->
<template>
  <section class="hero">
    <div class="container">
      <h1 class="hero-title">
        {{ $t('home.title') }}
      </h1>
      <p class="hero-subtitle">
        {{ $t('home.subtitle') }}
      </p>
      <NuxtLink
        :to="localePath('/contact')"
        class="cta-button"
      >
        {{ $t('home.cta') }}
      </NuxtLink>
    </div>
  </section>
</template>

<style scoped>
.hero {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 6rem 0;
  text-align: center;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 2rem;
}

.hero-title {
  font-size: 3rem;
  font-weight: 700;
  margin-bottom: 1rem;
}

.hero-subtitle {
  font-size: 1.25rem;
  margin-bottom: 2rem;
  opacity: 0.9;
}

.cta-button {
  display: inline-block;
  background: #ff6b6b;
  color: white;
  padding: 1rem 2rem;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 600;
  transition: all 0.3s ease;
}

.cta-button:hover {
  background: #ff5252;
  transform: translateY(-2px);
}
</style>

Features Component

<!-- components/Features.vue -->
<template>
  <section class="features">
    <div class="container">
      <h2>{{ $t('home.features.title') }}</h2>
      <div class="features-grid">
        <div
          v-for="feature in features"
          :key="feature.key"
          class="feature-card"
        >
          <div class="feature-icon">
            <component :is="feature.icon" />
          </div>
          <h3>{{ $t(`home.features.${feature.key}.title`) }}</h3>
          <p>{{ $t(`home.features.${feature.key}.description`) }}</p>
        </div>
      </div>
    </div>
  </section>
</template>

<script setup lang="ts">
import { IconRocket, IconSearch, IconDeviceMobile } from '@tabler/icons-vue'

const features = [
  { key: 'performance', icon: IconRocket },
  { key: 'seo', icon: IconSearch },
  { key: 'responsive', icon: IconDeviceMobile }
]
</script>

<style scoped>
.features {
  padding: 6rem 0;
  background: #f8f9fa;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 2rem;
}

.features h2 {
  text-align: center;
  font-size: 2.5rem;
  margin-bottom: 3rem;
  color: #333;
}

.features-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 2rem;
}

.feature-card {
  background: white;
  padding: 2rem;
  border-radius: 12px;
  text-align: center;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.feature-card:hover {
  transform: translateY(-4px);
}

.feature-icon {
  width: 64px;
  height: 64px;
  background: #667eea;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 1rem;
  color: white;
}

.feature-card h3 {
  font-size: 1.5rem;
  margin-bottom: 1rem;
  color: #333;
}

.feature-card p {
  color: #666;
  line-height: 1.6;
}
</style>

Contact Page

<!-- pages/contact.vue -->
<template>
  <div class="contact-page">
    <div class="container">
      <h1>{{ $t('contact.title') }}</h1>

      <form @submit.prevent="submitForm" class="contact-form">
        <div class="form-group">
          <label for="name">{{ $t('contact.form.name') }}</label>
          <input
            id="name"
            v-model="form.name"
            type="text"
            required
            :placeholder="$t('contact.form.name')"
          />
        </div>

        <div class="form-group">
          <label for="email">{{ $t('contact.form.email') }}</label>
          <input
            id="email"
            v-model="form.email"
            type="email"
            required
            :placeholder="$t('contact.form.email')"
          />
        </div>

        <div class="form-group">
          <label for="subject">{{ $t('contact.form.subject') }}</label>
          <input
            id="subject"
            v-model="form.subject"
            type="text"
            required
            :placeholder="$t('contact.form.subject')"
          />
        </div>

        <div class="form-group">
          <label for="message">{{ $t('contact.form.message') }}</label>
          <textarea
            id="message"
            v-model="form.message"
            rows="5"
            required
            :placeholder="$t('contact.form.message')"
          ></textarea>
        </div>

        <button
          type="submit"
          :disabled="isSubmitting"
          class="submit-btn"
        >
          {{ isSubmitting ? $t('contact.form.sending') : $t('contact.form.submit') }}
        </button>

        <div v-if="submitMessage" class="submit-message" :class="submitStatus">
          {{ submitMessage }}
        </div>
      </form>
    </div>
  </div>
</template>

<script setup lang="ts">
const { t } = useI18n()

// SEO meta
useSeoMeta({
  title: () => `${t('contact.title')} | ${t('meta.title')}`,
  description: () => t('meta.description')
})

// Form state
const form = reactive({
  name: '',
  email: '',
  subject: '',
  message: ''
})

const isSubmitting = ref(false)
const submitMessage = ref('')
const submitStatus = ref('')

const submitForm = async () => {
  isSubmitting.value = true

  try {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 2000))

    // Reset form
    Object.keys(form).forEach(key => form[key] = '')

    submitMessage.value = t('contact.form.success')
    submitStatus.value = 'success'
  } catch (error) {
    submitMessage.value = t('contact.form.error')
    submitStatus.value = 'error'
  } finally {
    isSubmitting.value = false

    // Clear message after 5 seconds
    setTimeout(() => {
      submitMessage.value = ''
      submitStatus.value = ''
    }, 5000)
  }
}
</script>

<style scoped>
.contact-page {
  padding: 4rem 0;
  min-height: 60vh;
}

.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 0 2rem;
}

h1 {
  text-align: center;
  font-size: 2.5rem;
  margin-bottom: 3rem;
  color: #333;
}

.contact-form {
  background: white;
  padding: 2rem;
  border-radius: 12px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 600;
  color: #333;
}

.form-group input,
.form-group textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
  transition: border-color 0.3s ease;
}

.form-group input:focus,
.form-group textarea:focus {
  outline: none;
  border-color: #667eea;
}

.submit-btn {
  background: #667eea;
  color: white;
  padding: 0.75rem 2rem;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: all 0.3s ease;
  width: 100%;
}

.submit-btn:hover:not(:disabled) {
  background: #5a6fd8;
}

.submit-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.submit-message {
  margin-top: 1rem;
  padding: 1rem;
  border-radius: 8px;
  text-align: center;
  font-weight: 600;
}

.submit-message.success {
  background: #d4edda;
  color: #155724;
  border: 1px solid #c3e6cb;
}

.submit-message.error {
  background: #f8d7da;
  color: #721c24;
  border: 1px solid #f5c6cb;
}
</style>
<!-- components/AppNavigation.vue -->
<template>
  <nav class="main-nav">
    <div class="nav-container">
      <NuxtLink :to="localePath('/')" class="logo">
        {{ $t('meta.title') }}
      </NuxtLink>

      <div class="nav-links">
        <NuxtLink
          v-for="item in navigation"
          :key="item.path"
          :to="localePath(item.path)"
          class="nav-link"
        >
          {{ $t(item.label) }}
        </NuxtLink>
      </div>

      <LanguageSwitcher />
    </div>
  </nav>
</template>

<script setup lang="ts">
const navigation = [
  { path: '/', label: 'nav.home' },
  { path: '/about', label: 'nav.about' },
  { path: '/services', label: 'nav.services' },
  { path: '/contact', label: 'nav.contact' },
  { path: '/blog', label: 'nav.blog' }
]
</script>

<style scoped>
.main-nav {
  background: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  position: sticky;
  top: 0;
  z-index: 100;
}

.nav-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 2rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 70px;
}

.logo {
  font-size: 1.5rem;
  font-weight: 700;
  color: #333;
  text-decoration: none;
}

.nav-links {
  display: flex;
  gap: 2rem;
}

.nav-link {
  color: #666;
  text-decoration: none;
  font-weight: 500;
  transition: color 0.3s ease;
}

.nav-link:hover,
.nav-link.router-link-active {
  color: #667eea;
}

@media (max-width: 768px) {
  .nav-container {
    flex-direction: column;
    height: auto;
    padding: 1rem;
  }

  .nav-links {
    margin: 1rem 0;
  }
}
</style>

Language Switcher Component

<!-- components/LanguageSwitcher.vue -->
<template>
  <div class="language-switcher">
    <button
      @click="isOpen = !isOpen"
      class="language-button"
      :aria-expanded="isOpen"
    >
      <span class="current-language">
        {{ currentLocale.name }}
      </span>
      <svg
        class="chevron"
        :class="{ 'chevron-open': isOpen }"
        width="16"
        height="16"
        viewBox="0 0 24 24"
        fill="currentColor"
      >
        <path d="M7 10l5 5 5-5z"/>
      </svg>
    </button>

    <Transition name="dropdown">
      <div v-if="isOpen" class="language-dropdown">
        <NuxtLink
          v-for="locale in availableLocales"
          :key="locale.code"
          :to="switchLocalePath(locale.code)"
          class="language-option"
          @click="isOpen = false"
        >
          {{ locale.name }}
        </NuxtLink>
      </div>
    </Transition>
  </div>
</template>

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

const isOpen = ref(false)

const currentLocale = computed(() => {
  return locales.value.find(l => l.code === locale.value)
})

const availableLocales = computed(() => {
  return locales.value.filter(l => l.code !== locale.value)
})

// Close dropdown when clicking outside
onMounted(() => {
  document.addEventListener('click', (e) => {
    if (!e.target.closest('.language-switcher')) {
      isOpen.value = false
    }
  })
})
</script>

<style scoped>
.language-switcher {
  position: relative;
}

.language-button {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  background: #f8f9fa;
  border: 1px solid #ddd;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.language-button:hover {
  background: #e9ecef;
}

.current-language {
  font-weight: 500;
  color: #333;
}

.chevron {
  transition: transform 0.3s ease;
}

.chevron-open {
  transform: rotate(180deg);
}

.language-dropdown {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 0.5rem;
  background: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  z-index: 1000;
}

.language-option {
  display: block;
  padding: 0.75rem 1rem;
  color: #333;
  text-decoration: none;
  white-space: nowrap;
  transition: background 0.3s ease;
}

.language-option:hover {
  background: #f8f9fa;
}

.dropdown-enter-active,
.dropdown-leave-active {
  transition: all 0.3s ease;
}

.dropdown-enter-from,
.dropdown-leave-to {
  opacity: 0;
  transform: translateY(-10px);
}
</style>

SimpleLocalize Integration

Set up SimpleLocalize CLI for translation management:

# macOS / Linux / Windows (WSL) curl -s https://get.simplelocalize.io/2.9/install | bash # Windows (PowerShell) . { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex; # npm npm install @simplelocalize/cli
# macOS / Linux / Windows (WSL)
curl -s https://get.simplelocalize.io/2.9/install | bash

# Windows (PowerShell)
. { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex;

# npm
npm install @simplelocalize/cli

Create simplelocalize.yml in your project root:

apiKey: YOUR_PROJECT_API_KEY

# Upload configuration
uploadFormat: single-language-json
uploadPath: ./locales/{lang}.json
uploadLanguageKey: en
uploadOptions:
  - REPLACE_TRANSLATION_IF_FOUND

# Download configuration
downloadPath: ./locales/{lang}.json
downloadLanguageKeys: ['en', 'es', 'fr', 'de']
downloadFormat: single-language-json
downloadOptions:
  - CREATE_DIRECTORIES

Translation workflow:

{
  "scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build",
    "preview": "nuxt preview",
    "translations:upload": "simplelocalize upload",
    "translations:download": "simplelocalize download",
    "translations:sync": "npm run translations:upload && npm run translations:download"
  }
}

Advanced Features

Dynamic Route Translation

// nuxt.config.ts
export default defineNuxtConfig({
  i18n: {
    customRoutes: 'config',
    pages: {
      'about': {
        en: '/about',
        es: '/acerca-de',
        fr: '/a-propos',
        de: '/uber-uns'
      },
      'services': {
        en: '/services',
        es: '/servicios',
        fr: '/services',
        de: '/dienstleistungen'
      }
    }
  }
})

Pluralization

{
  "items": {
    "0": "No items",
    "1": "One item",
    "other": "{{count}} items"
  }
}

Usage:

<template>
  <p>{{ $tc('items', itemCount, { count: itemCount }) }}</p>
</template>

Lazy Loading with Async Components

<script setup lang="ts">
// Lazy load heavy components based on locale
const HeavyComponent = defineAsyncComponent(() => {
  const { locale } = useI18n()
  return import(`~/components/heavy/Heavy-${locale.value}.vue`)
})
</script>

Resources

Was this helpful?