Why i18n Bugs Are Hard to Find#

Internationalization bugs are uniquely frustrating because:

  • They often only appear in specific languages, not your development language
  • They can be silent — a wrong translation doesn't throw an error
  • They cross boundaries between code, translation files, and runtime configuration
  • They may work in development but break in production (different locale settings, missing files, bundling issues)

This guide gives you a systematic approach to finding and fixing the most common i18n problems, regardless of which framework or platform you're using.

The Debugging Checklist#

Before diving into specific issues, run through this quick checklist:

text
□ Is the correct locale being detected/loaded?
□ Are translation files present and properly formatted?
□ Are translation keys matching between code and files?
□ Are placeholders/variables intact in translations?
□ Is the i18n library properly initialized?
□ Are there build/bundling issues with translation files?

If you can answer all of these, you've found 90% of i18n bugs.

Problem 1: Missing Translations (Keys Showing Instead of Text)#

Symptom: Users see nav.home or greeting.welcome instead of actual text.

Cause A: Translation Key Doesn't Exist#

The most common issue. The key in your code doesn't match any key in your translation file.

Debug steps:

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

Common causes:

  • Typo in the key name (nav.hom vs nav.home)
  • Wrong nesting level (nav.home expects { "nav": { "home": "..." } } not { "nav.home": "..." })
  • Key exists in English but not in the current locale

Fix: Use your IDE to search across all JSON files for the key. Many i18n libraries have a missing key handler you can configure:

ts
// react-i18next
i18n.init({
  missingKeyHandler: (lngs, ns, key) => {
    console.warn(`Missing translation: ${key} for ${lngs.join(', ')}`);
  },
  saveMissing: true, // logs all missing keys
});
ts
// vue-i18n
const i18n = createI18n({
  missing: (locale, key) => {
    console.warn(`Missing: [${locale}] ${key}`);
  },
});

Cause B: Translation File Not Loaded#

The file exists but isn't loaded by your app.

Debug steps:

tsx
// Check what's actually loaded
console.log('Resources:', i18n.store.data);
// Should output: { en: { translation: { ... } }, de: { translation: { ... } } }

Common causes:

  • File path mismatch in configuration
  • Async loading not awaited (translations load after render)
  • Wrong namespace (file is common.json but code reads from translation namespace)
  • Build tool doesn't include JSON files (check your webpack/vite config)

Fix for async loading:

tsx
// react-i18next: wait for translations
import { useTranslation } from 'react-i18next';

function App() {
  const { t, ready } = useTranslation();

  if (!ready) return <Loading />;
  return <h1>{t('welcome')}</h1>;
}

Cause C: Wrong Namespace#

If you use multiple namespaces (e.g., common.json, dashboard.json), you must specify which one:

tsx
// Looks in default namespace
t('sidebar.title');

// Looks in correct namespace
t('sidebar.title', { ns: 'dashboard' });

// Or set namespace for the entire component
const { t } = useTranslation('dashboard');

Problem 2: Broken Placeholders and Variables#

Symptom: Text shows Hello, {{name}}! literally, or variables are missing/wrong.

Cause A: Variable Not Passed#

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

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

Debug: Log the interpolation call:

tsx
const result = t('greeting', { name: userName });
console.log('Input:', { name: userName });
console.log('Output:', result);

Cause B: Placeholder Syntax Mismatch#

Different frameworks use different placeholder syntax:

FrameworkSyntaxExample
i18next{{var}}Hello, {{name}}!
vue-i18n{var}Hello, {name}!
Android%s, %d, %1$sHello, %1$s!
iOS (Swift)%@, %dHello, %@!
ICU MessageFormat{var}Hello, {name}!

Common mistake: Translators accidentally change {{name}} to {name} or {{Name}} (wrong case) or {{ name }} (added spaces — works in some implementations, breaks in others).

Fix: Validate placeholders after translation:

bash
# Quick check: find all placeholders in EN, verify they exist in DE
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

Cause C: Translation Tool Corrupted Placeholders#

