Skip to content

Internationalization (i18n) Guide

Add comprehensive multi-language support to your God Panel application with internationalization.

Overview

Internationalization (i18n) allows your application to support multiple languages and locales, making it accessible to users worldwide.

Setup

1. Install i18n Module

bash
npm install @nuxtjs/i18n

2. Configure Nuxt i18n

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

  i18n: {
    // Base URL
    baseUrl: process.env.BASE_URL || 'http://localhost:3000',

    // Supported locales
    locales: [
      {
        code: 'en',
        name: 'English',
        file: 'en.json',
        dir: 'ltr'
      },
      {
        code: 'fa',
        name: 'فارسی',
        file: 'fa.json',
        dir: 'rtl'
      },
      {
        code: 'es',
        name: 'Español',
        file: 'es.json',
        dir: 'ltr'
      }
    ],

    // Default locale
    defaultLocale: 'en',

    // Strategy for URL structure
    strategy: 'prefix_except_default',

    // Fallback locale
    fallbackLocale: 'en',

    // Custom routes
    pages: {
      'about': {
        en: '/about',
        fa: '/درباره',
        es: '/acerca-de'
      }
    },

    // SEO options
    seo: true,

    // Lazy loading
    lazy: true,

    // Language detection
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root'
    }
  }
})

3. Create Translation Files

Create translation files in the i18n/locales/ directory:

i18n/locales/en.json:

json
{
  "welcome": "Welcome",
  "home": {
    "title": "Home",
    "description": "Welcome to our application"
  },
  "nav": {
    "dashboard": "Dashboard",
    "users": "Users",
    "settings": "Settings",
    "profile": "Profile",
    "logout": "Logout"
  },
  "auth": {
    "login": "Login",
    "register": "Register",
    "email": "Email",
    "password": "Password",
    "confirmPassword": "Confirm Password",
    "forgotPassword": "Forgot Password?",
    "rememberMe": "Remember me",
    "loginButton": "Sign In",
    "registerButton": "Create Account"
  },
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "edit": "Edit",
    "add": "Add",
    "search": "Search",
    "loading": "Loading...",
    "error": "Error",
    "success": "Success",
    "warning": "Warning",
    "info": "Information"
  },
  "validation": {
    "required": "This field is required",
    "email": "Please enter a valid email address",
    "minLength": "Must be at least {min} characters",
    "maxLength": "Must be no more than {max} characters",
    "passwordMismatch": "Passwords do not match"
  }
}

i18n/locales/fa.json:

json
{
  "welcome": "خوش آمدید",
  "home": {
    "title": "خانه",
    "description": "به اپلیکیشن ما خوش آمدید"
  },
  "nav": {
    "dashboard": "داشبورد",
    "users": "کاربران",
    "settings": "تنظیمات",
    "profile": "پروفایل",
    "logout": "خروج"
  },
  "auth": {
    "login": "ورود",
    "register": "ثبت نام",
    "email": "ایمیل",
    "password": "رمز عبور",
    "confirmPassword": "تکرار رمز عبور",
    "forgotPassword": "رمز عبور را فراموش کرده‌اید؟",
    "rememberMe": "مرا به خاطر بسپار",
    "loginButton": "ورود",
    "registerButton": "ایجاد حساب"
  },
  "common": {
    "save": "ذخیره",
    "cancel": "لغو",
    "delete": "حذف",
    "edit": "ویرایش",
    "add": "افزودن",
    "search": "جستجو",
    "loading": "در حال بارگذاری...",
    "error": "خطا",
    "success": "موفقیت",
    "warning": "هشدار",
    "info": "اطلاعات"
  },
  "validation": {
    "required": "این فیلد الزامی است",
    "email": "لطفاً یک آدرس ایمیل معتبر وارد کنید",
    "minLength": "باید حداقل {min} کاراکتر باشد",
    "maxLength": "نباید بیش از {max} کاراکتر باشد",
    "passwordMismatch": "رمزهای عبور مطابقت ندارند"
  }
}

i18n/locales/es.json:

