Tutoriales//15 min de lectura

Localización de React Native con Expo — Publica tu App en más de 50 Idiomas

Eray Gündoğmuş
Compartir

Las apps móviles viven en el dispositivo del usuario, no en un servidor que tú controlas. Esa única diferencia hace que la localización en React Native sea fundamentalmente diferente a la localización web. Necesitas gestionar el acceso sin conexión, los cambios de idioma del dispositivo, los metadatos de la tienda de aplicaciones en múltiples idiomas y las actualizaciones OTA de traducciones — todo esto manteniendo un tamaño de bundle razonable.

Esta guía te explica cómo localizar una app de React Native con Expo, desde la detección del idioma del dispositivo hasta la entrega de traducciones OTA lista para producción. Usaremos expo-localization para la configuración del dispositivo, i18next como motor de traducción y el CDN de Better i18n para la entrega de traducciones. Para equipos que también desarrollan un frontend web, nuestra guía completa de i18n para Next.js en 2026 cubre el stack equivalente del lado web.

Por qué la i18n Móvil es Diferente

Antes de adentrarnos en el código, entendamos por qué la localización móvil tiene desafíos únicos:

1. Offline First

Las apps web pueden obtener traducciones en cada solicitud. Las apps móviles no pueden asumir disponibilidad de red. Tus traducciones deben estar incluidas en la app como respaldo, con actualizaciones OTA que obtienen contenido actualizado cuando hay conectividad.

2. Idioma del Dispositivo

En la web, detectas el idioma a partir de URLs, cookies o cabeceras Accept-Language. En móvil, el dispositivo tiene un idioma del sistema que el usuario configura en los ajustes de su teléfono. Tu app debe respetar esta preferencia por defecto, con un selector de idioma opcional dentro de la app.

3. Tamaño del Bundle

Las apps web pueden dividir las traducciones por idioma y cargarlas bajo demanda. Las apps móviles se distribuyen como un único binario. Cada idioma que incluyes aumenta el tamaño de la app. Para más de 50 idiomas, esto puede añadir megabytes a tu descarga. Este es uno de los desafíos principales cubiertos en nuestra guía más amplia sobre localización de apps móviles, que compara enfoques entre frameworks.

4. Metadatos de la Tienda de Aplicaciones

Google Play y la App Store requieren metadatos localizados (título, descripción, capturas de pantalla) para cada idioma compatible. Este es un flujo de trabajo separado de las traducciones dentro de la app. En Android, nuestra guía de localización para Android cubre en profundidad el flujo de metadatos de Play Store.

5. Soporte RTL

El árabe, hebreo, persa y urdu requieren diseño de derecha a izquierda (RTL). React Native tiene soporte RTL integrado a través de I18nManager, pero necesitas gestionarlo explícitamente — inversión del diseño, alineación del texto e iconos direccionales.

Configuración del Proyecto

Comienza con un nuevo proyecto de Expo o añádelo a uno existente:

npx create-expo-app my-localized-app
cd my-localized-app

Instala las dependencias:

npx expo install expo-localization
npm install i18next react-i18next @better-i18n/react-native

Qué hace cada paquete:

PaquetePropósito
expo-localizationDetección del idioma del dispositivo y preferencias de formato de calendario/números
i18nextMotor de traducción (resolución de claves, interpolación, pluralización)
react-i18nextBindings de React para i18next (hooks, componentes, contexto)
@better-i18n/react-nativeEntrega por CDN, caché offline y actualizaciones OTA para Better i18n

1. Detección del Idioma del Dispositivo

El primer paso es detectar el idioma preferido del usuario. Expo proporciona expo-localization para esto:

// lib/i18n/locale.ts
import { getLocales, getCalendars } from "expo-localization";

export function getDeviceLocale(): string {
  const locales = getLocales();

  if (locales.length === 0) {
    return "en";
  }

  // El primer idioma es el preferido por el usuario
  const preferred = locales[0];

  // Devuelve el código de idioma (p. ej., "en", "fr", "tr")
  // Usa languageTag para el idioma completo (p. ej., "en-US", "fr-FR")
  return preferred.languageCode ?? "en";
}

export function getDeviceRegion(): string | null {
  const locales = getLocales();
  return locales[0]?.regionCode ?? null;
}

export function isRTL(): boolean {
  const locales = getLocales();
  return locales[0]?.textDirection === "rtl";
}

