Skip to content

Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.

The Problem

Translation keys render as raw strings instead of translated text:

const { t } = useTranslation();
return <h1>{t('welcome')}</h1>;
// Renders: "welcome" instead of "Welcome to our app"

Or language switching does nothing:

i18n.changeLanguage('ja');
// UI doesn't update — still shows English

Or namespace loading fails:

i18next: key "common:greeting" for languages "en" won't get resolved

Why This Happens

i18next is the most widely used JavaScript internationalization framework. react-i18next provides React bindings. Common issues:

  • i18next must be initialized before renderinguseTranslation() returns raw keys if i18next hasn’t loaded translations yet. Initialization is async when loading translations from files or URLs.
  • Translation files must match the configured path — i18next-http-backend fetches translations from a URL pattern like /locales/{{lng}}/{{ns}}.json. If the file doesn’t exist at that path, translations silently fail.
  • Namespaces must be loaded before useuseTranslation('dashboard') loads the dashboard namespace. If it doesn’t exist or hasn’t loaded, keys from that namespace return as-is.
  • Language detection has a priority order — i18next-browser-languagedetector checks cookies, localStorage, navigator.language, etc. If the detected language doesn’t have translations, it falls back to fallbackLng.

The core problem with i18next failures is that they are almost always silent. There is no exception when a translation key is missing — by default i18next returns the key itself as a string and emits a console warning that nobody sees in production. A user opens your app and sees dashboard.title.welcome rendered as a literal string in the UI. Your CI passes, your tests pass, your production monitoring shows zero errors, and your conversion rate drops because customers think the site is broken.

The async initialization model is the second major source of incidents. When you load translations from JSON over HTTP, i18next returns a promise and useTranslation() reports ready: false until that promise resolves. If you forget to wrap the tree in <Suspense> or to check ready, the first render returns raw keys and React caches that render in production builds. The user sees garbage strings, refreshes the page, and the second render works — which makes the bug nearly impossible to reproduce in QA.

From a production incident lens, missing translation keys are the canonical case of a low-severity, high-frequency bug. They rarely page on-call but they accumulate as a tax on every release. A deployment that misses a namespace for one locale degrades the experience for thousands of users without ever triggering an alert. The fix is to instrument i18next’s missingKeyHandler, ship every missing key to your error tracker (Sentry, Datadog, etc.), and gate releases behind a translation-coverage check that fails CI when any namespace has fewer keys than its English baseline.

Fix 1: React Setup

npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector
// lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

i18n
  .use(HttpBackend)          // Load translations from files
  .use(LanguageDetector)     // Auto-detect user language
  .use(initReactI18next)     // React bindings
  .init({
    fallbackLng: 'en',
    supportedLngs: ['en', 'ja', 'es', 'fr', 'de'],

    // Default namespace
    defaultNS: 'common',
    ns: ['common', 'auth', 'dashboard'],

    // Backend — where to load translation files
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },

    // Language detection order
    detection: {
      order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
      caches: ['localStorage', 'cookie'],
    },

    // Interpolation
    interpolation: {
      escapeValue: false,  // React already escapes
    },

    // Debug — shows warnings in console
    debug: process.env.NODE_ENV === 'development',
  });

export default i18n;
// public/locales/en/common.json
{
  "welcome": "Welcome to our app",
  "greeting": "Hello, {{name}}!",
  "items_count": "{{count}} item",
  "items_count_plural": "{{count}} items",
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  }
}

// public/locales/ja/common.json
{
  "welcome": "アプリへようこそ",
  "greeting": "こんにちは、{{name}}さん!",
  "items_count": "{{count}}個のアイテム",
  "nav": {
    "home": "ホーム",
    "about": "概要",
    "contact": "お問い合わせ"
  }
}
// main.tsx — import i18n before rendering
import './lib/i18n';  // Must import before App
import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<div>Loading translations...</div>}>
      <MainContent />
    </Suspense>
  );
}

Fix 2: Use Translations in Components

'use client';

import { useTranslation, Trans } from 'react-i18next';

