
i18n y l10n en proyectos Vue/Nuxt desde una perspectiva de proyectos reales
Andrea Di Toro
Vue & Nuxt Senior Developer & Consultant
La internacionalización (i18n) y la localización (l10n) suelen verse como un detalle técnico menor, cuando en realidad implican decisiones de arquitectura con impacto directo en SEO, escalabilidad y mantenimiento.
La mayoría de los problemas que he visto en proyectos reales tienen un origen común: añadir i18n a posteriori, lo que genera cambios costosos que chocan con la arquitectura existente.
Este artículo propone un marco de decisión basado en preguntas clave que te ayudarán a definir la estrategia i18n adecuada desde el principio, permitiendo escalar sin reescrituras traumáticas.
Para plantear una estrategia, estas son las preguntas que suelo hacer siempre en fases tempranas. Las respuestas nos darán indicaciones sobre el tipo de solución que tenemos que plantear.
No existe una respuesta correcta universal. La estrategia depende de estas respuestas, no de la librería que esté de moda.
Si solo necesitas un idioma hoy, la estrategia más inteligente es preparar el terreno sin sobre-ingeniería. Instala @nuxtjs/i18n y pasa todos los literales a archivos JSON desde el principio:
// nuxt.config.ts - Configuración mínima para empezar
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [{ code: 'es', language: 'es-ES', file: 'es.json', name: 'Español' }],
defaultLocale: 'es',
lazy: true,
langDir: 'locales/',
},
})
Para proyectos más grandes, puedes organizar las traducciones por módulo o sección:
app/
├── locales/
│ └── es/
│ ├── common.json # Textos compartidos (navegación, botones)
│ ├── landing.json # Página principal
│ ├── dashboard.json # Panel de usuario
│ └── auth.json # Login, registro, errores
// nuxt.config.ts - Configuración con múltiples archivos
i18n: {
locales: [
{
code: 'es',
language: 'es-ES',
files: ['es/common.json', 'es/landing.json', 'es/dashboard.json', 'es/auth.json'],
name: 'Español',
},
]
}
Los archivos de idiomas viven dentro del repositorio y solo pueden ser modificados por desarrolladores. Esto puede parecer limitante, pero es la opción más simple y segura para equipos técnicos pequeños.
Con esta base, el día que necesites, por ejemplo, añadir inglés, solo tienes que:
locales/en/ con los mismos archivos// Añadir un segundo idioma es trivial
locales: [
{ code: 'es', language: 'es-ES', files: ['es/common.json', ...], name: 'Español' },
{ code: 'en', language: 'en-US', files: ['en/common.json', ...], name: 'English' }
]
Si los textos viven en la base de datos o en el backend, fuera de la aplicación de front, necesitamos hacer algo más que configurar Nuxt.js con el módulo de i18n. Hay que tomar decisiones importantes que pueden afectar a la arquitectura de la aplicación. Si estás en esta casuística, planteo las siguientes posibilidades:
El backend envía claves de traducción en lugar de textos. El frontend tiene todos los literales centralizados:
// Respuesta del backend
{
"status": "error",
"message_key": "errors.payment_failed", // Clave, no texto
"code": "PAYMENT_001"
}
// locales/es/errors.json
{
"errors": {
"payment_failed": "El pago no se ha podido procesar. Inténtalo de nuevo.",
"session_expired": "Tu sesión ha expirado. Por favor, inicia sesión de nuevo.",
"not_found": "El recurso solicitado no existe."
}
}
Ventajas: Todos los textos en un solo lugar, el backend se despreocupa del i18n. Desventajas: Requiere coordinación entre equipos para mantener las claves sincronizadas.
El backend tiene su propio sistema de i18n y envía textos ya traducidos. Útil cuando el backend sirve a múltiples clientes (web, móvil, terceros):
// El frontend envía el idioma en cada petición
const { data } = await useFetch('/api/products', {
headers: {
'Accept-Language': locale.value, // 'es', 'en', etc.
},
})
// O como query param
const { data } = await useFetch(`/api/products?lang=${locale.value}`)
Ventajas: La parte de backend está desacoplada del front y puede ser utilizada de forma independiente. Desventajas: Tendremos varios sitios donde traducir, luego puede ser complicado gestionar todas las traducciones.
Para contenido dinámico que vive en base de datos, necesitas un modelo de datos que soporte múltiples idiomas desde el diseño inicial.
Si usas un CMS como Strapi, activa la funcionalidad i18n desde el principio, incluso si hoy solo tienes un idioma. Strapi tiene soporte nativo para internacionalización y activarlo después implica migraciones de datos y cambios en la estructura de content-types que pueden ser costosos. Esto es un ejemplo en Nuxt.js con Strapi:
// Fetch de contenido localizado desde Strapi
const { locale } = useI18n()
const { findOne } = useStrapi()
const { data: article } = await useAsyncData(`article-${slug}-${locale.value}`, () =>
findOne('articles', slug, {
locale: locale.value,
populate: ['cover', 'author', 'category'],
})
)
Si diseñas tu propia base de datos (SQL o NoSQL), planifica desde el inicio una estructura que soporte múltiples idiomas. El patrón más común es crear una tabla de idiomas y desde las otras tablas puedes aplicar claves foráneas al idioma correspondiente.
En mi experiencia, lo más complicado es migrar bases de datos donde al principio del proyecto no tuvieron en cuenta el idioma. Puede ser un verdadero quebradero de cabeza porque no solo hay que modificar la base de datos, sino el proyecto entero: queries, llamadas AJAX pasando parámetros, JSON de respuesta, gestionar el idioma por defecto cuando no existe la traducción solicitada, etc.
Este es otro aspecto importante a tener en cuenta. Si el proyecto es un dashboard privado donde solo se accede tras un login y no tiene implicaciones con el SEO, la cosa es muy sencilla: no hay que hacer nada más de lo anteriormente comentado.
El SEO internacional requiere que cada recurso (página) tenga su propia URL indexable. Google necesita poder rastrear y mostrar la versión correcta a cada usuario. Por lo tanto, la misma página en diferentes idiomas debe tener diferentes URL, y hay que plasmar el idioma correspondiente en la URL.
Estrategias de URL en @nuxtjs/i18n:
// nuxt.config.ts
i18n: {
// Opción 1: Subdirectorios (recomendado)
// example.com/es/productos, example.com/en/products
strategy: 'prefix',
// Opción 2: Sin prefijo para idioma por defecto
// example.com/productos, example.com/en/products
strategy: 'prefix_except_default',
// Opción 3: Subdominios (requiere configuración DNS)
// es.example.com, en.example.com
differentDomains: true,
locales: [
{ code: 'es', domain: 'example.com' },
{ code: 'en', domain: 'en.example.com' }
]
}
URLs traducidas (slug localizados):
// nuxt.config.ts
i18n: {
customRoutes: 'config',
pages: {
'products/index': {
es: '/productos',
en: '/products',
de: '/produkte'
},
'about': {
es: '/nosotros',
en: '/about-us',
de: '/uber-uns'
},
'contact': {
es: '/contacto',
en: '/contact',
de: '/kontakt'
}
}
}
Aplicar estos cambios a un proyecto en producción ya indexado por Google puede ser problemático. Imagina un proyecto con cientos de URLs ya indexadas. Si cambias la estructura de URLs, hay que gestionar todos los redirects 301. Puede causar pérdida temporal de indexación y peor ranking, aunque sea temporal.
// Ejemplo de redirects en nuxt.config.ts para migración
routeRules: {
// Redirigir URLs antiguas a nuevas con idioma
'/productos': { redirect: '/es/productos' },
'/about': { redirect: '/es/nosotros' },
'/contact': { redirect: '/es/contacto' }
}
Esta pregunta determina si los archivos JSON viven en el repositorio o necesitas una solución externa para facilitar al otro equipo intervenir y modificar los textos. En estos casos puede ser útil hacer lo siguiente:
Los textos ya no viven en el mismo repositorio del proyecto, sino que los podemos mover a una plataforma externa especializada en gestión de traducciones. Típicamente ayudan a los equipos a colaborar, sugieren traducciones pendientes, detectan las que faltan, errores ortográficos y tienen integraciones con IA.
Plataforma de localización (Crowdin, Lokalise, Phrase, Weblate)
Estas herramientas sincronizan automáticamente con tu repositorio. Hay que configurar el flujo de CI/CD para que funcionen correctamente. El flujo sería:
es.json y hace un push en una rama del repositorio.CMS como fuente de traducciones
Puedes utilizar un gestor de contenido para traducir los textos. Típicamente suelen tener plugins de i18n, permisos de usuarios, y el mismo CMS puede ser el sitio donde vivan todos los textos. Si ya usas Strapi, puedes crear un content-type para traducciones de UI:
// Strapi: content-type "ui-translation"
{
"key": "hero.title", // Clave única
"es": "Bienvenido", // Texto español
"en": "Welcome", // Texto inglés
"context": "Título principal" // Contexto para traductores
}
Repositorio externo como dependencia npm (compile-time)
Extraer las traducciones a un repositorio independiente permite que el equipo de traducción trabaje sin tocar el código de la aplicación. Una vez terminadas las traducciones podemos incluirlas añadiendo el repositorio como dependencia, con cada npm install se vuelven a descargar las últimas traducciones.
# Estructura del repo de traducciones
my-app-i18n/
├── package.json
├── es.json
├── en.json
└── de.json
// package.json del repo de traducciones
{
"name": "@mycompany/app-translations",
"version": "1.2.0",
"main": "index.js",
"files": ["*.json"]
}
En tu aplicación Nuxt, instalas las traducciones como dependencia:
npm install @mycompany/app-translations
// nuxt.config.ts
i18n: {
locales: [
{ code: 'es', language: 'es-ES', file: 'es.json' },
{ code: 'en', language: 'en-US', file: 'en.json' }
],
langDir: 'node_modules/@mycompany/app-translations/'
}
El flujo de trabajo sería el siguiente:
npm update @mycompany/app-translations).Traducciones cargadas en runtime desde URL externa (run-time)
Esta es la opción más potente para equipos de traducción: los cambios se ven en producción sin necesidad de redesplegar la aplicación. Cuando el equipo de contenido ha terminado, con un flujo CI/CD podemos desplegar los nuevos archivos en un servidor o CDN y cargarlos dinámicamente en la aplicación.
Flujo de trabajo (cambios en tiempo real):
es.json en el repo de traducciones.Ventajas del enfoque runtime:
Consideraciones:
Esta es la diferencia entre internacionalización (i18n) y localización (l10n). Si solo traduces textos literalmente, estás haciendo i18n. Si adaptas contenido, precios, funcionalidades y tono por mercado, estás haciendo l10n.
La diferencia clave: i18n cambia el idioma, l10n cambia la experiencia.
Imagina un e-commerce en español que vende en España y México. Aunque el idioma es el mismo, la experiencia del usuario debe ser diferente:
Esto implica que el locale ya no es solo para traducir: se convierte en una variable de negocio que determina qué mostrar, qué ocultar y cómo formatear.
Estas configuraciones por mercado se pueden centralizar en los propios archivos de traducción:
// locales/es-ES.json
{
"config": {
"currency": "EUR",
"currency_symbol": "€",
"payment_methods": ["card", "bizum", "transfer"],
"express_delivery": "24 horas"
}
}
// locales/es-MX.json
{
"config": {
"currency": "MXN",
"currency_symbol": "$",
"payment_methods": ["card", "oxxo", "spei"],
"express_delivery": "2-3 días"
}
}
Así puedes acceder a estas configuraciones desde cualquier componente usando $t('config.currency') o $t('config.payment_methods').
Necesitas variantes por país cuando:
Si tu caso es más simple (misma moneda, mismo mercado, solo traducciones literales), no necesitas complicarte con variantes por país.
La internacionalización y localización (i18n y l10n) en Nuxt no es solo una decisión técnica. Es una combinación de arquitectura, contenido, SEO y negocio.
Desde mi experiencia, los proyectos que tienen un enfoque correcto desde el principio tienen menos problemas para escalar, añadir idiomas e internacionalizar. Las preguntas de este artículo no son teóricas: cada respuesta determina una estrategia técnica diferente.
Si estás planteando un proyecto multilenguaje en Vue o Nuxt, merece la pena parar, pensar y diseñar antes de instalar la primera librería.
Si buscas alguien que aporte estructura, criterio y experiencia real, hablemos.