aditoro.com
Estrategias de internacionalización y localización

Estrategias de internacionalización y localización Vue / Nuxt

i18n y l10n en proyectos Vue/Nuxt desde una perspectiva de proyectos reales

Vue.jsNuxt.jsi18nl10n
20 de enero de 2025
Andrea Di Toro

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.

Índice

  1. Preguntas clave para plantear una estrategia correcta
  2. ¿Cuántos idiomas necesitamos?
  3. ¿Los textos viven en el front, backend o en base de datos?
  4. ¿Es un proyecto público con SEO o un dashboard privado?
  5. ¿Quién gestiona las traducciones?
  6. ¿Habrá variaciones de contenido por país?
  7. Conclusión

Preguntas clave para plantear una estrategia correcta

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.

  • ¿Cuántos idiomas necesitamos hoy y cuántos podemos necesitar en el futuro?
  • ¿Los textos viven en el front, backend o en base de datos?
  • ¿Es un proyecto público con SEO o un dashboard privado disponible solo tras el login?
  • ¿Quién gestiona las traducciones: developers o hay un equipo de contenido?
  • ¿Habrá variaciones de contenido por país o solo traducción literal?

No existe una respuesta correcta universal. La estrategia depende de estas respuestas, no de la librería que esté de moda.

¿Cuántos idiomas necesitamos hoy y cuántos podemos necesitar en el futuro?

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:

  1. Crear la carpeta locales/en/ con los mismos archivos
  2. Añadir el locale a la configuración
  3. Traducir los JSON
// 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' }
]

¿Los textos viven en el front, backend o en base de datos?

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:

Backend envía claves i18n (recomendado para textos estáticos)

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.

Backend gestiona sus propias traducciones

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.

Contenido en base de datos (CMS, productos, artículos)

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.

¿Es un proyecto público con SEO o un dashboard privado disponible solo tras el login?

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.

Para proyectos públicos con SEO

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' }
}

¿Quién gestiona las traducciones: developers o hay un equipo de contenido?

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:

  • Mover los textos a un repositorio externo para gestionarlos con una herramienta apropiada.
  • Planificar una estrategia para cargar los textos en el proyecto.

Mover los textos a repositorios y herramientas externas

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:

  1. El developer añade una nueva clave en es.json y hace un push en una rama del repositorio.
  2. CI sube automáticamente los nuevos archivos a Weblate o la plataforma.
  3. Los traductores trabajan en la plataforma y modifican el contenido de los archivos a través de Crowdin.
  4. Cuando terminan, Crowdin envía los cambios (commit, PR, depende de la herramienta y configuración).
  5. CI descarga las traducciones y crea una PR.

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:

  1. Los traductores editan los JSON en el repo de traducciones con una herramienta externa.
  2. Se hace merge en el repo de traducciones y se dispara CI que publica una nueva versión.
  3. La app actualiza la dependencia (npm update @mycompany/app-translations).
  4. Nuevo deploy con traducciones actualizadas.

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):

  1. El traductor edita es.json en el repo de traducciones.
  2. Merge a main → GitHub Actions despliega a GitHub Pages.
  3. En 1-2 minutos, los usuarios ven las traducciones actualizadas sin redeploy de la app.

Ventajas del enfoque runtime:

  • Traducciones actualizadas sin CI/CD de la app.
  • El equipo de traducción es completamente autónomo.
  • Rollback instantáneo (revertir commit en el repo de traducciones).
  • Posibilidad de A/B testing de textos.

Consideraciones:

  • Añade latencia inicial (mitigable con caché).
  • Necesitas gestionar errores de red.
  • Las traducciones deben estar en un CDN rápido.

¿Habrá variaciones de contenido por país o solo traducción literal?

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.

Ejemplo: mismo idioma, diferente mercado

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:

  • Moneda: En España muestras euros (€), en México pesos (MXN).
  • Métodos de pago: En España ofreces Bizum y transferencia bancaria. En México, OXXO y SPEI.
  • Tiempos de entrega: En España el envío express es 24 horas. En México, 2-3 días.
  • Formateo de precios: "1.234,50 €" vs "$1,234.50 MXN" (separadores decimales diferentes).

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.

Dónde guardar estas diferencias

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').

Cuándo necesitas localización real

Necesitas variantes por país cuando:

  • Vendes en diferentes mercados con monedas, impuestos o logística diferentes.
  • Hay diferencias regulatorias: políticas de privacidad, términos legales, GDPR en Europa vs US.
  • El mismo idioma tiene diferencias culturales: el español de España, México y Argentina comparten idioma pero difieren en vocabulario y expectativas.

Si tu caso es más simple (misma moneda, mismo mercado, solo traducciones literales), no necesitas complicarte con variantes por país.

Conclusión

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.

¿Tienes un proyecto o necesitas apoyo técnico?

Si buscas alguien que aporte estructura, criterio y experiencia real, hablemos.