Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Die i18n-Schicht über TanStack Ships 14 Module habe ich 2025 ausgeliefert – 9 Sprachen, 3.400 übersetzte Message-Keys und ein peinlicher Moment, als ein deutscher String 4x länger war als die englische Version und das Layout der Preisstruktur zerbrach. Laut den Paraglide.js-Docs ist Message-Extraktion zur Build-Zeit das richtige Muster für Edge SSR.
Verifizierte Quellen: Paraglide.js-Docs · react-i18next-Docs · W3C hreflang-Spezifikation · TanStack Ship GitHub-Org Letzte Aktualisierung: 2026-08-09 · Changelog
TL;DR: Der React-i18n-Stack 2026 besteht aus Paraglide.js für statische Message-Extraktion + Edge-SSR-Routing + per-Locale-URL-Präfixe + hreflang-Tags + Drizzle-basiertem Dynamic-Content. Das Setup dauert 2 Stunden für eine App mit 14 Modulen, skaliert auf 9 Sprachen ohne Bundle-Bloat, liefert typsichere Messages, die der TypeScript-Compiler bemängelt, wenn ein Key falsch geschrieben wird.
Die i18n-Landschaft 2026: Drei Ansätze
Drei Ansätze dominieren den React-i18n-Bereich 2026. Sie unterscheiden sich darin, wo die Übersetzung stattfindet, was das Bundle kostet und wie sie mit Edge SSR umgehen.
Ansatz 1: Build-Time-Kompilierung (Paraglide.js)
Paraglide.js ist ein inlang-Projekt, das Messages zur Build-Zeit kompiliert. Die Source of Truth ist ein Ordner mit TypeScript-Message-Dateien; der Compiler gibt ein Tree-shakefähiges JavaScript-Bundle pro Sprache aus. Das Ergebnis ist ein Funktionsaufruf pro Message – keine Runtime-JSON-Lookup, keine clientseitige Übersetzungsbibliothek.
Laut den Paraglide.js-Docs ist das Bundle „1–2 kB pro Sprache" und „tree-shakeable by language". Der TypeScript-Compiler verifiziert zur Compile-Zeit, dass jeder Message-Key existiert, was Tippfehler abfängt, bevor sie in die Produktion gelangen.
Das ist der richtige Ansatz für produktives SaaS 2026, weil er am Edge funktioniert: Die Workers-Runtime empfängt eine Anfrage, erkennt das Locale aus der URL, lädt das passende kompilierte Bundle, die Message wird zurückgegeben. Kein KV-Lookup, keine D1-Query, keine clientseitige JSON-Hydration.
Ansatz 2: Runtime-JSON-Laden (react-i18next)
react-i18next ist der De-facto-Standard für Runtime-übersetzte React-Apps. Messages liegen in JSON-Dateien, der Client lädt sie on demand, der React-Tree konsumiert sie über Hooks.
Laut den react-i18next-Docs beträgt die Bundle-Kosten „12 kB für den Core + JSON pro Sprache". Die Runtime-Kosten sind ein JSON-Fetch pro Sprachwechsel.
Das ist der richtige Ansatz für content-reiche Sites, wo Übersetzungen von Nicht-Entwicklern hinzugefügt werden. Das JSON-Format ist menschenlesbar, das Übersetzungsmanagement-UI ist ausgereift, die Runtime-Kosten sind für traditionelle Vercel-artige Deployments akzeptabel.
Für Edge SSR hat react-i18next ein bekanntes Problem: Das Message-JSON muss im Worker-Bundle vorhanden sein, was bedeutet, dass alle 9 Sprachen-JSONs bei jeder Anfrage ausgeliefert werden. Paraglide vermeidet dies durch Tree-shaking pro Sprache.
Ansatz 3: API-basierte Übersetzung (Lingui, i18nexus)
Ein dritter Ansatz ist API-basiert: Messages liegen in einem Übersetzungsdienst, der Client fetched sie bei erster Verwendung, das Ergebnis wird in localStorage gecacht. Lingui und i18nexus verfolgen diesen Ansatz.
Der Kompromiss ist operativ: Sie sind von einem Drittanbieter-Service für jeden übersetzten String abhängig. Für ein SaaS, das offline-fähige PWAs ausliefert, ist das ein Showstopper. Für eine Marketing-Site, die Crowdsourced-Übersetzung möchte, ist es das richtige Format.
Für TanStack Ship habe ich Paraglide gewählt, weil die Edge-SSR-Anforderung (Cloudflare Workers, keine clientseitige Hydration) nicht mit dem Runtime-JSON-Ansatz kompatibel ist. Das TanStack Ship i18n-Modul liefert Paraglide als Standard und react-i18next als Opt-in. Der Blog-Vergleich zwischen den beiden Ansätzen findet sich auf tanstackship.com/compare.
Paraglide.js mit einer TanStack-Start-App einrichten
Das Paraglide-Setup für eine TanStack-Start-App auf Cloudflare Workers dauert etwa 20 Minuten. Die 7 Schritte unten sind die Produktionsversion, kein vereinfachtes Beispiel.
# Install Paraglide and the Paraglide Vite plugin
pnpm add @inlang/paraglide-js@^2
pnpm add -D @inlang/paraglide-vite@^2
# Initialize the project structure
npx paraglide-js init --project ./project.inlang
mkdir -p src/paraglide/messages
# Add a compile script
# package.json
"scripts": {
"paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide"
}
Der project.inlang-Ordner ist die Source of Truth. Der paraglide:compile-Schritt gibt die Tree-shakefähigen Bundles nach src/paraglide/ aus.
Die Message-Dateien
Eine Message-Datei ist eine TypeScript-Datei, die ein Default-Objekt exportiert. Der Key ist die Message-ID, der Wert ist der englische Quellstring.
// src/paraglide/messages/en.ts
export default {
pricing_title: "Pricing built for solo founders",
pricing_subtitle: "Start free, scale when you need to.",
cta_signup: "Create your account",
cta_learn_more: "Learn more",
};
Die gleiche Datei existiert für jede Sprache. Die deutsche Version:
// src/paraglide/messages/de.ts
export default {
pricing_title: "Preise für Solo-Gründer",
pricing_subtitle: "Starten Sie kostenlos, skalieren Sie, wenn Sie müssen.",
cta_signup: "Konto erstellen",
cta_learn_more: "Mehr erfahren",
};
Das 4-fach-längere German-String-Problem aus dem Hero-Block trat bei einer langen Nominalphrase an anderer Stelle der Preisstruktur auf. Der Fix war CSS min-width auf dem Button, nicht die Übersetzung.
Das Vite-Plugin
Das Vite-Plugin übernimmt die Kompilierung im Dev-Modus und gibt die Bundles für die Produktion aus.
// vite.config.ts
import { defineConfig } from "vite";
import { paraglide } from "@inlang/paraglide-vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
paraglide({
project: "./project.inlang",
outdir: "./src/paraglide",
languageNegotiation: { strategy: ["url"] },
}),
tsconfigPaths(),
],
});
Mit languageNegotiation.strategy: ["url"] teilen Sie Paraglide mit, das Locale aus dem URL-Präfix (/de/pricing, /fr/pricing) zu erkennen. URL-Präfix ist die richtige Wahl für SEO, weil jede Seite in jeder Sprache indexierbar ist.
Message-Extraktion und Übersetzungsmanagement
Eine häufige Frage ist: Wie halte ich 9 Sprachdateien synchron? Die Antwort ist Fink oder Paraglides VS-Code-Extension, beide aus dem inlang-Ökosystem.
Fink – Das Translation-Memory-UI
Fink ist ein gehostetes Translation-Memory-Tool, das sich mit Ihrem Paraglide-Projekt verbindet. Es zeigt alle nicht übersetzten Messages, lässt einen Übersetzer daran arbeiten und schreibt das Ergebnis zurück in die korrekte Datei.
Laut den inlang-Docs unterstützt Fink Machine-Translation-Prefill (DeepL, Google Translate) und Per-Message-Review-Workflow.
Für Solo-Entwickler reicht die VS-Code-Extension: Sie highlighted nicht übersetzte Messages, schlägt Vervollständigungen von DeepL vor, validiert die Message-ID gegen die Quelldatei und zeigt eine Side-by-Side-Vorschau des übersetzten Strings zur Design-Zeit.
Der Build-Time-Check
Fügen Sie einen paraglide:lint-Schritt zu CI hinzu, der den Build fehlschlagen lässt, wenn eine Sprache eine Übersetzung fehlt.
# .github/workflows/ci.yml
- name: Paraglide lint
run: pnpm paraglide:lint --project ./project.inlang
Dies fängt den „Ich habe eine Message auf Englisch hinzugefügt und sie zu übersetzen vergessen"-Bug ab, bevor er Staging erreicht. Der Rollout mit 3.400 Messages in TanStack Ship hat 47 nicht übersetzte Messages im ersten CI-Durchlauf gefunden; der zweite Durchlauf war fehlerfrei.
Locale-basiertes Routing
Drei Routing-Strategien sind verbreitet. Die richtige hängt von Ihren SEO-Zielen und Ihrer bestehenden URL-Struktur ab.
Strategie 1: URL-Präfix (/de/pricing)
// app/routes.tsx
import { createFileRoute } from "@tanstack/react-router";
import { pricingEn } from "./messages/en";
export const Route = createFileRoute("/{-$locale}/pricing")({
component: PricingPage,
loader: async ({ params }) => {
const locale = params.locale ?? "en";
return { locale };
},
});
function PricingPage() {
const { locale } = Route.useLoaderData();
const messages = locale === "de" ? pricingDe : pricingEn;
return <Pricing title={messages.pricing_title} />;
}
Das ist die richtige Strategie für SaaS-Marketing-Sites. Jede Seite hat eine eindeutige URL pro Sprache, die Suchmaschine indexiert alle 9 Versionen, der Nutzer kann einen Link zu einer bestimmten Sprache teilen.
Die Kosten sind ein zusätzliches URL-Segment pro Route. Für eine App mit 14 Modulen ist das überschaubar. Für eine 200-Route E-Commerce-Site sind die Kosten real.
Strategie 2: Subdomain (de.example.com)
Eine subdomain-basierte Strategie nutzt de.example.com für Deutsch, fr.example.com für Französisch. Das ist die richtige Strategie für Content-reiche Sites, die unterschiedliche Cookie-Richtlinien pro Region brauchen (DSGVO-Compliance) oder für Sites, die bereits in Subdomains investiert haben.
Die Kosten sind operativer Natur: DNS, Cookies, Suchmaschinen-Indexierung. Für TanStack Ship habe ich URL-Präfix gewählt, weil das SaaS klein genug ist, dass der Subdomain-Overhead nicht lohnend ist.
Strategie 3: Query-Parameter (?lang=de)
Query-Parameter-Routing ist die richtige Strategie für SPAs, die sich nicht um SEO kümmern. Die Suchmaschine behandelt ?lang=de und ?lang=fr als dieselbe URL, was den lokalisierten SEO-Vorteil zunichte macht.
Laut den MDN-URL-API-Docs sind Query-Parameter nicht Teil des URL-Pfads, daher tragen sie nicht zum SEO-Index bei.
SEO: hreflang, Canonical und lokalisierte URLs
Drei SEO-Elemente sind für mehrsprachiges SaaS relevant: hreflang-Tags, das Canonical-Tag und der x-default-hreflang.
Das hreflang-Tag
Das hreflang-Attribut auf einem <link>-Tag teilt der Suchmaschine mit, welche Version einer Seite für welche Sprache gedacht ist. Laut den Google-hreflang-Docs sollte jede Seite ein selbstreferenzielles hreflang plus ein hreflang für jede andere Sprachversion haben.
<link rel="alternate" hreflang="en" href="https://example.com/pricing" />
<link rel="alternate" hreflang="de" href="https://example.com/de/pricing" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing" />
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />
Das x-default-hreflang verweist auf die Version für Nutzer, deren Sprache nicht in der Liste ist. Für ein SaaS, das hauptsächlich englischsprachige Märkte mit Deutsch und Französisch als Erweiterung anvisiert, ist das x-default die englische Version.
Das Canonical-Tag
Das Canonical-Tag teilt der Suchmaschine mit, welche Version die „primäre" ist, wenn es duplizierte Inhalte gibt. Für mehrsprachige Sites ist das Canonical normalerweise die englische Version, während hreflang die sprachspezifischen Signale liefert.
<link rel="canonical" href="https://example.com/pricing" />
Laut der W3C-hreflang-Spezifikation ist hreflang ein Hinweis, keine Direktive; das Canonical-Tag ist ein stärkeres Signal.
Die TanStack-Start-Implementierung
In einer TanStack-Start-App werden die hreflang-Tags vom Route-Loader generiert und in den Document-Head injiziert.
// routes/-$locale/pricing.tsx
export const Route = createFileRoute("/{-$locale}/pricing")({
loader: async ({ params }) => {
const locale = params.locale ?? "en";
const alternates = SUPPORTED_LOCALES.map((l) => ({
hreflang: l,
href: `https://example.com/${l === "en" ? "" : l + "/"}pricing`,
}));
return { locale, alternates };
},
});
Die alternates werden im Document-Head gerendert. Laut den Paraglide.js-Docs ist dieses Muster das empfohlene für Edge SSR.
Dynamic-Content-Übersetzung (MDX, Datenbank)
Statische Messages in TypeScript-Dateien sind der einfache Fall. Dynamic Content – Blogbeiträge, Produktbeschreibungen, nutzergenerierte Inhalte – braucht ein separates Muster.
MDX: Eine Datei pro Sprache
Für MDX-Content (Blogbeiträge, Marketing-Seiten) ist das einfachste Muster eine Datei pro Sprache.
// content/blog/post-1/en.mdx
// content/blog/post-1/de.mdx
// content/blog/post-1/fr.mdx
Der Route-Loader liest die locale-präfigierte Datei. Laut den TanStack-Start-MDX-Docs wird die Dateiendung in der Router-Config registriert und der Loader gibt das geparste MDX zurück.
Die Kosten sind Content-Duplizierung: Jeder Blogbeitrag ist 9 Dateien. Der Vorteil ist Typsicherheit: Der TypeScript-Compiler verifiziert, dass jedes Locale eine entsprechende Datei hat, und der Build schlägt fehl, wenn eine Übersetzung fehlt. Der CI-Schritt paraglide:lint fängt fehlende Übersetzungen auf dieselbe Weise ab wie fehlende statische Messages.
Datenbank: Drizzle-Schema mit Locale
Für nutzergenerierte Inhalte (Produktnamen, Beschreibungen) braucht das Datenbankschema eine Locale-Spalte.
// db/schema/products.ts
export const products = sqliteTable("products", {
id: text("id").primaryKey(),
sku: text("sku").notNull(),
defaultLocale: text("default_locale").notNull().default("en"),
createdAt: integer("created_at").notNull(),
});
export const productTranslations = sqliteTable("product_translations", {
id: text("id").primaryKey(),
productId: text("product_id").notNull().references(() => products.id),
locale: text("locale").notNull(),
name: text("name").notNull(),
description: text("description").notNull(),
UNIQUE(productId, locale),
});
Das Zwei-Tabellen-Design vermeidet eine JSON-Spalte und hält das Schema lesbar. Laut den Drizzle-ORM-Docs erzwingt die UNIQUE(productId, locale)-Constraint die Invariante, dass ein Produkt maximal eine Übersetzung pro Sprache hat, auf die die Query-Layer für den Left-Join-Fallback angewiesen ist.
Der Fallback auf Englisch, wenn eine Übersetzung fehlt, wird im Loader gehandhabt, nicht in der Datenbank-Query. Das vollständige Muster findet sich im TanStack-Ship-i18n-Modul und im Drizzle-ORM-Guide.
Einschränkungen und was ich nicht abgedeckt habe
Drei Bereiche, die ich in der Produktion angetroffen habe und die dieser Leitfaden nicht abdeckt:
- Rechts-nach-links-Sprachen (RTL). Die arabischen und hebräischen Übersetzungen erfordern CSS
dir="rtl"und ein gespiegeltes Layout für einige Komponenten (Icons, Padding-Richtung, Textausrichtung). Arabisch habe ich noch nicht in der Produktion ausgeliefert; die Design-Notizen liegen noch auf Papier. - Pluralisierung und Genus. Russisch hat drei Pluralformen; Deutsch hat deklinierte Substantive. Paraglide unterstützt ICU MessageFormat, aber die Konfiguration ist nicht trivial. Für einfache Plurale reicht das Standard-Paraglide-Verhalten. Für vollständige Genus-Unterstützung ist MessageFormat erforderlich.
- Translation Memory über Module hinweg. Wenn derselbe String in zwei Modulen erscheint, sind Paraglides Message-IDs dateilokal, nicht global. Der Fix ist eine gemeinsame
common.ts-Message-Datei.
Paraglide habe ich nicht in einem 50-Sprachen-Enterprise-Fall getestet. Der 9-Sprachen-Rollout ist die Obergrenze meiner Evidenz. Behandeln Sie die Bundle-Größen-Zahlen als Arbeitsbaseline, nicht als Garantie.
TanStack Ships i18n-Modul liefert Paraglide.js, Locale-Routing, hreflang-Injection und das Drizzle-basierte Dynamic-Content-Schema als einzelnes tanstack-ship i18n-Template. Die vollständige Modulliste findet sich auf tanstackship.com/features, der Vergleich mit anderen SaaS-Starters auf tanstackship.com/compare und die Preismodelle auf tanstackship.com/pricing. Das 30-Tage-Rückgabefenster befindet sich auf der Preisstruktur-Seite.