function HomePage() {
  const { t, i18n } = useTranslation();  // Uses 'common' namespace by default

  return (
    <div>
      <h1>{t('welcome')}</h1>
      <p>{t('greeting', { name: 'Alice' })}</p>
      <p>{t('items_count', { count: 5 })}</p>

      {/* Nested keys */}
      <nav>
        <a href="/">{t('nav.home')}</a>
        <a href="/about">{t('nav.about')}</a>
      </nav>

      {/* Different namespace */}
      <DashboardContent />

      {/* Language switcher */}
      <div>
        <button onClick={() => i18n.changeLanguage('en')}>English</button>
        <button onClick={() => i18n.changeLanguage('ja')}>日本語</button>
        <button onClick={() => i18n.changeLanguage('es')}>Español</button>
      </div>

      {/* Current language */}
      <p>Current: {i18n.language}</p>
    </div>
  );
}

// Use a specific namespace
function DashboardContent() {
  const { t } = useTranslation('dashboard');
  return <h2>{t('title')}</h2>;  // Reads from dashboard.json
}

// Multiple namespaces
function AuthPage() {
  const { t } = useTranslation(['auth', 'common']);
  return (
    <div>
      <h1>{t('auth:login_title')}</h1>
      <p>{t('common:welcome')}</p>
    </div>
  );
}

// Trans component — for HTML in translations
// common.json: { "terms": "By signing up, you agree to our <link>Terms</link>" }
function Terms() {
  const { t } = useTranslation();

  return (
    <Trans
      i18nKey="terms"
      components={{ link: <a href="/terms" /> }}
    />
  );
}

Fix 3: Pluralization and Formatting

// public/locales/en/common.json
{
  "items_zero": "No items",
  "items_one": "{{count}} item",
  "items_other": "{{count}} items",

  "messages": {
    "unread_zero": "No unread messages",
    "unread_one": "You have {{count}} unread message",
    "unread_other": "You have {{count}} unread messages"
  },

  "date_format": "Last updated: {{date, datetime}}",
  "price": "Price: {{amount, currency(USD)}}",
  "relative_time": "{{val, relativetime(quarter)}}"
}
// Pluralization
t('items', { count: 0 });   // "No items"
t('items', { count: 1 });   // "1 item"
t('items', { count: 5 });   // "5 items"

// Date formatting
t('date_format', { date: new Date(), formatParams: { date: { dateStyle: 'long' } } });
// "Last updated: March 29, 2026"

// Number formatting
t('price', { amount: 49.99 });
// "Price: $49.99"

// i18n config for formatting
i18n.init({
  interpolation: {
    escapeValue: false,
    format: (value, format, lng) => {
      if (format === 'uppercase') return value.toUpperCase();
      if (value instanceof Date) {
        return new Intl.DateTimeFormat(lng).format(value);
      }
      return value;
    },
  },
});

Fix 4: Next.js App Router Integration

// For Next.js, consider next-intl (simpler) or use i18next with SSR:

// lib/i18n-server.ts — server-side translations
import { createInstance } from 'i18next';
import { initReactI18next } from 'react-i18next/initReactI18next';
import resourcesToBackend from 'i18next-resources-to-backend';

export async function initI18n(lng: string, ns: string | string[] = 'common') {
  const i18nInstance = createInstance();

  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language: string, namespace: string) =>
      import(`@/locales/${language}/${namespace}.json`)
    ))
    .init({
      lng,
      ns,
      fallbackLng: 'en',
      interpolation: { escapeValue: false },
    });

  return i18nInstance;
}

// app/[lng]/page.tsx — Server Component with translations
export default async function HomePage({ params }: { params: { lng: string } }) {
  const { lng } = await params;
  const i18n = await initI18n(lng);
  const t = i18n.getFixedT(lng, 'common');

  return (
    <div>
      <h1>{t('welcome')}</h1>
      <p>{t('greeting', { name: 'Alice' })}</p>
    </div>
  );
}

// middleware.ts — language detection and routing
import { NextResponse, type NextRequest } from 'next/server';

const locales = ['en', 'ja', 'es'];
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Check if pathname already has a locale
  const hasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (hasLocale) return;

  // Detect locale from Accept-Language header
  const acceptLang = request.headers.get('accept-language') || '';
  const detected = locales.find(l => acceptLang.includes(l)) || defaultLocale;

  return NextResponse.redirect(new URL(`/${detected}${pathname}`, request.url));
}

export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)'],
};

Fix 5: TypeScript Type Safety

// i18n.d.ts — type-safe translation keys
import 'i18next';
import common from '../public/locales/en/common.json';
import auth from '../public/locales/en/auth.json';
import dashboard from '../public/locales/en/dashboard.json';

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: {
      common: typeof common;
      auth: typeof auth;
      dashboard: typeof dashboard;
    };
  }
}

