Next.js 16 i18n mit next-intl: Internationalisierung im App Router (2026)

Ein praktischer Leitfaden für Next.js 16 mit next-intl v4: [locale]-Ordner, proxy.ts, Server-Components-Übersetzungen, hreflang, Static Rendering und der Vergleich zu next-i18next, mit lauffähigem TypeScript-Code.

Next.js 16 i18n mit next-intl (2026)

Aktualisiert: 31. August 2026

Next.js 16 i18n mit next-intl funktioniert über einen [locale]-Segmentordner im App Router, eine proxy.ts-Datei für die Locale-Erkennung und eine zentrale i18n/request.ts, die pro Request die passenden Übersetzungen lädt. Damit sind Server Components, Static Rendering und hreflang-Tags von Anfang an sauber verdrahtet. Ich schreibe Next.js seit den Pages-Router-Tagen und hab die eingebaute i18n-Konfiguration lange schmerzlich vermisst. Nach mehreren Migrationen von next-i18next auf next-intl v4 muss ich ehrlich sagen: das neue Modell ist besser, aber nur wenn man ein paar App-Router-Fallen kennt.

  • Der App Router hat die eingebaute i18n-Konfiguration aus next.config.ts ersatzlos entfernt; alles läuft jetzt über einen [locale]-Dynamic-Segmentordner.
  • next-intl v4 ist die für Server Components empfohlene Bibliothek und arbeitet nativ mit Next.js 16.2 und React 19.2 zusammen.
  • Statisches Rendering funktioniert nur, wenn du in jedem Layout und in jeder Seite setRequestLocale(locale) aufrufst, sonst fällt alles auf Dynamic Rendering zurück.
  • Die frühere middleware.ts heißt in Next.js 16 proxy.ts; next-intls Routing-Middleware wird identisch exportiert.
  • SEO-Tags (hreflang, kanonische URLs) gehören in generateMetadata, nicht in ein globales Layout, sonst verlieren alternative Sprachversionen ihre Zuordnung.

Warum next-intl statt eingebauter i18n?

Kurz gesagt: weil es in Next.js 16 keine eingebaute i18n mehr gibt. Die aus dem Pages Router bekannten i18n.locales- und i18n.defaultLocale-Optionen in next.config.ts haben mit dem App Router nie funktioniert und wurden inzwischen offiziell zurückgezogen. Das Team von Vercel verweist im aktuellen Internationalization-Guide ausdrücklich auf Community-Bibliotheken: next-intl, next-translate, lingui, paraglide-next und andere.

Für Server-Components-lastige Apps ist next-intl in v4 die stimmigste Wahl. Die Bibliothek wurde für den App Router von Grund auf gebaut, unterstützt async-Übersetzungen in RSC, hat einen Router-Wrapper für lokalisierte Pfade und liefert eine typsichere API auf Basis der ICU-Message-Syntax. In meinen produktiven Projekten mit acht bis zwölf Sprachen macht das den entscheidenden Unterschied: die Server-Bundle-Größe bleibt schlank, weil Nachrichten nur pro Request geladen werden, und der Client bekommt nur die für interaktive Komponenten wirklich nötige Teilmenge.

Der zweite Grund ist Wartbarkeit. Ich hab genug alte Projekte gesehen, die zwischen react-intl-Providern, next-i18next-Namespaces und selbstgeschriebenen Fetch-Wrappern eskaliert sind. next-intl ersetzt das durch ein einziges Konfigurationsobjekt, eine getTranslations()-Funktion auf dem Server und einen useTranslations()-Hook auf dem Client, mit identischer API.

Projektstruktur mit [locale]-Segment

Das mentale Umdenken vom Pages Router ist einfach: alles unter app/ zieht in einen app/[locale]/-Ordner. Die Locale ist damit ein normales Dynamic Segment, das du in Layouts und Seiten über params bekommst. Weil params in Next.js 16 asynchron sind, musst du es awaiten.

app/
├── [locale]/
│   ├── layout.tsx        // Root-Layout mit <html lang> + Provider
│   ├── page.tsx          // Startseite (locale-aware)
│   ├── about/
│   │   └── page.tsx
│   └── dashboard/
│       ├── layout.tsx
│       └── page.tsx
├── proxy.ts              // ehemals middleware.ts
i18n/
├── request.ts            // liefert Nachrichten pro Request
├── routing.ts            // Locales, Default, Prefix-Strategie
└── navigation.ts         // typsichere Link/Router-Wrapper
messages/
├── de.json
├── en.json
└── fr.json

