Warum i18n-Fehler schwer zu finden sind#
Internationalisierungsfehler sind aus vier Gründen besonders zäh:
- Sie zeigen sich oft nur in bestimmten Sprachen — nicht in der, in der entwickelt wird
- Sie sind stumm: eine falsche Übersetzung wirft keine Ausnahme
- Sie liegen zwischen Code, Übersetzungsdateien und Laufzeitkonfiguration
- Sie funktionieren in der Entwicklung und brechen in der Produktion (andere Spracheinstellung, fehlende Dateien, Bundling)
Dieser Leitfaden gibt ein systematisches Vorgehen für die häufigsten Fälle — unabhängig von Framework und Plattform.
Die Checkliste vorweg#
□ Wird die richtige Sprache erkannt und geladen?
□ Sind die Übersetzungsdateien vorhanden und wohlgeformt?
□ Stimmen die Schlüssel zwischen Code und Datei überein?
□ Sind Platzhalter in der Übersetzung unversehrt?
□ Ist die i18n-Bibliothek korrekt initialisiert?
□ Landen die Übersetzungsdateien im Build?
Wer alle sechs Punkte beantworten kann, hat 90 % der i18n-Fehler gefunden.
Problem 1: Fehlende Übersetzungen — der Schlüssel steht auf dem Bildschirm#
Symptom: Nutzer sehen nav.home oder greeting.welcome statt Text.
Ursache A: Der Schlüssel existiert nicht#
Der häufigste Fall. Der Schlüssel im Code passt zu keinem Eintrag in der Übersetzungsdatei.
Diagnose:
// React (react-i18next)
const { t, i18n } = useTranslation();
console.log('Key exists:', i18n.exists('nav.home'));
console.log('Current language:', i18n.language);
console.log('Loaded namespaces:', i18n.options.ns);
// Vue (vue-i18n)
console.log('Key exists:', this.$te('nav.home'));
console.log('Current locale:', this.$i18n.locale);
console.log('Available locales:', this.$i18n.availableLocales);
Typische Gründe:
- Tippfehler im Schlüssel (
nav.homstattnav.home) - Falsche Verschachtelung (
nav.homeerwartet{ "nav": { "home": "..." } }, nicht{ "nav.home": "..." }) - Der Schlüssel existiert in Englisch, aber nicht in der aktuellen Sprache
Lösung: Im Editor über alle JSON-Dateien nach dem Schlüssel suchen. Die meisten Bibliotheken haben außerdem einen Haken für fehlende Schlüssel:
// react-i18next
i18n.init({
missingKeyHandler: (lngs, ns, key) => {
console.warn(`Missing translation: ${key} for ${lngs.join(', ')}`);
},
saveMissing: true, // protokolliert alle fehlenden Schlüssel
});
// vue-i18n
const i18n = createI18n({
missing: (locale, key) => {
console.warn(`Missing: [${locale}] ${key}`);
},
});
Ursache B: Die Datei wird nicht geladen#
Die Datei existiert, kommt aber nie in der App an.
Diagnose:
// Was ist tatsächlich geladen?
console.log('Resources:', i18n.store.data);
// Erwartet: { en: { translation: { ... } }, de: { translation: { ... } } }
Typische Gründe:
- Pfad in der Konfiguration stimmt nicht
- Asynchrones Laden wird nicht abgewartet — gerendert wird vor dem Laden
- Falscher Namespace: die Datei heißt
common.json, gelesen wird austranslation - Das Build-Werkzeug nimmt die JSON-Dateien nicht mit (Webpack-/Vite-Konfiguration prüfen)
Lösung beim asynchronen Laden:
// react-i18next: auf die Übersetzungen warten
import { useTranslation } from 'react-i18next';
function App() {
const { t, ready } = useTranslation();
if (!ready) return <Loading />;
return <h1>{t('welcome')}</h1>;
}
Ursache C: Falscher Namespace#
Wer mehrere Namespaces nutzt (common.json, dashboard.json), muss sagen, welcher gemeint ist:
// Falsch: sucht im Standard-Namespace
t('sidebar.title');
// Richtig: sucht im passenden Namespace
t('sidebar.title', { ns: 'dashboard' });
// Oder den Namespace für die ganze Komponente setzen
const { t } = useTranslation('dashboard');
Problem 2: Kaputte Platzhalter#
Symptom: Auf dem Bildschirm steht wörtlich Hello, {{name}}!, oder Variablen fehlen.
Ursache A: Die Variable wird nicht übergeben#
// Falsch: Variable fehlt
t('greeting'); // "Hello, {{name}}!"
// Richtig
t('greeting', { name: 'Sarah' }); // "Hello, Sarah!"
Diagnose: den Aufruf protokollieren:
const result = t('greeting', { name: userName });
console.log('Input:', { name: userName });
console.log('Output:', result);
Ursache B: Unterschiedliche Platzhalter-Syntax#
Jedes Framework schreibt Platzhalter anders:
| Framework | Syntax | Beispiel |
|---|---|---|
| i18next | {{var}} | Hello, {{name}}! |
| vue-i18n | {var} | Hello, {name}! |
| Android | %s, %d, %1$s | Hello, %1$s! |
| iOS (Swift) | %@, %d | Hello, %@! |
| ICU MessageFormat | {var} | Hello, {name}! |
Der klassische Fehler: Beim Übersetzen wird aus {{name}} ein {name}, ein {{Name}} (falsche Groß-/Kleinschreibung) oder {{ name }} (Leerzeichen — in manchen Implementierungen egal, in anderen tödlich).
Lösung: Platzhalter nach der Übersetzung prüfen:
# Schnelltest: alle Platzhalter in EN mit denen in DE vergleichen
grep -oP '\{\{.*?\}\}' locales/en.json | sort -u > en_vars.txt
grep -oP '\{\{.*?\}\}' locales/de.json | sort -u > de_vars.txt
diff en_vars.txt de_vars.txt
Ursache C: Das Übersetzungswerkzeug hat die Platzhalter zerstört#
Manche Werkzeuge und Maschinenübersetzer verändern, übersetzen oder entfernen Platzhalter.
So sieht der Schaden aus:
// en.json (korrekt)
{ "items": "You have {{count}} items in {{location}}" }
// de.json (vom Werkzeug beschädigt)
{ "items": "Du hast {{Anzahl}} Artikel in {{Standort}}" }
// Die Variablennamen wurden mitübersetzt. Der Code übergibt {count}, nicht {Anzahl}.
Lösung: ein Werkzeug verwenden, das Platzhalter als solche erkennt. shipglobal.dev erkennt und erhält {{variablen}}, {variablen}, %s, %@ und die übrigen Formate.
Problem 3: Falsche Spracherkennung#
Symptom: Deutscher Text für englischsprachige Nutzer — oder umgekehrt.
Wie die Erkennung abläuft#
Die meisten Frameworks prüfen in dieser Reihenfolge:
- URL-Segment (
/de/about) oder Query-Parameter (?lang=de) - Cookie mit der gespeicherten Wahl
Accept-Language-Header des Browsers- Standardsprache als Rückfall
Diagnose#
// react-i18next
console.log('Detected language:', i18n.language);
console.log('Languages in order:', i18n.languages); // Rückfallkette
console.log('Resolved language:', i18n.resolvedLanguage);
// Was der Browser meldet
console.log('Browser languages:', navigator.languages);
// Beispiel: ["de-DE", "de", "en-US", "en"]
Die zwei häufigsten Fälle#
de-DE gegen de
Der Browser meldet de-DE, die App kennt aber nur de.json. Die Suche schlägt still fehl und landet auf Englisch.
Lösung: die Auflösung konfigurieren:
// react-i18next
i18n.init({
supportedLngs: ['en', 'de', 'fr', 'es'],
nonExplicitSupportedLngs: true, // de-DE trifft auf de
load: 'languageOnly', // lädt 'de' statt 'de-DE'
});
// vue-i18n
const i18n = createI18n({
fallbackLocale: 'en',
locale: navigator.language.split('-')[0], // 'de-DE' → 'de'
});
Server und Client laufen auseinander (SSR)
Der Server rendert Englisch (bei statischer Erzeugung gibt es keinen Browser-Header), der Client stellt auf Deutsch um. Ergebnis: ein sichtbares Umspringen nach der Hydration.
Lösung: die Sprache vom Server durchreichen, statt sie im Browser zu erraten:
// Next.js App Router
// Der [locale]-Parameter aus der URL ist die Wahrheit.
// Für den ersten Aufbau nicht auf die Browsererkennung verlassen.
export default function Page({ params: { locale } }) {
// locale aus der URL nutzen, nicht aus navigator
}
Problem 4: Pluralformen greifen nicht#
Symptom: Es steht immer „1 items" oder „5 item" da.
Wie Pluralisierung funktioniert#
Die Regeln unterscheiden sich stark. Englisch hat zwei Formen, Russisch drei, Arabisch sechs.
Plural-Schlüssel in i18next:
// Englisch (2 Formen)
{
"item_one": "{{count}} item",
"item_other": "{{count}} items"
}
// Russisch (3 Formen)
{
"item_one": "{{count}} предмет", // 1, 21, 31...
"item_few": "{{count}} предмета", // 2-4, 22-24...
"item_many": "{{count}} предметов" // 5-20, 25-30...
}
// Arabisch (6 Formen)
{
"item_zero": "...",
"item_one": "...",
"item_two": "...",
"item_few": "...",
"item_many": "...",
"item_other": "..."
}
Die drei üblichen Fehler#
1. Die alte Endung _plural (i18next vor v21)
// Alte Schreibweise (vor v21)
{ "item": "{{count}} item", "item_plural": "{{count}} items" }
// Neue Schreibweise (ab v21)
{ "item_one": "{{count}} item", "item_other": "{{count}} items" }
Wenn nach einem Upgrade plötzlich alle Plurale falsch sind, liegt es fast immer daran. Version prüfen:
npm list i18next
2. count wird nicht übergeben
In i18next löst erst count in den Optionen die Auswahl der Pluralform aus — und nur, wenn die Schlüssel die passenden Endungen tragen. Fehlt eins von beidem, greift die Auswahl nicht.
3. Fehlende Formen für die Zielsprache
Ins Russische übersetzt, aber nur _one und _other geliefert. Russisch braucht zusätzlich _few und _many.
Nachschlagen: welche Formen eine Sprache braucht, steht in den CLDR-Pluralregeln.
Problem 5: Kodierung und Zeichensalat#
Symptom: Aus ä wird ä, aus japanischem Text werden Fragezeichen.
Ursache A: Dateikodierung#
Übersetzungsdateien gehören in UTF-8 — für die meisten Systeme ohne BOM, für manche Windows-Werkzeuge mit.
Prüfen:
file -bi locales/de.json
# Erwartet: application/json; charset=utf-8
Lösung: In VS Code unten rechts die Kodierung anklicken, „Save with Encoding" → „UTF-8".
Ursache B: JSON-Maskierung#
Sonderzeichen müssen im JSON maskiert werden:
// Falsch: nicht maskierte Anführungszeichen zerstören das JSON
{ "quote": "She said "hello"" }
// Richtig
{ "quote": "She said \"hello\"" }
// Auch richtig: einfache Anführungszeichen im Text
{ "quote": "She said 'hello'" }
Unicode-Zeichen dürfen in UTF-8-JSON direkt stehen:
// Beide Schreibweisen sind gültig
{ "greeting": "こんにちは" }
{ "greeting": "こんにちは" }
Ursache C: Plattformeigenheiten#
Android (strings.xml):
<!-- Apostrophe müssen maskiert werden -->
<string name="error">It\'s an error</string>
<!-- Oder den Wert in Anführungszeichen setzen -->
<string name="error">"It's an error"</string>
iOS (Localizable.strings):
/* Anführungszeichen im Wert maskieren */
"greeting" = "Hello, \"World\"!";
Problem 6: RTL-Layout#
Symptom: Arabischer oder hebräischer Text steht links, Symbole zeigen falsch herum, das Layout kippt.
Der schnelle Test#
<!-- dir-Attribut auf html oder einen Container setzen -->
<html dir="rtl" lang="ar">
/* Logische CSS-Eigenschaften */
.container {
/* statt margin-left: */
margin-inline-start: 1rem;
/* statt padding-right: */
padding-inline-end: 1rem;
/* statt text-align: left: */
text-align: start;
}
Drei typische RTL-Fehler#
1. Symbole zeigen in die falsche Richtung
Pfeile zurück und vorwärts müssen sich spiegeln.
[dir="rtl"] .icon-forward {
transform: scaleX(-1);
}
2. Zahlen in RTL-Text
Zahlen bleiben auch in RTL-Text von links nach rechts. Browser erledigen das über den bidirektionalen Algorithmus — erzwingt man die Richtung, bricht es.
<!-- Richtig: den Browser machen lassen -->
<p dir="auto">السعر: 99.99€</p>
<!-- Falsch: RTL auf Zahlen erzwingen -->
<p dir="rtl" style="unicode-bidi: override">السعر: 99.99€</p>
3. Flexbox und Grid
/* Beachtet dir="rtl" von selbst */
.flex-container {
display: flex;
/* In zweisprachigen Apps flex-direction für waagerechte Layouts nicht setzen —
das dir-Attribut dreht die Reihenfolge bereits um. */
}
Problem 7: Datum, Zahlen, Währung#
Symptom: Deutsche Nutzer sehen 3/25/2026 statt 25.03.2026.
Die Intl-APIs benutzen#
// Datum
new Intl.DateTimeFormat('de-DE').format(new Date());
// "25.3.2026"
new Intl.DateTimeFormat('en-US').format(new Date());
// "3/25/2026"
new Intl.DateTimeFormat('ja-JP').format(new Date());
// "2026/3/25"
// Zahlen
new Intl.NumberFormat('de-DE').format(1234.56);
// "1.234,56"
new Intl.NumberFormat('en-US').format(1234.56);
// "1,234.56"
// Währung
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(9.99);
// "9,99 €"
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1500);
// "¥1,500"
Zwei Formatierungsfehler#
Trennzeichen fest verdrahtet
// Falsch: US-Format hart kodiert
const formatted = `$${price.toFixed(2)}`;
// Richtig: sprachabhängig
const formatted = new Intl.NumberFormat(locale, {
style: 'currency',
currency: userCurrency
}).format(price);
Eingaben mit der falschen Sprache geparst
// Deutscher Nutzer gibt "1.234,56" ein (also 1234.56)
const value = parseFloat("1.234,56"); // liefert 1.234 — falsch
// Vor dem Parsen normalisieren
const normalized = input.replace(/\./g, '').replace(',', '.');
const value = parseFloat(normalized); // liefert 1234.56
Problem 8: Build und Bundling#
Symptom: In der Entwicklung läuft alles, in der Produktion fehlen die Übersetzungen.
Ursache A: Die Dateien landen nicht im Build#
Next.js: Was unter public/locales/ liegt, kommt automatisch mit. Was unter src/ liegt, braucht eine ausdrückliche Behandlung.
Vite: JSON-Importe funktionieren, dynamische Importe brauchen Konfiguration:
// vite.config.ts
export default defineConfig({
plugins: [
// für dynamisches Laden der Sprachdateien
// sicherstellen, dass die JSON-Dateien im Build landen
],
build: {
rollupOptions: {
// alle Sprachdateien einschließen
}
}
});
Ursache B: Vollständig dynamische Importpfade#
// Für Bundler nicht analysierbar
const messages = await import(`./locales/${locale}.json`);
// Dem Bundler das Muster mitgeben:
// Webpack über einen webpackInclude-Kommentar
// Vite über Glob-Importe oder eine ausdrückliche Dateiliste
Ursache C: Tree-Shaking entfernt Schlüssel#
Werden Schlüssel nur als dynamische Strings referenziert, wirft mancher Bundler das JSON weg. Sprachdateien als Assets behandeln, nicht als optimierbare Codemodule.
Werkzeugkasten#
Schnelldiagnose in der Konsole#
// für react-i18next — in die Browserkonsole einfügen
const i18n = document.querySelector('[data-i18n]')?.__i18n || window.i18n;
if (i18n) {
console.table({
'Current Language': i18n.language,
'Fallback Language': i18n.options?.fallbackLng,
'Loaded Languages': Object.keys(i18n.store?.data || {}),
'Missing Keys Count': i18n.options?.saveMissing ? 'tracking enabled' : 'not tracked',
});
}
Übersetzungsdateien automatisch prüfen#
Ein kleines Skript fängt die häufigsten Fehler vor dem Deploy ab:
// scripts/validate-i18n.ts
import fs from 'fs';
import path from 'path';
const LOCALES_DIR = './locales';
const BASE_LOCALE = 'en';
function validateTranslations() {
const basePath = path.join(LOCALES_DIR, `${BASE_LOCALE}.json`);
const baseKeys = getAllKeys(JSON.parse(fs.readFileSync(basePath, 'utf-8')));
const errors: string[] = [];
const localeFiles = fs.readdirSync(LOCALES_DIR).filter(f => f.endsWith('.json') && f !== `${BASE_LOCALE}.json`);
for (const file of localeFiles) {
const locale = file.replace('.json', '');
const content = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, file), 'utf-8'));
const localeKeys = getAllKeys(content);
// fehlende Schlüssel
for (const key of baseKeys) {
if (!localeKeys.has(key)) {
errors.push(`[${locale}] Missing key: ${key}`);
}
}
// überzählige Schlüssel (womöglich veraltet)
for (const key of localeKeys) {
if (!baseKeys.has(key)) {
errors.push(`[${locale}] Extra key (not in ${BASE_LOCALE}): ${key}`);
}
}
// Platzhalter vergleichen
for (const key of baseKeys) {
if (localeKeys.has(key)) {
const basePlaceholders = extractPlaceholders(getValueByPath(JSON.parse(fs.readFileSync(basePath, 'utf-8')), key));
const localePlaceholders = extractPlaceholders(getValueByPath(content, key));
if (basePlaceholders.sort().join(',') !== localePlaceholders.sort().join(',')) {
errors.push(`[${locale}] Placeholder mismatch in "${key}": expected ${basePlaceholders}, got ${localePlaceholders}`);
}
}
}
}
if (errors.length > 0) {
console.error(`Found ${errors.length} i18n issues:\n`);
errors.forEach(e => console.error(` ${e}`));
process.exit(1);
} else {
console.log('All translation files are consistent.');
}
}
function getAllKeys(obj: any, prefix = ''): Set<string> {
const keys = new Set<string>();
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null) {
getAllKeys(obj[key], fullKey).forEach(k => keys.add(k));
} else {
keys.add(fullKey);
}
}
return keys;
}
function extractPlaceholders(value: string): string[] {
if (typeof value !== 'string') return [];
const matches = value.match(/\{\{.*?\}\}|\{[^}]+\}|%[sd@]|%\d+\$[sd@]/g);
return matches || [];
}
function getValueByPath(obj: any, path: string): any {
return path.split('.').reduce((o, k) => o?.[k], obj);
}
validateTranslations();
Vor jedem Deploy ausführen:
npx tsx scripts/validate-i18n.ts
Editor-Erweiterungen#
- i18n Ally (VS Code) — zeigt Übersetzungen inline, meldet fehlende Schlüssel, vervollständigt Schlüsselnamen
- i18next Scanner — zieht Schlüssel aus dem Code und vergleicht sie mit den JSON-Dateien
Schnellhilfe je Framework#
React (react-i18next)#
| Symptom | Abhilfe |
|---|---|
| Schlüssel statt Text | i18n.exists(key) prüfen, Namespace kontrollieren |
| Kurzes Aufblitzen der falschen Sprache | Suspense nutzen oder ready abfragen |
| Hydration stimmt nicht (SSR) | dieselbe Sprache auf Server und Client sicherstellen |
Trans rendert kein HTML | Groß-/Kleinschreibung der Komponentennamen prüfen |
Vue (vue-i18n)#
| Symptom | Abhilfe |
|---|---|
$t liefert den Schlüssel | mit $te(key) prüfen, ob er existiert |
| Sprachwechsel löst kein Update aus | $i18n.locale verwenden (reaktiv), nicht i18n.global.locale |
| Composition API greift nicht | useI18n() innerhalb von setup() aufrufen |
| Komponenten-Interpolation kaputt | <i18n-t> verwenden statt String-Interpolation |
Android#
| Symptom | Abhilfe |
|---|---|
| Falsche Sprache auf dem Gerät | Resources.getConfiguration().locale prüfen |
| Absturz wegen fehlender Strings | alle Schlüssel in values/strings.xml hinterlegen |
| Apostrophe zerstören das XML | mit \' maskieren oder Wert in Anführungszeichen setzen |
| Formatierte Strings falsch | getString(R.string.key, args) statt getString() |
iOS (Swift)#
| Symptom | Abhilfe |
|---|---|
NSLocalizedString liefert den Schlüssel | .lproj-Ordnernamen gegen die Sprachcodes prüfen |
| Pluralformen falsch | .stringsdict für Pluralregeln verwenden |
| Storyboard wird nicht lokalisiert | Base-Internationalisierung aktivieren |
| Variablen in falscher Reihenfolge | Positionsangaben verwenden: %1$@, %2$@ |
Vorbeugen statt suchen#
1. Prüfung in der CI#
Das Prüfskript von oben in die Pipeline hängen:
# .github/workflows/ci.yml
- name: Validate translations
run: npx tsx scripts/validate-i18n.ts
2. Auf Textlänge testen#
Deutsch ist rund 30 % länger als Englisch, Japanisch bis zu 50 % kürzer. Die Oberfläche gehört mit der längsten und der kürzesten Übersetzung geprüft:
// Pseudo-Lokalisierung: Text künstlich verlängern
const pseudoLocalize = (text: string) =>
text.replace(/[a-z]/g, c => `${c}${c}`); // verdoppelt jeden Buchstaben
3. Screenshots je Sprache#
Mit Playwright oder Cypress in jeder Sprache aufnehmen:
// Playwright-Test
for (const locale of ['en', 'de', 'ja', 'ar']) {
test(`homepage renders correctly in ${locale}`, async ({ page }) => {
await page.goto(`/${locale}`);
await expect(page).toHaveScreenshot(`home-${locale}.png`);
});
}
Weiterlesen#
- React i18next: die Trans-Komponente — JSX und HTML in Übersetzungen
- i18next: Interpolation und Platzhalter — Variablen im Detail
- 10 typische i18n-Fehler — die häufigsten Fallen
- Vue i18n — Einrichtung in Vue.js
- Android i18n mit JSON — Lokalisierung unter Android