Some translation tools or AI translation services modify, translate, or strip placeholders.

Example of corrupted translation:

json
// en.json (correct)
{ "items": "You have {{count}} items in {{location}}" }

// de.json (corrupted by bad translation tool)
{ "items": "Du hast {{Anzahl}} Artikel in {{Standort}}" }
// Variable names were translated! Code passes {count}, not {Anzahl}

Fix: Use a translation tool that understands i18n placeholder syntax. shipglobal.dev automatically detects and preserves {{variables}}, {variables}, %s, %@, and all other placeholder formats.

Problem 3: Wrong Locale Detection#

Symptom: App shows German text for an English-speaking user, or vice versa.

How Locale Detection Works#

Most frameworks follow this priority:

  1. URL parameter (/de/about) or query string (?lang=de)
  2. Cookie (saved language preference)
  3. Accept-Language header from browser
  4. Default locale (fallback)

Debugging Locale Detection#

tsx
// react-i18next
console.log('Detected language:', i18n.language);
console.log('Languages in order:', i18n.languages); // fallback chain
console.log('Resolved language:', i18n.resolvedLanguage);
js
// Check browser header
console.log('Browser languages:', navigator.languages);
// Example: ["de-DE", "de", "en-US", "en"]

Common Issues#

Issue: de-DE vs de

Browser sends de-DE but your app only has de.json. The lookup fails silently and falls back to English.

Fix: Configure locale resolution:

ts
// react-i18next
i18n.init({
  supportedLngs: ['en', 'de', 'fr', 'es'],
  nonExplicitSupportedLngs: true, // de-DE will match de
  load: 'languageOnly', // loads 'de' instead of 'de-DE'
});
ts
// vue-i18n
const i18n = createI18n({
  fallbackLocale: 'en',
  locale: navigator.language.split('-')[0], // 'de-DE' → 'de'
});

Issue: Server vs Client mismatch (SSR)

Server renders in English (no browser header in static generation), client switches to German. Causes hydration flash.

Fix: Pass the locale from server to client explicitly:

tsx
// Next.js App Router
// The [locale] param from the URL is the source of truth
// Don't rely on browser detection for initial render
export default function Page({ params: { locale } }) {
  // Use locale from URL, not from navigator
}

Problem 4: Pluralization Not Working#

Symptom: Always shows "1 items" or "5 item" (wrong plural form).

How Pluralization Works#

Different languages have different plural rules. English has 2 forms (singular/plural). Arabic has 6. Russian has 3.

i18next plural keys:

json
// English (2 forms)
{
  "item_one": "{{count}} item",
  "item_other": "{{count}} items"
}

// Russian (3 forms)
{
  "item_one": "{{count}} предмет",      // 1, 21, 31...
  "item_few": "{{count}} предмета",     // 2-4, 22-24...
  "item_many": "{{count}} предметов"    // 5-20, 25-30...
}

// Arabic (6 forms)
{
  "item_zero": "...",
  "item_one": "...",
  "item_two": "...",
  "item_few": "...",
  "item_many": "...",
  "item_other": "..."
}

Common Pluralization Bugs#

Bug 1: Using the old _plural suffix (i18next v21+)

json
// Old syntax (pre-v21)
{ "item": "{{count}} item", "item_plural": "{{count}} items" }

// New syntax (v21+)
{ "item_one": "{{count}} item", "item_other": "{{count}} items" }

If you upgraded i18next and plurals broke, this is likely the cause. Check your version:

bash
npm list i18next

Bug 2: Not passing count

tsx
// count is just a regular variable — no plural selection
t('item', { count: 5 });

// count triggers plural form selection in i18next
// (this is actually the same syntax — make sure the key has _one/_other suffixes)

In i18next, pluralization is triggered automatically when count is in the options AND the translation keys have plural suffixes. If it's not working, check that your keys follow the naming convention.

Bug 3: Missing plural forms for specific languages

You translated to Russian but only provided _one and _other. Russian needs _few and _many too.