Der große Vorteil dieses Layouts: Jede URL trägt die Locale, jedes Layout kann sich in der eigenen Sprache rendern, und Static Generation über generateStaticParams multipliziert einfach die Anzahl der Seiten mit der Anzahl der Sprachen. In meinem letzten Projekt haben wir so 1.400 Seiten in vier Sprachen komplett vorgerendert, ohne einen einzigen dynamischen Request auf dem Origin. Für Details zur Rendering-Strategie lohnt ein Blick in unseren Artikel zu Partial Prerendering im App Router.

next-intl v4 installieren und konfigurieren

Ich zeige die Konfiguration mit Next.js 16.2 und next-intl 4.x. Erst die Pakete installieren:

npm install next-intl@^4

Dann das Plugin in next.config.ts einhängen. Das Plugin akzeptiert einen Pfad zur Request-Konfiguration; ich lasse ihn Standard, damit alles unter ./i18n/request.ts liegt.

// next.config.ts
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./i18n/request.ts');

const nextConfig: NextConfig = {
  experimental: {
    // typedRoutes: true, // optional, aber empfohlen für getypte href
  },
};

export default withNextIntl(nextConfig);

Das Routing wird zentral in i18n/routing.ts definiert. Hier landen unterstützte Locales, die Default-Locale und ob die Default-Sprache einen URL-Prefix bekommt (always) oder ohne Prefix bedient wird (as-needed). Ich empfehle für neue Projekte always: konsistente URLs, klare Cache-Keys, und Google zeigt sofort das richtige hreflang-Ziel.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['de', 'en', 'fr'],
  defaultLocale: 'de',
  localePrefix: 'always',
});

Die Request-Konfiguration lädt für jeden Server-Request die passende Nachrichtendatei. In Produktion wird die Datei dank ES-Module-Caching pro Locale nur einmal geladen, du zahlst also keinen Preis pro Request:

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

Und die typsicheren Navigations-Wrapper: diese Link- und redirect-Exporte kennen die konfigurierten Locales und verhindern, dass man versehentlich auf /about statt /de/about verlinkt.

// i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';

export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);

proxy.ts: Locale-Erkennung und Redirects