// Now t('welcome') autocompletes
// t('nonexistent') shows a TypeScript error

Production Incident: Missing Keys Silently Break Production UI

The most painful i18next incidents are the ones nobody notices for days. A translator updates en/common.json with a new dashboard.welcome key. The developer ships a new button that calls t('dashboard.welcome'). The Japanese, Spanish, and German common.json files don’t get the new key. Users in those locales see dashboard.welcome rendered as a raw string in the UI.

The default i18next behavior — returnNull: false, return key as string, emit a console warning — is the exact behavior that hides the bug from your error tracker. Sentry sees zero events. Your synthetic monitoring is in English, so it sees nothing wrong. You only find out when a customer support ticket arrives with a screenshot.

Wire up a missingKeyHandler that reports to your error tracker:

import * as Sentry from '@sentry/react';

i18n.init({
  saveMissing: true,
  missingKeyHandler: (lngs, ns, key, fallbackValue) => {
    Sentry.captureMessage('i18n missing key', {
      level: 'warning',
      tags: { ns, locale: lngs[0] },
      extra: { key, fallbackValue },
    });
  },
});

Add a CI check that compares the key set of each locale to the English baseline:

# scripts/check-i18n-coverage.sh
for locale in ja es fr de; do
  diff <(jq -r 'paths(scalars) | join(".")' public/locales/en/common.json | sort) \
       <(jq -r 'paths(scalars) | join(".")' public/locales/$locale/common.json | sort) \
       && echo "$locale: OK" || (echo "$locale: missing keys" && exit 1)
done

The blast radius of a missing-key incident is wide because i18next silently degrades rather than failing fast. Loud failure modes — a thrown error, a hard 500 — are easier to triage than silent degradation. If you can tolerate the strictness, set returnNull: true and a fallbackLng that renders the English string instead of the key. Users see English instead of dashboard.welcome, which is much better than a broken UI and gives you time to ship the translation.

Fix 6: Lazy Loading and Performance

// Load namespaces on demand
i18n.init({
  partialBundledLanguages: true,
  ns: ['common'],  // Only load common initially

  backend: {
    loadPath: '/locales/{{lng}}/{{ns}}.json',
  },
});

// Load additional namespace when needed
function DashboardPage() {
  const { t, ready } = useTranslation('dashboard', { useSuspense: false });

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

// Preload a namespace
i18n.loadNamespaces('settings').then(() => {
  // Namespace is now available
});

// Preload a language
i18n.loadLanguages('ja').then(() => {
  // Japanese translations loaded
});

Still Not Working?

Keys render as-is (e.g., “welcome” instead of translated text) — translations haven’t loaded. Check the network tab for the JSON file request. Verify the file path matches loadPath in the backend config. Also wrap your app in <Suspense> — i18next loads translations async.

changeLanguage() doesn’t update the UI — ensure react-i18next is initialized with use(initReactI18next). Without it, React doesn’t re-render when the language changes. Also check that translations for the target language exist.

Pluralization returns the wrong form — i18next v21+ uses ICU-style plural keys: _zero, _one, _two, _few, _many, _other. The old format (_plural) still works but only for English. For other languages, use the correct plural forms for that locale.

TypeScript doesn’t autocomplete keys — add the CustomTypeOptions module augmentation. The resource types must match your actual JSON structure. Restart the TypeScript language server after adding the declarations.

Translations work in dev but break on the production buildi18next-http-backend fetches translation JSON from the public directory at runtime. Production builds with strict CORS or aggressive CDN caching can serve stale or 404 responses. Bundle the translations using i18next-resources-to-backend and dynamic imports so the JSON ships with your JavaScript bundle.

changeLanguage() works but the page reloads instead of updating in place — your language switcher is probably calling router.push('/?lng=ja') instead of i18n.changeLanguage('ja'). Use the i18n instance method directly; the router push triggers a full page navigation. For Next.js App Router with locale-prefixed routes, use the router but call changeLanguage() on the new locale segment.

Hydration mismatch errors after enabling i18n — server and client render different translations because language detection differs. The server may detect from cookies while the client falls back to navigator.language. Persist the detected language in a cookie during SSR and pass it to the client. See Fix: React Hydration Error for the underlying mismatch pattern.

For related i18n issues, see Fix: next-intl Not Working, Fix: Lingui Not Working, and Fix: date-fns Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles