Zum Inhalt springen

Eine mehrsprachige Next.js-Site, von Anfang bis Ende

Routing, Nachrichten, RTL, Metadaten, hreflang, Sitemaps und die Teile, die erst in der vierten Sprache brechen. Die Architektur, die wir ausliefern - mit markierten Einbahnstraßen.

12 Min. Lesezeit

Die meisten mehrsprachigen Next.js-Sites werden zweimal gebaut. Die erste Fassung routet nach Locale, übersetzt die Strings, geht live - und verbringt dann sechs Monate damit zu entdecken, dass die Sitemap eine Sprache listet, das arabische Layout ein gespiegeltes Durcheinander ist, die Metadaten überall englisch sind und niemand eine Sprache hinzufügen kann, ohne vierzig Dateien anzufassen.

Das hier ist die Architektur, die den zweiten Bau erspart. Die teuer umkehrbaren Entscheidungen sind markiert; den Rest können Sie an einem Dienstag ändern.

Die URL-Form, die Sie später nicht ändern können

Drei Möglichkeiten, und nur die ersten beiden sind vertretbar:

FormBeispielUrteil
Unterverzeichnisexample.com/de/blogStandard. Eine Domain-Autorität, ein Deployment.
Subdomainde.example.com/blogNur wenn getrennte Teams die Sprachen betreiben.
Query-Parameterexample.com/blog?lang=deNie. Crawler sehen darin eine Seite.

Das ist die unumkehrbare Entscheidung. Sie später zu ändern heißt, jede URL der Site umzuleiten und zu warten, bis der Index nachzieht - das dauert Monate. Entscheiden Sie es vor dem ersten Deploy.

Innerhalb der Unterverzeichnis-Form gibt es eine zweite Entscheidung: bekommt die Standardsprache ein Präfix? Ohne Präfix (/blog für Englisch, /de/blog für Deutsch) behält die größte Zielgruppe die kürzere URL - das liefern wir aus. Mit Präfix (/en/blog) ist symmetrischer und leichter zu durchdenken. Beides funktioniert; eine Mischung nicht.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
 
export const routing = defineRouting({
  locales: ['en', 'ar', 'de', 'es'],
  defaultLocale: 'en',
  // 'as-needed' lässt die Standardsprache ohne Präfix. 'always' setzt vor
  // jede Locale eines. Diese Einstellung entscheidet Ihre URL-Form.
  localePrefix: 'as-needed',
});

Routing ohne Redirect bei jeder Anfrage

Das Locale-Segment ist ein dynamisches Segment, und der ganze Baum liegt darunter:

app/
  [locale]/
    layout.tsx
    page.tsx
    blog/
      page.tsx
      [slug]/page.tsx

Erzeugen Sie die statischen Parameter, damit jede Locale vorgerendert wird statt auf Anfrage:

// app/[locale]/page.tsx
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
 
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
 
export default async function Page({ params }) {
  const { locale } = await params;
  // Ohne das entscheidet sich die Seite für dynamisches Rendering, denn die
  // Locale aus der Anfrage zu lesen ist ein Request-Time-Read. Eine Zeile,
  // und der Unterschied zwischen statischer Route und Render pro Besucher.
  setRequestLocale(locale);
 
  // ...
}

setRequestLocale ist die Zeile, die vergessen wird, und das Symptom ist eine Build-Ausgabe, in der jede lokalisierte Route ƒ statt zeigt. Es kostet Sie das CDN auf jeder Seite der Site.

Nachrichten - und nicht alle ausliefern

Laden Sie die Nachrichten einer Locale, nicht vier. Der naive import eines Nachrichten-Index legt jede Sprache in jedes Bundle.

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

Der dynamische Import hält die anderen drei aus der Payload. Prüfen Sie es: taucht de.json im Chunk der englischen Route auf, wurde der Import irgendwohin hochgezogen.

Halten Sie die Nachrichtendateien strukturell identisch. Ein fehlender Schlüssel in einer Sprache ist ein Laufzeitfehler in Produktion und sonst nirgends, also lohnt sich ein Test:

// Ein Schlüssel, den es auf Englisch gibt, muss es in jeder Locale geben.
const flatten = (obj, prefix = '') =>
  Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && !Array.isArray(v)
      ? flatten(v, `${prefix}${k}.`)
      : [`${prefix}${k}`],
  );
 
for (const locale of ['ar', 'de', 'es']) {
  const missing = flatten(en).filter((k) => !flatten(messages[locale]).includes(k));
  if (missing.length) throw new Error(`${locale} fehlt: ${missing.join(', ')}`);
}

RTL ist ein Layout-Problem, kein Übersetzungsproblem

Arabisch ist der Punkt, an dem ein rein lateinisches Layout auseinanderfällt, und die Lösung ist kein Stylesheet je Richtung. Sie ist, das Layout von Anfang an in logischen Eigenschaften zu schreiben, damit Richtung Daten sind und kein Zweig.

<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>

Dann ist jeder horizontale Wert logisch:

PhysischLogischBedeutung
ml-4ms-4Abstand am Zeilenanfang
pr-6pe-6Innenabstand am Zeilenende
left-0start-0Inset am Anfang
text-lefttext-startam Anfang ausgerichtet

So geschrieben spiegelt sich das arabische Layout selbst, und es gibt kein zweites Stylesheet zu pflegen. Drei Dinge bleiben absichtlich physisch: Codeblöcke, die in jeder Sprache LTR sind; Icons, die Richtung kodieren - ein "Weiter"-Pfeil etwa -, die eine explizite Spiegelung brauchen; und Zahlen, die nicht gespiegelt werden.

[dir='rtl'] .rtl-flip {
  transform: scaleX(-1);
}

