Caso de uso · Multilenguaje i18n

Un SaaS que se lee nativo en cada idioma que entrega, no tres reescrituras del mismo copy en inglés

Diccionarios de strings tipados por idioma, locale routing bajo `/[lang]` con redirects desde las URLs legacy, hreflang que Google lee bien, una pipeline de revisión nativa para que el copy en español no suene a inglés disfrazado. Formato de precios, fechas y números atado al idioma. Un repo, un design system, cuatro idiomas.

El problema

Tres reescrituras del mismo copy en inglés no son un producto multilenguaje

El patrón falla siempre del mismo modo. El founder contrata un traductor, exporta los strings a un Google Sheet, los recibe en tres idiomas, los mete en un archivo JSON y sale. Seis meses después el equipo tiene el 40% de los strings traducidos (el resto dice `[en] sign up`), los clientes españoles se quejan de que el copy parece Google Translate de 2014, Google indexa solo las rutas inglesas porque los hreflang están mal, y la página de precios muestra euros a clientes americanos porque la moneda está hardcoded. Reconstruimos la superficie i18n desde dentro. Diccionarios tipados con una sola fuente de verdad por idioma. Routing bajo `/[lang]` que respeta el idioma del navegador y se queda fijo cuando el usuario elige. Revisión nativa de cada traducción, nunca un volcado de AI. SEO que ranquea cada idioma en su mercado. El producto se lee nativo en cada idioma que el founder esté dispuesto a sostener.

Cómo lo abordamos

Seis pasos de un idioma a cuatro que se leen nativos

Audit de copy antes que estructura. Estructura antes que routing. Routing antes que SEO. SEO antes que revisión. Revisión antes que lanzamiento. Saltarse el audit produce un codebase donde el 30% de los strings no está en el diccionario; saltarse la revisión nativa produce copy que pierde el deal en la primera frase.

  1. 01

    Auditar el copy

    Hacemos grep en cada JSX, MDX, email, push notification y seed de base de datos buscando strings hardcoded de cara al usuario. Salida: un CSV con archivo, línea y el string. El audit de un SaaS de verdad devuelve 800-2.500 strings; la mitad en componentes, un tercio en páginas marketing, el resto en emails, mensajes de error y datos seed. El audit es el brief; nada se mueve antes de que el founder vea el número.

  2. 02

    Construir la estructura del diccionario

    Un diccionario tipado por idioma, organizado por superficie (`nav`, `home`, `pricing`, `account`, `emails`). El diccionario es un módulo TypeScript, no un archivo JSON, así las claves que falten dan error en compile time. Cada clave lleva un comentario de una línea explicando el contexto (dónde aparece, qué acción precede, qué tono lleva). Los traductores leen los comentarios; el resultado es copy que cabe en el momento, no en la traducción literal del inglés.

  3. 03

    Locale routing

    La estructura de URL pasa a `/[lang]/...`. El middleware lee la cabecera `Accept-Language` en la primera visita, elige el mejor idioma de la lista soportada, y redirige. Una cookie recuerda la elección cuando el usuario la cambia. Las URLs legacy (las que salieron antes del i18n) reciben redirects 301 a las URLs nuevas prefijadas por idioma. El mapa de redirects se genera una vez desde el inventario de rutas; los enlaces entrantes y las campañas de email existentes siguen funcionando.

  4. 04

    hreflang y canonical

    Cada página emite `<link rel="alternate" hreflang="...">` para cada idioma soportado más `x-default`. Las URLs canonical se setean por idioma, no se colapsan a la inglesa. El sitemap lista cada idioma de cada página. Google indexa cada mercado correctamente; la landing española ranquea en España, la italiana en Italia, ninguna le hace la competencia a la inglesa.

  5. 05

    Pipeline de revisión nativa

    Las traducciones pasan por una revisión de un hablante nativo antes de aterrizar en el repo. Trabajamos con los traductores que ya tiene el founder cuando se puede; tenemos una rotación de tres para inglés, italiano y español cuando el founder no los tiene. Cada traducción recibe una nota de contexto (dónde aparece, qué acción precede) y un SLA de 24 horas. Las primeras pasadas con AI están permitidas; las traducciones solo-AI no. La revisión marca los strings que necesitan reescritura cultural, no solo literal.

  6. 06

    Formato y moneda

    Las fechas, números, monedas y ordinales vienen de `Intl.*`. El idioma trae el formato; nada hardcoded. Los clientes americanos ven dólares y MM/DD/YYYY; los alemanes ven euros y DD.MM.YYYY; los japoneses ven yenes y un calendario que respeta el idioma. Las monedas que cambian con el país de facturación del cliente (no con el idioma de la página) leen del registro de facturación, no de la URL. Los dos casos están explícitos en la API.

