React i18n: Internationalization with react-i18next (2026)

react-i18next is the de-facto React internationalization library. Built on i18next, it handles translation lookup, plural forms, interpolation, namespace splitting, and lazy-loaded locale files. This guide covers setup from scratch through Next.js App Router i18n with locale routing.

Setup and Configuration

npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend

// src/i18n/index.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import Backend from 'i18next-http-backend'

i18n
  .use(Backend)              // Lazy-load from /public/locales/
  .use(LanguageDetector)     // Detect browser language
  .use(initReactI18next)     // Bind to React
  .init({
    fallbackLng: 'en',
    supportedLngs: ['en', 'es', 'fr', 'de', 'ja'],
    defaultNS: 'common',
    ns: ['common', 'auth', 'dashboard'],
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
    detection: {
      order: ['querystring', 'cookie', 'localStorage', 'navigator'],
      caches: ['localStorage', 'cookie'],
    },
    interpolation: { escapeValue: false },  // React escapes by default
  })

export default i18n

// src/main.tsx
import './i18n'  // Initialize before rendering
import React from 'react'
import { Suspense } from 'react'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <Suspense fallback="Loading...">  {/* Suspense waits for translations */}
    <App />
  </Suspense>
)

Translation Files

// public/locales/en/common.json
{
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  },
  "greeting": "Hello, {{name}}!",
  "items_count": "{{count}} item",
  "items_count_plural": "{{count}} items",
  "date_format": "{{date, datetime}}",
  "price": "{{value, currency}}"
}

// public/locales/es/common.json
{
  "nav": {
    "home": "Inicio",
    "about": "Acerca de",
    "contact": "Contacto"
  },
  "greeting": "¡Hola, {{name}}!",
  "items_count_one": "{{count}} artículo",
  "items_count_other": "{{count}} artículos"
}

// public/locales/en/auth.json
{
  "login": {
    "title": "Sign in to your account",
    "email": "Email address",
    "password": "Password",
    "submit": "Sign in",
    "forgot": "Forgot your password?",
    "no_account": "Don't have an account? <1>Sign up</1>"
  },
  "errors": {
    "invalid_credentials": "Invalid email or password",
    "account_locked": "Account locked. Try again in {{minutes}} minutes."
  }
}

useTranslation Hook

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

function Navbar() {
  const { t } = useTranslation('common')

  return (
    <nav>
      <a href="/">{t('nav.home')}</a>
      <a href="/about">{t('nav.about')}</a>
      <a href="/contact">{t('nav.contact')}</a>
    </nav>
  )
}

// Multi-namespace in one component
function Dashboard() {
  const { t: tc } = useTranslation('common')
  const { t: td } = useTranslation('dashboard')

  return (
    <div>
      <h1>{td('title')}</h1>
      <p>{tc('greeting', { name: 'Alice' })}</p>
    </div>
  )
}

// Trans component for JSX interpolation (links, bold, etc.)
function AuthPage() {
  const { t } = useTranslation('auth')

  return (
    <Trans
      i18nKey="auth:login.no_account"
      components={[<a key="signup" href="/signup" />]}
    />
    // Renders: Don't have an account? <a href="/signup">Sign up</a>
  )
}

Interpolation and Plurals

function CartSummary({ count, total }: { count: number; total: number }) {
  const { t, i18n } = useTranslation('common')

  return (
    <div>
      {/* Plural forms — pass count as option */}
      <p>{t('items_count', { count })}</p>
      {/* en: "1 item" or "3 items" */}

      {/* Built-in date formatting */}
      <p>{t('date_format', { date: new Date(), formatParams: {
        date: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
      }})}</p>

      {/* Currency formatting */}
      <p>{t('price', {
        value: total,
        formatParams: {
          value: { style: 'currency', currency: 'USD' }
        }
      })}</p>
    </div>
  )
}

// Advanced plurals (CLDR plural categories: zero, one, two, few, many, other)
// public/locales/ar/common.json — Arabic has 6 plural forms
{
  "items_count_zero": "لا عناصر",
  "items_count_one": "عنصر واحد",
  "items_count_two": "عنصران",
  "items_count_few": "{{count}} عناصر",
  "items_count_many": "{{count}} عنصرًا",
  "items_count_other": "{{count}} عنصر"
}