Importante: getLocales() devuelve un array ordenado de los idiomas preferidos del usuario. En iOS, esto proviene de Ajustes → General → Idioma y Región. En Android, de Ajustes → Sistema → Idioma. Usa siempre el primer elemento como preferencia principal.

2. Configurar i18next

Configura i18next con detección del idioma del dispositivo y entrega por CDN de Better i18n:

// lib/i18n/config.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { createBetterI18nBackend } from "@better-i18n/react-native";
import { getDeviceLocale } from "./locale";

// Traducciones de respaldo incluidas en la app
import en from "../../locales/en.json";
import es from "../../locales/es.json";

const fallbackResources = {
  en: { translation: en },
  es: { translation: es },
};

const betterI18nBackend = createBetterI18nBackend({
  project: "acme/mobile-app",
  cacheTTL: 60 * 60 * 1000, // Caché de traducciones por 1 hora
  offlineStorage: true,      // Persiste en AsyncStorage para uso offline
});

i18n
  .use(betterI18nBackend)
  .use(initReactI18next)
  .init({
    lng: getDeviceLocale(),
    fallbackLng: "en",
    resources: fallbackResources, // Se usa cuando el CDN no está disponible
    interpolation: {
      escapeValue: false, // React Native gestiona el escapado
    },
    react: {
      useSuspense: true,
    },
  });

export default i18n;

Cómo funciona la cadena de respaldo:

  1. Fetch del CDN — Intenta cargar las traducciones desde el CDN de Better i18n
  2. Caché offline — Si el CDN no está disponible, usa las traducciones en caché de AsyncStorage
  3. Recursos incluidos — Si no hay caché, usa las traducciones distribuidas con la app

Esta estrategia de tres niveles garantiza que tu app siempre tenga traducciones, incluso en el primer lanzamiento sin internet.

3. Uso del Hook de Traducción

Usa el hook useTranslation en tus componentes:

// screens/HomeScreen.tsx
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";

export function HomeScreen() {
  const { t } = useTranslation();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>{t("home.title")}</Text>
      <Text style={styles.subtitle}>
        {t("home.welcome", { name: "Developer" })}
      </Text>
      <Text style={styles.count}>
        {t("home.itemCount", { count: 5 })}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 24, fontWeight: "bold" },
  subtitle: { fontSize: 16, marginTop: 8 },
  count: { fontSize: 14, marginTop: 4, color: "#666" },
});

Estructura del archivo de traducciones:

{
  "home": {
    "title": "Bienvenido",
    "welcome": "¡Hola, {{name}}!",
    "itemCount_one": "{{count}} elemento",
    "itemCount_other": "{{count}} elementos"
  }
}

Pluralización: i18next gestiona la pluralización automáticamente. Usa los sufijos _one, _other, _few, _many para las diferentes formas plurales. Esto es fundamental para idiomas como el árabe (6 formas plurales), el polaco (3 formas) y el ruso (3 formas). Para una exploración completa de las reglas de pluralización entre idiomas, consulta nuestra guía de pluralización.

4. Selector de Idioma en la App

Aunque la app debe usar el idioma del dispositivo por defecto, los usuarios a menudo quieren cambiarlo. Aquí tienes un componente selector de idioma:

// components/LanguagePicker.tsx
import { View, Text, TouchableOpacity, FlatList, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import AsyncStorage from "@react-native-async-storage/async-storage";

const LANGUAGES = [
  { code: "en", name: "English", nativeName: "English" },
  { code: "es", name: "Spanish", nativeName: "Español" },
  { code: "fr", name: "French", nativeName: "Français" },
  { code: "de", name: "German", nativeName: "Deutsch" },
  { code: "tr", name: "Turkish", nativeName: "Türkçe" },
  { code: "ja", name: "Japanese", nativeName: "日本語" },
  { code: "ko", name: "Korean", nativeName: "한국어" },
  { code: "ar", name: "Arabic", nativeName: "العربية" },
];

export function LanguagePicker() {
  const { i18n } = useTranslation();

  const handleLanguageChange = async (code: string) => {
    await i18n.changeLanguage(code);
    await AsyncStorage.setItem("userLanguage", code);
  };

  return (
    <FlatList
      data={LANGUAGES}
      keyExtractor={(item) => item.code}
      renderItem={({ item }) => (
        <TouchableOpacity
          style={[
            styles.item,
            item.code === i18n.language && styles.selected,
          ]}
          onPress={() => handleLanguageChange(item.code)}
        >
          <Text style={styles.nativeName}>{item.nativeName}</Text>
          <Text style={styles.name}>{item.name}</Text>
        </TouchableOpacity>
      )}
    />
  );
}

const styles = StyleSheet.create({
  item: {
    flexDirection: "row",
    justifyContent: "space-between",
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: "#eee",
  },
  selected: { backgroundColor: "#f0f7ff" },
  nativeName: { fontSize: 16, fontWeight: "600" },
  name: { fontSize: 14, color: "#666" },
});

Persistencia: Cuando el usuario selecciona manualmente un idioma, guárdalo en AsyncStorage. Al lanzar la app, comprueba si hay una preferencia guardada antes de recurrir al idioma del dispositivo:

// En la inicialización de tu configuración de i18n
const savedLanguage = await AsyncStorage.getItem("userLanguage");
const initialLanguage = savedLanguage || getDeviceLocale();

5. Soporte de Diseño RTL

Para el árabe, hebreo y otros idiomas RTL, React Native necesita configuración explícita:

// lib/i18n/rtl.ts
import { I18nManager } from "react-native";
import * as Updates from "expo-updates";

const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];

