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#

text
□ 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:

tsx
// 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);
js
// 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.hom statt nav.home)
  • Falsche Verschachtelung (nav.home erwartet { "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:

ts
// react-i18next
i18n.init({
  missingKeyHandler: (lngs, ns, key) => {
    console.warn(`Missing translation: ${key} for ${lngs.join(', ')}`);
  },
  saveMissing: true, // protokolliert alle fehlenden Schlüssel
});
ts
// 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:

tsx
// 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 aus translation
  • Das Build-Werkzeug nimmt die JSON-Dateien nicht mit (Webpack-/Vite-Konfiguration prüfen)

Lösung beim asynchronen Laden:

tsx
// 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:

tsx
// 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#

tsx
// Falsch: Variable fehlt
t('greeting'); // "Hello, {{name}}!"

// Richtig
t('greeting', { name: 'Sarah' }); // "Hello, Sarah!"

Diagnose: den Aufruf protokollieren:

tsx
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:

FrameworkSyntaxBeispiel
i18next{{var}}Hello, {{name}}!
vue-i18n{var}Hello, {name}!
Android%s, %d, %1$sHello, %1$s!
iOS (Swift)%@, %dHello, %@!
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:

bash
# 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:

json
// 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:

  1. URL-Segment (/de/about) oder Query-Parameter (?lang=de)
  2. Cookie mit der gespeicherten Wahl
  3. Accept-Language-Header des Browsers
  4. Standardsprache als Rückfall

Diagnose#

tsx
// react-i18next
console.log('Detected language:', i18n.language);
console.log('Languages in order:', i18n.languages); // Rückfallkette
console.log('Resolved language:', i18n.resolvedLanguage);
js
// 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:

ts
// react-i18next
i18n.init({
  supportedLngs: ['en', 'de', 'fr', 'es'],
  nonExplicitSupportedLngs: true, // de-DE trifft auf de
  load: 'languageOnly', // lädt 'de' statt 'de-DE'
});
ts
// 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:

tsx
// 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:

json
// 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)

json
// 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:

bash
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:

bash
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:

json
// 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:

json
// Beide Schreibweisen sind gültig
{ "greeting": "こんにちは" }
{ "greeting": "こんにちは" }

Ursache C: Plattformeigenheiten#

Android (strings.xml):

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):

text
/* 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#

html
<!-- dir-Attribut auf html oder einen Container setzen -->
<html dir="rtl" lang="ar">
css
/* 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.

css
[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.

html
<!-- 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

css
/* 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#

ts
// 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

tsx
// 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

tsx
// 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:

ts
// 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#

tsx
// 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#

ts
// 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:

ts
// 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:

bash
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)#

SymptomAbhilfe
Schlüssel statt Texti18n.exists(key) prüfen, Namespace kontrollieren
Kurzes Aufblitzen der falschen SpracheSuspense nutzen oder ready abfragen
Hydration stimmt nicht (SSR)dieselbe Sprache auf Server und Client sicherstellen
Trans rendert kein HTMLGroß-/Kleinschreibung der Komponentennamen prüfen

Vue (vue-i18n)#

SymptomAbhilfe
$t liefert den Schlüsselmit $te(key) prüfen, ob er existiert
Sprachwechsel löst kein Update aus$i18n.locale verwenden (reaktiv), nicht i18n.global.locale
Composition API greift nichtuseI18n() innerhalb von setup() aufrufen
Komponenten-Interpolation kaputt<i18n-t> verwenden statt String-Interpolation

Android#

SymptomAbhilfe
Falsche Sprache auf dem GerätResources.getConfiguration().locale prüfen
Absturz wegen fehlender Stringsalle Schlüssel in values/strings.xml hinterlegen
Apostrophe zerstören das XMLmit \' maskieren oder Wert in Anführungszeichen setzen
Formatierte Strings falschgetString(R.string.key, args) statt getString()

iOS (Swift)#

SymptomAbhilfe
NSLocalizedString liefert den Schlüssel.lproj-Ordnernamen gegen die Sprachcodes prüfen
Pluralformen falsch.stringsdict für Pluralregeln verwenden
Storyboard wird nicht lokalisiertBase-Internationalisierung aktivieren
Variablen in falscher ReihenfolgePositionsangaben verwenden: %1$@, %2$@

Vorbeugen statt suchen#

1. Prüfung in der CI#

Das Prüfskript von oben in die Pipeline hängen:

yaml
# .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:

ts
// 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:

ts
// 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#