Índice
Remix i18n: Internacionalización para Aplicaciones Remix
La arquitectura server-first de Remix hace que la internacionalización sea tanto poderosa como distintiva en comparación con los frameworks puramente del lado del cliente. Con Remix, puedes detectar el locale en el servidor, cargar las traducciones del lado del servidor y enviar HTML completamente localizado al cliente — sin destellos de contenido sin traducir, sin retrasos en la detección del locale del lado del cliente.
Esta guía cubre cómo implementar i18n en Remix desde cero, incluyendo enrutamiento, detección de locale, carga de traducciones e integración con remix-i18next, la biblioteca i18n más popular para Remix.
Por qué Remix i18n es diferente
Remix ejecuta código en el servidor dentro de loaders y actions, y renderiza componentes tanto en el servidor como en el cliente. Esto cambia i18n de maneras importantes:
Carga de traducciones del lado del servidor: En las apps React del lado del cliente, las traducciones las carga el navegador. En Remix, las traducciones pueden cargarse en las funciones loader y pasarse a los componentes — lo que significa que el HTML que sale del servidor ya está traducido.
Enrutamiento basado en URL: El enrutamiento basado en archivos de Remix se mapea naturalmente a patrones de URL basados en locale como /en/products y /fr/products.
Mejora progresiva: Remix está diseñado para funcionar sin JavaScript. Tus rutas localizadas deben renderizarse correctamente sin hidratación del lado del cliente.
Enrutamiento anidado: Los layouts anidados de Remix significan que la detección de locale y el contexto de traducción pueden establecerse alto en el árbol de rutas y ser heredados por las rutas hijas.
Configurando remix-i18next
remix-i18next está construido sobre i18next y proporciona utilidades específicas de Remix para i18n del lado del servidor y del cliente.
Instalación
npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend
Estructura del Proyecto
app/ ├── locales/ │ ├── en/ │ │ ├── common.json │ │ └── home.json │ └── fr/ │ ├── common.json │ └── home.json ├── i18n.server.ts # Configuración i18n del lado del servidor ├── i18n.client.ts # Configuración i18n del lado del cliente ├── i18next.server.ts # Instancia de remix-i18next └── root.tsx
Configuración del Lado del Servidor
// app/i18n.server.ts
import { RemixI18Next } from 'remix-i18next';
import i18n from './i18n';
import Backend from 'i18next-fs-backend';
import { resolve } from 'node:path';
const i18next = new RemixI18Next({
detection: {
supportedLanguages: i18n.supportedLngs,
fallbackLanguage: i18n.fallbackLng,
},
i18next: {
...i18n,
backend: {
loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
},
},
plugins: [Backend],
});
export default i18next;
// app/i18n.ts - configuración compartida
export default {
supportedLngs: ['en', 'fr', 'de', 'ja', 'ar'],
fallbackLng: 'en',
defaultNS: 'common',
react: { useSuspense: false },
};
Configuración de la Ruta Raíz
// app/root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import {
Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData,
} from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import { useChangeLanguage } from 'remix-i18next';
import i18next from '~/i18next.server';
import i18n from '~/i18n';
export async function loader({ request }: LoaderFunctionArgs) {
const locale = await i18next.getLocale(request);
return json({ locale });
}
export let handle = { i18n: 'common' };
export default function App() {
const { locale } = useLoaderData<typeof loader>();
const { i18n } = useTranslation();
useChangeLanguage(locale);
return (
<html lang={locale} dir={i18n.dir()}>
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
Inicialización del Lado del Cliente
// app/entry.client.tsx
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { RemixBrowser } from '@remix-run/react';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getInitialNamespaces } from 'remix-i18next';
import i18n from '~/i18n';
async function hydrate() {
await i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(Backend)
.init({
...i18n,
ns: getInitialNamespaces(),
backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
detection: {
order: ['htmlTag'],
caches: [],
},
});
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
}
hydrate();
Punto de Entrada del Lado del Servidor
// app/entry.server.tsx
import { createInstance } from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-fs-backend';
import { renderToReadableStream } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
import { resolve } from 'node:path';
import i18n from '~/i18n';
import i18next from '~/i18next.server';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const instance = createInstance();
const lng = await i18next.getLocale(request);
const ns = i18next.getRouteNamespaces(remixContext);
await instance
.use(initReactI18next)
.use(Backend)
.init({
...i18n,
lng,
ns,
backend: {
loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
},
});
const body = await renderToReadableStream(
<I18nextProvider i18n={instance}>
<RemixServer context={remixContext} url={request.url} />
</I18nextProvider>,
{
onError(error) { responseStatusCode = 500; },
}
);
responseHeaders.set('Content-Type', 'text/html');
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
Enrutamiento de Locale Basado en URL
Un patrón común es codificar el locale en la URL: /en/products, /fr/produits. En Remix, implementa esto con una ruta de layout:
app/routes/ ├── ($locale).tsx # Ruta de layout que maneja el prefijo de locale ├── ($locale).index.tsx # Página de inicio ├── ($locale).products.tsx # Página de productos └── ($locale).blog.$slug.tsx # Entrada de blog
// app/routes/($locale).tsx
import { json, redirect, type LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData } from '@remix-run/react';
const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'ja', 'ar'];
export async function loader({ params, request }: LoaderFunctionArgs) {
const { locale } = params;
// Sin prefijo de locale: detectar y redirigir
if (!locale) {
const acceptLanguage = request.headers.get('Accept-Language') ?? 'en';
const preferredLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);
return redirect(`/${preferredLocale}`);
}
// Locale inválido: 404
if (!SUPPORTED_LOCALES.includes(locale)) {
throw new Response('Not Found', { status: 404 });
}
return json({ locale });
}
function parseAcceptLanguage(header: string, supported: string[]): string {
// Parsear el encabezado Accept-Language y devolver la mejor coincidencia
const languages = header
.split(',')
.map(lang => lang.trim().split(';')[0].trim().split('-')[0].toLowerCase());
for (const lang of languages) {
if (supported.includes(lang)) return lang;
}
return supported[0];
}
export default function LocaleLayout() {
return <Outlet />;
}
Usando Traducciones en Componentes de Ruta
// app/routes/($locale).products.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import i18next from '~/i18next.server';
export let handle = { i18n: ['common', 'products'] };
export async function loader({ request }: LoaderFunctionArgs) {
const t = await i18next.getFixedT(request, 'products');
// Usar t() del lado del servidor para contenido crítico de SEO
const title = t('page.title');
const description = t('page.description');
return json({ title, description });
}
export function meta({ data }: MetaArgs) {
return [
{ title: data?.title },
{ name: 'description', content: data?.description },
];
}
export default function ProductsPage() {
const { t } = useTranslation('products');
const { t: tc } = useTranslation('common');
return (
<main>
<h1>{t('page.title')}</h1>
<p>{t('page.description')}</p>
<a href="/">{tc('nav.home')}</a>
</main>
);
}
Componente Selector de Idioma
// app/components/LanguageSwitcher.tsx
import { Link, useLocation, useParams } from '@remix-run/react';
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
{ code: 'ja', label: '日本語' },
{ code: 'ar', label: 'العربية' },
];
export function LanguageSwitcher() {
const { locale = 'en' } = useParams();
const { pathname } = useLocation();
// Reemplazar el locale actual en la ruta
const getLocalePath = (newLocale: string) => {
return pathname.replace(`/${locale}`, `/${newLocale}`);
};
return (
<nav aria-label="Language selection">
{LANGUAGES.map(({ code, label }) => (
<Link
key={code}
to={getLocalePath(code)}
aria-current={code === locale ? 'page' : undefined}
hrefLang={code}
>
{label}
</Link>
))}
</nav>
);
}
Soporte RTL
Para árabe y hebreo, establece el atributo dir en el elemento <html>:
// app/root.tsx
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
export default function App() {
const { locale } = useLoaderData<typeof loader>();
const { i18n } = useTranslation();
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
{/* ... */}
</html>
);
}
Ver soporte RTL en CSS y React para la implementación detallada de CSS.
Consideraciones de SEO
Para las apps Remix multilingües, el SEO requiere:
Etiquetas hreflang alternativas en el <head>:
export function meta({ data, location }: MetaArgs) {
const baseUrl = 'https://example.com';
const pathWithoutLocale = location.pathname.replace(/^\/(en|fr|de|ja|ar)/, '');
return [
{ title: data?.title },
// enlaces alternativos hreflang
{ tagName: 'link', rel: 'alternate', hrefLang: 'en', href: `${baseUrl}/en${pathWithoutLocale}` },
{ tagName: 'link', rel: 'alternate', hrefLang: 'fr', href: `${baseUrl}/fr${pathWithoutLocale}` },
{ tagName: 'link', rel: 'alternate', hrefLang: 'x-default', href: `${baseUrl}/en${pathWithoutLocale}` },
];
}
Para estrategias completas de SEO de localización, ver SEO de localización.
Pluralización en Remix
i18next gestiona la pluralización mediante los sufijos de clave _one, _other, _zero:
// public/locales/en/common.json
{
"items_one": "{{count}} item",
"items_other": "{{count}} items",
"items_zero": "No items"
}
const { t } = useTranslation();
t('items', { count: 0 }); // "No items"
t('items', { count: 1 }); // "1 item"
t('items', { count: 42 }); // "42 items"
Para idiomas con reglas de plural complejas (el ruso tiene 4 formas, el árabe tiene 6), i18next usa automáticamente las reglas de plural CLDR. Ver reglas de pluralización en diferentes idiomas para más detalles.
Optimización del Rendimiento
División de namespaces: Carga solo los namespaces necesarios por ruta usando la exportación handle.i18n. Los paquetes de traducción grandes ralentizan la carga inicial.
Generación estática: Para sitios con mucho contenido, usa la generación estática de Remix o Cloudflare Pages para servir páginas localizadas pre-renderizadas.
Caché de traducciones: En producción, almacena en caché los archivos de traducción a nivel de CDN. Los archivos de traducción rara vez cambian y son buenos candidatos para un caché agresivo.
Carga diferida de namespaces: Carga de forma diferida namespaces adicionales en el cliente para contenido que no está visible en la pantalla inicial.
Lleva tu aplicación al mundo con better-i18n
better-i18n combina traducciones con IA, flujos de trabajo nativos de git y distribución CDN global en una plataforma pensada para desarrolladores. Deja de gestionar hojas de cálculo y empieza a publicar en todos los idiomas.
Comienza gratis → · Explorar funcionalidades · Leer la documentación