Qué entregamos

Audit de copy

Un CSV que lista cada string hardcoded de cara al usuario en el codebase, con archivo, línea y un tag entre `component`, `page`, `email`, `error` o `seed`. El artefacto que lee el resto del encargo. Re-ejecutable; una regla de lint impide que strings hardcoded nuevos vuelvan a colarse.

Scaffold del diccionario tipado

Un módulo TypeScript por idioma, organizado por superficie. Strict type-check; las claves que falten son errores de compilación. Comentarios en cada clave explicando el contexto. El idioma inglés es la fuente de verdad; los diccionarios no-ingleses heredan las claves.

Brief para el traductor

Un documento que el traductor lee antes de arrancar. Brand voice (con las palabras del founder, no una plantilla de marketing), registro de tono por superficie (formal para legal, conversacional para producto, directo para marketing), un glosario de términos que tienen que quedar consistentes. El documento que el founder escribe una vez y los traductores leen cada vez.

Middleware de locale routing

Un middleware Next.js que lee `Accept-Language`, elige el mejor idioma soportado, redirige en la primera visita y respeta la cookie en las visitas siguientes. Documentado. La ruta `app.x/it` sirve siempre italiano; la ruta `app.x/` redirige al idioma del usuario.

Mapa de redirects de URLs legacy

Un archivo de redirects que mapea cada URL pre-i18n a la URL localizada equivalente con un 301. Generado una vez desde el inventario de rutas; cubre campañas de email, enlaces entrantes, páginas indexadas en el sitemap. Sale en la misma PR que el cambio de routing, así no se rompe ningún enlace durante el cutover.

Setup de hreflang y canonical

Un helper que emite `<link rel="alternate" hreflang="...">` por cada idioma en cada página, más `x-default`, más canonical. Saca un sitemap que lista cada idioma de cada página. Verificado contra Google Search Console tras el cutover; entregamos los arreglos que la consola marca en las dos primeras semanas.

Workflow de revisión de traducciones

Un workflow Linear-o-similar con un ticket por superficie por idioma. Los traductores cogen tickets, los reviewers (hablantes nativos, a veces el founder) aprueban o piden cambios. El workflow vive fuera del codebase; los strings aterrizan en el repo solo después de la revisión.

Helpers de formato

Una pequeña librería de wrappers `formatDate`, `formatCurrency`, `formatNumber`, `formatOrdinal` que cogen el idioma (y, para la moneda, el país de facturación) y devuelven el string formateado. Cada sitio del codebase que formatea valores de cara al usuario pasa por aquí. Los format strings hardcoded los bloquea el lint.

Traducciones de emails y notificaciones

Cada email transaccional y cuerpo de push notification vive en el mismo diccionario que el copy in-product. El email de reset de contraseña se lee natural en italiano porque el traductor italiano lo revisó; el detectado como AI no.

SEO por idioma

`<title>` y `<meta description>` localizados por página por idioma. Tags Open Graph localizados. Structured data localizado donde lleva contenido (FAQ, Breadcrumb, Article). Quien busque el producto italiano en italiano encuentra la página italiana, no el fallback inglés.

Guardarraíles i18n en CI

Un paso de CI que marca claves que faltan, claves sin traducir (texto inglés en un diccionario no-inglés) y claves presentes en diccionarios no-ingleses que ya no existen en inglés. Los diccionarios se mantienen sincronizados; el desarrollador que añade una clave nueva la añade en cada idioma (o la marca como `pending_translation` con el enlace a una issue).

Checklist de lanzamiento y audit post-lanzamiento

