Índice
Vue i18n ha madurado significativamente con la Composition API. Si todavía sigues los patrones de Options API de tutoriales escritos en 2020, estás dejando mucho sobre la mesa — especialmente en lo que respecta a la seguridad de tipos, la carga diferida y los mensajes servidos desde CDN.
Esta es una guía práctica para construir aplicaciones Vue 3 multilingües que escalen: desde traducciones por componente hasta el cambio de idioma basado en rutas y la integración con Nuxt 3. Cubriremos los patrones que se sostienen a escala empresarial, no solo los que funcionan en demos.
Configurar vue-i18n con Composition API
Comienza con el modo Composition API. El modo Options API sigue existiendo por compatibilidad con versiones anteriores, pero no te proporciona los patrones de composables que hacen manejables las bases de código grandes.
npm install vue-i18n@9
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
export type MessageSchema = typeof import('./locales/en.json')
export const i18n = createI18n<[MessageSchema], 'en' | 'fr' | 'de'>({
legacy: false, // Composition API mode
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {} // loaded lazily
}
})
Establecer legacy: false es el paso crítico. Desbloquea useI18n() y toda la superficie de la Composition API.
useI18n(): El composable central
Cada componente que necesita traducciones usa useI18n(). El patrón básico:
// src/components/WelcomeHeader.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<h1>{{ t('welcome.heading') }}</h1>
<p>{{ t('welcome.subheading', { plan: 'Pro' }) }}</p>
</template>
La función t() es reactiva. Cuando locale cambia, todas las llamadas a t() se reevalúan automáticamente. Esto significa que el cambio de idioma simplemente funciona sin ningún re-renderizado manual.
Alcance global vs. local
Aquí es donde la mayoría de los equipos se equivocan al escalar. vue-i18n soporta dos alcances, y mezclarlos descuidadamente crea problemas de mantenimiento.
Alcance global — mensajes definidos a nivel de la instancia i18n, accesibles desde cualquier lugar:
// src/i18n/locales/en.json
{
"nav.home": "Home",
"nav.pricing": "Pricing",
"common.save": "Save",
"common.cancel": "Cancel"
}
Alcance local — mensajes limitados a un componente específico, definidos dentro del propio componente:
// src/components/BillingForm.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n({
messages: {
en: {
title: 'Billing Information',
cardNumber: 'Card number',
expiryDate: 'Expiry date',
errors: {
invalidCard: 'Please check your card number'
}
},
fr: {
title: 'Informations de facturation',
cardNumber: 'Numéro de carte',
expiryDate: "Date d'expiration",
errors: {
invalidCard: 'Veuillez vérifier votre numéro de carte'
}
}
}
})
</script>
La regla general: la navegación, las acciones comunes y los mensajes de error que aparecen en toda la aplicación van en el alcance global. El texto de la interfaz específico de una funcionalidad va en el alcance local, colocado junto al componente.
Los mensajes de alcance local no contaminan el espacio de nombres global y dejan claro qué traducciones pertenecen a qué funcionalidad. Cuando eliminas un componente, eliminas sus traducciones con él.
Seguridad de tipos con TypeScript
Sin seguridad de tipos, i18n se convierte en un problema de errores en tiempo de ejecución. No sabrás que falta una clave de traducción hasta que un usuario vea una cadena vacía en producción.
vue-i18n soporta integración completa con TypeScript. La configuración requiere una declaración de tipos:
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import enMessages from './locales/en.json'
type MessageSchema = typeof enMessages
declare module 'vue-i18n' {
export interface DefineLocaleMessage extends MessageSchema {}
}
export const i18n = createI18n<false, MessageSchema>({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
Ahora t() está completamente tipado. Si pasas una clave que no existe, TypeScript lo detecta en tiempo de compilación:
const { t } = useI18n()
t('nav.home') // OK
t('nav.homee') // TypeScript error: Argument of type '"nav.homee"' is not assignable
t('welcome.heading') // OK — if it exists in the schema
A escala, esta es la diferencia entre que i18n sea una carga de mantenimiento o que sea invisible. Tu IDE autocompleta las claves. Los errores tipográficos fallan en tiempo de compilación, no en producción.
Carga diferida de locales
Cargar todos los locales al inicio está bien para aplicaciones pequeñas. Para aplicaciones empresariales con 20 o más idiomas y miles de claves, no lo está. La solución es la carga diferida: cargar el locale activo bajo demanda.
// src/i18n/loader.ts
import { i18n } from './index'
const loadedLocales = new Set<string>(['en']) // en loaded at init
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) return
const messages = await import(`./locales/${locale}.json`)
i18n.global.setLocaleMessage(locale, messages.default)
loadedLocales.add(locale)
}
export async function setLocale(locale: string): Promise<void> {
await loadLocale(locale)
i18n.global.locale.value = locale as any
document.documentElement.setAttribute('lang', locale)
}
Usa esto en un componente selector de idioma:
// src/components/LocaleSwitcher.vue
<script setup lang="ts">
import { setLocale } from '@/i18n/loader'
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
]
async function handleChange(code: string) {
await setLocale(code)
}
</script>
<template>
<select @change="e => handleChange((e.target as HTMLSelectElement).value)">
<option v-for="l in locales" :key="l.code" :value="l.code">
{{ l.label }}
</option>
</select>
</template>
Cada paquete de locale es un chunk separado. Los usuarios solo descargan el idioma que usan.
Cambio de idioma basado en rutas
Para aplicaciones críticas para el SEO, el locale debe vivir en la URL — /en/pricing, /fr/tarifs. Este enfoque le da a cada locale su propia URL indexable y permite que los motores de búsqueda entiendan la estructura de idiomas de tu sitio.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { setLocale } from '@/i18n/loader'
const SUPPORTED_LOCALES = ['en', 'fr', 'de']
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/:locale(en|fr|de)',
component: () => import('@/layouts/LocaleLayout.vue'),
children: [
{ path: '', component: () => import('@/pages/Home.vue') },
{ path: 'pricing', component: () => import('@/pages/Pricing.vue') },
{ path: 'features', component: () => import('@/pages/Features.vue') },
]
},
{
path: '/:pathMatch(.*)*',
redirect: to => `/en/${to.params.pathMatch}`
}
]
})
router.beforeEach(async (to) => {
const locale = to.params.locale as string
if (SUPPORTED_LOCALES.includes(locale)) {
await setLocale(locale)
}
})
export default router
El componente de diseño de locale gestiona las etiquetas <link rel="alternate" hreflang> para el SEO:
// src/layouts/LocaleLayout.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useHead } from '@vueuse/head'
const route = useRoute()
const locale = route.params.locale as string
const LOCALES = ['en', 'fr', 'de']
useHead({
htmlAttrs: { lang: locale },
link: LOCALES.map(l => ({
rel: 'alternate',
hreflang: l,
href: `https://example.com/${l}${route.path.replace(`/${locale}`, '')}`
}))
})
</script>
<template>
<RouterView />
</template>
Pluralización y formateo
La pluralización es donde las implementaciones ingenuas de i18n fallan. El inglés tiene dos formas plurales. El árabe tiene seis. El ruso usa formas diferentes según el último dígito del número. vue-i18n lo maneja correctamente si lo usas correctamente.
Pluralización:
// en.json
{
"items": "no items | one item | {count} items"
}
// ru.json — Russian plural rules
{
"items": "нет элементов | {count} элемент | {count} элемента | {count} элементов"
}
const { t, n, d } = useI18n()
// Pluralization
t('items', 0) // "no items"
t('items', 1) // "one item"
t('items', 42) // "42 items"
// Number formatting with named formats
n(1234567.89, 'currency') // "$1,234,567.89" in en, "1 234 567,89 €" in fr
Define los formatos con nombre a nivel de la instancia i18n:
export const i18n = createI18n({
legacy: false,
locale: 'en',
numberFormats: {
en: {
currency: { style: 'currency', currency: 'USD', notation: 'standard' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
},
fr: {
currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
}
},
datetimeFormats: {
en: {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
},
fr: {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
}
}
})
Ahora n() y d() formatean correctamente para cada locale sin ninguna lógica por componente.
Mensajes servidos desde CDN vs. JSON incluido en el bundle
Incluir los archivos JSON de locale en tu aplicación tiene un coste real: cada actualización de traducción requiere un redespliegue. Para los equipos que actualizan las traducciones con frecuencia — realizando pruebas A/B en los textos, corrigiendo errores, localizando para nuevos mercados — esto es una restricción importante.
La alternativa son los mensajes servidos desde CDN. Tus archivos de traducción viven en un CDN y tu aplicación los obtiene en tiempo de ejecución. Esto funciona con cualquier configuración de Vue — Nuxt 3, Vite SPA o renderizado en servidor personalizado.
// src/i18n/cdn-loader.ts
const CDN_BASE = 'https://cdn.example.com/i18n'
export async function loadLocaleFromCDN(locale: string): Promise<Record<string, string>> {
const res = await fetch(`${CDN_BASE}/${locale}.json`, {
headers: { 'Cache-Control': 'max-age=3600' }
})
if (!res.ok) throw new Error(`Failed to load locale: ${locale}`)
return res.json()
}
El coste es una solicitud de red al cargar el locale. Con un CDN con caché adecuada, esto suele ser un acierto de caché después de la primera carga. El beneficio es que tu equipo de traducción puede publicar actualizaciones de texto de forma instantánea, sin tocar el pipeline de despliegue de código.
En Better i18n, la entrega por CDN está integrada en la plataforma. Las traducciones pasan por un flujo de revisión, se publican y están disponibles al instante globalmente a través del CDN — sin redespliegue, sin PR para corregir una errata. La página de características explica cómo funciona el pipeline de entrega, y si estás evaluando esto específicamente para un proyecto Vue, la página para desarrolladores tiene ejemplos de integración del SDK.
Integración con Nuxt 3
Nuxt 3 tiene soporte i18n de primera clase a través de @nuxtjs/i18n, que envuelve vue-i18n y añade renderizado en el servidor, generación de rutas y utilidades SEO.
npm install @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'en', iso: 'en-US', file: 'en.json' },
{ code: 'fr', iso: 'fr-FR', file: 'fr.json' },
{ code: 'de', iso: 'de-DE', file: 'de.json' }
],
defaultLocale: 'en',
langDir: 'i18n/locales/',
lazy: true,
strategy: 'prefix_except_default',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root'
}
}
})
Con strategy: 'prefix_except_default', las rutas en inglés no tienen prefijo (/pricing) mientras que los demás locales sí (/fr/tarifs). Nuxt gestiona la generación de rutas automáticamente.
En los componentes, useI18n() funciona de forma idéntica a una aplicación sin Nuxt:
// pages/pricing.vue
<script setup lang="ts">
const { t } = useI18n()
useSeoMeta({
title: t('pricing.meta.title'),
description: t('pricing.meta.description')
})
</script>
La integración con SSR significa que el contenido traducido está en el HTML inicial — sin parpadeo de contenido sin traducir, y los motores de búsqueda ven la versión localizada.
Escalar el flujo de trabajo del desarrollador
A medida que crece tu superficie de traducción, el cuello de botella pasa de la configuración técnica al flujo de trabajo. Algunos patrones que se mantienen a escala:
Mantén las claves de traducción semánticas, no posicionales. nav.pricing envejece mejor que link3. Cuando la navegación se rediseña, link3 pierde su significado. nav.pricing sigue siendo correcto.
Trata las traducciones que faltan como errores de compilación. Configura tu proceso de compilación para que avise o falle cuando una clave existe en el locale predeterminado pero falta en un locale de destino. Detéctalo antes de que lo hagan los usuarios.
Automatiza la extracción de traducciones. Ejecutar vue-i18n-extract o herramientas similares como parte del CI garantiza que cada nueva cadena se marque para su traducción antes de que se publique.
Separa la revisión de traducciones de la revisión de código. La calidad de la traducción es una habilidad diferente a la calidad del código. Si los traductores necesitan abrir una PR para corregir una errata, has creado fricción innecesaria. Usa una plataforma de traducción donde la revisión lingüística ocurra en el idioma, no en Git.
La base técnica descrita en este artículo se encarga del lado de la ingeniería. El lado del flujo de trabajo — conseguir que se revisen las traducciones, gestionar el acceso de los traductores, mantener los glosarios coherentes entre idiomas — es donde las herramientas dedicadas ofrecen un rendimiento desproporcionado.