

1/1/1970
Cómo implementé i18n en 20 idiomas en 3 días
¡Hola! Acabo de terminar una tarea enorme en la que traduje Foony a 20 idiomas distintos. Fue un trabajo monumental que implicó tocar casi todos los archivos del repositorio, pero conseguí dejarlo todo listo en tan solo 3 días.
A continuación te explico cómo lo hice, los números concretos detrás del cambio y por qué decidí (una vez más) montar mi propia librería de traducción en lugar de usar el estándar de la industria.
¿Por qué no i18next?
Cuando empecé a plantearme añadir traducciones, consideré el estándar de la industria: i18next y react-i18next.
En su lugar, decidí optimizar para la mantenibilidad por IA. i18next es potente, pero la variedad de su API puede provocar que los LLM alucinen o escriban código inconsistente. Al limitar la librería a un simple t() e interpolate(), me aseguré de que más de 10 agentes en paralelo pudieran escribir código 100% type-safe casi sin intervención humana.
También me preocupaba meterme en un ecosistema grande que pudiera introducir cambios incompatibles más adelante. Después de haber sufrido migraciones dolorosas como React Router v5 y MUI v4 → v5, sé que romper la retrocompatibilidad rápidamente es algo demasiado habitual en el mundo de JavaScript. El coste de añadir funciones de pluralización más adelante es menor que el de migrar manualmente 139.000 líneas de código ahora.
Quería algo súper sencillo, extremadamente ligero y adaptado exactamente a las necesidades de mi equipo.
Así que escribí la mía propia.
Construí un subconjunto restringido de 3 KB diseñado específicamente para permitir refactorizaciones autónomas y precisas con IA. Esto me permitió actuar como un único ingeniero realizando el trabajo de 3 semanas de un equipo de 5 personas en tan solo 3 días.
La implementación personalizada
Diseñé una librería i18n mínima que ronda los 3 KB comprimidos. Expone dos funciones principales: getTranslation() para contextos fuera de React y un hook useTranslation() para los componentes.
Estas devuelven t() para el reemplazo simple de cadenas e interpolate() para cuando necesito inyectar componentes de React en una cadena traducida (como un enlace o un icono). Ambas funciones soportan reemplazo de variables, por ejemplo "Hello {{thing}}", {thing: 'World'}.
Las claves siguen una notación "slash-dot" (barras para la ruta del archivo de localización, puntos para los objetos anidados dentro del archivo). Para garantizar la unicidad, las claves de traducción dentro de un archivo no pueden contener barras.
Aquí está la función principal t():
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
Y el hook de React:
export function useTranslation() {
const [language] = useLanguage();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
El núcleo de toda la librería son solo unas 580 líneas de código. Se encarga de:
- Cargar de forma diferida los archivos de traducción para no enviar los 20 idiomas a cada usuario.
- Dividir las traducciones por "namespace" (por ejemplo,
common,misc,games/{gameId}). - Un locale "debug" que muestra las claves en bruto para verificar que todo está bien conectado.
Para que el sistema sea fácil de mantener, también añadí documentación completa en shared/src/i18n/README.md, que cubre desde la estructura de archivos hasta ejemplos de uso tanto en cliente como en servidor. Como no estoy usando una librería estándar, tener esta referencia es clave para incorporar a nuevos miembros del equipo (o simplemente para recordarme a mí mismo en el futuro o a los LLM cómo funciona).
En cifras
Para que te hagas una idea de la magnitud de esta actualización, esto es lo que cambió en el código:
- 20 idiomas soportados (más un locale debug para desarrollo).
- 360 archivos de localización creados.
- 139.031 líneas de código de traducción.
- 3.938 llamadas a
t()añadidas en el cliente. - 728 archivos fuente modificados.
- 18 archivos fuente en inglés que sirven como fuente de verdad (16 juegos + common + misc).
Orquestación con agentes
Hacer esto a mano habría supuesto meses de trabajo mecánico y embrutecedor. En su lugar, orquesté más de una docena de agentes de Cursor en paralelo para que se encargaran del trabajo pesado.
Empecé dividiendo el código en "secciones" según las carpetas. Cada juego de Foony tiene su propia carpeta y su propio namespace de traducción. Esto mantiene pequeño el tamaño de carga inicial, ya que solo se cargan las traducciones del juego al que estás jugando.
Lancé varios agentes de Cursor a la vez. A cada agente le asignaba una sección concreta, por ejemplo "convierte el juego de Ajedrez para que use traducciones", y este iba archivo por archivo, encontrando cadenas visibles para el usuario y sustituyéndolas por t('games/chess/some.key').
El agente añadía entonces esa clave al archivo de localización en inglés correspondiente con un comentario JSDoc explicando el "qué" y el "dónde" de la cadena. Este contexto es importante a la hora de generar las traducciones para los demás idiomas, ya que ayuda al LLM a entender si "Save" significa "Guardar configuración del juego" o "Guardar tu dibujo de Draw & Guess".
Control de calidad
Revisé rápidamente todo el código generado. Los agentes eran sorprendentemente buenos, pero cometían errores ocasionales, como poner el hook useTranslation después de un return temprano.
Las traducciones tipadas con fuerza ayudaron muchísimo. Esto garantizó que todas las traducciones de cada locale tuvieran todas las claves correctas (y ninguna incorrecta). También garantizó que las llamadas a t() e interpolate() usaran cadenas de traducción reales que existían.
El sistema de tipos extrae todas las claves de traducción posibles a partir de los archivos fuente en inglés:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
Esto da un autocompletado perfecto en TypeScript, y cualquier errata en una clave de traducción se detecta en tiempo de compilación. Los agentes no pueden cometer errores como t('games/ches/name') porque TypeScript lo señala al instante.
Localización
Una vez terminada la conversión al inglés, dividí las tareas restantes de localización. Hice que cada agente fuera responsable de convertir un único archivo de localización en inglés a un idioma concreto.
Por ejemplo, di a los agentes un prompt como este:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
Pensé en hacer que Cursor creara un script para pasar cada uno de estos archivos a un LLM y generar las traducciones, pero quería ahorrar un poco en costes de LLM. Usar un script para actualizar solo las traducciones que faltan era el mejor enfoque, y probablemente use una solución similar en el futuro. Me gustaría llevar un seguimiento de qué cadenas necesitan actualizarse o traducirse, pero quiero mantener las cosas simples. Puede que mueva el trabajo de traducción a una base de datos o algo así.
También añadí un locale "debug" que solo está disponible en desarrollo. Esto me permite ver todas las cadenas reemplazadas para verificar que todo funciona (además, me parece molón). Cuando usas el locale debug, t() devuelve la clave envuelta entre corchetes:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Así que en lugar de ver "¡Bienvenido a Foony!", verías ⟦welcome⟧, lo que facilita detectar cualquier traducción que falte.
Por último, otro agente implementó el enrutado /{locale}/** para que cosas como /ja/games/chess rutearan al idioma correcto (en este caso, japonés).
Traduciendo el blog
Traducir las cadenas de la interfaz era una cosa, pero ¿y los posts del blog? No quería poner en marcha y gestionar todavía más agentes para traducir todos mis posts.
Lo resolví haciendo que un agente creara un script (scripts/src/generateBlogTranslations.ts) que automatiza todo el proceso.
Así funciona:
- Escanea el directorio
client/src/posts/enen busca de archivos MDX en inglés. - Comprueba qué traducciones faltan en las demás carpetas de locale (por ejemplo,
posts/ja,posts/es). - Si falta una traducción, lee el contenido en inglés y se lo pasa a Gemini 3 Pro Preview con un prompt específico para traducir el contenido preservando el formato Markdown.
- Guarda el nuevo archivo en la ubicación correcta.
En el frontend, uso import.meta.glob para importar dinámicamente todos estos archivos MDX. Mi componente PostPage simplemente comprueba el locale actual del usuario y carga de forma diferida el archivo MDX correcto. Si falta una traducción (porque aún no he ejecutado el script), recurre al inglés sin problemas.
Día 4: generación automatizada de traducciones
Sabía que la solución original no iba a escalar. Así que, ya con i18n en marcha, era hora de robustecerla un poco con un enfoque basado en base de datos.
En resumen: cuando el texto en inglés o los comentarios JSDoc cambiaban, las traducciones había que regenerarlas. Hacer un seguimiento manual de qué necesitaba actualizarse habría sido propenso a errores y un desperdicio de tiempo de desarrollo.
Así que construí la solución que había planeado originalmente: un sistema de generación de traducciones respaldado por PostgreSQL.
El esquema de la base de datos
Añadí una tabla translations a nuestra base de datos PostgreSQL con la siguiente estructura:
key: la clave de traducción en notación "slash-dot" (por ejemplo,"games/yacht/nested.name","config.timeLimit.label").en_value: el valor original en ingléstarget_locale: el código del locale de destino (por ejemplo,"es","fr","zh")target_value: el valor traducidocontext: un campo JSONB que contiene el JSDoc para esta clave y todas las claves ancestrascreated_atyupdated_at: marcas de tiempo para el seguimiento
El índice único está sobre (key, target_locale, en_value, context). Esto es crucial: al incluir context en la restricción de unicidad, podemos detectar automáticamente cuándo cambian los comentarios JSDoc y regenerar las traducciones. Las traducciones antiguas se conservan como referencia histórica.
El script de generación
Creé scripts/src/generateLocalizations.ts, que automatiza todo el flujo de traducción:
- Extrae las claves en inglés: usa parsing AST (ts-morph) para extraer todas las claves de traducción de los archivos
shared/src/i18n/locales/en/**, procesando solo los exports por defecto - Extrae el contexto JSDoc: parsea los comentarios JSDoc de cada clave y de todas las claves ancestras (objetos padre) para proporcionar un contexto rico
- Consulta la base de datos: comprueba las traducciones existentes en PostgreSQL, haciendo coincidencia por
key,target_locale,en_valueYcontext. Si cualquiera de estos cambia, la traducción se regenera. - Identifica claves nuevas o modificadas: encuentra claves que necesitan traducción o cuyo valor o comentario en inglés ha cambiado
- Agrupa traducciones en lotes: agrupa por locale y prefijo de namespace para hacer llamadas al LLM más eficientes (también acelera las traducciones). Sin embargo, si el lote es demasiado grande, la calidad de la traducción empeora.
- Genera traducciones: usa GPT 5.1 con un contexto completo (JSDoc, idioma+región, tono, glosario, ejemplos). He leído que 5.1 escribe mejor que 5.2 (no suena tan soso), pero no lo he confirmado.
- Comprobaciones de QA: valida la preservación de placeholders, por ejemplo
{{name}}, la integridad de las claves y el formato JSON - Almacena en base de datos: guarda las traducciones con el contexto completo (JSDoc + JSDoc de los ancestros)
- Genera los archivos de locale: lee de la base de datos y escribe archivos de locale TypeScript correctamente formateados con tipos
RecursivePartial
Beneficios clave
Este enfoque nos aporta varias mejoras de DevEx:
- Regeneración automática: cuando cambia el texto en inglés O los comentarios JSDoc, las traducciones se regeneran automáticamente. Así que si alguien dice que una traducción es mala, es muy fácil regenerarla añadiendo más contexto en un comentario.
- Contexto rico: los comentarios JSDoc proporcionan contexto de traducción (por ejemplo, "Mensaje de error mostrado a los jugadores, máx. 15 caracteres"), ayudando al LLM a producir traducciones más precisas
- Contexto de los ancestros: el JSDoc del objeto padre aporta contexto del namespace (por ejemplo, "Logro por estar en una partida en la que se destruyen todos los huevos"), dando un poco más de claridad
- Seguimiento histórico: las traducciones antiguas se guardan en la base de datos. No ocupan mucho espacio, así que de momento no veo motivo para borrarlas, y mola ver el historial.
Detalles técnicos
La implementación usa varias técnicas para garantizar la fiabilidad y la eficiencia:
- Extracción basada en AST para asegurarme de obtener los comentarios correctos
- Procesamiento en paralelo usando un Semaphore para la traducción simultánea de lotes
- Lógica de reintento con backoff exponencial ante fallos de la API. Las llamadas a LLM son notoriamente inestables.
El script se puede ejecutar con npm run generate-localizations desde el directorio scripts. Se conecta a PostgreSQL y procesa todas las traducciones nuevas o modificadas para todos los locales soportados al ejecutarse.
Conclusión
¡Llegados a este punto, tenía un sitio totalmente funcional traducido a los 20 locales!
Han sido 3 días de locos, pero el resultado es un sitio totalmente localizado que se siente (en su mayor parte) nativo para usuarios de todo el mundo. Construyendo una librería ligera y a medida y aprovechando agentes de IA para el trabajo tedioso de refactorización, conseguí algo que habría sido imposible hace apenas un año: i18n completo en 3 días para una web compleja, hecho por 1 ingeniero. El futuro de la programación no consiste en escribir código rápido. Consiste en orquestar agentes de IA y tener la experiencia profunda en el dominio necesaria para verificar lo que producen.