i18n – Multilinguale Apps und Websites mit Next.js 13
Julian
Jeder, der multilinguale Websites und Apps baut, wird sich schon einmal mit dem Thema Internationalization (i18n) befasst haben. Es gibt mehrere Schritte, die wir grundsätzlich befolgen müssen, um eine Seite mit mehreren sprachabhängigen Varianten darzustellen. Wir...
- finden heraus, welche Sprache der Nutzer bevorzugt (locale detection)
- leiten automatisch auf die einschlägige URL weiter (sprachabhängiges Routing)
- bringen die Sprachumgebung und Übersetzungen in unsere Komponenten (z.B. React Context)
Bisher konnte Next.js diese Anforderungen mit integriertem i18n-Support erfüllen; doch das wird sich zukünftig ändern,
wenn wir zur neuen /app
-Architektur migrieren. Dort ist i18n nämlich nicht mehr fester Bestandteil des Routers, wie es
in /pages
der Fall war. In der Beta-Dokumentation gibt es dazu einen
lesenswerten Abschnitt mit Empfehlungen für den Umstieg. Da
dieser aber etwas kurz ausfällt, wollen wir in diesem Beitrag noch weiter ins Detail eingehen.
i18n-fähiges Routing
In Next.js 13 gibt es zwei Routing-Architekturen, die nebeneinander existieren: ausgehend von den Verzeichnissen
/pages
und /app
. In /pages
, dem Vorgänger der neuen /app
, können Routes automatisch mit einer locale
(Sprachumgebung) versehen werden. Das geschieht über die Einstellung
i18n
in der next.config.js
. So verändert sich die Route
/blog
, je nach Spracheinstellung des Browsers, zum Beispiel in /en/blog
oder /de/blog
. Dieser Prozess ist für die
Anwendung transparent; das bedeutet unter anderem, dass useRouter()
auf dieser Route als pathname
trotzdem /blog
liefert, und <Link>
-Elemente, die lediglich auf /blog
verweisen, gegebenenfalls automatisch umgeleitet werden. Um
auf die locale
zuzugreifen gibt es zwei Möglichkeiten: useRouter().locale
in Komponenten bzw. context.locale
in
den Data-Fetching-Methoden getServerSideProps
und getStaticProps
.
Anders sieht es im /app
-Verzeichnis aus. Dort kommt die i18n
-Konfiguration aus der next.config.js
nicht zum
Tragen. Stattdessen ist es dem Entwickler selbst überlassen, die gewünschte Logik umzusetzen. Kurz gefasst: wir
schachteln unsere gesamte Ordnerstruktur in ein [locale]
-Verzeichnis, und erweitern in der Middleware die angefragte
URL je nach Bedarf um die entsprechende locale
. Die eckigen Klammern repräsentieren, wie auch in /pages
, ein
parametrisiertes (dynamisches) Segment in der Route. Aber wie sorgen wir dafür, dass dieser locale
-Parameter immer
richtig befüllt wird? Dafür kommt eine Middleware ins Spiel:
/app
/[locale]
/blog
page.tsx
...
middleware.ts
Middleware
Mit Middleware können Requests an eine Next.js-Anwendung abgefangen und verändert werden: Mit NextResponse.redirect
wird ein Status Code 307 erzeugt, der den Browser auf
eine andere URL leitet, was auch in der Adressleiste zu sehen ist. Mit NextResponse.rewrite
hingegen wird die URL von
Next.js intern umgewandelt, was dem Browser verborgen bleibt. Dies ist nützlich, wenn wir eine defaultLocale
(Standardsprache) definieren wollen, die keinen Präfix in der URL enthalten soll.
const locales = ['de', 'en'];
const defaultLocale = 'de';
const withLocalePattern = new RegExp(`^\/(${locales.join('|')})(?:\/|$)`);
const getLocale = (req: NextRequest): string => {
// TODO (machen wir im nächsten Teil)
};
const middleware = (req: NextRequest) => {
const pathname = req.nextUrl.pathname;
const match = withLocalePattern.exec(pathname);
// Wenn die locale bereits in der URL enthalten ist, können wir diese später aus
// `match[1]` extrahieren, um sie z.B. in ein Cookie zu schreiben.
if (!match) {
const locale = getLocale(req);
// Wir nutzen `rewrite` für eine "unsichtbare" `defaultLocale`. Diesen Teil
// können wir aber auch weglassen, sodass die locale immer in der URL steht.
return (locale === defaultLocale ? NextResponse.rewrite : NextResponse.redirect)(new URL(`/${locale}${pathname}`));
}
// Wenn wir nichts zurückgeben, bleibt der Request unverändert. Wir können aber auch
// explizit `NextResponse.next()` als Rückgabewert angeben.
};
Sprache ermitteln
Gehen wir davon aus, dass ein Besucher die App über die URL /blog
, also ohne locale
-Präfix, anfragt. Obwohl uns
dieser konkrete Teil fehlt, teilt uns der Browser die Spracheinstellung des Nutzers im Accept-Language
-Header mit.
Diese Information ist meistens zuverlässiger als etwa mittels Geolocation der IP-Adresse die Sprachregion zu erraten.
Das negotiator
Package kann dabei helfen, den Accept-Language
-Header zu
interpretieren und eine passende Sprache auszuwählen. Das ist nämlich nicht ganz so einfach, wenn man bedenkt, dass
beispielsweise auch mehrere bevorzugte Sprachen und Varianten mit unterschiedlichen Gewichtungen in Accept-Language
angegeben werden können.
import Negotiator from 'negotiator';
const getLocale = (req: NextRequest): string => {
const negotiator = new Negotiator(req);
return negotiator.language(locales) ?? defaultLocale;
};
// Beispiel-Request
const request = {headers: {'Accept-Language': 'en-US,en;q=0.5'}};
getLocale(request); // 'en'
Locale Cookie
Wir möchten dem Nutzer zusätzlich die Möglichkeit bieten, die bevorzugte Sprache unabhängig von der Browser-Einstellung zu speichern – z.B. mit einer Sprachauswahl-Komponente. In einem Cookie können wir diese Einstellung nicht nur für die aktuelle Session, sondern auch für den nächsten Besuch aufbewahren:
document.cookie = `NEXT_LOCALE=${locale};path=/;max-age=31536000;samesite=lax`;
Obwohl diese etwas eigenartige Syntax vielleicht den Anschein erweckt, dass dadurch alle anderen Cookies überschrieben werden, ist das nicht der Fall – stattdessen wird das angegebene Cookie zur "Keksdose" hinzugefügt. Die durch Semikola getrennten Werte sind Metadaten, die das Cookie näher beschreiben: zum Beispiel wann es ausläuft und auf welche Pfade es begrenzt ist.
In der Middleware greifen wir dann über das Request
-Objekt auf die Cookies zu, und lesen daraus, falls vorhanden, die
locale
aus.
const localeFromCookie = req.cookies.get('NEXT_LOCALE')?.value;
const locale = locales.includes(localeFromCookie) ? localeFromCookie : getLocale(req);
Am Rande bemerkt: in der EU ist die Rechtslage der Cookies ein leidiges Thema. Aber Cookies, die zur Personalisierung von Diensten (u.a. der Sprachauswahl) eingesetzt werden, können als "essenziell", also für den Betrieb der Website erforderlich, betrachtet werden [1].
Der eine oder andere fragt sich jetzt vielleicht, warum wir das Cookie nicht direkt in der Middleware setzen. Das hat
einen praktischen Grund: Next.js kann statische Seiten im Frontend (Browser) cachen, sodass diese nach dem ersten Aufruf
nicht mehr vom Server abgefragt werden. Wenn wir also /en/blog
besuchen und anschließend auf /de/blog
wechseln,
würde das Cookie zwar wie erwartet auf de
gesetzt werden, beim erneuten Navigieren auf /en/blog
wäre diese Seite
dann aber bereits im Cache, und das Cookie bliebe unverändert.
Vorteile von React Server Components für i18n
Mit diesen Anpassungen ist das Routing für multilinguale Anwendungen bereit. Nun wollen uns dem eigentlichen Grund
widmen, warum sich der Umstieg auf die /app
-Architektur lohnt: React Server Components.
Was sind Server Components?
Hintergrund dieser Geschichte ist Version 13 von Next.js, mit der das beliebte Meta-Framework eines der bisher umfangreichsten Updates erfahren hat. Ein großer Fokus liegt nun auf React Server Components; das sind Komponenten, die ausschließlich auf dem Server gerendert werden und keinerlei JavaScript in den Browser laden. Dadurch können wir den relativ aufwändigen Prozess der Hydration, dem Abgleich eines clientseitigen virtuellen DOMs mit dem vom Server erzeugten HTML, teilweise oder auch ganz überspringen. Davon profitieren vor allem Seiten mit statischen - also nicht-interaktiven - Inhalten, die sich in ihrer Darstellung nicht verändern, und daher nur einmal pro Seitenaufruf gerendert werden müssen. Auf diese Weise verbringt der Browser weniger Zeit mit dem Ausführen von JavaScript, was sich positiv auf Core Web Vitals wie Time To Interactive (TTI) und Total Blocking Time (TBT) auswirken kann.
Was ist, wenn wir JavaScript brauchen? Um Interaktionen wie Event Handler und die meisten Hooks (die ja auch auf JS
angewiesen sind) zu ermöglichen, werden Server Components durch Client Components ergänzt, die wir dann ineinander
verschachteln können. Client Components verhalten sich im Prinzip analog zu den React-Komponenten wie wir sie aus
Next.js 12 kennen, nur mit einem speziellen "use client"
-Directive, um sie von Server Components zu unterscheiden.
Der Vorteil, den Server Components für i18n haben, ist dass nur der übersetzte Text im HTML steht. Es macht keinen Unterschied, wenn diese Übersetzungen beispielsweise aus einer riesigen JSON-Quelldatei mit tausenden Einträgen geladen werden, oder vielleicht aus einem externen CMS stammen; dieses Umsetzungsdetail verbergen wir auf dem Server.
Indirekte Kopplung mit getStaticProps
In /pages
ist es möglich, Übersetzungen in getStaticProps
zu laden, um diese auf der Seiten-Komponente
bereitzustellen. Da wir auf jeder Seite in der Regel nur an einer Teilmenge der verfügbaren Übersetzungen interessiert
sind, müssen wir die Auswahl (z.B. mittels Namespaces) beschränken. Schließlich wäre es eine Verschwendung, wenn wir auf
jede Seite den gesamten Datensatz laden würden. Dadurch haben wir jedoch eine indirekte Kopplung zwischen den
übersetzten Komponenten und der Seite, die diese Komponenten enthält, hergestellt, da in getStaticProps
nun auch
dieses Auswahlverfahren stattfinden muss.
Mittels Context (wie etwa I18nextProvider
aus dem next-18next
Package) kann man das "Prop-drilling" der
Übersetzungs-Strings in die Verbraucherkomponenten umgehen, wodurch wir die Kopplung allerdings nicht loswerden, sondern
nur weiter verstecken. Das folgende Beispiel veranschaulicht, wie ein hypothetisches i18n-Framework in diesem System
grob vereinfacht funktionieren könnte:
// _app.tsx
const App: FC<AppProps> = ({Component, pageProps}) => {
const {translations, ...rest} = pageProps;
// Diese Komponente wrapt jede `Page` in unserer App.
return (
<TranslationProvider translations={translations}>
<Component {...rest} />
</TranslationProvider>
);
};
export default App;
// navigation.tsx
export const Navigation = () => {
const t = useTranslation();
return (
<nav>
<Link href='/'>{t('ui.navigation.home')}</Link>
<Link href='/about'>{t('ui.navigation.about')}</Link>
<Link href='/contact'>{t('ui.navigation.contact')}</Link>
</nav>
);
};
// page.tsx
const Page: NextPage = ({translations}) => {
return (
<>
<Navigation />
<main>{/* Content */}</main>
</>
);
};
export default Page;
export const getStaticProps: GetStaticProps = async (context) => {
return {
// Hier ist die Page-Komponente indirekt an die verwendete UI-Komponente
// gekoppelt. Das müssen wir auf jeder Seite wiederholen, die diese
// `Navigation`-Komponente enthält (direkt oder indirekt).
props: {translations: await getNamespace('ui.navigation', context.locale)},
};
};
Diese Art der Kopplung ist nicht auf Übersetzungen beschränkt: sie entsteht immer dann, wenn eine Komponente vom
Ergebnis einer Data-Fetching-Methode wie getStaticProps
abhängig ist ist. Je tiefer die Schachtelung zwischen
Versorger und Verbraucher ist und auf je mehr Dateien diese verteilt sind, desto schwieriger wird es, die Kopplung zu
verfolgen. Selbst im next-translate
Package, welches diese Methoden zwar "automagisch" ergänzt, müssen wir alle (pro
Seite) referenzierten Namespaces
in einer JSON-Datei hinterlegen; es ist das
gleiche Prinzip, nur auf Umwegen.
Data fetching: Kapseln statt koppeln
Server Components sind die Antwort schlechthin auf das Kopplungsproblem: jede Serverkomponente ist dank asynchronem
Rendering in der Lage, die benötigten Daten selbst laden, zum Beispiel in Form eines Netzwerk-Requests mittels fetch
oder eines Zugriffs auf das lokale Dateisystem mit der fs
API aus NodeJS.
import { readFile } from 'node:fs/promises';
// navigation.tsx
export const NavigationServerComponent = async () => {
// Da es in /app kein Äquivalent zu `useRouter().locale` gibt, holen wir uns
// die locale aus einem eigenen (server) context. Wie das funktioniert erklären
// wir weiter unten.
const locale = useLocale();
// Diesen Aufruf können wir mit `useLocale()` in einen weiteren Hook zusammenfassen.
const translationsFromCms = await (
await fetch(`https://cms.example.com/translations/navigation/${locale}.json`))
).json();
// Alternativ zu `readFile` können wir auch `import()` verwenden - mehr dazu im
// nächsten Teil.
const translationsFromFile = JSON.parse(
await readFile(`.../translations/navigation.${locale}.json`)
);
...
}
So erreichen wir eine saubere Kapselung der übersetzten Komponenten, die jetzt keine Abhängigkeiten von externen
Data-fetching Methoden mehr mit sich bringen. Tatsächlich sind getStaticProps
und Co. nun obsolet und werden im
/app
-Verzeichnis nicht mehr unterstützt.
Locale Parameter und Server Context
i18n-Frameworks müssen in der Lage sein, die derzeit aktive locale
abzurufen, um die Strings in der entsprechenden
Sprache auszuwählen. Das geht in /pages
sehr einfach mit useRouter().locale
, in /app
müssen wir diese Information
jedoch manuell aus den Route-Parametern herausziehen. Jede Page-Komponente, die aus einer page.tsx
exportiert wird,
erhält als Props ein Objekt mit allen Parametern: das sind die in eckige Klammern gesetzten Variablen im Ordnernamen,
wie [locale]
.
// [locale]/page.tsx
const Page = ({params}: {params: {locale: string}}) => {
return <div>Current locale is {params.locale}.</div>;
};
export default Page;
Normalerweise würden wir, um das "Prop-drilling" von einer Komponente in die nächste zu vermeiden, auf Context zugreifen. Context können wir aber nur in Client Components und nicht in Server Components verwenden. Glücklicherweise gibt es für Server Components ein "geheimes" Gegenstück: Server Context.
Über Server Context ist öffentlich noch nicht viel bekannt; alles, was wir bisher davon erfahren haben, stammt aus diesem Tweet von React-Entwickler Sebastian Markbåge und direkt aus dem React-Quellcode. Server Context funktioniert ähnlich dem herkömmlichen (client-side) Context, sollte aber auf kleine Datenmengen beschränkt sein und muss zudem eine global eindeutigen Kennung tragen:
import {createServerContext, use} from 'react';
export const LocaleContext = createServerContext(
'locale', // globalName
null, // defaultValue
);
export const LocaleProvider = LocaleContext.Provider;
export const useLocale = () => {
return use(LocaleContext);
};
Hier noch ein kleiner Hinweis: einige neue React Features, die erst vor Kurzem entwickelt wurden, darunter auch Server
Context, sind noch nicht in der aktuellen Version (React 18.2.0) verfügbar. Next.js 13 bündelt aber eine eigene Version
aus dem next
-Branch, die wir automatisch nutzen können, wenn wir uns in der /app
-Struktur befinden.
// /app/page.tsx
import {version} from 'react';
console.log(version); // `18.3.0-next-2655c9354-20221121` 👀
@atmina/inting
Um uns intern auf den Umstieg auf die /app
-Architektur vorzubereiten (und weil JavaScript-Programmierer
es sich nicht verkneifen können), haben wir unser eigenes Framework entwickelt:
inting
ist vollständig auf React Server Components ausgelegt, um den
Fußabdruck auf das JavaScript-Bundle im Browser so klein wie möglich zu halten. Unser Ansatz basiert auf
Code-Generation, womit wir in der Lage sind, Strings statisch mit dem Rest des serverseitigen Quellcodes zu verpacken,
und dank Typensicherheit jegliche Laufzeitfehler durch unbekannte Keys zu verhindern. Codegen ist in unseren Projekten
gang und gäbe, da wir zum Beispiel auch unsere GraphQL-Schemata mittels graphql-code-generator
in
typensichere Client-APIs umwandeln.
Alternative: Gekapseltes i18n mit dynamischen Imports
Mit Server Components können wir trotz Kapselung auf Komponenten-Ebene die gesamte Übersetzungslogik auf dem Server
ausführen und so unsere App schlank halten. Zu diesem Zeitpunkt ist die /app
-Architektur in Next.js 13 allerdings von
offizieller Seite noch ein "experimentelles" Feature und wird vom Entwicklungsteam nicht für Apps in Produktion
empfohlen. Aus diesem Grund wollen wir noch einen weiteren Ansatz vorstellen, mit dem wir eine ähnliche Kapselung
erreichen, die auch mit /pages
kompatibel ist.
import()
Neben statischen ESM-Imports (import foo from './foo.ts'
), die das angeforderte Modul sofort bereitstellen, gibt es
auch dynamische Imports in der Form const foo = await import('./foo.tsx')
. Wichtig zu beachten ist hier, dass import
als Funktion aufgerufen wird und ein Promise mit dem Inhalt des Moduls liefert. Der Import ist asynchron, da das Modul
erst beim Aufruf der import
-Funktion angefordert wird. Webpack, der von Next.js genutzte Bundler,
erkennt dieses Muster, und legt automatisch einen separaten
Code-Chunk mit diesem Modul an, welcher zur Laufzeit nachgeladen werden kann. Es ist theoretisch auch möglich, den Pfad
dynamisch zu konstruieren, aber dadurch würden wir verhindern, dass Webpack den import statisch analysieren kann. Daher
nutzen wir hier einen hardcodeten String.
useTranslation() mit Suspense
Mit dynamischen Imports sind wir in der Lage, unsere Übersetzungen in Chunks aufzuteilen, die dann on-demand im Browser (bzw. bei SSR auch im Server-Prozess) importiert werden.
const loadTranslation = async (key: string) => {
let m: Promise<{default: any}>;
switch (key) {
// Hier sorgen wir dafür, dass jeder mögliche `import` explizit aufgelistet ist,
// damit Webpack die entsprechenden Chunks bereitstellen kann. Mit Codegen können
// wird diesen Schritt automatisieren.
case 'ui.navigation.en': m = import('./translations/ui.navigation.en.json'); break;
case 'ui.navigation.de': m = import('./translations/ui.navigation.de.json'); break;
...
default: throw new Exception(`No such namespace: ${key}`);
}
return (await m).default;
Da die Imports in loadTranslation
aber asynchron sind, bekommen wir als Ergebnis ein Promise zurück. Um dieses in
nicht-asynchronen Komponenten wie jenen in /pages
verwenden zu können, greifen wir in die Trickkiste und holen uns den
Suspense
-Mechanismus aus React 18. Mit Suspense kann das Rendern
einer Komponente ausgesetzt werden, solange noch Daten geladen werden. Auf dem Server bedeutet das, dass der gesamte
Seiten-Request beim Rendering an der nächsten <Suspense>
-Boundary pausiert wird, bis der import
durchgelaufen ist,
sodass das ausgegebene HTML den vollständigen Text enthält; das ist wichtig, da wir auch auf SEO Rücksicht nehmen
wollen. Beim anschließenden Navigieren im Browser werden fehlende Übersetzungs-Chunks wie normales JavaScript
nachträglich geladen. Die Logik dafür verpacken wir wie immer in einem useTranslation
-Hook:
const cache: Record<string, () => (key: string) => string> = {};
export const useTranslation = (namespaceKey: string) {
const { defaultLocale, locale = defaultLocale } = useRouter();
let data: Record<string, any> | undefined;
let error: string | undefined;
let promise: Promise<Record<string, any>> | undefined;
const t = useCallback((translationKey: string) => {
return data[translationKey];
}, [locale]);
if (!cache[namespaceKey]) {
cache[namespaceKey] = () => {
if (error !== undefined) {
throw error;
}
if (data) {
return t;
}
if (!promise) {
promise = loadTranslation(`${namespaceKey}.${locale}`)
.then((module) => data = module)
.catch((e) => error = e + '');
}
// Durch das "throwen" eines Promises signalisieren wir Suspense, dass wir noch
// auf das Ergebnis des Promise warten.
throw promise;
}
}
return cache[namespaceKey]();
}
Das Auflösen eines Promises ist ein praktischer Anwendungsfall für Suspense und hat daher vom React-Entwicklungsteam
einen eigenen React-internen Hook verdient, der einfach nur use
heißt
(RFC), dieser
ist jedoch eines der neuen Features, die nur in der von Next.js (in /app
) gebündelten Version von React enthalten
sind. Da wir hier aber von /pages
ausgehen, müssen wir uns selbst um die Implementierung kümmern.
Den fertigen useTranslation
-Hook können wir nun direkt in einer übersetzten Komponente aufrufen:
// /components/navigation.tsx
export const Navigation = () => {
const t = useTranslation('ui.navigation');
return (
<nav>
<Link href='/'>{t('home')}</Link>
<Link href='/about'>{t('about')}</Link>
<Link href='/contact'>{t('contact')}</Link>
</nav>
);
};
Wir brauchen noch mindestens ein <Suspense>
-boundary, welches die übersetzte Komponente umschließt und dafür sorgt,
dass das Rendering an dieser Stelle anhält, falls die angeforderten Übersetzungsdaten noch nicht runtergeladen wurden.
Wenn wir es uns einfach machen wollen, legen wir diesen direkt auf der App-Komponente an.
// /pages/_app.tsx
const App: FC<AppProps> = ({Component, pageProps}) => {
const {translations, ...rest} = pageProps;
return (
<Suspense>
<Navigation />
</Suspense>
);
};
export default App;
Diesen Ansatz verwenden wir zum Beispiel in einem großen mehrsprachigen
Tourismus-Portal, welches auf /pages
basiert.
Nachwort
Hoffentlich konntet ihr etwas aus diesem Artikel mitnehmen und vielleicht auch in euren zukünftigen Next.js 13 Projekten umsetzen. Wer sich allgemein für dieses Thema interessiert oder nach weiteren Lösungsansätzen sucht, wird vielleicht in diesem Thread aus dem Next.js Issue Tracker fündig. Mit Sicherheit werden auch andere i18n-Frameworks nachziehen und für den Einsatz in Server Components vorbereitet werden; wir sind gespannt, was es noch für Entwicklungen aus dieser Richtung geben wird.
[1]: Einwilligung und Cookie Banner: Was sind essenzielle Cookies? - https://www.e-recht24.de/artikel/datenschutz/12962-was-sind-essenzielle-cookies.html
Themen:
- Softwareentwicklung
- Technik
- Von Entwickler zu Entwickler