i18n – Multilinguale Apps und Websites mit Next.js 13

Julian

i18n-multilinguale-apps-und-websites-mit-nextjs-13

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);
Info

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.

Info

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 wrappt 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 hardgecodeten 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