export async function handleRTL(languageCode: string) {
  const shouldBeRTL = RTL_LANGUAGES.includes(languageCode);
  const isCurrentlyRTL = I18nManager.isRTL;

  if (shouldBeRTL !== isCurrentlyRTL) {
    I18nManager.forceRTL(shouldBeRTL);
    I18nManager.allowRTL(shouldBeRTL);

    // Los cambios RTL requieren reiniciar la app para tener efecto
    if (!__DEV__) {
      await Updates.reloadAsync();
    }
  }
}

Nota importante: I18nManager.forceRTL() no tiene efecto hasta que la app se reinicia. En desarrollo puedes usar hot reload, pero en producción necesitas llamar a Updates.reloadAsync() para aplicar el cambio de dirección del diseño.

Estilos Conscientes de RTL

Usa I18nManager.isRTL para invertir condicionalmente los estilos:

import { I18nManager, StyleSheet } from "react-native";

const styles = StyleSheet.create({
  row: {
    flexDirection: I18nManager.isRTL ? "row-reverse" : "row",
  },
  icon: {
    marginLeft: I18nManager.isRTL ? 0 : 8,
    marginRight: I18nManager.isRTL ? 8 : 0,
  },
});

Para Expo SDK 50+, puedes usar las propiedades start y end en lugar de left y right:

const styles = StyleSheet.create({
  container: {
    paddingStart: 16, // Izquierda en LTR, Derecha en RTL
    paddingEnd: 8,    // Derecha en LTR, Izquierda en RTL
  },
});

6. Actualizaciones OTA de Traducciones

La funcionalidad estrella: actualiza traducciones sin enviar una nueva versión de la app a la tienda.

// lib/i18n/ota.ts
import { AppState } from "react-native";
import i18n from "./config";

export function setupOTAUpdates() {
  // Comprueba nuevas traducciones cuando la app pasa a primer plano
  const subscription = AppState.addEventListener("change", (state) => {
    if (state === "active") {
      refreshTranslations();
    }
  });

  return () => subscription.remove();
}

async function refreshTranslations() {
  try {
    // El backend de Better i18n gestiona la invalidación de caché
    // Esto desencadena un fetch fresco si el TTL de la caché ha expirado
    await i18n.reloadResources(i18n.language);
  } catch (error) {
    // Fallo silencioso — la app continúa con las traducciones en caché
    console.warn("Translation refresh failed:", error);
  }
}

Cómo funciona OTA con Better i18n:

  1. Actualizas las traducciones en la plataforma Better i18n (mediante AI Drawer, el editor o MCP)
  2. Publicas los cambios en el CDN
  3. La próxima vez que el TTL de la caché de la app expire y el usuario abra la app, se obtienen traducciones actualizadas
  4. La UI se actualiza automáticamente a través del ciclo de re-render de React

Sin envío a la tienda de aplicaciones. Sin actualización del binario. Las traducciones se publican en minutos. Para una visión más amplia de cómo la entrega OTA de traducciones encaja en una estrategia de localización multiplataforma, nuestra guía sobre traducciones OTA para móvil y web cubre en profundidad las decisiones de arquitectura.

7. Formato de Fechas, Números y Monedas

Usa expo-localization para el formato adaptado al idioma:

// lib/i18n/formatting.ts
import { getLocales } from "expo-localization";

export function formatNumber(value: number): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  return new Intl.NumberFormat(locale).format(value);
}