Debug: Check which plural forms your target language needs at unicode-org/cldr.

Problem 5: Encoding and Character Issues#

Symptom: Characters appear as ä instead of ä, or ??? instead of Japanese text.

Cause A: File Encoding#

Translation files must be saved as UTF-8 (without BOM for most systems, with BOM for some Windows tools).

Check encoding:

bash
file -bi locales/de.json
# Should output: application/json; charset=utf-8

Fix: In VS Code, check the bottom-right corner for encoding. Click it and select "Save with Encoding" → "UTF-8".

Cause B: JSON Escaping#

Special characters in JSON need proper escaping:

json
// Unescaped quotes break JSON
{ "quote": "She said "hello"" }

// Properly escaped
{ "quote": "She said \"hello\"" }

// Or use single quotes in the text (no escaping needed in JSON)
{ "quote": "She said 'hello'" }

Unicode characters can be written directly in UTF-8 JSON:

json
// Both are valid
{ "greeting": "こんにちは" }
{ "greeting": "\u3053\u3093\u306B\u3061\u306F" }

Cause C: Platform-Specific Encoding#

Android (strings.xml):

xml
<!-- Apostrophes must be escaped in Android XML -->
<string name="error">It\'s an error</string>

<!-- Or use quotes -->
<string name="error">"It's an error"</string>

iOS (Localizable.strings):

text
/* Must use escaped quotes for the value */
"greeting" = "Hello, \"World\"!";

Problem 6: RTL (Right-to-Left) Layout Issues#

Symptom: Arabic or Hebrew text is left-aligned, numbers are mirrored, or layout is broken.

Quick RTL Debug#

html
<!-- Set dir attribute on html or a container -->
<html dir="rtl" lang="ar">
css
/* CSS Logical Properties (modern approach) */
.container {
  /* Instead of margin-left, use: */
  margin-inline-start: 1rem;

  /* Instead of padding-right, use: */
  padding-inline-end: 1rem;

  /* Instead of text-align: left, use: */
  text-align: start;
}

Common RTL Bugs#

Bug 1: Icons pointing the wrong direction

Back arrows, forward arrows, and directional icons should flip in RTL.

css
/* Flip icons in RTL */
[dir="rtl"] .icon-forward {
  transform: scaleX(-1);
}

Bug 2: Numbers in RTL text

Numbers should remain LTR even in RTL text. Most browsers handle this automatically with the Unicode Bidirectional Algorithm, but if you force direction, numbers can break.

html
<!-- Let the browser handle bidirectional text -->
<p dir="auto">السعر: 99.99€</p>

<!-- Forcing RTL on numbers -->
<p dir="rtl" style="unicode-bidi: override">السعر: 99.99€</p>

Bug 3: Flexbox and Grid direction

css
/* This respects dir="rtl" automatically */
.flex-container {
  display: flex;
  /* Don't set flex-direction for horizontal layouts in bilingual apps */
  /* The dir attribute handles reversal */
}

Problem 7: Date, Number, and Currency Formatting#

Symptom: Dates show as 3/25/2026 (US format) for German users who expect 25.03.2026.

Use Intl APIs#

The built-in Intl object handles locale-aware formatting:

ts
// Date formatting
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"

// Number formatting
new Intl.NumberFormat('de-DE').format(1234.56);
// "1.234,56"

new Intl.NumberFormat('en-US').format(1234.56);
// "1,234.56"

// Currency
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"

Common Formatting Bugs#

Bug: Hardcoded separators

tsx
// Hardcoded US format
const formatted = `$${price.toFixed(2)}`;

// Locale-aware
const formatted = new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: userCurrency
}).format(price);

Bug: Parsing user input with wrong locale

tsx
// German user enters "1.234,56" (German format for 1234.56)
const value = parseFloat("1.234,56"); // Returns 1.234 (wrong!)

// Fix: normalize input format before parsing
const normalized = input.replace(/\./g, '').replace(',', '.');
const value = parseFloat(normalized); // Returns 1234.56

