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 settingslazy
- Load translation files on demand for better performanceseo
- 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>
Navigation and Language Switching
Main Navigation Component
<!-- 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?