Artículos
Una plataforma Next.js en 22 idiomas: lecciones de GAGA
Next.js multiidioma en 22 idiomas: rutas por idioma, serbio latino y cirílico, Intl, fuentes y CI de traducciones. Lecciones de una plataforma en producción.
Equipo de ingeniería de sigmacode.io10 min de lectura
En esta página (11)
- Dale a cada idioma su propia URL
- Dos alfabetos, un idioma
- Formatea números, monedas y fechas con Intl
- Fuentes y cobertura de glifos
- Un flujo de traducción que aguanta 22 idiomas
- Datos en tiempo real sin valores caducados
- Una única fuente de verdad para PDF, Excel, CSV y XML
- SEO en muchos idiomas
- Accesibilidad
- Tests: rutas × idiomas
- Qué conviene prever
GAGA Menjačnica es una casa de cambio y comercio de metales preciosos con varias oficinas en Novi Sad, Serbia. Nuestro equipo construyó su plataforma, menjacnicegaga.rs, con Next.js y React sobre Vercel. Funciona en 22 idiomas, entre ellos serbio en alfabeto latino y cirílico, alemán, chino, ruso, turco, ucraniano, griego y búlgaro. Muestra los tipos de compra y venta en tiempo real junto a los tipos de referencia del Banco Nacional de Serbia, e incluye un conversor de divisas, listas de tipos descargables en cinco formatos, un localizador de oficinas y guías sobre oro y plata de inversión.
Con veintidós idiomas, la internacionalización deja de ser una funcionalidad más. A esa escala condiciona la arquitectura. Este artículo repasa lo que, en nuestra opinión, importa al construir una plataforma así. Está escrito para CTO, product owners e ingenieros frontend que estén planificando una. Si te interesa el proyecto en sí, consulta el caso de estudio de GAGA.
Dale a cada idioma su propia URL#
La decisión más importante va primero: cada versión lingüística de cada página necesita su propia URL, estable y rastreable. No cambies de idioma en función de una cookie ni de la cabecera Accept-Language. Los buscadores no pueden indexar eso, los usuarios no pueden compartirlo y las CDN no pueden cachearlo de forma limpia.
Recomendamos un prefijo de idioma en la ruta, como /sr/..., /de/... y /zh/.... Con el App Router de Next.js, eso significa un segmento [locale] en la raíz de la aplicación. El proxy de peticiones (proxy.ts, antes middleware) puede redirigir la primera visita a una opción por defecto razonable según el idioma del navegador. A partir de ahí, la URL es la única fuente de verdad, y el selector de idioma tiene que enlazar a la misma página en el otro idioma, no a su página de inicio.
Planifica pronto tus identificadores de idioma y usa etiquetas BCP 47 en todas partes. Solo el serbio ya necesita dos, sr-Latn y sr-Cyrl. Mapearlas en un único lugar a segmentos de URL, valores hreflang y locales de Intl ahorra muchos problemas más adelante.
hreflang, x-default y sitemaps#
Cada página debe enumerar todas sus alternativas, incluida ella misma, y añadir un x-default para los usuarios cuyo idioma no ofreces. En el App Router, la API de metadatos lo hace por ti:
// app/[locale]/rates/page.tsx
import type { Metadata } from "next";
const BASE = "https://example.com";
const LOCALES = ["sr-Latn", "sr-Cyrl", "en", "de", "zh", "ru"] as const;
const segment = (l: string) => l.toLowerCase(); // "sr-Latn" -> "sr-latn"
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const path = "/rates";
const languages: Record<string, string> = Object.fromEntries(
LOCALES.map((l) => [l, `${BASE}/${segment(l)}${path}`])
);
languages["x-default"] = `${BASE}/en${path}`;
return {
alternates: { canonical: `${BASE}/${locale}${path}`, languages },
};
}
Genera el sitemap a partir de la misma lista de idiomas y del mismo registro de rutas, con las alternativas de cada entrada. En cuanto las etiquetas hreflang y las entradas del sitemap salen de rutas de código distintas, acaban contradiciéndose, y los buscadores dejan de fiarse de ambas sin avisar.
Dos alfabetos, un idioma#
El serbio se escribe tanto en alfabeto latino como en cirílico, y muchos lectores tienen una preferencia muy marcada. Trátalos como dos locales completos, no como un locale con un conmutador de visualización. Cada uno tiene su propia URL, su propio valor hreflang y su propio atributo lang.
Resulta tentador escribirlo todo en un alfabeto y transliterar el otro de forma automática. Para texto corrido, un paso de transliteración bien probado puede ser un punto de partida razonable. Pero no lo apliques a ciegas a todo:
- Nombres propios y marcas. Los nombres de empresas, los nombres de productos y los extranjerismos suelen conservar su forma latina incluso en un texto en cirílico. Un conversor mecánico los transformará «amablemente» en algo que nadie escribió.
- Ambigüedad de los dígrafos. Los dígrafos latinos
nj,ljydžsuelen corresponder a una sola letra cirílica, pero no siempre. Las palabras compuestas y los préstamos rompen la regla. De cirílico a latino la conversión es determinista. De latino a cirílico, no. - URL, códigos e identificadores. Los códigos de divisa como
EUR, las direcciones de correo, los slugs y todo lo que vaya dentro de marcadores de interpolación no debe transliterarse jamás. - Búsqueda y ordenación. Un usuario puede escribir en latino en el buscador de una página en cirílico. Normaliza ambos lados antes de comparar.
Lo que recomendamos: guarda la fuente en cirílico (la dirección determinista) o mantén dos catálogos separados, y conserva una pequeña lista de excepciones de la que se haga cargo un hablante nativo. Todo lo que haya producido una máquina debería revisarse antes del lanzamiento.
Formatea números, monedas y fechas con Intl#
En un sitio de tipos de cambio, los números son el producto. El serbio usa la coma como separador decimal, el alemán agrupa los millares con un punto y los usuarios chinos esperan otras convenciones distintas. No formatees a mano. Usa las API Intl integradas y pásales la etiqueta de idioma completa:
const rateFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
});
const moneyFormatter = (locale: string, currency: string) =>
new Intl.NumberFormat(locale, { style: "currency", currency });
const asOf = (locale: string, date: Date) =>
new Intl.DateTimeFormat(locale, {
dateStyle: "long",
timeStyle: "short",
timeZone: "Europe/Belgrade",
}).format(date);
rateFormatter("sr-Latn").format(117.1234); // "117,1234"
rateFormatter("de").format(117.1234); // "117,1234"
moneyFormatter("en", "EUR").format(1250); // "€1,250.00"
asOf("sr-Cyrl", new Date()); // e.g. "18. септембар 2026. 10:30" (exact output depends on the ICU version)
Algunas cosas que conviene prever. Fija la zona horaria de forma explícita. De lo contrario, las páginas renderizadas en servidor usan la zona del servidor, que suele ser UTC. Crea los formateadores una sola vez y reutilízalos, porque construirlos una y otra vez en una tabla grande acaba pesando. Y mantén el número de decimales tanto en la capa de datos como en la interfaz, para que las exportaciones y la pantalla coincidan.
Fuentes y cobertura de glifos#
Veintidós idiomas significan caracteres latinos, latinos extendidos (serbio, croata, turco), cirílicos (serbio, ruso, ucraniano, búlgaro), griegos y CJK. Pocas tipografías de marca los cubren todos, y las que lo hacen pesan mucho.
Lo que importa:
- Comprueba la cobertura por sistema de escritura antes de elegir una tipografía. Prueba con cadenas reales, no con «Lorem ipsum». El cirílico serbio tiene letras propias y formas cursivas locales, el ucraniano tiene letras que el ruso no tiene y el búlgaro prefiere sus propias formas de glifo.
- Divide en subconjuntos por escritura y carga según el idioma.
next/fontadmite subconjuntos comolatin,latin-ext,cyrillicygreek. Una página en alemán no debería descargar glifos cirílicos. - No alojes tú mismo una fuente CJK completa en cada página. Las fuentes chinas pueden ocupar varios megabytes. Una pila de fuentes del sistema para CJK, como
"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, suele ser el compromiso adecuado. - Diseña una pila de fallback explícita y define fuentes de reserva con métricas compatibles, para que el texto no salte cuando se carga la fuente web.
Un flujo de traducción que aguanta 22 idiomas#
Con dos o tres idiomas bastan una hoja de cálculo y algo de disciplina. Con 22 necesitas un pipeline.
Catálogos de mensajes con claves estables. Usa un archivo JSON por idioma, con claves que nombren el significado, como rates.table.buy, y nunca el texto en inglés. Usa ICU MessageFormat para plurales e interpolación. Las reglas de plural varían mucho: el ruso, el ucraniano y el serbio tienen varias formas, y el chino no tiene ninguna.
Paridad de claves en CI. Una clave que falta en un idioma es el bug más habitual en un sitio multilingüe, y también el más fácil de detectar automáticamente:
// scripts/check-i18n.ts — run in CI, fail the build on drift
import { readdirSync, readFileSync } from "node:fs";
const dir = "messages";
const flatten = (o: Record<string, unknown>, p = ""): string[] =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object" ? flatten(v as Record<string, unknown>, `${p}${k}.`) : [`${p}${k}`]
);
const load = (f: string) => new Set(flatten(JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))));
const source = load("en.json");
let failed = false;
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "en.json")) {
const keys = load(file);
const missing = [...source].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !source.has(k));
if (missing.length || extra.length) {
failed = true;
console.error(`${file}: missing ${missing.length}, extra ${extra.length}`, { missing, extra });
}
}
process.exit(failed ? 1 : 0);
Amplía el mismo script para comprobar que los marcadores de interpolación coinciden en todos los idiomas. Un nombre de marcador traducido falla en tiempo de ejecución, no en la compilación.
Traducción asistida por IA, con revisión humana. La traducción automática y la de los LLM ya producen buenos primeros borradores, y con 22 idiomas eso cambia las cuentas. Pero una casa de cambio trabaja con dinero y con confianza. Las etiquetas de los tipos, los avisos legales y las guías de inversión debería revisarlos una persona que domine el idioma antes de publicarse. Dales contexto a los traductores: capturas de pantalla, límites de caracteres y un glosario de términos fijos como «tipo de compra», «tipo de venta» y «tipo de referencia».
Datos en tiempo real sin valores caducados#
Los tipos de cambio varían a lo largo del día. La trampa está en cachearlos con la misma agresividad que el resto de un sitio Next.js pensado como estático.
Estrategias generales que recomendamos:
- Separa la estructura de los datos. El layout de la página, las traducciones y las guías pueden ser estáticos o revalidarse muy de vez en cuando. La tabla de tipos debería tener su propia ventana de revalidación corta, o bien obtenerse en el cliente o servirse por streaming.
- Mantén la revalidación corta y deliberada. Elige una ventana acorde con la frecuencia real con la que cambian los tipos, y documéntala. Siempre que puedas, usa revalidación bajo demanda cuando se publiquen tipos nuevos, en lugar de depender solo de un temporizador.
- Cachea en el edge con cuidado. Un
s-maxagecorto constale-while-revalidatemantiene las páginas rápidas, pero asegúrate de que la ventana de datos caducados sea una que el negocio pueda aceptar. - Muestra siempre una marca de tiempo «actualizado a», formateada según el idioma y en la zona horaria de la oficina. Es la respuesta honesta a la pregunta que plantea todo sistema de caché: ¿cómo de reciente es este dato?
Una única fuente de verdad para PDF, Excel, CSV y XML#
GAGA publica su lista de tipos para descargar en PDF, JPG, Excel, CSV y XML. Cinco formatos son cinco ocasiones para que los números no coincidan.
La regla: genera todas las exportaciones a partir de la misma estructura de datos normalizada, la misma que pinta la tabla en pantalla. Pon el redondeo, el orden y los metadatos de cada divisa en esa estructura, no en cada exportador. Así cada formato se reduce a un serializador muy fino. Cosas que conviene prever:
- CSV necesita un delimitador y una codificación declarados. En muchas configuraciones regionales europeas, Excel espera un punto y coma y gestiona mejor UTF-8 con BOM.
- Excel debe recibir celdas numéricas reales con formatos de número, no cadenas ya formateadas, para que los usuarios puedan calcular con ellas.
- XML necesita un esquema estable y documentado, porque alguien va a integrarse contra él.
- PDF e imágenes tienen que incrustar fuentes que cubran el sistema de escritura solicitado, lo que te devuelve a la cobertura de glifos.
Incluye también la marca de tiempo «actualizado a» en cada exportación.
SEO en muchos idiomas#
Más allá de hreflang y los sitemaps:
- Traduce títulos, descripciones y metadatos Open Graph en cada idioma. No dejes metadatos en inglés en una página en griego.
- Localiza los slugs solo si puedes mantenerlos estables. Un slug que cambia en un idioma supone redirecciones y un clúster hreflang roto.
- Evita los duplicados con poco contenido. Si un idioma solo tiene contenido parcial, plantéate si ya debería indexarse.
- Las URL canónicas deben apuntar a la propia página, nunca a la versión en otro idioma.
Accesibilidad#
Define lang en el elemento html para cada idioma, con la etiqueta completa (sr-Latn, sr-Cyrl), para que los lectores de pantalla elijan la voz y la pronunciación correctas. Cuando dentro de una página aparezca una frase en otro idioma, márcala con su propio lang.
Aunque hoy no ofrezcas ningún idioma que se escriba de derecha a izquierda, tenlo previsto. Usa propiedades lógicas de CSS como margin-inline-start en lugar de margin-left, y deriva dir del idioma. Añadir el árabe más adelante sale mucho más barato cuando el layout no da por hecho que se lee de izquierda a derecha.
Tests: rutas × idiomas#
Con 22 idiomas, es fácil que se te escape un bug que solo aparece en uno de ellos. Recomendamos un smoke test automatizado que recorra todas las rutas públicas en todos los idiomas y compruebe lo básico:
- la página devuelve 200 y se renderiza sin errores en tiempo de ejecución;
htmltiene ellangcorrecto;- las alternativas hreflang están completas y apuntan a URL que existen;
- no se ven claves de mensaje sin resolver (como
rates.table.buy) ni cadenas vacías; - el conversor y la tabla de tipos muestran los números en el formato esperado.
Añade capturas visuales de los idiomas más largos. Las etiquetas en alemán y en griego suelen romper maquetaciones que en inglés se veían bien.
Qué conviene prever#
Si vas a empezar una plataforma Next.js multilingüe, estas son las decisiones que hay que tomar el primer día: una estrategia de URL con prefijo de idioma, un único registro de idiomas del que dependan el enrutamiento, los metadatos y los sitemaps, una política clara sobre alfabetos y transliteración, Intl para todo el formateo, un plan de fuentes por sistema de escritura, comprobaciones en CI de la paridad de las traducciones, una política explícita de actualidad para los datos en tiempo real y una única fuente de verdad para todas las exportaciones.
Nada de esto es exótico, pero sale mucho más barato diseñarlo desde el principio que adaptarlo después. Nuestro trabajo lo dirige un tech lead con más de 20 años de experiencia, y este es el tipo de trabajo de base en el que nos centramos en Web y plataformas. Si estás planificando algo parecido, escríbenos: respondemos en 24 horas en días laborables.