Una aplicación, muchos idiomas
Lanzar una aplicación en un solo idioma limita su alcance desde el primer día. Un usuario que no entiende los botones, ve fechas en un formato extraño o precios en una moneda que no reconoce abandona antes de descubrir lo que el producto ofrece. Por eso la internacionalización de aplicaciones React ha pasado de ser un extra a convertirse en un requisito para muchos proyectos, desde tiendas en línea hasta paneles corporativos.
Conviene distinguir dos términos que suelen usarse como sinónimos. La internacionalización, abreviada i18n, es preparar el código para que pueda funcionar en cualquier idioma y región: extraer los textos, usar formatos adaptables y no dar por hecho el orden de las palabras. La localización, abreviada l10n, es el trabajo de adaptar la aplicación a un mercado concreto: traducir los textos, ajustar formatos de fecha, número y moneda, e incluso cambiar imágenes o contenidos.
En esta guía vas a internacionalizar una pequeña aplicación de recetas con i18next, la biblioteca de traducción más usada del ecosistema JavaScript, y su integración oficial para React. Usaremos las versiones actuales, i18next 26 y react-i18next 17, sobre React 19 y Vite. Verás cómo organizar los archivos de traducción, traducir componentes con hooks, insertar variables y enlaces, manejar plurales y formatos, detectar el idioma del navegador, cargar traducciones bajo demanda y tipar las claves con TypeScript.
Cómo funciona i18next
La idea central es sencilla: los textos dejan de estar escritos dentro de los componentes y pasan a vivir en archivos separados, uno por idioma. Cada texto se identifica con una clave, y el componente pide la clave en lugar del texto. Según el idioma activo, i18next devuelve el valor correspondiente.
Componente: t("receta.titulo")
│
┌───────────┴───────────┐
▼ ▼
es/comun.json en/comun.json
"Recetas de temporada" "Seasonal recipes"
La biblioteca se divide en piezas con responsabilidades claras:
i18nextes el núcleo: gestiona idiomas, recursos, interpolación, plurales y formatos. No depende de ningún framework.react-i18nextconecta el núcleo con React mediante el hookuseTranslation, el componenteTransy el proveedorI18nextProvider.- Los plugins añaden capacidades opcionales, como detectar el idioma del navegador o cargar traducciones desde el servidor.
Las traducciones se agrupan además en espacios de nombres, o namespaces. En una aplicación pequeña basta con uno, pero en proyectos grandes conviene dividirlas por áreas, como comun, recetas o perfil, para cargar solo lo que cada pantalla necesita.
Preparar el proyecto
Crea una aplicación React con Vite e instala las dependencias de traducción:
npm create vite@latest recetario -- --template react
cd recetario
npm install
npm install i18next react-i18next i18next-browser-languagedetector
Comprueba las versiones instaladas:
npm ls i18next react-i18next
[email protected] /home/ana/recetario
├── [email protected]
└─┬ [email protected]
└── [email protected] deduped
Organiza los recursos en una carpeta propia. Mantener los archivos de traducción en JSON separados del código facilita que personas sin conocimientos de programación, o servicios de traducción profesionales, trabajen sobre ellos sin tocar los componentes:
recetario/
├── src/
│ ├── i18n/
│ │ ├── config.js
│ │ └── locales/
│ │ ├── es/
│ │ │ └── comun.json
│ │ └── en/
│ │ └── comun.json
│ ├── componentes/
│ │ ├── Cabecera.jsx
│ │ ├── TarjetaReceta.jsx
│ │ └── SelectorIdioma.jsx
│ ├── App.jsx
│ └── main.jsx
└── package.json
Escribir los archivos de traducción
Cada idioma tiene un archivo con las mismas claves. Puedes usar estructuras anidadas para agrupar los textos por contexto, lo que mejora la legibilidad cuando el archivo crece:
{
"app": {
"titulo": "Recetario de temporada",
"bienvenida": "Hola, {{nombre}}. ¿Qué cocinamos hoy?"
},
"nav": {
"inicio": "Inicio",
"favoritas": "Favoritas",
"perfil": "Mi perfil"
},
"receta": {
"tiempo": "Listo en {{minutos}} minutos",
"ver": "Ver receta completa"
}
}
El archivo en inglés replica la estructura con los textos traducidos:
{
"app": {
"titulo": "Seasonal recipes",
"bienvenida": "Hi, {{nombre}}. What are we cooking today?"
},
"nav": {
"inicio": "Home",
"favoritas": "Favorites",
"perfil": "My profile"
},
"receta": {
"tiempo": "Ready in {{minutes}} minutes",
"ver": "See full recipe"
}
}
Observa el error deliberado en la clave receta.tiempo del archivo inglés: la variable se llama minutes en lugar de minutos. Los nombres de las variables deben coincidir exactamente en todos los idiomas; si no, el texto mostrará el marcador sin sustituir. Es uno de los fallos más frecuentes y lo verás de nuevo en la sección de solución de problemas. En el archivo real, corrígelo a {{minutos}}.
Configurar i18next
La configuración vive en src/i18n/config.js. Este archivo crea la instancia, registra los plugins y define el comportamiento por defecto:
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import comunEs from "./locales/es/comun.json";
import comunEn from "./locales/en/comun.json";
export const idiomasDisponibles = {
es: "Español",
en: "English",
};
i18n.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
es: { comun: comunEs },
en: { comun: comunEn },
},
supportedLngs: Object.keys(idiomasDisponibles),
fallbackLng: "es",
ns: ["comun"],
defaultNS: "comun",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator", "htmlTag"],
caches: ["localStorage"],
},
});
export default i18n;
Cada opción tiene un propósito concreto:
resourcescontiene las traducciones, agrupadas por idioma y espacio de nombres.supportedLngsrestringe los idiomas aceptados. Si el navegador está en francés, i18next no intentará cargarlo y usará el idioma de respaldo.fallbackLngdefine qué idioma usar cuando falta una traducción o el idioma detectado no está disponible.defaultNSevita tener que indicar el espacio de nombres en cada llamada.escapeValue: falsedesactiva el escape de HTML en la interpolación, porque React ya escapa los valores al renderizar. Mantenerlo activo produciría textos con entidades como&.
Importa la configuración una sola vez en el punto de entrada, antes de renderizar la aplicación:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./i18n/config.js";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
Si vienes de versiones antiguas de i18next, ten en cuenta que la versión 26 eliminó opciones obsoletas como initImmediate, sustituida por initAsync, y la antigua función interpolation.format. Para formatear valores se usa ahora siempre el formateador integrado, que verás más adelante.
Traducir componentes con useTranslation
El hook useTranslation es la forma recomendada de traducir en componentes funcionales. Devuelve la función t, que recibe una clave y devuelve el texto en el idioma activo, y la instancia i18n:
import { useTranslation } from "react-i18next";
export function Cabecera({ usuario }) {
const { t } = useTranslation();
return (
<header>
<h1>{t("app.titulo")}</h1>
<p>{t("app.bienvenida", { nombre: usuario.nombre })}</p>
<nav>
<a href="/">{t("nav.inicio")}</a>
<a href="/favoritas">{t("nav.favoritas")}</a>
<a href="/perfil">{t("nav.perfil")}</a>
</nav>
</header>
);
}
El segundo argumento de t es un objeto con los valores de las variables. i18next sustituye cada {{nombre}} del texto por el valor correspondiente, un mecanismo llamado interpolación.
Cuando el idioma cambia, todos los componentes que usan el hook se vuelven a renderizar automáticamente con los nuevos textos. No necesitas gestionar ningún estado adicional.
Si un componente usa casi siempre el mismo grupo de claves, puedes indicar un prefijo para escribir menos:
const { t } = useTranslation("comun", { keyPrefix: "receta" });
<span>{t("tiempo", { minutos: 25 })}</span>
<button>{t("ver")}</button>
Para componentes de clase, react-i18next sigue ofreciendo el componente de orden superior withTranslation, que inyecta t en las props. En proyectos nuevos, sin embargo, lo habitual es usar componentes funcionales y el hook.
Textos con enlaces y formato: el componente Trans
Algunas frases mezclan texto con elementos de React, como un enlace en mitad de una oración o una palabra en negrita. No conviene dividirlas en trozos, porque el orden de las palabras cambia entre idiomas y el traductor necesita ver la frase completa. Para estos casos existe el componente Trans.
En el archivo de traducción, los elementos se marcan con etiquetas. Puedes usar índices numéricos o, de forma más legible, nombres propios:
{
"aviso": {
"registro": "<enlace>Crea una cuenta</enlace> para guardar tus recetas <fuerte>favoritas</fuerte>."
}
}
En el componente, la prop components asocia cada etiqueta con un elemento real:
import { Trans } from "react-i18next";
import { Link } from "react-router";
export function AvisoRegistro() {
return (
<p>
<Trans
i18nKey="aviso.registro"
components={{
enlace: <Link to="/registro" />,
fuerte: <strong />,
}}
/>
</p>
);
}
El resultado en español es un párrafo con un enlace y una palabra destacada, y en inglés la misma estructura con el orden que corresponda a ese idioma. Este enfoque evita insertar HTML en las traducciones con dangerouslySetInnerHTML, que abriría la puerta a problemas de seguridad.
Plurales, contexto y formatos
Los plurales son mucho más complejos de lo que parece. En español e inglés distinguimos entre uno y varios, pero otros idiomas tienen hasta seis formas distintas según la cantidad. i18next resuelve esto con la API Intl.PluralRules del navegador y sufijos en las claves.
{
"favoritas": {
"contador_zero": "Aún no tienes recetas favoritas",
"contador_one": "Tienes {{count}} receta favorita",
"contador_many": "Tienes {{count}} recetas favoritas",
"contador_other": "Tienes {{count}} recetas favoritas"
}
}
Al llamar a t con la variable especial count, i18next elige la forma correcta:
<p>{t("favoritas.contador", { count: total })}</p>
count: 0 -> Aún no tienes recetas favoritas
count: 1 -> Tienes 1 receta favorita
count: 12 -> Tienes 12 recetas favoritas
El sufijo _zero es opcional y permite un mensaje especial para cero. El sufijo _many aparece porque las reglas de plural del español incluyen esa categoría para cantidades muy grandes, como un millón; incluirlo evita sorpresas. Los sufijos antiguos _plural, que verás en tutoriales de hace años, pertenecen a un formato de archivo obsoleto.
El contexto permite variar un texto según una condición, por ejemplo el género o el tipo de elemento, con el sufijo _ seguido del valor:
{
"autor": {
"creada": "Receta creada por {{nombre}}",
"creada_equipo": "Receta creada por el equipo de {{nombre}}"
}
}
t("autor.creada", {
nombre: "Cocina Norte",
context: receta.esDeEquipo ? "equipo" : undefined,
});
Para fechas, números y monedas, i18next incluye un formateador basado en la API Intl del navegador. Se indica el formato dentro de la variable:
{
"receta": {
"precio": "Ingredientes por {{valor, currency(EUR)}}",
"publicada": "Publicada el {{fecha, datetime}}",
"calorias": "{{valor, number}} kcal por ración"
}
}
<p>{t("receta.precio", { valor: 12.5 })}</p>
<p>{t("receta.publicada", { fecha: new Date(receta.fecha), formatParams: { fecha: { dateStyle: "long" } } })}</p>
<p>{t("receta.calorias", { valor: 1450 })}</p>
es: Ingredientes por 12,50 € | Publicada el 27 de octubre de 2026 | 1450 kcal por ración
en: Ingredients for €12.50 | Published on October 27, 2026 | 1,450 kcal per serving
Cada idioma aplica automáticamente sus separadores decimales, el orden del símbolo de moneda y el formato de fecha, sin escribir lógica propia.
Crear un selector de idioma en React
Un selector de idioma en React solo necesita llamar a i18n.changeLanguage() con el código del idioma elegido. Usar un elemento select en lugar de botones sueltos escala mejor cuando añades idiomas:
import { useTranslation } from "react-i18next";
import { idiomasDisponibles } from "../i18n/config.js";
export function SelectorIdioma() {
const { i18n } = useTranslation();
return (
<label>
<span className="solo-lectores">Idioma</span>
<select
value={i18n.resolvedLanguage}
onChange={(evento) => i18n.changeLanguage(evento.target.value)}
>
{Object.entries(idiomasDisponibles).map(([codigo, nombre]) => (
<option key={codigo} value={codigo} lang={codigo}>
{nombre}
</option>
))}
</select>
</label>
);
}
Hay dos detalles importantes en este componente. Se usa i18n.resolvedLanguage en lugar de i18n.language, porque el primero indica el idioma que realmente se está mostrando; si el navegador está en es-MX, language valdría es-MX, pero resolvedLanguage valdrá es, que es el que coincide con las opciones. Además, cada opción muestra el nombre del idioma en su propio idioma, de modo que un usuario que no entiende la interfaz actual pueda reconocer el suyo.
Conviene también actualizar el atributo lang del documento cada vez que cambia el idioma. Los lectores de pantalla lo usan para elegir la pronunciación, y los buscadores para indexar el contenido:
i18n.on("languageChanged", (idioma) => {
document.documentElement.lang = idioma;
document.documentElement.dir = i18n.dir(idioma);
});
La función i18n.dir() devuelve rtl para idiomas que se escriben de derecha a izquierda, como el árabe o el hebreo, lo que permite adaptar el diseño con CSS.
Detectar el idioma del navegador
El plugin i18next-browser-languagedetector, que ya registraste en la configuración, permite detectar el idioma del navegador y recordar la elección del usuario. La opción order define en qué fuentes busca y en qué orden:
querystring: un parámetro en la URL, como?lng=en, útil para compartir enlaces en un idioma concreto.localStorageocookie: la elección guardada en visitas anteriores.navigator: los idiomas configurados en el navegador.htmlTag: el atributolangdel documento.
Con caches: ["localStorage"], cada vez que el usuario cambia de idioma con el selector la elección queda guardada y se respeta en la siguiente visita. El orden elegido en la configuración da prioridad a esa preferencia explícita sobre la configuración del navegador, que es el comportamiento que la mayoría de usuarios espera.
Carga diferida de traducciones
Importar todos los archivos de traducción en el paquete principal es cómodo, pero en aplicaciones con muchos idiomas o textos aumenta el tamaño de la descarga inicial. La carga diferida de traducciones descarga solo el idioma y el espacio de nombres que se necesitan en cada momento.
Con Vite, la forma más sencilla es usar importaciones dinámicas mediante el plugin i18next-resources-to-backend, que convierte cualquier función asíncrona en una fuente de traducciones:
npm install i18next-resources-to-backend
import i18n from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
i18n.use(
resourcesToBackend(
(idioma, espacio) => import(`./locales/${idioma}/${espacio}.json`)
)
)
.use(initReactI18next)
.init({
supportedLngs: ["es", "en"],
fallbackLng: "es",
ns: ["comun"],
defaultNS: "comun",
interpolation: { escapeValue: false },
});
Vite genera un archivo independiente por cada combinación de idioma y espacio de nombres. Como ahora las traducciones llegan de forma asíncrona, react-i18next suspende los componentes mientras se cargan, así que debes envolver la aplicación en un Suspense con un estado de carga:
import { Suspense } from "react";
createRoot(document.getElementById("root")).render(
<Suspense fallback={<p>Cargando…</p>}>
<App />
</Suspense>
);
Si prefieres alojar las traducciones fuera del paquete, por ejemplo en una CDN o un servicio de gestión de traducciones, el plugin i18next-http-backend las descarga desde una URL con el patrón que indiques.
Claves tipadas con TypeScript
Una de las mejoras más útiles en proyectos grandes es que el compilador avise cuando usas una clave que no existe. i18next lo permite declarando los recursos en un archivo de tipos:
// src/i18n/i18next.d.ts
import "i18next";
import type comun from "./locales/es/comun.json";
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "comun";
resources: {
comun: typeof comun;
};
}
}
A partir de ese momento, el editor autocompleta las claves y el compilador marca los errores:
t("nav.inicio"); // correcto
t("nav.incio"); // error de tipos: la clave no existe
error TS2345: Argument of type '"nav.incio"' is not assignable to parameter of type ...
Usa como referencia el idioma base, el que se mantiene siempre completo, y deja que una herramienta de validación compruebe que los demás tienen las mismas claves.
Buenas prácticas para proyectos reales
Usa claves semánticas y estables. Una clave como receta.ver describe el propósito del texto; usar el propio texto como clave obliga a cambiar el código cada vez que alguien corrige una errata.
No concatenes fragmentos traducidos. Construir una frase como t("hola") + " " + nombre + t("bienvenido") impide que los traductores reordenen las palabras. Usa siempre una clave completa con interpolación.
Deja espacio en el diseño. Los textos en alemán o finés pueden ser un 30 % o más largos que en inglés, mientras que en chino o japonés suelen ser más cortos. Prueba la interfaz con el idioma más extenso para detectar botones que se desbordan.
Automatiza la extracción y validación de claves. Herramientas como i18next-parser o i18next-cli recorren el código, detectan claves nuevas o sin uso y actualizan los archivos JSON. Ejecútalas en tu integración continua para que ningún idioma se quede atrás.
Traduce también lo que no se ve. Los atributos alt de las imágenes, aria-label, los títulos de página y los mensajes de validación de formularios forman parte de la experiencia y de la accesibilidad.
Activa los avisos de claves faltantes en desarrollo:
i18n.init({
// ...
debug: import.meta.env.DEV,
saveMissing: import.meta.env.DEV,
missingKeyHandler: (idiomas, espacio, clave) => {
console.warn(
`Falta la traducción "${clave}" en ${idiomas.join(", ")} (${espacio})`
);
},
});
i18next frente a otras alternativas
i18next no es la única opción para React. Conocer las alternativas ayuda a elegir según el proyecto:
| Biblioteca | Enfoque | Cuándo elegirla |
|---|---|---|
| i18next con react-i18next | Claves en JSON, ecosistema de plugins muy amplio | Proyectos de cualquier tamaño, también fuera de React |
| FormatJS con react-intl | Mensajes en formato ICU, muy orientado a estándares | Equipos que trabajan con plataformas de traducción basadas en ICU |
| LinguiJS | Mensajes en el propio código y extracción automática | Quienes prefieren escribir el texto fuente directamente en JSX |
| next-intl | Integración profunda con Next.js y componentes de servidor | Aplicaciones construidas con Next.js |
| Paraglide | Compilación de mensajes a funciones con eliminación de código no usado | Proyectos que priorizan el tamaño mínimo del paquete |
i18next destaca por su madurez, su enorme ecosistema y por funcionar igual en React, en Node.js, en React Native o en otros frameworks, lo que permite compartir traducciones entre frontend y backend.
Errores comunes y cómo evitarlos
Olvidar importar la configuración. Si config.js no se importa antes de renderizar, los componentes no encuentran ninguna instancia de i18next.
Variables con nombres distintos entre idiomas. Como viste con minutos y minutes, un nombre distinto deja el marcador sin sustituir solo en uno de los idiomas, un error difícil de detectar sin pruebas.
Mantener escapeValue: true con React. Produce textos con entidades HTML visibles, como & o ', porque el valor se escapa dos veces.
Usar el índice de idioma en lugar del idioma resuelto. Comparar con i18n.language falla cuando el navegador usa variantes regionales como en-US o es-AR. Usa i18n.resolvedLanguage.
Traducir dentro de constantes fuera de los componentes. Si llamas a t en el nivel superior de un módulo, el texto se calcula una sola vez y no cambiará al cambiar de idioma. Llama a t durante el renderizado, o guarda las claves en lugar de los textos.
Dividir frases para insertar enlaces. Usa el componente Trans para que el traductor vea la frase completa.
Solución de problemas: mensajes frecuentes
No hay instancia de i18next disponible:
react-i18next:: You will need to pass in an i18next instance by using initReactI18next
El archivo de configuración no se importó o no registra initReactI18next. Comprueba que main.jsx importa ./i18n/config.js antes de renderizar.
Clave inexistente:
i18next::translator: missingKey es comun receta.dificultad receta.dificultad
La clave no existe en el idioma indicado, y la interfaz muestra la clave en lugar del texto. Añádela al archivo JSON o revisa si hay una errata en el código.
Marcador sin sustituir en la interfaz:
Ready in {{minutos}} minutes
La variable del archivo de traducción no coincide con la que pasas a t. Unifica los nombres en todos los idiomas.
Componente suspendido sin límite de carga:
Error: A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator.
Las traducciones se cargan de forma asíncrona y no hay un Suspense por encima del componente. Envuelve la aplicación en Suspense o desactiva la suspensión con useSuspense: false en las opciones de react, gestionando tú mismo el estado de carga con la propiedad ready del hook.
Opción eliminada tras actualizar:
error TS2353: Object literal may only specify known properties, and 'initImmediate' does not exist in type 'InitOptions'.
Actualizaste desde una versión anterior a la 26 y el compilador ya no reconoce la opción. Sustituye initImmediate por initAsync y revisa las notas de migración por si usas otras opciones retiradas, como interpolation.format.
Textos con entidades HTML visibles:
Pan & aceite
La interpolación está escapando valores que React ya escapa. Configura interpolation: { escapeValue: false }.
Casos de uso reales
Una tienda en línea que vende en varios países necesita mucho más que traducir botones: precios en la moneda local, formatos de fecha para las entregas, unidades de medida y textos legales específicos. Los formateadores integrados de i18next y la organización por espacios de nombres permiten cubrir todo esto sin duplicar componentes.
Las aplicaciones SaaS para empresas suelen permitir que cada usuario elija su idioma en el perfil. En ese caso, el idioma se guarda en el backend y se aplica con i18n.changeLanguage() al iniciar sesión, mientras que el detector solo actúa para visitantes anónimos.
Los portales públicos y las administraciones están obligados a menudo a ofrecer varios idiomas oficiales y a cumplir normas de accesibilidad. Actualizar el atributo lang, traducir textos alternativos y etiquetas ARIA y soportar escritura de derecha a izquierda forman parte de los requisitos.
Los equipos que comparten código entre web y móvil aprovechan que i18next funciona igual en React y React Native, reutilizando los mismos archivos de traducción en ambas plataformas.
Conclusiones
Internacionalizar una aplicación React con i18next consiste en separar los textos del código, identificarlos con claves y dejar que la biblioteca elija el valor según el idioma activo. Con i18next 26 y react-i18next 17, el flujo es directo: archivos JSON por idioma, una configuración central, el hook useTranslation en los componentes y el componente Trans para frases con enlaces o formato.
Más allá de la traducción básica, la biblioteca resuelve los problemas que hacen difícil la localización real: plurales correctos para cada idioma mediante Intl.PluralRules, contexto, formatos de fecha, número y moneda con la API Intl, detección y persistencia del idioma del usuario y carga diferida de recursos. Con TypeScript, además, las claves quedan comprobadas en tiempo de compilación.
Si empiezas un proyecto nuevo, prepara la internacionalización desde el principio aunque solo publiques un idioma: extraer los textos más adelante es mucho más costoso. Cuida la accesibilidad con el atributo lang, deja espacio en el diseño para textos más largos y automatiza la validación de claves. Así, añadir un nuevo idioma se reducirá a crear un archivo de traducción.