Problem 8: Build and Bundling Issues#

Symptom: Translations work in development but not in production.

Cause A: Translation Files Not Included in Build#

Next.js: Files in public/locales/ are included automatically. Files in src/ need explicit handling.

Vite: JSON imports work, but dynamic imports need plugin configuration:

ts
// vite.config.ts
export default defineConfig({
  plugins: [
    // For dynamic locale loading
    // Ensure JSON files are in the build output
  ],
  build: {
    rollupOptions: {
      // Include all locale files
    }
  }
});

Cause B: Dynamic Import Paths#

tsx
// Fully dynamic — bundlers can't analyze this
const messages = await import(`./locales/${locale}.json`);

// Give bundlers a hint about the path pattern
const messages = await import(`./locales/${locale}.json`);
// Webpack: add a webpackInclude comment
// Vite: uses glob imports or explicit file listing

Cause C: Tree-Shaking Removes Translation Keys#

If your translation keys are only referenced as dynamic strings, some bundlers might tree-shake the JSON. Ensure your JSON files are treated as assets, not as code modules to be optimized.

The i18n Debugging Toolkit#

Console Commands for Quick Diagnosis#

ts
// For react-i18next — paste in browser console
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',
  });
}

Automated Translation File Validation#

Create a simple script to catch common issues:

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

    // Check for missing keys
    for (const key of baseKeys) {
      if (!localeKeys.has(key)) {
        errors.push(`[${locale}] Missing key: ${key}`);
      }
    }

    // Check for extra keys (possibly outdated)
    for (const key of localeKeys) {
      if (!baseKeys.has(key)) {
        errors.push(`[${locale}] Extra key (not in ${BASE_LOCALE}): ${key}`);
      }
    }

    // Check placeholder consistency
    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();

Run before every deployment:

bash
npx tsx scripts/validate-i18n.ts

IDE Extensions#

  • i18n Ally (VS Code) — shows inline translations, detects missing keys, autocomplete for keys
  • i18next Scanner — extracts translation keys from code and compares with JSON files

Framework-Specific Quick Fixes#

React (react-i18next)#

IssueFix
Keys show instead of textCheck i18n.exists(key), verify namespace
Flash of wrong languageUse Suspense or check ready flag
SSR hydration mismatchEnsure same locale on server and client
Trans component not rendering HTMLCheck component name case matching

Vue (vue-i18n)#

IssueFix
$t returns keyCheck $te(key) to verify key exists
Reactivity lost on locale changeUse $i18n.locale (reactive) not i18n.global.locale
Composition API not workingUse useI18n() inside setup()
Component interpolation brokenUse <i18n-t> component, not string interpolation

Android#

IssueFix
Wrong language on deviceCheck Resources.getConfiguration().locale
Missing strings crashAdd all keys to default values/strings.xml
Apostrophes break XMLEscape with \' or wrap value in quotes
Formatted strings wrongUse getString(R.string.key, args) not just getString()

iOS (Swift)#

IssueFix
NSLocalizedString returns keyCheck .lproj folder names match locale codes
Plural forms wrongUse .stringsdict for pluralization rules
Storyboard not localizingVerify Base internationalization is enabled
Variables in wrong orderUse positional specifiers: %1$@, %2$@

Prevention: Catching i18n Bugs Before Production#

1. CI/CD Validation#

Add the validation script above to your CI pipeline:

yaml
# .github/workflows/ci.yml
- name: Validate translations
  run: npx tsx scripts/validate-i18n.ts

2. Visual Regression for Long Text#

German text is ~30% longer than English. Japanese can be 50% shorter. Test your UI with the longest and shortest translations:

ts
// Pseudo-localization: replace text with longer versions for testing
const pseudoLocalize = (text: string) =>
  text.replace(/[a-z]/g, c => `${c}${c}`); // doubles every letter

3. Screenshot Testing Per Locale#

Use tools like Playwright or Cypress to capture screenshots in every locale:

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`);
  });
}