Una checklist de 20 líneas que el equipo pasa antes de cada lanzamiento de un idioma nuevo (sitemap enviado, hreflang verificado, moneda probada, mapa de redirects confirmado, imágenes OG regeneradas). Un audit a 30 días del lanzamiento mide el rank en búsqueda por idioma y el volumen de tickets desde cada mercado; el resultado guía la siguiente ronda de trabajo de traducción.

Cinco archivos que componen un producto Next.js multilenguaje

Los cinco archivos de abajo componen la superficie i18n: el loader de diccionarios tipados, el middleware de locale routing, el helper de hreflang y canonical, los helpers de formato, y el guardarraíl de traducción en CI.

Un build multilenguaje es un problema de copy antes que un problema de routing. El routing, el hreflang, el scaffold del diccionario se resuelven todos para la tercera semana. El trabajo que queda es hacer que el producto se lea natural a un hablante nativo, y ese trabajo depende de que el copy fuente sea claro, de que el brief sea detallado, y de que el reviewer sea un nativo que distinga entre una traducción y una reescritura.

Los cinco archivos de abajo son la armadura que deja el encargo. El loader de diccionarios tipados, el middleware de locale routing, el helper de hreflang y canonical, los helpers de formato, y el guardarraíl de traducción en CI.

1. El loader de diccionarios tipados

El diccionario inglés es la fuente de verdad. Los demás idiomas importan el tipo inglés y tienen que implementar cada clave; las claves que falten son errores de compilación, no bugs en runtime que aparecen en producción.

// src/config/i18n/en.ts
export const en = {
  nav: {
    home: 'Home',
    pricing: 'Pricing',
    signIn: 'Sign in',
  },
  pricing: {
    title: 'Pricing that scales with you',
    subtitle: 'No setup fee. Cancel anytime.',
    cta: 'Start free trial',
  },
  emails: {
    welcome: {
      subject: 'Welcome to {productName}',
      body: 'You are in. The next step is …',
    },
  },
} as const

export type Dictionary = typeof en
// src/config/i18n/es.ts
import type { Dictionary } from './en'

export const es: Dictionary = {
  nav: {
    home: 'Inicio',
    pricing: 'Precios',
    signIn: 'Entrar',
  },
  pricing: {
    title: 'Precios que crecen contigo',
    subtitle: 'Sin coste de alta. Cancelas cuando quieras.',
    cta: 'Empieza gratis',
  },
  emails: {
    welcome: {
      subject: 'Te damos la bienvenida a {productName}',
      body: 'Estás dentro. El siguiente paso es …',
    },
  },
}
// src/config/i18n/index.ts
import { en } from './en'
import { it } from './it'
import { es } from './es'

export const locales = ['en', 'it', 'es'] as const
export type Locale = (typeof locales)[number]

const dictionaries: Record<Locale, typeof en> = { en, it, es }

export function getDictionary(locale: Locale): typeof en {
  return dictionaries[locale] ?? en
}

2. El middleware de locale routing

El middleware lee URL, cabecera Accept-Language y cookie. Las primeras visitas se redirigen al mejor idioma; las visitas siguientes respetan la cookie. La cookie se setea cuando el usuario cambia idioma desde el language picker.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { locales, type Locale } from '@/config/i18n'

const DEFAULT_LOCALE: Locale = 'en'

function pickLocale(req: NextRequest): Locale {
  const cookie = req.cookies.get('NEXT_LOCALE')?.value
  if (cookie && locales.includes(cookie as Locale)) return cookie as Locale

  const accept = req.headers.get('accept-language') ?? ''
  for (const part of accept.split(',')) {
    const tag = part.split(';')[0].trim().toLowerCase().split('-')[0]
    if (locales.includes(tag as Locale)) return tag as Locale
  }
  return DEFAULT_LOCALE
}

export function middleware(req: NextRequest): NextResponse {
  const { pathname } = req.nextUrl

  if (locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
    return NextResponse.next()
  }

  const locale = pickLocale(req)
  const url = req.nextUrl.clone()
  url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
  return NextResponse.redirect(url)
}

export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] }

3. El helper de hreflang y canonical

El helper produce los tags <link> que cada página emite. El canonical apunta a la página en el idioma actual; el hreflang lista cada idioma soportado; x-default apunta a la versión inglesa.