json
{
  "welcome": "Bienvenido",
  "home": {
    "title": "Inicio",
    "description": "Bienvenido a nuestra aplicación"
  },
  "nav": {
    "dashboard": "Panel",
    "users": "Usuarios",
    "settings": "Configuración",
    "profile": "Perfil",
    "logout": "Cerrar Sesión"
  },
  "auth": {
    "login": "Iniciar Sesión",
    "register": "Registrarse",
    "email": "Correo Electrónico",
    "password": "Contraseña",
    "confirmPassword": "Confirmar Contraseña",
    "forgotPassword": "¿Olvidaste tu contraseña?",
    "rememberMe": "Recordarme",
    "loginButton": "Iniciar Sesión",
    "registerButton": "Crear Cuenta"
  },
  "common": {
    "save": "Guardar",
    "cancel": "Cancelar",
    "delete": "Eliminar",
    "edit": "Editar",
    "add": "Agregar",
    "search": "Buscar",
    "loading": "Cargando...",
    "error": "Error",
    "success": "Éxito",
    "warning": "Advertencia",
    "info": "Información"
  },
  "validation": {
    "required": "Este campo es obligatorio",
    "email": "Por favor ingresa una dirección de correo válida",
    "minLength": "Debe tener al menos {min} caracteres",
    "maxLength": "No debe tener más de {max} caracteres",
    "passwordMismatch": "Las contraseñas no coinciden"
  }
}

Basic Usage

In Templates

vue
<template>
  <div>
    <h1>{{ $t('welcome') }}</h1>
    <p>{{ $t('home.description') }}</p>

    <!-- With interpolation -->
    <p>{{ $t('validation.minLength', { min: 8 }) }}</p>

    <!-- Pluralization -->
    <p>{{ $t('items', count) }}</p>
  </div>
</template>

<script setup>
// Access current locale
const { locale } = useI18n()

// Switch locale
const switchToPersian = () => {
  await navigateTo('/', { locale: 'fa' })
}
</script>

In Components

vue
<!-- components/LocaleSwitcher.vue -->
<template>
  <div class="locale-switcher">
    <button
      v-for="locale in availableLocales"
      :key="locale.code"
      @click="switchLocale(locale.code)"
      :class="{ active: locale.code === currentLocale }"
      class="locale-btn"
    >
      <Icon :name="locale.icon" />
      {{ locale.name }}
    </button>
  </div>
</template>

<script setup>
const { locale: currentLocale, locales } = useI18n()

const availableLocales = computed(() =>
  locales.value.map(loc => ({
    ...loc,
    icon: getLocaleIcon(loc.code)
  }))
)

const getLocaleIcon = (code: string) => {
  const icons: Record<string, string> = {
    en: 'mdi-alpha-e',
    fa: 'mdi-alpha-f',
    es: 'mdi-alpha-e'
  }
  return icons[code] || 'mdi-translate'
}

const switchLocale = async (localeCode: string) => {
  await navigateTo($route.path, { locale: localeCode })
}
</script>

<style scoped>
.locale-switcher {
  display: flex;
  gap: var(--space-2);
}

.locale-btn {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border-primary);
  border-radius: var(--radius-md);
  background: none;
  cursor: pointer;
  font-size: var(--text-sm);
  transition: all 0.2s ease;
}

.locale-btn:hover {
  background-color: var(--bg-secondary);
}

.locale-btn.active {
  background-color: var(--color-primary);
  color: white;
  border-color: var(--color-primary);
}
</style>

In Composables

typescript
// composables/useTranslation.ts
export const useTranslation = () => {
  const { t, locale } = useI18n()

  const translateWithFallback = (key: string, fallback: string): string => {
    try {
      return t(key)
    } catch {
      return fallback
    }
  }

  const formatMessage = (key: string, params: Record<string, any> = {}): string => {
    let message = t(key)

    // Replace parameters
    Object.entries(params).forEach(([param, value]) => {
      message = message.replace(`{${param}}`, String(value))
    })

    return message
  }

  const getLocalizedRoute = (path: string, targetLocale?: string): string => {
    const target = targetLocale || locale.value
    return `/${target}${path === '/' ? '' : path}`
  }

  return {
    t,
    locale,
    translateWithFallback,
    formatMessage,
    getLocalizedRoute
  }
}

Advanced Features

RTL Support

vue
<!-- layouts/rtl.vue -->
<template>
  <div dir="rtl" class="rtl-layout">
    <slot />
  </div>
</template>

<style scoped>
.rtl-layout {
  direction: rtl;
  text-align: right;
}

/* RTL specific styles */
[dir="rtl"] .nav-item {
  text-align: right;
}

[dir="rtl"] .icon-left {
  transform: scaleX(-1);
}

[dir="rtl"] .dropdown-menu {
  left: 0;
  right: auto;
}
</style>

Date and Number Formatting