Mit Next.js 16 wurde die Konvention middleware.ts in proxy.ts umbenannt, und next-intls Routing-Handler wird genauso exportiert. Die Middleware liest den Accept-Language-Header, prüft ein optionales NEXT_LOCALE-Cookie und leitet auf die passende URL um. Der matcher sorgt dafür, dass statische Assets, API-Routen und _next/*-Pfade unberührt bleiben, sonst würde jedes Bild einen Redirect auslösen.

// proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  matcher: [
    // Alle Pfade außer:
    // - API-Routen (/api/*)
    // - Interne Next.js-Pfade (/_next/*)
    // - Assets mit Endung (favicon.ico, images, fonts …)
    '/((?!api|_next|.*\\..*).*)',
  ],
};

Ein Detail, das mich beim ersten Projekt eine halbe Stunde gekostet hat: wenn du Auth mit einer eigenen Middleware kombinierst, brauchst du eine komponierte Middleware, sonst wird nur eine der beiden ausgeführt. Für Auth.js v5 zeige ich das Zusammenspiel in unserer Anleitung zu Auth.js v5 mit Next.js 16. Grundsätzlich rufst du in proxy.ts zuerst den next-intl-Handler auf und leitest das Ergebnis in die Auth-Middleware weiter, oder umgekehrt, je nachdem welche Reihenfolge dein Redirect-Verhalten erwartet.

Server Components vs. Client Components

Der zentrale Vorteil von next-intl im App Router: Übersetzungen funktionieren in beiden Welten mit fast identischer API. In Server Components nutzt du getTranslations(), das ein async-Aufruf ist und automatisch die Request-Konfiguration verwendet.

// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';

export default async function HomePage() {
  const t = await getTranslations('home');

  return (
    <main>
      <h1>{t('title')}</h1>
      <p>{t('subtitle')}</p>
    </main>
  );
}

Für Client Components musst du im Root-Layout einen Provider setzen und dann den useTranslations()-Hook verwenden. Wichtig: der Provider bekommt die Nachrichten server-seitig übergeben, der Client lädt sie also nicht separat nach.

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import { hasLocale } from 'next-intl';

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages} locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
// components/CartButton.tsx
'use client';

import { useTranslations } from 'next-intl';

export function CartButton({ itemCount }: { itemCount: number }) {
  const t = useTranslations('cart');

  return (
    <button>
      {t('items', { count: itemCount })}
    </button>
  );
}

Der Nachrichten-Eintrag cart.items nutzt hier die ICU-Plural-Syntax:

{
  "cart": {
    "items": "{count, plural, =0 {Warenkorb leer} one {# Artikel} other {# Artikel}}"
  }
}

Statisches Rendering mit setRequestLocale

Wer wie ich aus dem Pages Router kommt, denkt bei i18n zuerst an getStaticPaths. Im App Router heißt das Pendant generateStaticParams, und der einzige zusätzliche Handgriff für next-intl ist ein Aufruf von setRequestLocale(locale) ganz oben in jedem Layout und jeder Page, die statisch gerendert werden soll. Ohne diesen Aufruf fällt die Seite auf Dynamic Rendering zurück, weil next-intl die Locale sonst nur aus dem Request-Header ableiten kann (und der ist bei Build-Time nicht vorhanden).

// app/[locale]/layout.tsx (ergänzt)
import { setRequestLocale } from 'next-intl/server';
import { routing } from '@/i18n/routing';

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

export default async function LocaleLayout({
  children, params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  setRequestLocale(locale);
  // … rest wie oben
}
// app/[locale]/about/page.tsx
import { setRequestLocale, getTranslations } from 'next-intl/server';

export default async function AboutPage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  setRequestLocale(locale);

  const t = await getTranslations('about');
  return <article><h1>{t('heading')}</h1></article>;
}

Ein häufiger Fehler: der Aufruf wird im Root-Layout gemacht, aber in verschachtelten Layouts vergessen. Der Effekt ist heimtückisch, weil die Seite lokal noch funktioniert. Erst im next build-Log siehst du in der Route-Tabelle ein ƒ (Dynamic) statt ○ (Static), und die Time-to-First-Byte steigt in Produktion um zweistellige Millisekunden. Deshalb: jede Layout- und Page-Datei unter [locale] ruft setRequestLocale auf.

Sprachumschalter und lokalisierte Links

Für internen Traffic benutzt du ausschließlich die Wrapper aus i18n/navigation.ts. Sie erweitern die App-Router-Link-Komponente um automatisches Locale-Prefixing. Ein Sprachumschalter, der auf demselben Pfad bleibt und nur die Locale tauscht, sieht so aus:

// components/LocaleSwitcher.tsx
'use client';

import { useLocale, useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';
import { useTransition } from 'react';

export function LocaleSwitcher() {
  const locale = useLocale();
  const pathname = usePathname();
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  const t = useTranslations('common');

  return (
    <select
      defaultValue={locale}
      disabled={isPending}
      aria-label={t('changeLanguage')}
      onChange={(event) => {
        const nextLocale = event.target.value;
        startTransition(() => {
          router.replace(pathname, { locale: nextLocale });
        });
      }}
    >
      {routing.locales.map((cur) => (
        <option key={cur} value={cur}>
          {t(`locale.${cur}`)}
        </option>
      ))}
    </select>
  );
}

Der Trick mit useTransition: der Wechsel läuft als non-blocking Transition, sodass die alte Seite sichtbar bleibt, bis das neue Locale gestreamt ist. Ohne startTransition siehst du bei langsameren Verbindungen einen kurzen weißen Blitz. Für dynamische Datenabhängigkeiten und Streaming ist unser Deep-Dive zu Streaming SSR und Suspense ergänzend hilfreich.

SEO: hreflang und generateMetadata

Ohne korrekte hreflang-Angaben zeigt Google im schlimmsten Fall die englische URL an einen deutschen Nutzer aus, oder umgekehrt. Die saubere Lösung: in generateMetadata pro Route die Alternates zu setzen. next-intl hilft mit getPathname, den lokalisierten Pfad für jede Sprache zu berechnen.

// app/[locale]/about/page.tsx
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { getPathname } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'about.meta' });

  const languages = Object.fromEntries(
    routing.locales.map((cur) => [
      cur,
      getPathname({ locale: cur, href: '/about' }),
    ]),
  );

  return {
    title: t('title'),
    description: t('description'),
    alternates: {
      canonical: getPathname({ locale, href: '/about' }),
      languages: {
        ...languages,
        'x-default': getPathname({ locale: routing.defaultLocale, href: '/about' }),
      },
    },
  };
}

Das erzeugt im HTML <link rel="alternate" hreflang="de" href="/de/about" /> für jede Sprache plus einen x-default. Wichtig: Google verlangt eine gegenseitige Verlinkung. Jede Sprachversion muss alle anderen aufführen, sonst werden die Signale ignoriert. Vergiss außerdem nicht die Sitemap: eine sprachbewusste sitemap.ts gehört ins Root-Verzeichnis, nicht unter [locale]. Mehr zur Metadaten-API im App Router steht in unserem Artikel zu dynamischer SEO mit generateMetadata. Wer die offizielle Empfehlung nachlesen will, findet die Details im Google Search Central hreflang-Guide.

next-intl vs. next-i18next: Was passt wann?

Beide Bibliotheken sind 2026 aktiv. next-i18next hat mit v16 endlich App-Router-Support bekommen und ist attraktiv, wenn du bereits ein i18next-Ökosystem mit CI-basiertem Übersetzungs-Workflow, Namespaces und LingoHub- oder Locize-Anbindung hast. next-intl ist konzeptionell schlanker und für Server Components von Anfang an gebaut.

Kriteriumnext-intl v4next-i18next v16
App-Router-SupportNativ, seit v3Ab v16 (2026 nachgezogen)
Server-Components-APIgetTranslations() asyncWrapper um i18next-Instanz
Message-FormatICU MessageFormati18next-Syntax (Plurals, Interpolation)
Bundle-Größe (Client)~15 KB gzipped~45 KB gzipped (mit i18next)
TypeScript-AutocompleteEingebaut über global.d.tsÜber Plugin manuell konfigurierbar
Ökosystem (TMS, CLI-Tools)Wachsend, Cloud-Anbindung nötigSehr groß, i18next-Ökosystem seit 2016
Static RenderingÜber setRequestLocaleÜber eigene Instance pro Request
Empfehlung 2026Neue App-Router-ProjekteMigration bestehender i18next-Setups

In der Praxis: wer bei Null anfängt und den App Router konsequent nutzt, sollte next-intl wählen. Wer aus einem next-i18next-Projekt migriert und ein volles i18next-basiertes Übersetzungssystem betreibt, kann bei v16 bleiben, sollte aber proxy.ts-Support und den neuen Server-Wrapper explizit einbauen. Für die Migration vom Pages Router lohnt außerdem ein Blick in unseren Beitrag zu Turbopack in der Produktion, weil beide Umstellungen meist zusammen fallen.

Typsichere Übersetzungsschlüssel

Ein Grund, warum ich next-intl in Teams liebe: die Nachrichten-Keys werden vollständig typisiert, sobald du in global.d.ts die Messages-Struktur exportierst. Danach schreit TypeScript, sobald jemand t('titel') statt t('title') tippt, und das im Editor, nicht in Produktion.

// global.d.ts
import de from './messages/de.json';

declare module 'next-intl' {
  interface AppConfig {
    Messages: typeof de;
    Locale: 'de' | 'en' | 'fr';
  }
}

Für große Teams empfehle ich zusätzlich ein Lint-Skript, das ungenutzte Keys aus messages/*.json flaggt, und einen CI-Check, der die JSON-Dateien gegen die Default-Locale abgleicht. Die offizielle next-intl-Dokumentation liefert für beides fertige Rezepte.

Häufige Fragen

Wie fügt man i18n zu einem bestehenden Next.js-16-App-Router-Projekt hinzu?

Verschiebe alle Dateien aus app/ in einen neuen app/[locale]/-Ordner, installiere next-intl, richte i18n/routing.ts, i18n/request.ts und proxy.ts ein und wickle das Root-Layout in einen NextIntlClientProvider. Für statisches Rendering ruf in jedem Layout und in jeder Page setRequestLocale(locale) auf.

Was ist der Unterschied zwischen next-intl und next-i18next?

next-intl ist eine für den App Router gebaute Bibliothek mit nativer Server-Components-API und ICU-Message-Format. next-i18next ist ein Wrapper um das i18next-Ökosystem, hat App-Router-Support erst seit v16 und ist attraktiv, wenn du bereits i18next-Übersetzungs-Tools nutzt.

Warum werden meine Seiten dynamisch statt statisch gerendert?

Der häufigste Grund ist ein fehlender setRequestLocale(locale)-Aufruf. Ohne diesen kann next-intl die Locale nicht zur Build-Zeit bestimmen, und Next.js muss auf Dynamic Rendering ausweichen. Prüfe die Route-Tabelle im next build-Log auf ƒ- statt ○-Markierungen.

Wie handhabt man SEO in einer mehrsprachigen Next.js-App?

Setze in generateMetadata pro Route die alternates.languages mit den lokalisierten URLs sowie einen x-default-Eintrag. Vergiss die kanonische URL nicht und generiere eine sprachbewusste sitemap.ts im Root, die alle Locale-Varianten jeder Seite auflistet.

Kann next-intl mit Server Actions und Formularen umgehen?

Ja. In Server Actions rufst du getTranslations() genauso wie in RSC auf, weil beide im Server-Kontext laufen. Für Fehlermeldungen aus useActionState übergibst du die übersetzten Strings idealerweise als Rückgabewert der Action, nicht als geworfene Exception.

Ben Howard
Über den Autor Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.