// src/lib/seo/alternates.ts
import { locales, type Locale } from '@/config/i18n'

const BASE = process.env.PUBLIC_APP_URL ?? 'https://adamarant.com'

export function hreflang(pathByLocale: Record<Locale, string>, current: Locale) {
  const alternates: Record<string, string> = {}
  for (const locale of locales) {
    alternates[locale] = `${BASE}${pathByLocale[locale]}`
  }
  alternates['x-default'] = `${BASE}${pathByLocale.en}`

  return {
    canonical: alternates[current],
    languages: alternates,
  }
}
// app/[lang]/pricing/page.tsx
import type { Metadata } from 'next'
import { hreflang } from '@/lib/seo/alternates'

export async function generateMetadata({ params }: { params: Promise<{ lang: 'en' | 'it' | 'es' }> }): Promise<Metadata> {
  const { lang } = await params
  return {
    alternates: hreflang(
      {
        en: '/en/pricing',
        it: '/it/prezzi',
        es: '/es/precios',
      },
      lang,
    ),
  }
}

4. Los helpers de formato

Los helpers de formato envuelven Intl.*. Los format strings hardcoded los bloquea el lint; cada valor formateado pasa por el helper, que coge un idioma y (para la moneda) el país de facturación.

// src/lib/format.ts
import type { Locale } from '@/config/i18n'

const LOCALE_TAG: Record<Locale, string> = {
  en: 'en-US',
  it: 'it-IT',
  es: 'es-ES',
}

export function formatDate(date: Date, locale: Locale): string {
  return new Intl.DateTimeFormat(LOCALE_TAG[locale], { dateStyle: 'long' }).format(date)
}

export function formatNumber(value: number, locale: Locale): string {
  return new Intl.NumberFormat(LOCALE_TAG[locale]).format(value)
}

export function formatCurrency(amountCents: number, currency: string, locale: Locale): string {
  return new Intl.NumberFormat(LOCALE_TAG[locale], {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
  }).format(amountCents / 100)
}

5. El guardarraíl de traducción en CI

Un paso de CI hace diff de los diccionarios y hace fallar la build cuando un diccionario no-inglés tiene una clave que falta, tiene texto inglés donde debería haber traducción, o tiene una clave que ya no existe en inglés. Los diccionarios se mantienen sincronizados, y al equipo se le avisa antes del deploy, no después.

// scripts/i18n/check.ts
import { en } from '../../src/config/i18n/en'
import { it } from '../../src/config/i18n/it'
import { es } from '../../src/config/i18n/es'

type Dictionary = typeof en

function diffKeys(a: Dictionary, b: Dictionary, path = ''): string[] {
  const issues: string[] = []
  for (const key of Object.keys(a) as Array<keyof Dictionary>) {
    const here = path ? `${path}.${String(key)}` : String(key)
    if (!(key in b)) {
      issues.push(`MISSING in target: ${here}`)
      continue
    }
    const aValue = a[key] as unknown
    const bValue = (b as Record<string, unknown>)[String(key)]
    if (typeof aValue === 'object' && aValue && !Array.isArray(aValue)) {
      issues.push(...diffKeys(aValue as Dictionary, bValue as Dictionary, here))
    } else if (typeof aValue === 'string' && typeof bValue === 'string') {
      if (aValue === bValue && a !== b) {
        issues.push(`UNTRANSLATED in target (matches English): ${here}`)
      }
    }
  }
  for (const key of Object.keys(b) as Array<keyof Dictionary>) {
    if (!(key in a)) {
      const here = path ? `${path}.${String(key)}` : String(key)
      issues.push(`STALE in target (no longer in English): ${here}`)
    }
  }
  return issues
}

const issues = [...diffKeys(en, it as Dictionary, 'it'), ...diffKeys(en, es as Dictionary, 'es')]

if (issues.length > 0) {
  console.error('i18n issues:\n' + issues.join('\n'))
  process.exit(1)
}
console.log('i18n: all dictionaries in sync')

6. Lo que esto compone

Los diccionarios tipados mantienen cada string bajo un solo techo y hacen fallar la build cuando una desaparece. El middleware manda a cada visitante al idioma correcto y respeta su elección. El helper de hreflang le dice a Google que cada mercado existe y qué página pertenece a cuál. Los helpers de formato impiden que el símbolo del dólar aparezca en España. El guardarraíl de CI impide que el siguiente desarrollador entregue una página a medias.

