Tutorials//10 Min. Lesezeit

Next.js App Router i18n: Server Components und RSC-Muster für 2026

Eray Gündoğmuş
Teilen

Der App Router hat verändert, wie wir über Datenabruf in Next.js denken. Er hat auch verändert, wie wir über i18n denken sollten. Die meisten verfügbaren Anleitungen behandeln Übersetzungen noch immer als clientseitiges Thema – ein Bündel von JSON-Dateien, die zur Laufzeit geladen werden. Das war in der Pages-Router-Ära sinnvoll. Im Jahr 2026, mit React Server Components als Standard, können wir es besser machen.

Dieser Beitrag beschreibt die Muster, auf die wir uns für RSC-natives i18n geeinigt haben: wie man das Locale in Middleware erkennt, wie man Übersetzungen in Server Components ohne Client-Overhead abruft, wie man Client-Islands sauber hydriert und wie CDN-first-Auslieferung die Gleichung grundlegend verändert.


Warum RSC die i18n-Grundlagen verändert

Im Pages Router riefen getServerSideProps oder getStaticProps Übersetzungen serverseitig ab und serialisierten sie dann in das Seiten-Bundle. Der Client re-hydrierte alles, einschließlich der Übersetzungswörterbücher, die sich pro Nutzer nie änderten.

Server Components machen dieses Muster überflüssig. Es gibt keinen Hydrations-Schritt für RSC-Ausgaben – das HTML ist final. Das bedeutet, wir können Übersetzungen in einer Server Component abrufen, übersetzte Strings direkt in HTML rendern und kein übersetzungsbezogenes JavaScript an den Client ausliefern. Kein Wörterbuch-Bundle, kein Laufzeit-Lookup, kein Hydrations-Mismatch.

Der Haken: Client Components, die Übersetzungen benötigen (interaktive Formulare, dynamische UIs), brauchen noch immer clientseitigen Zugriff. Das Muster wird klar, sobald man beide Belange explizit trennt.


Middleware: Locale-Erkennung, bevor die Anfrage die App erreicht

Locale-Routing findet in der Middleware statt, weil sie am Edge laufen muss, bevor eine Komponente gerendert wird.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'

function detectLocale(request: NextRequest): Locale {
  // 1. Check URL path prefix
  const pathname = request.nextUrl.pathname
  const pathLocale = pathname.split('/')[1] as Locale
  if (SUPPORTED_LOCALES.includes(pathLocale)) {
    return pathLocale
  }

  // 2. Check Accept-Language header
  const acceptLanguage = request.headers.get('accept-language')
  if (acceptLanguage) {
    const preferred = acceptLanguage
      .split(',')
      .map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
      .find(lang => SUPPORTED_LOCALES.includes(lang))
    if (preferred) return preferred
  }

  return DEFAULT_LOCALE
}

export function middleware(request: NextRequest) {
  const locale = detectLocale(request)
  const pathname = request.nextUrl.pathname

  // Redirect root to locale-prefixed path
  if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
    return NextResponse.redirect(
      new URL(`/${locale}${pathname}`, request.url)
    )
  }

  // Inject locale into headers for server components
  const response = NextResponse.next()
  response.headers.set('x-locale', locale)
  return response
}

export const config = {
  matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}

Der x-locale-Header ist die Brücke zwischen Middleware und Server Components. Wir setzen ihn bei jeder Anfrage, damit jede Server Component ihn ohne Prop-Drilling lesen kann.


App-Router-Struktur: Locale als Layout-Segment

Der konventionelle App-Router-Ansatz bündelt locale-spezifische Routen unter einem [locale]-Segment:

app/
  [locale]/
    layout.tsx        ← setzt Locale-Kontext, ruft Basis-Übersetzungen ab
    page.tsx          ← Startseite
    blog/
      [slug]/
        page.tsx
    dashboard/
      page.tsx
  api/
    ...

In [locale]/layout.tsx werden Übersetzungen für den gesamten Teilbaum initialisiert:

// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'

const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']

interface LayoutProps {
  children: React.ReactNode
  params: { locale: string }
}

export default async function LocaleLayout({ children, params }: LayoutProps) {
  const { locale } = await params

  if (!SUPPORTED_LOCALES.includes(locale)) {
    notFound()
  }

  // Fetch base translations for layout-level strings
  const baseTranslations = await fetchTranslations(locale, 'common')

  return (
    <html lang={locale}>
      <body>
        <TranslationProvider locale={locale} messages={baseTranslations}>
          {children}
        </TranslationProvider>
      </body>
    </html>
  )
}

Server Components: Übersetzungen am Edge abrufen

