gcampes
Back

Drei Sprachen, ein Wochenende: So habe ich meine Next.js-App mit Next Intl lokalisiert

Sep 25, 2025 09/25/2025

nextjsi18nlocalizationreactreact-intltailwindmdx

Warum überhaupt Lokalisierung?

Ich bin Brasilianer, lebe seit 2017 in Deutschland und arbeite für ein US-Unternehmen. Meine Website war bisher nur auf Englisch. Das war irgendwie verschenkt.

Außerdem brauchte ich einen Vorwand, endlich vom Pages Router wegzukommen. Im Job hängen wir schon länger darauf fest, und meine private Seite war genauso aufgestellt. Lokalisierung war dafür ein angenehm klar abgegrenztes Projekt: App Router + Next Intl, ein Wochenende.


Routing und Middleware

Es gibt ein paar Wege, Locale in URLs unterzubringen: eigene Domains (gcampes.de), Subdomains (de.gcampes.me) oder ein Prefix im Pfad. Ich habe mich für Next Intl und prefix-basiertes Routing entschieden. Das ist die einfachste Variante, bei der die Locale am Anfang jeder URL steht: /en/blog, /pt/experience/2022-01-01-seatgeek.

Alle unterstützten Locales liegen in einer einzigen Config-Datei:

// i18n/routing.ts
import { defineRouting } from "next-intl/routing";
 
export const locales = ["en", "pt", "de"];
 
export const routing = defineRouting({
  locales,
  defaultLocale: "en",
});

Und mit einer middleware.ts sorge ich dafür, dass Requests an der richtigen Stelle landen:

// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
 
export default createMiddleware(routing);
 
export const config = {
  matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)"
};

Der Matcher sagt Next.js, dass die Middleware auf allen Routen laufen soll, außer auf API-Endpunkten, internen Next.js-Pfaden (_next, _vercel) und statischen Dateien, also allem mit Dateiendung wie .css oder .png.

Das bedeutet: Ein nacktes /blog wird standardmäßig zu /en/blog umgeleitet. Wenn der Browser des Besuchers aber einen Accept-Language-Header mit Portugiesisch schickt, leitet die Middleware stattdessen auf /pt/blog weiter.

Noch eine Sache zum Routing: Prefixe nicht manuell in Links einbauen. Leg dir eine i18n/navigation.ts an, importiere sie einmal und denk danach nicht mehr drüber nach:

// i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
 
export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);

Dann verwendest du einfach die Link-Komponente, und das Locale-Prefix kommt automatisch dazu:

import { Link } from "@/i18n/navigation";
 
<Link href="/">
  {t("home")}
</Link>

Messages (Übersetzungen)

Die Übersetzungs-Strings liegen in schlichten JSON-Dateien, eine pro Locale. Nichts Besonderes:

{
  "metadata": {
    "title": "Senior Software Engineer in Berlin | gcampes",
    "description": "Gabriel Carvalho de Campes, Senior Software Engineer in Berlin, Germany."
  },
  "navigation": {
    "home": "Home",
    "experience": "Experience"
  },
  "homepage": {
    "experienceDescription": "I have been working as a software engineer for {years} years."
  }
}

Die portugiesischen und deutschen Versionen spiegeln dieselbe Struktur. Achte auf den {years}-Placeholder in experienceDescription. Next Intl interpoliert solche Werte zur Render-Zeit, sodass jede Sprache sie dort platzieren kann, wo es grammatikalisch passt.


Request-Config

Die Request-Config-Datei sagt Next Intl, wie die aktuelle Locale aufgelöst und wie für jedes serverseitige Render die passenden Messages geladen werden. In der Doku fand ich das erstaunlich schlecht erklärt. Hier also die Version, die ich gern direkt gelesen hätte.

Wenn du die Datei unter i18n/request.ts ablegst, erkennt Next Intl sie automatisch. Ganz ohne zusätzliche Config:

// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale;
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Weil ich mich an die Naming-Convention gehalten habe, braucht next.config.ts nur createNextIntlPlugin() ohne Argumente. Wenn die Datei woanders liegt, übergibst du stattdessen den Pfad, also createNextIntlPlugin("./your-path/your-file.ts").


Layout und Provider

Das [locale]-Layout packt alles in einen NextIntlClientProvider. Dadurch funktionieren clientseitige hooks wie useRouter und useLocale.

Da bei mir alle Übersetzungen serverseitig über getTranslations geladen werden, übergebe ich dem Provider keine messages. Wenn du useTranslations in Client Components verwendest, brauchst du sie allerdings.

// app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { routing } from "@/i18n/routing";
 