typescript
// composables/useLocale.ts
export const useLocale = () => {
  const { locale } = useI18n()

  const formatDate = (date: Date | string, options?: Intl.DateTimeFormatOptions) => {
    const dateObj = typeof date === 'string' ? new Date(date) : date

    return new Intl.DateTimeFormat(locale.value, {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      ...options
    }).format(dateObj)
  }

  const formatNumber = (number: number, options?: Intl.NumberFormatOptions) => {
    return new Intl.NumberFormat(locale.value, {
      minimumFractionDigits: 0,
      maximumFractionDigits: 2,
      ...options
    }).format(number)
  }

  const formatCurrency = (amount: number, currency: string = 'USD') => {
    return new Intl.NumberFormat(locale.value, {
      style: 'currency',
      currency,
      minimumFractionDigits: 2
    }).format(amount)
  }

  const formatRelativeTime = (date: Date | string) => {
    const dateObj = typeof date === 'string' ? new Date(date) : date
    const now = new Date()
    const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000)

    const rtf = new Intl.RelativeTimeFormat(locale.value, {
      numeric: 'auto'
    })

    if (diffInSeconds < 60) {
      return rtf.format(-diffInSeconds, 'second')
    } else if (diffInSeconds < 3600) {
      return rtf.format(-Math.floor(diffInSeconds / 60), 'minute')
    } else if (diffInSeconds < 86400) {
      return rtf.format(-Math.floor(diffInSeconds / 3600), 'hour')
    } else {
      return rtf.format(-Math.floor(diffInSeconds / 86400), 'day')
    }
  }

  return {
    formatDate,
    formatNumber,
    formatCurrency,
    formatRelativeTime
  }
}

Lazy Loading Translations

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  i18n: {
    lazy: true,
    langDir: 'locales/',
    defaultLocale: 'en',

    // Lazy loading strategy
    strategy: 'prefix_except_default',

    // Custom loader
    customRoutes: [
      {
        prefix: 'docs/',
        pages: {
          'about': {
            en: '/docs/about',
            fa: '/docs/درباره',
            es: '/docs/acerca-de'
          }
        }
      }
    ]
  }
})

SEO and Meta Tags

Dynamic Meta Tags

vue
<!-- pages/about.vue -->
<template>
  <div>
    <h1>{{ $t('about.title') }}</h1>
    <p>{{ $t('about.description') }}</p>
  </div>
</template>

<script setup>
// Set meta tags dynamically
const { t } = useI18n()

useHead({
  title: t('about.title'),
  meta: [
    {
      name: 'description',
      content: t('about.description')
    },
    {
      property: 'og:title',
      content: t('about.title')
    },
    {
      property: 'og:description',
      content: t('about.description')
    },
    {
      property: 'og:locale',
      content: t('meta.locale')
    }
  ]
})
</script>

Language-Specific URLs

typescript
// composables/useLocalizedRoutes.ts
export const useLocalizedRoutes = () => {
  const { locale, locales } = useI18n()

  const getLocalizedPath = (path: string, targetLocale?: string): string => {
    const target = targetLocale || locale.value
    const defaultLocale = 'en'

    if (target === defaultLocale) {
      return path
    }

    return `/${target}${path}`
  }

  const getAlternateLinks = (path: string) => {
    return locales.value.map(loc => ({
      rel: 'alternate',
      hreflang: loc.code,
      href: `${process.env.BASE_URL}${getLocalizedPath(path, loc.code)}`
    }))
  }

  const switchLocale = async (newLocale: string) => {
    const newPath = getLocalizedPath($route.path, newLocale)
    await navigateTo(newPath)
  }

  return {
    getLocalizedPath,
    getAlternateLinks,
    switchLocale
  }
}

Translation Management

Translation Keys Structure

json
{
  "nav": {
    "dashboard": "Dashboard",
    "users": "Users",
    "settings": "Settings"
  },
  "auth": {
    "login": {
      "title": "Login",
      "button": "Sign In",
      "forgot": "Forgot password?"
    }
  },
  "forms": {
    "labels": {
      "email": "Email address",
      "password": "Password"
    },
    "errors": {
      "required": "This field is required",
      "email": "Invalid email format"
    }
  }
}

Missing Translation Handler

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

  // Custom missing translation handler
  const originalT = t
  const customT = (key: string, ...params: any[]) => {
    const translation = originalT(key, ...params)

    // Check if translation is missing (fallback to key)
    if (translation === key) {
      console.warn(`Missing translation for key: ${key}`)

      // You could also send this to a translation service
      // reportMissingTranslation(key)
    }

    return translation
  }

  return {
    provide: {
      t: customT
    }
  }
})

Testing i18n

Unit Tests

