SEO//12 min de lecture

Remix i18n : Internationalisation des applications Remix

Eray Gündoğmuş
Partager

Remix i18n : Internationalisation des applications Remix

L'architecture server-first de Remix rend l'internationalisation à la fois puissante et distinctive par rapport aux frameworks purement côté client. Avec Remix, vous pouvez détecter la locale sur le serveur, charger les traductions côté serveur et envoyer un HTML entièrement localisé au client — sans flash de contenu non traduit, sans délais de détection de locale côté client.

Ce guide explique comment implémenter l'i18n dans Remix from scratch, y compris le routage, la détection de locale, le chargement des traductions et l'intégration avec remix-i18next, la bibliothèque i18n la plus populaire pour Remix.

Pourquoi l'i18n Remix est différent

Remix exécute du code sur le serveur dans les loaders et les actions, et effectue le rendu des composants côté serveur et côté client. Cela change l'i18n de manière importante :

Chargement des traductions côté serveur : Dans les applications React côté client, les traductions sont chargées par le navigateur. Dans Remix, les traductions peuvent être chargées dans les fonctions loader et transmises aux composants — ce qui signifie que le HTML qui quitte le serveur est déjà traduit.

Routage basé sur l'URL : Le routage basé sur les fichiers de Remix correspond naturellement aux schémas d'URL basés sur la locale, comme /en/products et /fr/products.

Amélioration progressive : Remix est conçu pour fonctionner sans JavaScript. Vos routes localisées doivent s'afficher correctement sans hydratation côté client.

Routage imbriqué : Les mises en page imbriquées de Remix signifient que la détection de locale et le contexte de traduction peuvent être établis haut dans l'arbre de routes et hérités par les routes enfants.

Configurer remix-i18next

remix-i18next est construit sur i18next et fournit des utilitaires spécifiques à Remix pour l'i18n côté serveur et côté client.

Installation

npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend

Structure du projet

app/
├── locales/
│   ├── en/
│   │   ├── common.json
│   │   └── home.json
│   └── fr/
│       ├── common.json
│       └── home.json
├── i18n.server.ts       # Config i18n côté serveur
├── i18n.client.ts       # Config i18n côté client
├── i18next.server.ts    # Instance remix-i18next
└── root.tsx

Configuration côté serveur

// 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 - configuration partagée
export default {
  supportedLngs: ['en', 'fr', 'de', 'ja', 'ar'],
  fallbackLng: 'en',
  defaultNS: 'common',
  react: { useSuspense: false },
};

Configuration de la route racine

// 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>
  );
}

Initialisation côté client

// 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();

Entrée côté serveur

// 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,
  });
}

Routage de locale basé sur l'URL

Un schéma courant consiste à encoder la locale dans l'URL : /en/products, /fr/produits. Dans Remix, implémentez cela avec une route de mise en page :

app/routes/
├── ($locale).tsx          # Route de mise en page gérant le préfixe de locale
├── ($locale).index.tsx    # Page d'accueil
├── ($locale).products.tsx # Page produits
└── ($locale).blog.$slug.tsx # Article 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;

  // Pas de préfixe de locale : détecter et rediriger
  if (!locale) {
    const acceptLanguage = request.headers.get('Accept-Language') ?? 'en';
    const preferredLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);
    return redirect(`/${preferredLocale}`);
  }

  // Locale invalide : 404
  if (!SUPPORTED_LOCALES.includes(locale)) {
    throw new Response('Not Found', { status: 404 });
  }

  return json({ locale });
}

function parseAcceptLanguage(header: string, supported: string[]): string {
  // Analyser l'en-tête Accept-Language et retourner la meilleure correspondance
  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 />;
}

Utiliser les traductions dans les composants de routes

// 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');

  // Utiliser t() côté serveur pour le contenu critique pour le 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>
  );
}

Composant de sélection de langue

// 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();

  // Remplacer la locale actuelle dans le chemin
  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>
  );
}

Support RTL

Pour l'arabe et l'hébreu, définissez l'attribut dir sur l'élément <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>
  );
}

Consultez le support RTL en CSS et React pour une implémentation CSS détaillée.

Considérations SEO

Pour les applications Remix multilingues, le SEO requiert :

Balises hreflang alternatives dans le <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 },
    // Liens alternatifs 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}` },
  ];
}

Pour des stratégies SEO de localisation complètes, consultez le SEO de localisation.

Pluralisation dans Remix

i18next gère la pluralisation via les suffixes de clés _one, _other, _zero :

// public/locales/en/common.json
{
  "items_one": "{{count}} élément",
  "items_other": "{{count}} éléments",
  "items_zero": "Aucun élément"
}
const { t } = useTranslation();
t('items', { count: 0 });   // "Aucun élément"
t('items', { count: 1 });   // "1 élément"
t('items', { count: 42 });  // "42 éléments"

Pour les langues avec des règles de pluriel complexes (le russe en a 4, l'arabe en a 6), i18next utilise automatiquement les règles de pluriel CLDR. Consultez les règles de pluralisation entre les langues pour plus de détails.

Optimisation des performances

Découpage des namespaces : Chargez uniquement les namespaces nécessaires par route en utilisant l'export handle.i18n. Les grands bundles de traduction ralentissent le chargement initial.

Génération statique : Pour les sites riches en contenu, utilisez la génération statique de Remix ou Cloudflare Pages pour servir des pages localisées pré-rendues.

Mise en cache des traductions : En production, mettez en cache les fichiers de traduction au niveau du CDN. Les fichiers de traduction changent rarement et sont de bons candidats pour une mise en cache agressive.

Chargement paresseux des namespaces : Chargez les namespaces supplémentaires de manière paresseuse côté client pour le contenu qui n'est pas au-dessus de la ligne de flottaison.


Rendez votre application mondiale avec better-i18n

better-i18n combine des traductions alimentées par l'IA, des workflows natifs git et une livraison CDN mondiale en une seule plateforme orientée développeur. Arrêtez de gérer des feuilles de calcul et commencez à publier dans toutes les langues.

Commencer gratuitement → · Explorer les fonctionnalités · Lire la documentation

Comments

Loading comments...