El producto se lee nativo a un cliente español, a un cliente italiano, a un cliente francés que entre después. La landing italiana ranquea en Italia porque Google ve una página italiana de verdad, no la traducción de una página inglesa. La página de precios enseña euros en Madrid y dólares en San Francisco sin que nadie toque un switch. El equipo que pensaba que lanzar en un mercado nuevo era un trimestre de trabajo ahora lo piensa como un sprint de traducción y un envío de sitemap.

Stacks relacionados

Preguntas frecuentes

¿Podemos añadir un idioma tras el lanzamiento sin reconstruir la superficie i18n?

Sí, a propósito. El scaffold del diccionario y el routing aguantan N idiomas; añadir el quinto es una PR más el trabajo de traducción. El coste del build es sobre todo el audit y la estructura; cuando existen, cada idioma adicional lleva una a tres semanas de revisión de traducción, no una reconstrucción.

¿Necesitamos un translation management system (Lokalise, Crowdin, Phrase)?

A menudo no, sobre todo por debajo de 1.500 strings y 4 idiomas. Los diccionarios TypeScript más un workflow tipo Linear gestionan bien esa escala; el TMS añade coste e indirección que los equipos pequeños no necesitan. Por encima de esa escala, o cuando los traductores piden glosario y translation memory, cableamos Lokalise o similar y dejamos los diccionarios tipados como fuente de verdad.

¿Qué hay de la traducción asistida por AI?

Las primeras pasadas asistidas por AI están bien y las usamos. Las traducciones solo-AI no. El reviewer nativo coge el calco, el mismatch cultural, la brand voice que se va hacia el default del modelo. La pipeline obliga a un ojo nativo en cada string; el AI acelera la primera pasada.

¿Cómo gestionáis los precios en distintas monedas?

Dos patrones, los dos soportados. Patrón A: el pricing depende del idioma (el precio EUR aparece en la página española, el precio USD en la página de US). Patrón B: el pricing sigue al país de facturación del cliente (el precio EUR se le enseña a un cliente español independientemente del idioma en el que navega). La mayoría de SaaS hace B; la API distingue idioma y país de facturación explícitamente.

¿Y si nuestra app es híbrida (algunas páginas traducidas, otras no)?

El diccionario soporta cobertura parcial. Las páginas que tienen una clave traducida sirven la traducción; las que no caen al fallback en inglés con un warning en consola. El audit saca el hueco de cobertura para que el founder decida qué traducir luego. No bloqueamos el lanzamiento porque tres mensajes de error sigan en inglés.

¿Cuánto tarda?

De seis a diez semanas para el primer idioma nuevo desde el kickoff, dependiendo del conteo de strings. El audit de copy lleva una semana. El scaffold de diccionario y el routing llevan una semana. La revisión de traducción entre 800 y 2.500 strings lleva de tres a seis semanas (paralelizable entre traductores). hreflang, moneda y traducciones de email salen en las dos últimas semanas. Añadir el siguiente idioma lleva dos a tres semanas cada uno.

¿Qué pasa con los idiomas right-to-left (árabe, hebreo)?

En alcance cuando el founder los pide; auditamos el CSS para logical properties (el design system ya usa `padding-inline` etc.) y entregamos una variante RTL. RTL dobla el esfuerzo de QA en las páginas afectadas; lo marcamos en la llamada de scoping para que el calendario cuadre con el alcance.

¿Reemplazáis a nuestro copywriter o al equipo de marketing?

No. La pipeline hace que su trabajo sea load-bearing en cada idioma. El copywriter inglés es la fuente; los traductores nativos son las segundas fuentes; el founder revisa la brand voice en cada idioma. El encargo le deja al equipo la capacidad de entregar una campaña nueva en cuatro idiomas en una semana, no en cuatro meses.

Define el alcance de tu build i18n

Una llamada de scoping, un audit de copy en la primera semana, un alcance fijo y un número que mantenemos. De seis a diez semanas desde el kickoff a un producto que se lee nativo en los idiomas que entregas, con el SEO y el formato que van con ello.