Localizing a React application
The Short Answer
Localizing (i18n) a React application means making it adaptable to different languages, regions, and cultural conventions without changing the code. This involves extracting all user-facing strings into translation files, formatting dates/numbers/currencies according to locale, handling right-to-left (RTL) layouts, and managing pluralization rules. The standard approach uses a library like react-intl (FormatJS) or next-intl that provides components and hooks for rendering translated content, with translation files (JSON or ICU message format) organized by locale.
The Architecture
A well-structured i18n setup separates concerns into three layers: translation storage (JSON files per locale), a provider that supplies the current locale and translations to the component tree, and components/hooks that consume translations at the point of use. The locale is typically determined by URL path (/en/about), cookie, or browser preference — and can be changed without a full page reload.
- Translation files store messages keyed by ID (one file per locale)
- Provider wraps the app with current locale and loaded messages
- Components use hooks or components to render translated strings
- Locale detection picks the right language (URL, cookie, Accept-Language header)
- Formatting functions handle dates, numbers, and plurals per locale rules
Translation Files
Translations are stored in JSON files organized by locale. Each file maps message IDs to translated strings. The ICU Message Format is the standard for handling plurals, gender, and interpolation — it's supported by most i18n libraries and translation management tools. Keep message IDs descriptive and namespaced to avoid collisions as the app grows.
{
"common.welcome": "Welcome back, {name}!",
"common.logout": "Log out",
"nav.home": "Home",
"nav.settings": "Settings",
"cart.itemCount": "{count, plural, =0 {No items} one {1 item} other {{count} items}} in your cart",
"cart.total": "Total: {amount, number, ::currency/USD}",
"date.lastLogin": "Last login: {date, date, medium}",
"errors.required": "{field} is required",
"errors.minLength": "{field} must be at least {min} characters"
}
{
"common.welcome": "¡Bienvenido de nuevo, {name}!",
"common.logout": "Cerrar sesión",
"nav.home": "Inicio",
"nav.settings": "Configuración",
"cart.itemCount": "{count, plural, =0 {Sin artículos} one {1 artículo} other {{count} artículos}} en tu carrito",
"cart.total": "Total: {amount, number, ::currency/USD}",
"date.lastLogin": "Último acceso: {date, date, medium}",
"errors.required": "{field} es obligatorio",
"errors.minLength": "{field} debe tener al menos {min} caracteres"
}
Implementation with next-intl
In a Next.js App Router project, next-intl is the standard choice. It integrates with the routing system (locale in URL path), supports server components, and handles message loading efficiently. The setup involves a middleware for locale detection, a provider for the messages, and hooks for consuming translations in components.
// middleware.ts — detect and redirect to correct locale
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'es', 'fr', 'ja'],
defaultLocale: 'en',
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
// app/[locale]/layout.tsx — provide messages to the tree
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const messages = await getMessages();
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
// components/header.tsx — consume translations
import { useTranslations } from 'next-intl';
export function Header({ userName }: { userName: string }) {
const t = useTranslations();
return (
<header>
<h1>{t('common.welcome', { name: userName })}</h1>
<nav>
<a href="/">{t('nav.home')}</a>
<a href="/settings">{t('nav.settings')}</a>
</nav>
<button>{t('common.logout')}</button>
</header>
);
}
Formatting Dates, Numbers, and Plurals
Localization goes beyond translating strings. Dates, numbers, and currencies must be formatted according to locale conventions. January 5, 2024 is '1/5/2024' in the US but '5/1/2024' in the UK and '2024年1月5日' in Japan. The Intl API (built into JavaScript) handles this natively, and i18n libraries wrap it for convenience. Pluralization rules also vary wildly — English has 2 forms (singular/plural), Arabic has 6, and some languages have none.
import { useFormatter, useTranslations } from 'next-intl';
function OrderSummary({ itemCount, total, lastOrder }: {
itemCount: number;
total: number;
lastOrder: Date;
}) {
const t = useTranslations('cart');
const format = useFormatter();
return (
<div>
{/* Pluralization — handled by ICU message format */}
<p>{t('itemCount', { count: itemCount })}</p>
{/* en: "3 items in your cart" */}
{/* es: "3 artículos en tu carrito" */}
{/* Currency formatting */}
<p>{format.number(total, { style: 'currency', currency: 'USD' })}</p>
{/* en-US: "$1,234.56" */}
{/* de-DE: "1.234,56 $" */}
{/* Date formatting */}
<p>{format.dateTime(lastOrder, { dateStyle: 'medium' })}</p>
{/* en-US: "Jan 5, 2024" */}
{/* ja-JP: "2024年1月5日" */}
{/* Relative time */}
<p>{format.relativeTime(lastOrder)}</p>
{/* en: "3 days ago" */}
{/* es: "hace 3 días" */}
</div>
);
}
// Native Intl API (no library needed for basic formatting)
const price = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(1234.5); // "1.234,50 €"
const date = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'long',
}).format(new Date()); // "2024年1月5日"
RTL Support
Languages like Arabic, Hebrew, and Persian read right-to-left. Supporting RTL requires setting the dir attribute on the HTML element and using CSS logical properties (margin-inline-start instead of margin-left, padding-inline-end instead of padding-right). Tailwind CSS supports this with the rtl: variant or by using logical utilities like ms-4 (margin-start) and pe-2 (padding-end).
// Use CSS logical properties — work in both LTR and RTL
// Tailwind logical utilities:
// ms-4 = margin-inline-start (left in LTR, right in RTL)
// me-4 = margin-inline-end (right in LTR, left in RTL)
// ps-4 = padding-inline-start
// pe-4 = padding-inline-end
function Sidebar() {
return (
// ✅ Logical properties — adapts to text direction automatically
<aside className="border-e ps-4 pe-6">
{/* border-e = border on the end side (right in LTR, left in RTL) */}
<nav className="flex flex-col gap-2">
<a className="ms-2">Home</a> {/* margin-start */}
</nav>
</aside>
);
}
// For icons that should flip in RTL (arrows, chevrons):
function BackButton() {
return (
<button className="flex items-center gap-2">
{/* Arrow flips direction in RTL */}
<ChevronIcon className="rtl:rotate-180" />
<span>{t('common.back')}</span>
</button>
);
}
Why Interviewers Ask This
Localization tests your ability to build applications for a global audience. Interviewers want to see that you know the architecture (translation files, providers, hooks), understand that i18n is more than string replacement (dates, numbers, plurals, RTL), can explain locale detection strategies (URL path, cookie, Accept-Language), know about ICU Message Format for complex messages, and have thought about the developer experience (namespaced keys, type safety, missing translation handling).
Quick Revision Cheat Sheet
Architecture: Translation JSON files → Provider with locale → useTranslations hook in components
Message format: ICU Message Format — handles plurals, gender, interpolation across all languages
Formatting: Use Intl API or library formatters for dates, numbers, currencies — never format manually
RTL: Set dir attribute, use CSS logical properties (margin-inline-start, not margin-left)
Locale detection: URL path (/en/about) > cookie > Accept-Language header > default
Libraries: next-intl (Next.js), react-intl (FormatJS), i18next (framework-agnostic)