export default async function LocaleLayout({ children, params }) {
  const { locale } = params;
 
  if (!hasLocale(routing.locales, locale)) throw new Error("Unknown locale");
 
  setRequestLocale(locale);
 
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Nach diesem setup kannst du getTranslations in jeder Server Component verwenden:

import { getTranslations } from 'next-intl/server';
 
export default async function HomePage() {
  const t = await getTranslations('homepage');
  return <h1>{t('name')}</h1>;
}

Für Client Components verwendest du stattdessen den useTranslations-Hook. In dem Fall musst du messages an den Provider durchreichen.


Metadata pro Locale

Du willst natürlich auch lokalisierte <title>- und <meta>-Tags. Ohne die indexiert Google im Zweifel alles als Englisch. Mit generateMetadata ist das zum Glück ziemlich direkt:

export async function generateMetadata({ params }) {
  const { locale } = await params;
 
  const t = await getTranslations({ locale, namespace: "metadata" });
 
  return {
    title: t("title"),
    description: t("description"),
    alternates: {
      languages: {
        en: "https://gcampes.me/en",
        pt: "https://gcampes.me/pt",
        de: "https://gcampes.me/de"
      }
    }
  };
}

Rich Text mit .rich

Den Punkt hätte ich fast übersehen. Normalerweise würdest du, wenn du einen Teil eines übersetzten Strings stylen willst, die Übersetzung in Stücke zerlegen und manuell mit JSX zusammensetzen. Mit .rich kannst du leichte Tags direkt in den Message-String schreiben und sie beim Rendern auf React-Komponenten mappen:

// en.json
"experience": {
  "title": "{denomination} {title} at <company>{companyName}</company>"
}
 
// pt.json
"experience": {
  "title": "{title} {denomination} em <company>{companyName}</company>"
}

Gerendert wird das dann so:

<h1>
  {t.rich("title", {
    denomination: "Senior",
    title: "Software Engineer",
    companyName: "SeatGeek",
    company: (chunks) => <span className="text-brand">{chunks}</span>
  })}
</h1>

Auffällig ist hier, dass Portugiesisch die Reihenfolge umdreht, also eher „Software Engineer Senior“ statt „Senior Software Engineer“, und statt „at“ ein „em“ benutzt. Das <company>-Tag bleibt in beiden Fällen direkt am Firmennamen hängen.


Datumsformatierung mit date-fns

Für Datumsformatierung habe ich date-fns verwendet, weil es sowieso schon in meinem Stack war. Next Intl bringt zwar einen eingebauten Date Formatter mit, der das eventuell vereinfacht. Für dieses Projekt war es aber sinnvoller, bei dem zu bleiben, was ich schon kannte. Scope klein halten und so.

import { format } from "date-fns";
import { ptBR, enUS, de } from "date-fns/locale";
 
function getDateFnsLocale(appLocale) {
  switch (appLocale) {
    case "pt": return ptBR;
    case "de": return de;
    default: return enUS;
  }
}
 
<p>
  {t("fromTo", {
    from: format(new Date("2021-05-01"), "MMMM yyyy", { locale: getDateFnsLocale(locale) }),
    to: format(new Date("2024-08-01"), "MMMM yyyy", { locale: getDateFnsLocale(locale) })
  })}
</p>

Die Messages übernehmen die Grammatik, also etwa „Von {from} bis {to}“, und date-fns kümmert sich um die Monatsnamen.


MDX-Posts pro Locale

Jede Locale bekommt ihren eigenen Ordner:

blog/
  en/
    my-post.mdx
  pt/
    my-post.mdx
  de/
    my-post.mdx

Ein kleiner Helper liest dann mit Node-fs die richtige Datei von der Platte:

import fs from "fs";
import matter from "gray-matter";
 
export function getPost(locale, slug) {
  const raw = fs.readFileSync(`blog/${locale}/${slug}.mdx`, "utf8");
  return matter(raw);
}

Nichts Cleveres. Derselbe slug, anderer Ordner. Die englische und portugiesische Version kannst du direkt gegeneinander diffen.

Ich habe kurz darüber nachgedacht, pro Post nur eine Datei mit Locale-Flags im Frontmatter zu behalten. Separate Dateien pro Locale waren aber sauberer in Diffs und ich konnte einzelne Übersetzungen leichter weitergeben, ohne gleich das komplette Repo teilen zu müssen.


Über Dinge, über die ich gestolpert bin

Metadata

Ruf setRequestLocale(locale) immer innerhalb von generateMetadata auf. Wenn du das weglässt, fällt Next Intl auf die Default-Locale zurück. Dann hat deine deutsche Seite plötzlich englische Metadata. Nicht ideal.

import { setRequestLocale } from "next-intl/server";
 
export async function generateMetadata({ params }) {
  const { locale } = await params;
  setRequestLocale(locale);
  // ...
}

Static Site Generation

Standardmäßig erzeugt der App Router Seiten bei Bedarf. Wenn du willst, dass Seiten schon zur Build-Zeit vorgebaut werden, also schneller laden und ohne Server funktionieren, musst du Next.js jede gültige Kombination von Routenparametern über generateStaticParams mitteilen. Für meine Index-Route:

export async function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

Bei Routen mit mehreren dynamischen Segmenten, etwa Blog-Posts, musst du jede Kombination aus Locale und slug erzeugen:

export async function generateStaticParams() {
  const routes: { locale: string; slug: string }[] = [];
 
  routing.locales.forEach((locale) => {
    const posts = getPosts(locale);
    posts.forEach((post) => {
      const { data } = matter(post);
      routes.push({ locale, slug: data.slug });
    });
  });
 
  return routes;
}

Fazit

Das Ganze hat ein Wochenende gedauert. Der Großteil davon ging für request.ts und die kleinen Gemeinheiten rund um generateStaticParams drauf. Die eigentliche Next-Intl-API ist dagegen angenehm klein und hält sich, sobald alles verkabelt ist, ziemlich aus dem Weg.

Ganz ehrlich: Der beste Moment war, auf Portugiesisch umzuschalten und meine eigene Website zum ersten Mal in meiner Muttersprache zu sehen.