typescript
// test/i18n.test.ts
describe('Internationalization', () => {
  test('should translate text correctly', () => {
    const { t } = useI18n()

    expect(t('welcome')).toBe('Welcome')
  })

  test('should switch locales correctly', async () => {
    const { locale, locales } = useI18n()

    expect(locale.value).toBe('en')

    // Switch to Persian
    await navigateTo('/', { locale: 'fa' })

    expect(locale.value).toBe('fa')
    expect(t('welcome')).toBe('خوش آمدید')
  })

  test('should format dates correctly for locale', () => {
    const { formatDate } = useLocale()
    const date = new Date('2025-01-15')

    // Test English formatting
    expect(formatDate(date, { locale: 'en' }))
      .toBe('January 15, 2025')

    // Test Persian formatting
    expect(formatDate(date, { locale: 'fa' }))
      .toBe('۱۵ ژانویه ۲۰۲۵')
  })
})

Performance Optimization

Code Splitting

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  i18n: {
    lazy: true,
    langDir: 'locales/',

    // Only load used translations
    compilation: {
      strictMessage: false
    }
  },

  // Optimize bundle size
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            i18n: ['@nuxtjs/i18n']
          }
        }
      }
    }
  }
})

Translation Caching

typescript
// composables/useTranslationCache.ts
export const useTranslationCache = () => {
  const cache = new Map<string, string>()

  const getCachedTranslation = (key: string, locale: string): string | null => {
    const cacheKey = `${locale}:${key}`
    return cache.get(cacheKey) || null
  }

  const setCachedTranslation = (key: string, locale: string, translation: string) => {
    const cacheKey = `${locale}:${key}`
    cache.set(cacheKey, translation)
  }

  const clearCache = () => {
    cache.clear()
  }

  return {
    getCachedTranslation,
    setCachedTranslation,
    clearCache
  }
}

Deployment Considerations

CDN Translation Loading

typescript
// For CDN deployment, load translations dynamically
export const loadTranslations = async (locale: string) => {
  try {
    const translations = await $fetch(`/locales/${locale}.json`)
    return translations
  } catch (error) {
    console.error(`Failed to load translations for ${locale}:`, error)
    return {}
  }
}

Environment Variables

env
# i18n Configuration
NUXT_I18N_DEFAULT_LOCALE=en
NUXT_I18N_FALLBACK_LOCALE=en
NUXT_I18N_BASE_URL=https://your-domain.com
NUXT_I18N_DEBUG=false

Best Practices

Translation Key Naming

typescript
// ✅ Good
const keys = {
  'nav.dashboard': 'Dashboard',
  'nav.users': 'Users',
  'auth.login.title': 'Sign In',
  'auth.login.button': 'Sign In',
  'forms.email.label': 'Email Address',
  'forms.email.error.required': 'Email is required',
  'forms.email.error.invalid': 'Invalid email format'
}

// ❌ Avoid
const keys = {
  'dashboard': 'Dashboard',
  'login': 'Sign In', // Ambiguous
  'email': 'Email Address' // Context missing
}

Pluralization

json
{
  "items": "no items | {count} item | {count} items",
  "person": "no people | {count} person | {count} people"
}

Context-Aware Translations

json
{
  "save": "Save",
  "save.context.file": "Save File",
  "save.context.settings": "Save Settings",
  "save.context.draft": "Save Draft"
}

Troubleshooting

Common Issues

Translations not loading:

typescript
// Check if translation files exist
const { t } = useI18n()
console.log('Available locales:', useI18n().locales.value)
console.log('Translation test:', t('welcome'))

RTL layout issues:

css
/* Ensure proper RTL support */
[dir="rtl"] {
  text-align: right;
}

[dir="rtl"] .flex {
  flex-direction: row-reverse;
}

[dir="rtl"] .space-x-4 > * + * {
  margin-right: 1rem;
  margin-left: 0;
}

SEO meta tags not updating:

vue
<script setup>
const { t, locale } = useI18n()

useHead({
  title: t('page.title'),
  htmlAttrs: {
    lang: locale.value,
    dir: t('meta.direction')
  },
  meta: [
    { name: 'description', content: t('page.description') }
  ]
})
</script>

Migration from Other i18n Solutions

From Vue I18n

typescript
// If migrating from vue-i18n
import { createI18n } from 'vue-i18n'

export default createI18n({
  legacy: false,
  globalInjection: true,
  locale: 'en',
  messages: {
    en: { /* translations */ },
    fa: { /* translations */ }
  }
})

From Nuxt i18n v7

typescript
// Migration to v8
export default defineNuxtConfig({
  i18n: {
    // v8 uses 'locales' instead of 'langDir'
    locales: [
      { code: 'en', file: 'en.json' },
      { code: 'fa', file: 'fa.json' }
    ],

    // Strategy changes
    strategy: 'prefix_except_default',

    // SEO is now enabled by default
    seo: true
  }
})

Resources


Next: Check out the Component Library to see how components work with internationalization!

Released under the MIT License.