export function formatCurrency(value: number, currency: string = "USD"): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(value);
}

export function formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  return new Intl.DateTimeFormat(locale, options).format(date);
}

export function formatRelativeTime(date: Date): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });

  const diffMs = date.getTime() - Date.now();
  const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));

  if (Math.abs(diffDays) < 1) {
    const diffHours = Math.round(diffMs / (1000 * 60 * 60));
    return rtf.format(diffHours, "hour");
  }

  return rtf.format(diffDays, "day");
}

Nota: El motor Hermes de React Native soporta las APIs Intl desde Hermes 0.12. Si estás en una versión anterior, añade los polyfills intl-pluralrules y @formatjs/intl-numberformat.

8. Pruebas de Componentes Localizados

Prueba tus componentes con diferentes idiomas:

// __tests__/HomeScreen.test.tsx
import { render, screen } from "@testing-library/react-native";
import { I18nextProvider } from "react-i18next";
import i18n from "../lib/i18n/config";
import { HomeScreen } from "../screens/HomeScreen";

function renderWithI18n(component: React.ReactElement, locale: string = "en") {
  i18n.changeLanguage(locale);
  return render(
    <I18nextProvider i18n={i18n}>{component}</I18nextProvider>
  );
}

describe("HomeScreen", () => {
  it("renders in English", () => {
    renderWithI18n(<HomeScreen />, "en");
    expect(screen.getByText("Welcome")).toBeTruthy();
  });

  it("renders in Spanish", () => {
    renderWithI18n(<HomeScreen />, "es");
    expect(screen.getByText("Bienvenido")).toBeTruthy();
  });

  it("handles pluralization", () => {
    renderWithI18n(<HomeScreen />, "en");
    expect(screen.getByText("5 items")).toBeTruthy();
  });
});

9. Localización de la Tienda de Aplicaciones

El listado de tu app en la tienda necesita localización propia. Expo lo soporta a través de app.json:

{
  "expo": {
    "name": "My App",
    "locales": {
      "es": "./locales/store/es.json",
      "fr": "./locales/store/fr.json",
      "de": "./locales/store/de.json",
      "tr": "./locales/store/tr.json",
      "ja": "./locales/store/ja.json"
    }
  }
}

Cada archivo de idioma contiene los metadatos de la tienda:

{
  "CFBundleDisplayName": "Mi Aplicación",
  "NSLocationWhenInUseUsageDescription": "Necesitamos tu ubicación para mostrarte tiendas cercanas."
}

En iOS, esto localiza el nombre de la app en la pantalla de inicio y los diálogos de permisos. En Android, debes gestionar los metadatos de Play Store a través de Google Play Console o Fastlane.

Lista de Verificación para Producción

Antes de lanzar tu app de React Native localizada:

  • Detección del idioma predeterminado funciona desde los ajustes del dispositivo
  • Traducciones de respaldo están incluidas en el binario de la app
  • Modo offline funciona con traducciones en caché
  • Actualizaciones OTA obtienen traducciones actualizadas al pasar a primer plano
  • Diseño RTL ha sido probado con árabe o hebreo
  • Pluralización funciona para todos los idiomas objetivo
  • Formato de fechas/números respeta el idioma del dispositivo
  • Selector de idioma persiste la preferencia del usuario
  • Metadatos de la tienda están traducidos para todos los mercados objetivo
  • Tamaño del bundle es razonable (verificar con npx expo export)
  • Rendimiento — las traducciones se cargan sin bloquear la UI

Conclusión

Localizar una app de React Native con Expo es más complejo que la localización web, pero las herramientas han madurado significativamente. Con expo-localization para las preferencias del dispositivo, i18next como motor de traducción y Better i18n para la entrega por CDN y las actualizaciones OTA, puedes lanzar una app multilingüe lista para producción sin la complejidad de gestionar archivos de traducción manualmente.

La clave: incluye traducciones mínimas en el bundle y entrega el resto desde el CDN. Esto mantiene el tamaño de tu app pequeño mientras soportas más de 50 idiomas mediante actualizaciones OTA. Tus usuarios obtienen traducciones en su idioma y tú no necesitas enviar una nueva versión de la app cada vez que cambia una traducción.

Para equipos que quieran extender los mismos principios a apps Flutter multiplataforma, nuestra guía de localización Flutter intl explica la configuración equivalente para el ecosistema Dart.


Recursos Relacionados

Comments

Loading comments...