Inhaltsverzeichnis
Remix i18n: Internationalisierung für Remix-Anwendungen
Remix' Server-First-Architektur macht die Internationalisierung im Vergleich zu rein clientseitigen Frameworks sowohl leistungsfähig als auch einzigartig. Mit Remix können Sie das Locale auf dem Server erkennen, Übersetzungen serverseitig laden und vollständig lokalisiertes HTML an den Client senden – kein Aufblitzen von nicht übersetztem Inhalt, keine Verzögerungen bei der clientseitigen Locale-Erkennung.
Dieser Leitfaden behandelt, wie Sie i18n in Remix von Grund auf implementieren, einschließlich Routing, Locale-Erkennung, Übersetzungsladung und Integration mit remix-i18next, der beliebtesten i18n-Bibliothek für Remix.
Warum Remix i18n anders ist
Remix führt Code auf dem Server in Loadern und Actions aus und rendert Komponenten sowohl auf dem Server als auch auf dem Client. Dies verändert i18n auf wichtige Weise:
Serverseitiges Laden von Übersetzungen: In clientseitigen React-Apps werden Übersetzungen vom Browser geladen. In Remix können Übersetzungen in loader-Funktionen geladen und an Komponenten übergeben werden – das bedeutet, dass das HTML, das den Server verlässt, bereits übersetzt ist.
URL-basiertes Routing: Remix' dateibasiertes Routing passt natürlich zu Locale-basierten URL-Mustern wie /en/products und /fr/products.
Progressive Enhancement: Remix ist darauf ausgelegt, ohne JavaScript zu funktionieren. Ihre lokalisierten Routen sollten ohne clientseitige Hydration korrekt gerendert werden.
Verschachteltes Routing: Remix' verschachtelte Layouts bedeuten, dass Locale-Erkennung und Übersetzungskontext hoch im Routenbaum eingerichtet und von untergeordneten Routen geerbt werden können.
Einrichten von remix-i18next
remix-i18next basiert auf i18next und bietet Remix-spezifische Hilfsprogramme für serverseitiges und clientseitiges i18n.
Installation
npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend
Projektstruktur
app/ ├── locales/ │ ├── en/ │ │ ├── common.json │ │ └── home.json │ └── fr/ │ ├── common.json │ └── home.json ├── i18n.server.ts # Serverseitige i18n-Konfiguration ├── i18n.client.ts # Clientseitige i18n-Konfiguration ├── i18next.server.ts # remix-i18next-Instanz └── root.tsx
Serverseitige Konfiguration
// 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 - gemeinsame Konfiguration
export default {
supportedLngs: ['en', 'fr', 'de', 'ja', 'ar'],
fallbackLng: 'en',
defaultNS: 'common',
react: { useSuspense: false },
};
Einrichten der Root-Route
// 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>
);
}
Clientseitige Initialisierung
// 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();
Serverseitiger Einstiegspunkt
// 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,
});
}
URL-basiertes Locale-Routing
Ein verbreitetes Muster ist das Kodieren des Locales in der URL: /en/products, /fr/produits. In Remix implementieren Sie dies mit einer Layout-Route:
app/routes/ ├── ($locale).tsx # Layout-Route zur Verarbeitung des Locale-Präfixes ├── ($locale).index.tsx # Startseite ├── ($locale).products.tsx # Produktseite └── ($locale).blog.$slug.tsx # Blogbeitrag
// 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;
// Kein Locale-Präfix: erkennen und weiterleiten
if (!locale) {
const acceptLanguage = request.headers.get('Accept-Language') ?? 'en';
const preferredLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);
return redirect(`/${preferredLocale}`);
}
// Ungültiges Locale: 404
if (!SUPPORTED_LOCALES.includes(locale)) {
throw new Response('Not Found', { status: 404 });
}
return json({ locale });
}
function parseAcceptLanguage(header: string, supported: string[]): string {
// Accept-Language-Header parsen und beste Übereinstimmung zurückgeben
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 />;
}
Übersetzungen in Routen-Komponenten verwenden
// 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');
// Serverseitiges t() für SEO-kritische Inhalte verwenden
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>
);
}
Sprachumschalter-Komponente
// 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();
// Aktuelles Locale im Pfad ersetzen
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>
);
}
RTL-Unterstützung
Für Arabisch und Hebräisch setzen Sie das dir-Attribut am <html>-Element:
// 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>
);
}
Siehe RTL-Unterstützung in CSS und React für eine detaillierte CSS-Implementierung.
SEO-Überlegungen
Für mehrsprachige Remix-Apps erfordert SEO:
Alternate-hreflang-Tags im <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 },
// hreflang-Alternativlinks
{ 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}` },
];
}
Für umfassende Lokalisierungs-SEO-Strategien siehe Lokalisierungs-SEO.
Pluralisierung in Remix
i18next behandelt Pluralisierung über die Schlüsselsuffixe _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"
Für Sprachen mit komplexen Pluralregeln (Russisch hat 4 Formen, Arabisch hat 6) verwendet i18next automatisch CLDR-Pluralregeln. Siehe Pluralisierungsregeln in verschiedenen Sprachen für Details.
Leistungsoptimierung
Namespace-Aufteilung: Laden Sie mit dem handle.i18n-Export nur die pro Route benötigten Namespaces. Große Übersetzungspakete verlangsamen den ersten Ladevorgang.
Statische Generierung: Für inhaltsreiche Seiten verwenden Sie Remix' statische Generierung oder Cloudflare Pages, um vorgerenderte lokalisierte Seiten bereitzustellen.
Übersetzungs-Caching: Cachen Sie in der Produktion Übersetzungsdateien auf CDN-Ebene. Übersetzungsdateien ändern sich selten und sind gute Kandidaten für aggressives Caching.
Lazy Loading von Namespaces: Laden Sie ergänzende Namespaces auf dem Client für Inhalte, die sich nicht im sichtbaren Bereich befinden, verzögert.
Machen Sie Ihre App mit better-i18n global
better-i18n kombiniert KI-gestützte Übersetzungen, git-native Workflows und globale CDN-Auslieferung in einer entwicklerorientierten Plattform. Hören Sie auf, Tabellen zu verwalten, und beginnen Sie, in jeder Sprache auszuliefern.
Kostenlos starten → · Funktionen entdecken · Dokumentation lesen