Und noch etwas, das viele erwischt: Arabisch ist eine verbundene Schrift, und das negative Letter-Spacing, das eine lateinische Display-Überschrift knackig macht, zieht die Verbindungen auseinander und ist dafür schlicht falsch. Stoppen Sie das Tracking an der Sprachgrenze.

Sprache wechseln, ohne die Seite zu verlieren

Der Sprachumschalter, der alle auf die Startseite schickt, ist der häufigste Fehler in dieser ganzen Architektur - und der, den Besucher bemerken. Wer einen Artikel auf Englisch liest, klickt auf Deutsch und landet auf der deutschen Startseite, ohne das, was er gerade las.

Wechseln Sie auf dem aktuellen Pfad, nicht auf der Wurzel:

'use client';
 
import { usePathname, useRouter } from '@/i18n/navigation';
 
export function LocaleSwitcher({ current }: { current: Locale }) {
  // Dieser Pfad ist locale-bereinigt: auf /de/blog/x liefert er /blog/x,
  // derselbe Wert funktioniert also für jede Ziel-Locale.
  const pathname = usePathname();
  const router = useRouter();
 
  return routing.locales.map((locale) => (
    <button
      key={locale}
      lang={locale}
      aria-current={locale === current ? 'true' : undefined}
      onClick={() => router.replace(pathname, { locale })}
    >
      {names[locale]}
    </button>
  ));
}

Ein Vorbehalt, den man kennen sollte, bevor ein Besucher ihn findet: eine Seite, die es in der Zielsprache nicht gibt, liefert 404. Blenden Sie entweder die Locales aus, für die es keine Übersetzung gibt, oder leiten Sie auf das übersetzte Elternelement - aber entscheiden Sie, denn der Standard ist eine Sackgasse.

Metadaten, pro Seite, pro Sprache

Jede Seite braucht eigenen Titel, eigene Beschreibung, eigenes Canonical und eigene Alternates, in ihrer Sprache. Aus einem Helfer erzeugt, sonst laufen sie auseinander:

// lib/seo.ts
export function buildMetadata({ locale, title, description, path, availableLocales }) {
  const url = absoluteUrl(locale, path);
 
  return {
    title,
    description,
    alternates: {
      canonical: url,
      languages: {
        // Nur die Locales, die diese Seite wirklich haben. hreflang="de" auf
        // eine englische Seite zu zeigen ist schlechter als kein hreflang.
        ...Object.fromEntries(
          availableLocales.map((l) => [l, absoluteUrl(l, path)]),
        ),
        'x-default': absoluteUrl(defaultLocale, path),
      },
    },
    openGraph: { url, title, description, locale },
  };
}

Die Regel, auf die es ankommt: hreflang muss abbilden, was existiert. Ein Cluster, der vier Übersetzungen behauptet, von denen zwei die englische Seite sind, ist ein Cluster, dem Suchmaschinen zu misstrauen lernen. Leiten Sie die Liste aus dem Dateisystem ab, nicht aus einer Konstante. Die Begründung steht in korrektes hreflang in Next.js, erzeugt statt gepflegt.

Die Sitemap, aus derselben Quelle

Eine Sitemap, jede Locale, mit den Alternates an jedem Eintrag:

// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return routes.flatMap((route) =>
    localesFor(route).map((locale) => ({
      url: absoluteUrl(locale, route.path),
      lastModified: route.updated,
      alternates: {
        languages: Object.fromEntries(
          localesFor(route).map((l) => [l, absoluteUrl(l, route.path)]),
        ),
      },
    })),
  );
}

routes kommt aus derselben Funktion, die die Seiten benutzen. Eine Sitemap aus einer handgepflegten Liste widerspricht dem Router binnen eines Monats: Canonicals und Sitemaps, die nicht auseinanderlaufen können.

Was wir prüfen, bevor eine Sprache live geht

Keine Checkliste für das Gedächtnis der Entwicklerin - Skripte, die den Build scheitern lassen:

  1. Jeder Nachrichtenschlüssel existiert in jeder Locale. Ein fehlender Schlüssel ist ein Absturz in Produktion, in genau einer Sprache.
  2. Kein horizontales Scrollen, bei 320px, in jeder Sprache. Deutsche Komposita sind ein Drittel länger als das Englische, und es gibt kein Wörterbuch, das sie trennt. Das ist mit Abstand die häufigste Art, wie eine mehrsprachige Site bricht.
  3. Keine Wortbrüche mitten im Wort. Der Fallback, der das Überlaufen verhindert, zerschneidet lieber ein Wort, als die Seite zu verbreitern - was aussieht wie ein Fehler, weil es einer ist.
  4. Jedes Canonical zeigt auf die eigene Adresse, in jeder Locale, und die präfigierte Form der Standardsprache leitet um.
  5. hreflang-Cluster sind wechselseitig. Listet die deutsche Seite Spanisch, muss die spanische Seite Deutsch listen.

Die ersten beiden fangen die Fehler, die ausgeliefert werden. Die letzten drei fangen die Fehler, die Sie still Rankings kosten, sechs Wochen später, ohne eine Zeile im Log.

Der Teil, der keine Technik ist

Ein übersetzter String ist keine übersetzte Seite. Suchintention unterscheidet sich je Markt: die deutsche Phrase, die ein Käufer tippt, ist keine Übersetzung der englischen, sondern eine andere Phrase mit anderem Volumen. Ihre Keyword-Recherche zu übersetzen gibt Ihnen Seiten, die in drei Sprachen für nichts ranken.

Budgetieren Sie das getrennt, und betrachten Sie die Architektur oben als das, was es möglich macht, auf die Antwort zu reagieren - nicht als die Antwort.

Zurück zu allen Artikeln