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.
Table of Contents
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.