Seis idiomas, una base de código: la i18n como arquitectura
vaynerov.com/de/leistungen no es una página en inglés con un selector de idioma atornillado encima. Es otra URL, otro catálogo, otro conjunto de alternativas hreflang y una porción de JavaScript cuidadosamente racionada. Esta es la arquitectura que hay debajo de seis idiomas.
En esta página
Mira la URL: vaynerov.com/de/leistungen. Nada en ella está en inglés. El segmento de idioma dice alemán y el slug también — Leistungen, servicios. Quien llega desde un resultado de búsqueda alemán no ve ni una palabra en inglés en la barra de direcciones, y un rastreador que indexe esa página indexa una ruta en alemán, no una ruta en inglés disfrazada con un ?lang=de.
Esa única decisión — que el slug forma parte de la traducción — es la razón de que la internacionalización de este sitio sea un problema de arquitectura y no un problema de traducción. Traducir es una hoja de cálculo. La arquitectura es lo que pasa cuando una ruta canónica tiene que convertirse en seis URL, seis entradas de sitemap, seis alternativas hreflang y un solo árbol de React compartido, sin duplicar el código ni triplicar los bytes que llegan a un móvil.
- 6
- idiomas — en, ru, es, de, fr, pt
- 28
- entradas de rutas localizadas
- 1.19 MB
- catálogos de mensajes en disco
- 11
- namespaces enviados al navegador
La forma medida de la capa de i18n: lo que se enruta, lo que se almacena y la pequeña fracción de todo ello que llega a descargar un navegador.
El slug forma parte de la traducción
Usamos next-intl con localePrefix: "as-needed" e inglés como idioma por defecto. Las URL en inglés no llevan prefijo alguno — /services es la página canónica de servicios — mientras que los otros cinco idiomas van siempre prefijados. El trato es deliberado: as-needed cuesta una decisión de enrutado en cada petición, y compra URL canónicas limpias y sin prefijo para el idioma al que apunta la mayoría de los enlaces.
Sobre el prefijo se apoya el mapa de rutas: 28 entradas, cada una emparejando una ruta interna con seis slugs públicos. Servicios, trabajos, precios, el diario, sobre nosotros, carreras, inversores, contacto, presupuesto, privacidad y términos: todos se traducen.
const pathnames = {
"/services": {
en: "/services",
ru: "/uslugi",
es: "/servicios",
de: "/leistungen",
fr: "/services",
pt: "/servicos",
},
// …27 more
};Una ruta, seis URL. El francés coincide con el inglés en la misma palabra — inofensivo, porque el prefijo de idioma desambigua: /services y /fr/services son páginas distintas.
| Ruta | ru | es | de | fr | pt |
|---|---|---|---|---|---|
| services | uslugi | servicios | leistungen | services | servicos |
| work | raboty | trabajos | projekte | realisations | projetos |
| pricing | tseny | precios | preise | tarifs | precos |
| articles | stati | articulos | artikel | articles | artigos |
Cuatro de las 28 entradas de rutas. Los slugs rusos son transliteraciones latinas, así que todas las rutas del sitio siguen siendo ASCII en una línea de log, en un resultado de búsqueda y en un panel de analítica.
La parte interesante de la tabla es lo que falta en ella. Un puñado de rutas no se traduce nunca: /museum, /games, /roadmap, /cookies, /nda. Dos razones distintas, y ambas merecen decirse en voz alta. Los nombres en clave de producto son nombres — al Reliquary se le llama Reliquary en todos los idiomas, y un visitante alemán que lo busque teclea el nombre que le dimos, no una traducción de él. El texto legal es el caso contrario: esas páginas cambian rara vez, las leen abogados y rastreadores más que humanos, y una ruta estable en los seis idiomas vale más que una localizada.
Un middleware, tres excepciones
Cada petición se encuentra con un enrutador artesanal envuelto alrededor de createIntlMiddleware. Casi todas pasan directas a la negociación de idioma. Tres prefijos no. /admin* se entrega a updateSession para que se refresque la cookie de autenticación de Supabase; /api* y /pitch* pasan a next() sin tocarse, porque ninguno tiene por qué ser redirigido a un idioma que nunca pidió. El propio matcher excluye _next y cualquier cosa con un punto, así que los recursos estáticos ni siquiera despiertan al middleware.
La excepción que se paga sola es la de la sesión, y el razonamiento está en un comentario justo al lado: ninguna página pública lee la sesión de Supabase, así que ejecutar updateSession sobre el tráfico público compraría una ida y vuelta de autenticación por petición y nada más. Es un acoplamiento deliberado, no un descuido: el día en que una página pública necesite un usuario autenticado, ese comentario es lo primero que habrá que revisar, que es exactamente por lo que es un comentario y no un silencio.
1,19 MB de catálogos de mensajes, y el problema del ruso
Seis catálogos JSON contienen todas las cadenas del sitio. No tienen el mismo tamaño.
| Idioma | Bytes del catálogo | vs. inglés |
|---|---|---|
| en | 170,069 | — |
| es | 186,854 | +10% |
| pt | 187,552 | +10% |
| de | 188,041 | +11% |
| fr | 193,393 | +14% |
| ru | 260,610 | +53% |
Tamaños de los catálogos de mensajes medidos con wc -c. Total en los seis idiomas: 1.186.519 bytes.
Cuatro de las cinco traducciones se agrupan entre un diez y un catorce por ciento por encima del inglés, que es más o menos la expansión que cualquier traducción románica o germánica te regala. El ruso se sitúa en +53%, y merece la pena ser preciso sobre por qué, porque el número invita a la conclusión equivocada: buena parte de esa diferencia es aritmética de UTF-8. Los caracteres cirílicos cuestan dos bytes cada uno donde el latino cuesta uno, así que una frase rusa de longitud idéntica ya llega al doble de bytes antes de añadir una sola palabra extra. El ruso también es genuinamente más prolijo que el inglés — pero el recuento de bytes mide la codificación y la prosa a la vez, y fingir lo contrario haría que nuestras propias gráficas nos mintieran.
El tamaño importa por cómo se cargan los catálogos: la configuración de petición los importa enteros, en cada petición. Eso es un buen trato para las cadenas de interfaz y uno pésimo para la prosa larga, que es la razón de que el artículo que estás leyendo no viva en un catálogo de mensajes. Las piezas del diario son módulos de contenido tipados bajo src/content/articles/<slug>/<locale>.ts, importados solo por las rutas de artículo. El texto largo en los catálogos gravaría todas las páginas del sitio por un texto que aparece en una.
Once namespaces llegan al navegador
Los componentes de servidor traducen en el servidor; las cadenas nunca salen del renderizado. Los componentes de cliente no pueden hacer eso, y el arreglo ingenuo — entregarle el catálogo entero a NextIntlClientProvider — serializaba más de 10 KB de cadenas que ningún componente de cliente de la página llegaría a leer, dentro de la carga RSC de todas las páginas.
Así que el proveedor recibe un subconjunto elegido a mano. Un ayudante pickMessages resuelve rutas con puntos contra el catálogo, y una única lista GLOBAL_CLIENT_NAMESPACES — once entradas — decide qué se le permite ver a un navegador. Algunas de esas entradas no son namespaces enteros sino hojas individuales: solo se envían tres hojas de pages.services, porque un componente interactivo necesita esas tres y el resto de ese namespace se renderiza en el servidor. El procedimiento de mantenimiento para que esa lista siga siendo honesta es un grep, documentado en el archivo junto a la lista, que es la forma de documentación menos lucida y más fiable que conocemos.
Cada cadena que llega al navegador está ahí porque alguien decidió que debía estar. No existe un valor por defecto de «enviarlo todo».
La excepción es el Laboratorio. Los componentes de juego tienen mucho más texto que una página de marketing — el namespace games llega a unas 150 claves para un solo juego — y nada de eso pinta en la lista global. Así que el subárbol de juegos monta su propio proveedor anidado con su propio namespace, y ahí es donde vive una trampa genuinamente desagradable.
Traducciones que cambian sin desplegar
Los catálogos estáticos son el suelo, no el techo. El texto de las superficies gestionadas por el CMS puede anularse desde la base de datos, así que corregir una palabra en español no necesita una compilación. La fusión es deliberadamente cuidadosa: las anulaciones se aplican con setNestedValue sobre un structuredClone del catálogo importado, nunca sobre el objeto de módulo en sí — ese objeto se comparte entre todas las peticiones del proceso, y mutarlo convertiría la anulación de un visitante en la de todos. La lectura vive detrás de unstable_cache etiquetada como i18n-overrides con una ventana de cinco minutos, y las escrituras del panel invalidan la etiqueta en vez de esperarla.
Si la base de datos no está accesible, un catch pelado vuelve al JSON estático y la página se renderiza. Ese es el comportamiento correcto y además produjo el mejor bug de la historia de este sistema.
Llamar a cookies() dentro de unstable_cache lanza una excepción en Next 15+. El lector cacheado de anulaciones hacía exactamente eso. La excepción aterrizaba en el catch pelado, se servía el catálogo estático, todas las páginas se veían perfectas — y la funcionalidad de anulaciones llevaba muerta en silencio quién sabe cuánto.
Las anulaciones se guardaban bien. Se cacheaban bien. Simplemente no aparecían nunca, y nada en ninguna parte informaba de un error, porque el respaldo hacía exactamente el trabajo para el que se escribió. El arreglo es pequeño — la lectura de anulaciones usa el cliente público de Supabase sin cookies, que no tiene por qué leer cookies de entrada —, pero la lección sobrevivió al arreglo. Ahora tratamos un respaldo silencioso como un respaldo sin terminar: si un catch se traga un fallo que cambia lo que ve el usuario, tiene que dejar rastro en alguna parte, o no es una red de seguridad, es una venda en los ojos.
Un respaldo que no puede avisarte de que ha saltado acabará escondiendo una funcionalidad muerta.
Una consecuencia de las anulaciones alcanza también a la carga del cliente: el proveedor se alimenta de getMessages() y no de una importación cruda del JSON. Importar el JSON directamente sería marginalmente más rápido y además saltaría la fusión por completo, así que los componentes de cliente se quedarían ahí renderizando el texto anterior a la anulación mientras la mitad renderizada en servidor de la misma página mostraría el texto corregido. Dos fuentes de verdad en una pantalla es peor que una lenta.
Respaldos, hreflang y lo que reciben los rastreadores
La cobertura de traducción nunca es uniforme. Una sección del CMS puede existir en inglés y alemán pero todavía no en portugués, y las opciones honestas son una región en blanco o texto en inglés. Elegimos el inglés, en la capa de contenido: fetchSectionWithFallback, fetchRowsWithFallback y fetchPageMeta prueban cada uno el idioma solicitado, reintentan en inglés si fallan y se saltan el reintento cuando el idioma solicitado ya es el inglés. Una página traducida a medias se degrada a una página bilingüe en vez de a una rota.
El mapa de alternativas no sabe nada de todo eso, y es a propósito. alternates.ts declara el conjunto de URL — seis entradas hreflang más x-default apuntando a la URL inglesa — y nada más. Mezclar la lógica de respaldo ahí dentro significaría que el grafo de hreflang cambiaría de forma según lo avanzado que fuera un traductor, que es una manera estupenda de enseñarle a un buscador que tu estructura de URL es inestable. La cobertura se expresa donde le corresponde: el sitemap restringe las alternativas hreflang de las páginas de detalle de trabajos y del diario a los idiomas que de verdad tienen una fila publicada.
Los slugs localizados tienen además una consecuencia en la frontera de los rastreadores que nadie predice la primera vez. El flujo de presupuesto está prohibido en robots.txt — es un embudo, no una página de aterrizaje — y, como el slug está localizado, no hay una sola ruta que prohibir. Hay seis. Añade que los grupos de robots.txt son independientes entre sí (una prohibición escrita en un grupo no se aplica al siguiente), y que damos acceso a dieciocho rastreadores de IA en dieciocho grupos separados, y la lista de prohibiciones tiene que repetirse entera, todas las veces, en todos los grupos. Se genera, no se teclea, por razones que deberían ser obvias.
La multiplicación aparece también en el sitemap. Veinte páginas estáticas se convierten en 120 entradas antes de contar un solo juego, caso de estudio o pieza del diario, y cada una de esas entradas lleva su propio bloque de alternativas. Un sitemap de esa forma no es algo que nadie mantenga a mano; es una función del mapa de rutas, que es la misma razón por la que a ese mapa se le permite ser el único sitio donde se escribe un slug. Cualquier cosa que haya que repetir en seis sitios acabará repitiéndose en cinco.
La última milla son los metadatos sociales, donde seis idiomas se convierten en un mapa de Open Graph — en_US, ru_RU, es_ES, de_DE, fr_FR, pt_PT — emitido por un ayudante compartido que siempre vuelve a declarar todos los campos. Esa última parte no es estilística: Next.js reemplaza en lugar de fusionar en profundidad el openGraph por segmento de ruta, así que cualquier página que fije uno de sus propios campos descarta sin avisar los valores por defecto del layout de idioma y se lleva por delante og:site_name y og:locale. El ayudante existe para que nadie tenga que acordarse de esto.
Nada de lo anterior es exótico. Lo que lo mantiene en pie es que cada uno de los seis idiomas se trata como una superficie de primera clase con las mismas garantías — su URL, su catálogo, sus metadatos, su historia frente a los rastreadores — y no como inglés con una tabla de consulta delante. La medida de eso no es el lanzamiento; es lo que cuesta la vigesimonovena entrada de rutas, que es una línea en el mapa, seis slugs, una fila de sitemap y ninguna discusión. Escribimos sobre por qué sostenemos ese listón en El estándar del conjuro; esto es lo que parece cuando el listón es una tabla de enrutado.