Das ist die entscheidende Verschiebung: Anstatt Locale-Dateien mit der App zu bündeln, rufen wir Übersetzungen zur Anfragelaufzeit von einem CDN ab. Die Funktion sieht aus wie jeder andere asynchrone Datenabruf:

// lib/translations.ts
import { cache } from 'react'

type Messages = Record<string, string>

// React cache() deduplicates fetches within a single render pass
export const fetchTranslations = cache(async (
  locale: string,
  namespace: string
): Promise<Messages> => {
  const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
  
  const res = await fetch(url, {
    next: {
      revalidate: 3600, // CDN-delivered, revalidate hourly
      tags: [`translations-${locale}-${namespace}`],
    },
  })

  if (!res.ok) {
    // Fall back to English rather than breaking the page
    if (locale !== 'en') {
      return fetchTranslations('en', namespace)
    }
    throw new Error(`Failed to fetch translations: ${res.status}`)
  }

  return res.json()
})

Der cache()-Wrapper von React stellt sicher, dass mehrere Server Components auf derselben Seite, die denselben Namespace anfordern, nur eine Netzwerkanfrage pro Render-Durchlauf auslösen. In Kombination mit dem Fetch-Caching von Next.js wird die CDN-Antwort typischerweise aus dem Edge-Cache bedient – mit Sub-Millisekunden-Latenz für Übersetzungs-Lookups.

Eine Server Component, die dieses Muster verwendet:

// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'

interface PageProps {
  params: { locale: string; slug: string }
}

export default async function BlogPost({ params }: PageProps) {
  const { locale, slug } = await params

  // Both fetches happen in parallel
  const [t, post] = await Promise.all([
    fetchTranslations(locale, 'blog'),
    getPost(slug),
  ])

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      <p className="meta">
        {t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
      </p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <footer>
        <a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
      </footer>
    </article>
  )
}

Kein Client-JavaScript. Keine Hydration. Der Übersetzungs-Key-Lookup findet auf dem Server statt, und das Ergebnis wird als serialisiertes HTML ausgegeben.


Typsichere Übersetzungs-Keys

String-basierte Key-Lookups (t['blog.readTime']) schlagen still fehl, wenn sich Keys ändern. Wir generieren einen Typ aus der Nachrichten-Datei des Basis-Locales:

// types/translations.ts (generated, do not edit manually)
// Generated from: en/blog.json

export type BlogNamespaceKey =
  | 'blog.readTime'
  | 'blog.backToList'
  | 'blog.publishedOn'
  | 'blog.updatedOn'
  | 'blog.authorBy'
  | 'blog.sharePost'
  | 'blog.relatedPosts'

// Typed accessor
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
  const value = messages[key]
  if (value === undefined) {
    // In development, surface the missing key
    if (process.env.NODE_ENV === 'development') {
      console.warn(`Missing translation key: ${key}`)
    }
    return key
  }
  return value
}

Der Generierungs-Schritt läuft als Teil der CI-Pipeline, sobald sich Übersetzungsdateien ändern. TypeScript erkennt dann fehlende oder umbenannte Keys zur Kompilierzeit – nicht zur Laufzeit in der Produktion.

Das ist ein Bereich, in dem eine Plattform wie Better i18n echten Mehrwert bietet – die Typ-Generierung ist automatisiert, und die Typ-Ausgabe wird im Editor aktualisiert, sobald neue Keys über das Dashboard eingespielt werden.


Client-Component-Hydration: Translation Islands

Nicht alles kann eine Server Component sein. Formulare, Modals und interaktive UIs benötigen clientseitigen Zugriff auf Übersetzungen für dynamische Strings – Fehlermeldungen, Validierungsfeedback, asynchrone Zustands-Labels.

Das Muster ist ein Context-Provider, der die relevante Teilmenge der Nachrichten serialisiert:

// components/translation-provider.tsx
'use client'

import { createContext, useContext } from 'react'

type Messages = Record<string, string>

const TranslationContext = createContext<{
  messages: Messages
  locale: string
} | null>(null)

export function TranslationProvider({
  children,
  messages,
  locale,
}: {
  children: React.ReactNode
  messages: Messages
  locale: string
}) {
  return (
    <TranslationContext.Provider value={{ messages, locale }}>
      {children}
    </TranslationContext.Provider>
  )
}

export function useTranslations(namespace?: string) {
  const ctx = useContext(TranslationContext)
  if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')

  return {
    t: (key: string) => {
      const fullKey = namespace ? `${namespace}.${key}` : key
      return ctx.messages[fullKey] ?? fullKey
    },
    locale: ctx.locale,
  }
}

Die Layout-Server-Component ruft Nachrichten ab und übergibt sie an den Provider. Client Components rufen useTranslations() auf, ohne zusätzliche Fetches auszulösen:

