Engineering
Eine zweisprachige Website mit dem Next.js App Router: i18n, hreflang und strukturierte Daten, richtig umgesetzt
Ein praxisnaher Rundgang, wie wir zweisprachige (EN/TR) Websites mit dem Next.js App Router ausliefern – Locale-Routing, hreflang-Alternates, JSON-LD pro Sprache und die kleinen Middleware-Kniffe, mit denen beide Sprachen gleichberechtigt bleiben.
In diesem Artikel
Die meisten zweisprachigen Websites sind einsprachige Websites mit angeflanschter Übersetzungsschicht. Die englische Version ist die, die zuerst live geht, die mit den funktionierenden Formularen und die, die Suchmaschinen zuerst kennenlernen. Die übersetzte Ausgabe ist eine JSON-Datei, die irgendwo aus einem Übersetzungsspeicher gefallen ist, plus ein Dropdown im Header, das nie jemand getestet hat.
So etwas wollten wir nicht bauen. neptay.com startete auf Englisch und Türkisch und läuft heute in zehn Sprachen aus derselben Inhaltsebene – mit sprachabhängigem Routing, Metadaten, Sitemaps, hreflang und strukturierten Daten. Die türkische Ausgabe ist nicht nachgerüstet, sondern dieselbe Website mit einer anderen Stimme. Dieser Artikel ist der technische Rundgang: wie wir das Ganze auf dem Next.js App Router aufgesetzt haben, was in die Middleware gehört, wie hreflang aussehen muss, damit Google ihm vertraut, und welche kleinen strukturellen Entscheidungen sich später auszahlen.
Wenn Sie eine kleine zweisprachige Website ausliefern – eine Studio-Seite, eine Speisekarte, eine regionale Landingpage –, können Sie dieses Muster fast eins zu eins übernehmen. Wenn Sie einen Onlineshop in fünfzig Sprachen ausliefern, ist das hier das Fundament; Sie tauschen nur das Wörterbuch gegen einen CMS-Feed.
Die Struktur des URL-Raums
Im Wesentlichen gibt es drei Möglichkeiten, die URLs einer zweisprachigen Next.js-Website anzulegen:
- Eine Subdomain pro Sprache (en.example.com, tr.example.com). Klare Trennung, aber TLS und Analytics werden aufwendig, und für eine kleine Website wirkt es optisch zersplittert.
- Eine Länder-TLD pro Sprache (example.com, example.com.tr). Hervorragend für Geo-Targeting, aber nur gerechtfertigt, wenn Sie wirklich ein auf die Türkei ausgerichtetes Geschäft betreiben – sonst teuer.
- Ein Locale-Präfix im Pfad (example.com/en/, example.com/tr/). Eine Domain, ein Deployment, eine Analytics-Property. Das nutzen wir – und das sollten die meisten kleinen zweisprachigen Websites nutzen.
Der Next.js App Router handhabt Locale-Präfixe sauber, weil Routen aus dem Dateisystem entstehen. Die gesamte Website liegt unter app/[locale]/, und jede einzelne Seite erhält die Locale als Routenparameter. Keine Sonderbehandlung für die Startseite, keine versteckte Trennung zwischen „übersetzten“ und „unübersetzten“ Routen – die Locale ist einfach ein weiteres Segment.
Unsere Verzeichnisstruktur
app/
[locale]/
layout.tsx ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
page.tsx ← home
services/page.tsx
work/
page.tsx ← work index
[slug]/page.tsx← case study
studio/page.tsx
contact/page.tsx
insights/
page.tsx
[slug]/page.tsx
[...rest]/page.tsx ← catch-all → notFound()
not-found.tsx ← localized 404
sitemap.ts
robots.tsAchten Sie darauf, was fehlt: Es gibt kein app/layout.tsx. Das Locale-Layout ist das Root-Layout – es besitzt das <html>-Element direkt, sodass <html lang="…"> unmittelbar aus dem Routenparameter kommt. Keine Header-Tricks, kein clientseitiges Nachpatchen und vor allem kein headers()-Aufruf, der stillschweigend jede Route aus der statischen Generierung nehmen würde. Bereiche außerhalb des Locale-Systems (unsere Live-Demos) bekommen ein eigenes Root-Layout – Next.js unterstützt mehrere Root-Layouts, und die Locale-Grenze ist genau der richtige Ort dafür.
Middleware: die richtige Sprache erkennen und weiterleiten
Wenn jemand die Domain ohne Pfad aufruft, müssen Sie entscheiden: Wohin geht es? Sie haben drei Signale – die URL (wenn /tr/ ausdrücklich eingegeben wurde), den Accept-Language-Header (was der Browser möchte) und ein Cookie (was beim letzten Mal gewählt wurde). Unsere Regel ist die langweilige, die funktioniert:
- Enthält die URL bereits ein Locale-Präfix, gilt es. Wir setzen ein Cookie, damit wir uns die Wahl merken.
- Andernfalls, wenn ein Cookie von einem früheren Besuch existiert, leiten wir auf diese Sprache weiter.
- Andernfalls, wenn der Accept-Language-Header einer unterstützten Sprache entspricht, leiten wir dorthin weiter.
- Ansonsten greift die Standardsprache (Englisch).
Das erledigen wir in middleware.ts, nicht in einem Layout – das ist schneller, läuft am Edge und vermeidet Hydration-Mismatches. Hier eine vereinfachte Version der Middleware, die auf neptay.com läuft:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";
const LOCALE_COOKIE = "neptay.locale";
function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
if (!header) return defaultLocale;
const lower = header.toLowerCase();
if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
return defaultLocale;
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Live demos live outside the locale system — they handle their own language.
if (pathname === "/demo" || pathname.startsWith("/demo/")) {
return NextResponse.next();
}
const matchedLocale = locales.find(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
);
if (matchedLocale) {
const res = NextResponse.next();
// Remember the locale so the next bare-domain visit lands on it.
if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
res.cookies.set(LOCALE_COOKIE, matchedLocale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
}
return res;
}
// Honour the user's manual choice if it was already remembered.
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));
const url = req.nextUrl.clone();
url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
return NextResponse.redirect(url);
}
export const config = {
// Skip Next internals + static assets so they aren't redirected.
matcher: [
"/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
],
};Zwei Details machen hier den Unterschied. Die Accept-Language-Prüfung parst die kommagetrennte Liste des Headers wirklich, statt nur auf startsWith zu setzen – ein Browser, der "en-GB,tr;q=0.8" sendet, soll nicht als türkisch gelten, nur weil irgendwo "tr" vorkommt. Und das Cookie wird bei jedem Besuch mit Locale-Präfix geschrieben, nicht nur beim manuellen Umschalten, sodass der nächste Aufruf der Domain ohne Pfad in der Sprache landet, die zuletzt gelesen wurde. Ohne das wirkt der Sprachwechsel kaputt: Wer wiederkommt und ausdrücklich Türkisch gewählt hat, wird zurück auf die Accept-Language-Vermutung geworfen.
Metadaten pro Sprache, pro Route
Jede Seite im App Router kann generateMetadata exportieren. Bei einer zweisprachigen Website müssen in dieser Funktion drei Dinge passieren:
- Titel und Beschreibung in der aktuellen Sprache setzen.
- Eine Canonical-URL setzen, die genau auf diesen Pfad zeigt (keine Probleme mit abschließendem Slash, keine Protokoll-Merkwürdigkeiten).
- hreflang-Alternates für jede Sprache setzen, in der diese Seite existiert, plus ein x-default.
Mit dem Objekt alternates.languages macht Next.js das trivial. So sieht es pro Route aus:
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
if (!isLocale(params.locale)) return {};
const seo = WORK_SEO[params.locale];
return {
title: seo.title,
description: seo.description,
alternates: {
canonical: `/${params.locale}/work`,
languages: {
en: "/en/work",
tr: "/tr/work",
"x-default": "/en/work",
},
},
openGraph: { title: seo.title, description: seo.description, type: "website" },
};
}Zwei Dinge, die bei hreflang oft falsch laufen:
- Jedes Alternate muss zurückverweisen. Führt /en/work die Seite /tr/work als Alternate auf, muss /tr/work auch /en/work aufführen. Fehlen Rückverweise, ignoriert Google die gesamte hreflang-Gruppe.
- x-default ist keine Locale. Es ist die URL für Nutzer, deren Sprache keiner Ihrer Sprachen entspricht. Wir lassen es auf /en/work zeigen, weil Englisch unser breitester Fallback ist – nicht, weil Englisch wichtiger wäre.
Bei vielen Seiten lohnt sich ein kleiner Helper, der das alternates-Objekt für beliebige Pfade erzeugt. Wir schreiben es inline, weil wir nur eine Handvoll Seiten haben und sich die explizite Variante leichter debuggen lässt, wenn man um 23 Uhr auf die Search Console starrt.
Sitemaps und hreflang in der Sitemap
Das hreflang pro Route in den Metadaten ist erst die halbe Arbeit. Google liest hreflang auch aus der Sitemap, und die Sitemap ist das maßgeblichere Signal, weil sie die ganze Website auf einmal abdeckt. Der App Router bringt eine eingebaute sitemap.ts-Konvention mit; so sieht unsere aus:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";
const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return locales.flatMap((locale) =>
PATHS.map((p) => ({
url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
lastModified,
changeFrequency: "monthly" as const,
priority: p === "" ? 1 : 0.7,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
),
},
}))
);
}Beachten Sie, dass die Sitemap einen Eintrag pro Kombination aus (Locale, Pfad) erzeugt und jeder Eintrag alle Sprachalternativen auflistet. Das ist repetitiv – mit Absicht. Suchmaschinen crawlen lieber explizite Paare, als aus URL-Mustern zu raten.
Strukturierte Daten: ein Basisgraph, routenspezifische Erweiterungen
Schema.org-JSON-LD ist das zweite Standbein, über das Google eine mehrsprachige Website versteht. Wir teilen es in zwei Ebenen auf:
- Ein Basisgraph (Organization + WebSite), den das Root-Layout einmal einbindet. Er legt fest, wem die Website gehört, wo das Logo liegt und in welchen Sprachen die Website erscheint.
- Routenspezifische Schemas (CreativeWork, BreadcrumbList, Article, FAQPage), die jede Seite selbst einbindet. Das sind kleine JSON-LD-Blöcke, die beschreiben, was genau diese Seite ist.
Beide setzen inLanguage auf die aktuelle Sprache. Lassen Sie das nicht weg – ohne inLanguage muss Google die Sprache aus dem Seitentext ableiten, und das ist bei kurzen Seiten langsam und ungenau.
// lib/structured-data.ts
export const organizationLd = {
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: "Neptay",
url: SITE_URL,
logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
knowsLanguage: ["en", "tr"],
areaServed: "Worldwide",
};
export function articleLd(input: ArticleInput) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: input.headline,
inLanguage: input.locale, // ← critical for bilingual sites
datePublished: input.datePublished,
author: { "@id": `${SITE_URL}/#organization` },
publisher: { "@id": `${SITE_URL}/#organization` },
// …
};
}Das Wörterbuch-Muster
Übersetzungstexte liegen in einem einzigen typisierten Wörterbuch, exportiert als const-Map mit der Locale als Schlüssel. Das ist aus der Mode – der gängige Rat lautet, ein Framework wie next-intl oder react-intl mit ICU Message Format zu nutzen. Für eine kleine Website ist das typisierte Wörterbuch schneller, kleiner und fängt fehlende Übersetzungen schon beim Kompilieren ab:
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];
export interface Dictionary {
locale: Locale;
nav: { services: string; work: string; studio: string; contact: string };
// …
}
export const dictionaries: Record<Locale, Dictionary> = {
en: { /* … */ },
tr: { /* … */ },
};Fehlt für einen String die türkische Übersetzung, meldet sich TypeScript beim Build. Wird ein Schlüssel auf Englisch ergänzt, aber nicht auf Türkisch, schlägt der Build fehl. Bei zwei Sprachen ist das Gold wert – ausliefern lässt sich nur, was im Gleichschritt bleibt.
Das Muster stößt bei etwa zwanzig Sprachen an seine Grenzen – oder sobald Nicht-Entwickler Texte bearbeiten müssen. Dann gehört das Wörterbuch in ein CMS, und man akzeptiert, dass Übersetzungen hinterherhinken. Für zwei Sprachen und eine Studio-Website gewinnt schlichtes TypeScript.
Statisches Rendering, Edge-Caching und warum das für SEO zählt
All das ist bei einem einzelnen Seitenaufruf performanceneutral. Wichtig wird es in der Masse. Weil jede Seite statisch gerendert wird (keine Datenbankabfragen zur Laufzeit, keine Übersetzungsaufrufe pro Request), ist jede Sprachversion jeder Seite ein cachebares, am Edge gecachtes HTML-Dokument. Die Time to First Byte ist dann, was Ihr CDN hergibt – typischerweise 30–80 ms, überall auf der Welt.
Für SEO zählt das aus zwei Gründen: Core Web Vitals gewichten TTFB und LCP stark, und Googles Crawler hat ein Budget. Eine statische, am Edge gecachte Seite kostet den Crawler einen günstigen Roundtrip; eine serverseitig gerenderte Seite kann das Zehnfache kosten. Für eine Studio-Website mit fünf Seiten ist das Crawl-Budget kein Thema, für einen mehrsprachigen Blog mit zweihundert Artikeln wird es schnell eins.
Was wir noch verbessern würden
Eine ehrliche Liste dessen, was sich bei einer zweisprachigen App-Router-Website nach den Grundlagen noch lohnt:
- OpenGraph-Bilder pro Sprache. Derzeit ist das OG-Bild in beiden Sprachen gleich; idealerweise wäre auch die Headline im Bild übersetzt.
- Automatische Erkennung von Übersetzungsabweichungen. Ein CI-Schritt, der die Wörterbuch-Schlüssel von EN und TR vergleicht und bei Abweichungen fehlschlägt. Einfach, wertvoll.
- Eine Sprachumschalter-Komponente, die Nutzer auf derselben Seite hält, wenn es eine Übersetzung gibt, und sonst sauber (mit Hinweis) ausweicht.
Planen Sie eine zweisprachige Website und wünschen sich einen zweiten Blick auf die Architektur – oder möchten Sie, dass wir sie bauen? Sie erreichen uns unter hello@neptay.com.