Namespaces

// Load multiple namespaces upfront for a page
function AdminPage() {
  const { t } = useTranslation(['common', 'dashboard', 'admin'])
  // Access other namespaces with colon prefix
  return <h1>{t('admin:title')}</h1>
}

// Preload namespaces for a route (avoids Suspense flash)
import { useTranslation } from 'react-i18next'

function usePageTranslations(namespaces: string[]) {
  const { t, i18n, ready } = useTranslation(namespaces)
  return { t, ready }
}

// In a loader (React Router or Next.js generateMetadata)
async function loader() {
  await i18next.loadNamespaces(['dashboard'])
  return null
}

Language Switching

function LanguageSwitcher() {
  const { i18n } = useTranslation()

  const languages = [
    { code: 'en', label: 'English', flag: '🇺🇸' },
    { code: 'es', label: 'Español', flag: '🇪🇸' },
    { code: 'fr', label: 'Français', flag: '🇫🇷' },
    { code: 'ja', label: '日本語', flag: '🇯🇵' },
  ]

  async function changeLanguage(lng: string) {
    await i18n.changeLanguage(lng)
    // Persisted automatically via LanguageDetector + localStorage
    // RTL languages — update document direction
    document.documentElement.dir = ['ar', 'he', 'fa'].includes(lng) ? 'rtl' : 'ltr'
    document.documentElement.lang = lng
  }

  return (
    <select
      value={i18n.language}
      onChange={e => changeLanguage(e.target.value)}
      aria-label="Select language"
    >
      {languages.map(lang => (
        <option key={lang.code} value={lang.code}>
          {lang.flag} {lang.label}
        </option>
      ))}
    </select>
  )
}

Next.js App Router i18n

// next.config.ts
import type { NextConfig } from 'next'
export default { } satisfies NextConfig  // App Router handles i18n via middleware

// middleware.ts — redirect to locale prefix
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

const locales = ['en', 'es', 'fr']
const defaultLocale = 'en'

function getLocale(request: NextRequest) {
  const headers: Record<string, string> = {}
  request.headers.forEach((v, k) => { headers[k] = v })
  const languages = new Negotiator({ headers }).languages()
  return match(languages, locales, defaultLocale)
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname
  const pathnameHasLocale = locales.some(l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)
  if (pathnameHasLocale) return
  const locale = getLocale(request)
  return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
}

export const config = { matcher: ['/((?!_next|favicon.ico|api|locales).*)'] }

// app/[lang]/layout.tsx
import { dir } from 'i18next'
export default function RootLayout({ children, params: { lang } }) {
  return (
    <html lang={lang} dir={dir(lang)}>
      <body>{children}</body>
    </html>
  )
}

// Server component translation (no useTranslation — use server-side API)
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'

async function getT(lang: string, ns: string) {
  const instance = createInstance()
  await instance.use(resourcesToBackend((lng: string, namespace: string) =>
    import(`@/public/locales/${lng}/${namespace}.json`)
  )).init({ lng: lang, ns })
  return instance.t.bind(instance)
}

export default async function Page({ params: { lang } }) {
  const t = await getT(lang, 'common')
  return <h1>{t('nav.home')}</h1>
}

Lazy Loading Translations

// Split translations by route — only load what the page needs
// Loaded automatically by i18next-http-backend when a namespace is accessed

// Preload for critical path to avoid Suspense fallback
import i18n from '@/i18n'

// In a route loader
export async function loader() {
  if (!i18n.hasLoadedNamespace('checkout')) {
    await i18n.loadNamespaces('checkout')
  }
  return null
}

// Custom backend for bundled translations (no network requests)
import resourcesToBackend from 'i18next-resources-to-backend'
i18n.use(resourcesToBackend(
  (language: string, namespace: string) =>
    import(`./locales/${language}/${namespace}.json`)
))
// Vite/webpack chunk splits each JSON into a separate async chunk
TypeScript types for translations: Use i18next-parser to generate type-safe translation keys. Install @types/i18next and declare your namespace resources in i18next.d.ts — then t('nav.home') gets autocomplete and type errors for missing keys.