// components/contact-form.tsx
'use client'

import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'

export function ContactForm() {
  const { t } = useTranslations('forms')
  const [error, setError] = useState<string | null>(null)

  async function handleSubmit(formData: FormData) {
    const result = await submitContact(formData)
    if (!result.ok) {
      setError(t('contact.submitError'))
    }
  }

  return (
    <form action={handleSubmit}>
      <label>{t('contact.nameLabel')}</label>
      <input name="name" required />
      <label>{t('contact.emailLabel')}</label>
      <input name="email" type="email" required />
      <button type="submit">{t('contact.submitButton')}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  )
}

Die wichtigste Einschränkung: Nur den oder die Namespace(s) übergeben, den die Client Component tatsächlich benötigt. Das gesamte Übersetzungswörterbuch ins Client-Bundle zu serialisieren, verfehlt den Zweck.


Statische Routen: generateStaticParams

Für statisch generierte Seiten (Marketing-Seiten, Blog-Beiträge, Docs) verwenden wir generateStaticParams, um jedes Locale zur Build-Zeit vorzurendern:

// app/[locale]/page.tsx

export async function generateStaticParams() {
  const locales = ['en', 'de', 'fr', 'es']
  return locales.map(locale => ({ locale }))
}

export async function generateMetadata({ params }: { params: { locale: string } }) {
  const { locale } = await params
  const t = await fetchTranslations(locale, 'home')

  return {
    title: t['home.meta.title'],
    description: t['home.meta.description'],
    alternates: {
      languages: {
        'en': '/en',
        'de': '/de',
        'fr': '/fr',
        'es': '/es',
      },
    },
  }
}

Mit CDN-ausgelieferten Übersetzungen ruft der Build zur Build-Zeit Nachrichten-Dateien vom CDN ab und bakt sie in das statische HTML ein. Zur Laufzeit bedient das CDN die statische Datei – die Zeit für den Übersetzungs-Lookup ist effektiv null.


Vergleich mit next-intl und next-i18next

next-intl ist die ausgereifteste App-Router-kompatible Bibliothek. Sie übernimmt viel des oben beschriebenen Middleware- und Kontext-Boilerplates. Der Kompromiss: Sie geht noch davon aus, dass Locale-Dateien im Repository liegen (/messages/en.json, /messages/de.json). Jede neue Übersetzung erfordert ein Code-Deployment.

next-i18next wurde für den Pages Router entwickelt. In App-Router-Projekten ist es bestenfalls ein Kompatibilitäts-Shim. Für neue Projekte würden wir es vermeiden.

CDN-first-Ansatz (wie oben beschrieben) entkoppelt Übersetzungsinhalte von Code-Deployments. Eine französische Übersetzung aktualisieren, und sie ist innerhalb von Sekunden auf dem CDN live – kein Redeploy, kein PR, kein Warten auf CI. Das Übersetzungsteam arbeitet unabhängig vom Entwicklungsteam.

Das Argument des operativen Aufwands für dateibasierte Ansätze («es ist einfacher») bricht schnell zusammen, wenn man 20 Locales und ein dediziertes Lokalisierungsteam hat. Übersetzungs-PRs zu mergen ist Lärm, kein Mehrwert.


Was wir 2026 anders machen würden

  • Streaming-Übersetzungen: <Suspense> um übersetzungsintensive Komponenten, während der CDN-Fetch aufgelöst wird – nützlich für Randfälle, bei denen kalte CDN-Caches Latenz verursachen.
  • Per-Komponenten-Namespace-Granularität: Statt Layout-level-Fetches nur die Namespaces abrufen, die jede Komponente tatsächlich benötigt. Reduziert die Payload-Größe auf Seiten mit geringem Übersetzungsbedarf.
  • Plural- und Formatierungsregeln: Intl.PluralRules und Intl.NumberFormat direkt verwenden, anstatt sich auf Bibliotheks-Abstraktionen zu verlassen. Die Browser-APIs sind nun in allen unterstützten Umgebungen zuverlässig.

Erste Schritte

Die oben beschriebenen Muster funktionieren mit jeder CDN-ausgelieferten Übersetzungsquelle. Wenn Sie die Infrastruktur-Einrichtung überspringen und typsichere Keys, automatisierte CDN-Auslieferung und einen übersetzerfreundlichen Editor out of the box erhalten möchten, deckt die Next.js-Integration von Better i18n diesen Stack ab – Middleware-Helfer, typisierte Key-Generierung und ein Dashboard, das Ihr Übersetzungsteam ohne Code-Änderungen nutzen kann.

Siehe die Entwicklerdokumentation für die vollständige API-Referenz und SDK-Integrations-Guides.

Comments

Loading comments...