Table of Contents
Table of Contents
- React i18n: The Complete Guide to Internationalization in React Apps
- TL;DR / Key Takeaways
- Why React Needs an i18n Library
- Popular React i18n Libraries Compared
- react-intl (FormatJS)
- Installation
- Wrapping Your App with IntlProvider
- FormattedMessage Component
- useIntl Hook
- react-i18next
- Installation
- Configuration
- useTranslation Hook and Namespace Splitting
- LinguiJS
- Installation
- Configuration
- Using @lingui/macro
- Extraction Workflow
- Better i18n React SDK
- Installation
- Setup
- useTranslations Hook
- Key Implementation Patterns
- Language Switching
- Lazy Loading Translations
- SSR / SSG Considerations
- URL-Based Locale Routing
- Pluralization
- Date and Number Formatting
- Common Mistakes
- FAQ
- Conclusion
- References
React i18n: The Complete Guide to Internationalization in React Apps
Internationalization (i18n) is one of those features that looks simple from the outside and turns into a weeks-long rabbit hole once you start implementing it. A React app that greets users in English needs more than string replacement to truly serve a global audience. It needs currency formatting, plural rules, date localization, right-to-left layout support, and a translation workflow that does not break every time a developer renames a key.
This guide walks through everything you need to know to ship a production-ready multilingual React app in 2026 — from choosing the right library to avoiding the mistakes that will cost you time later.
TL;DR / Key Takeaways
- React has no built-in i18n. The browser's
IntlAPI handles formatting, but you need a dedicated library to manage translation strings, dynamic loading, and locale routing. - react-intl (FormatJS) and react-i18next are the most widely adopted options with large communities and years of production use behind them.
- LinguiJS is the best choice if you want compile-time extraction and minimal runtime bundle size.
- Better i18n is the newest entrant — it adds AI-powered translations and CDN delivery, but its community is smaller than the established alternatives.
- The right library depends on your team size, SSR requirements, and how much you value translation workflow automation versus raw ecosystem maturity.
Why React Needs an i18n Library
React renders UI. It does not manage translations, detect browser locales, load language files asynchronously, or handle the complex formatting rules that differ across languages. That is by design — React is a view library.
The browser ships with the Intl API, which is genuinely powerful. Intl.DateTimeFormat, Intl.NumberFormat, and Intl.PluralRules give you locale-aware formatting without any external dependencies. But Intl solves formatting, not string management.
What you still need beyond Intl:
- A string catalog — A structure to store translated strings keyed by locale and message ID.
- A React binding — Hooks and components that read from the catalog and re-render when the locale changes.
- Dynamic loading — Loading only the current locale's strings, not all of them at once.
- Pluralization and interpolation — Handling
"1 item"versus"3 items"and inserting dynamic values like usernames into messages. - A translation workflow — How translators receive new strings, translate them, and how those translations get back into your app.
Rolling all of this yourself is possible but inadvisable. The libraries below have solved these problems across millions of production deployments. Start with one of them.
Popular React i18n Libraries Compared
| Library | Bundle Size | ICU Support | TypeScript Support | SSR Support | Learning Curve |
|---|---|---|---|---|---|
| react-intl (FormatJS) | ~20KB gzipped | Full | Strong | Yes | Medium |
| react-i18next | ~6KB gzipped | Via plugin | Strong | Yes | Low-Medium |
| LinguiJS | ~2KB runtime | Full | Strong | Yes | Medium-High |
| Better i18n React SDK | ~2KB | Partial | Full (inferred) | Yes | Low |
Bundle sizes are approximate and depend on tree-shaking and the features you use. ICU (International Components for Unicode) message format is the standard for complex pluralization, gender selection, and conditional text — not every library supports it natively.
react-intl (FormatJS)
react-intl is part of the FormatJS suite maintained by Formatly (formerly Yahoo). It has been the de facto standard for complex i18n in React for years and offers the most complete implementation of the ICU message format in the ecosystem.
Installation
npm install react-intl
Wrapping Your App with IntlProvider
IntlProvider establishes the locale context for your entire component tree. Every FormattedMessage and useIntl call below it reads from this context.
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { IntlProvider } from "react-intl";
import App from "./App";
import enMessages from "./locales/en.json";
import frMessages from "./locales/fr.json";
const messages: Record<string, Record<string, string>> = {
en: enMessages,
fr: frMessages,
};
const userLocale = navigator.language.split("-")[0] ?? "en";
const activeLocale = userLocale in messages ? userLocale : "en";
ReactDOM.createRoot(document.getElementById("root")!).render(
<IntlProvider locale={activeLocale} messages={messages[activeLocale]}>
<App />
</IntlProvider>
);
Your message catalog is a flat JSON file:
// src/locales/en.json
{
"greeting": "Hello, {name}!",
"itemCount": "{count, plural, one {# item} other {# items}}",
"welcomeBack": "Welcome back, <bold>{name}</bold>!"
}
FormattedMessage Component
Use FormattedMessage when you need rich text formatting or inline JSX inside your translations:
// src/components/Welcome.tsx
import React from "react";
import { FormattedMessage } from "react-intl";
interface WelcomeProps {
name: string;
itemCount: number;
}
export function Welcome({ name, itemCount }: WelcomeProps) {
return (
<div>
<h1>
<FormattedMessage
id="greeting"
values={{ name }}
/>
</h1>
<p>
<FormattedMessage
id="itemCount"
values={{ count: itemCount }}
/>
</p>
<p>
<FormattedMessage
id="welcomeBack"
values={{
name,
bold: (chunks) => <strong>{chunks}</strong>,
}}
/>
</p>
</div>
);
}
useIntl Hook
For imperative use — formatting strings outside of JSX, inside event handlers, or for aria-label attributes — use the useIntl hook:
// src/components/SearchBar.tsx
import React from "react";
import { useIntl } from "react-intl";
export function SearchBar() {
const intl = useIntl();
const placeholder = intl.formatMessage({
id: "search.placeholder",
defaultMessage: "Search products...",
});
const formattedPrice = intl.formatNumber(29.99, {
style: "currency",
currency: "USD",
});
const formattedDate = intl.formatDate(new Date(), {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div>
<input type="search" placeholder={placeholder} aria-label={placeholder} />
<span>{formattedPrice}</span>
<time>{formattedDate}</time>
</div>
);
}
react-intl's documentation is at formatjs.io/docs/react-intl.
react-i18next
react-i18next is the React binding for i18next, the most widely used JavaScript i18n framework. Its strength is flexibility: it works across React, React Native, Node.js, and non-React environments. If your team already uses i18next anywhere else, the shared mental model is a genuine advantage.
Installation
npm install i18next react-i18next
Configuration
// src/i18n/config.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import enDashboard from "./locales/en/dashboard.json";
import frCommon from "./locales/fr/common.json";
import frDashboard from "./locales/fr/dashboard.json";
i18n
.use(initReactI18next)
.init({
resources: {
en: {
common: enCommon,
dashboard: enDashboard,
},
fr: {
common: frCommon,
dashboard: frDashboard,
},
},
lng: "en",
fallbackLng: "en",
defaultNS: "common",
interpolation: {
escapeValue: false, // React already escapes values
},
});
export default i18n;
Import the config at your app's entry point before any component renders:
// src/main.tsx
import "./i18n/config";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
useTranslation Hook and Namespace Splitting
Namespace splitting is one of react-i18next's most useful features for large applications. Instead of loading one enormous JSON file, you split translations by feature or domain and load namespaces on demand.
// src/i18n/locales/en/common.json
{
"nav.home": "Home",
"nav.dashboard": "Dashboard",
"nav.settings": "Settings",
"button.save": "Save",
"button.cancel": "Cancel"
}
// src/i18n/locales/en/dashboard.json
{
"title": "Your Dashboard",
"stats.users": "{{count}} active user",
"stats.users_other": "{{count}} active users",
"welcome": "Welcome back, {{name}}!"
}
// src/components/Dashboard.tsx
import React from "react";
import { useTranslation } from "react-i18next";
interface DashboardProps {
userName: string;
activeUsers: number;
}
export function Dashboard({ userName, activeUsers }: DashboardProps) {
// Load the "dashboard" namespace specifically
const { t } = useTranslation("dashboard");
return (
<main>
<h1>{t("title")}</h1>
<p>{t("welcome", { name: userName })}</p>
<p>{t("stats.users", { count: activeUsers })}</p>
</main>
);
}
// src/components/Nav.tsx
import React from "react";
import { useTranslation } from "react-i18next";
export function Nav() {
// Uses the default "common" namespace
const { t, i18n } = useTranslation();
const switchLanguage = (locale: string) => {
i18n.changeLanguage(locale);
};
return (
<nav>
<a href="/">{t("nav.home")}</a>
<a href="/dashboard">{t("nav.dashboard")}</a>
<button onClick={() => switchLanguage("fr")}>Francais</button>
<button onClick={() => switchLanguage("en")}>English</button>
</nav>
);
}
The react-i18next documentation lives at react.i18next.com.
LinguiJS
LinguiJS takes a fundamentally different approach. Instead of managing translation keys in JSON files that you reference by ID, you write your UI in natural language directly in your JSX. A CLI then extracts those strings into a catalog, which translators fill in. At build time, the messages are compiled into an optimized binary format.
The result is a smaller runtime bundle and a lower risk of broken key references — but it requires a build step that the other libraries do not.
Installation
npm install @lingui/react @lingui/core
npm install --save-dev @lingui/cli @lingui/macro @lingui/vite-plugin
Configuration
// lingui.config.js
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ["en", "fr", "de"],
sourceLocale: "en",
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"],
},
],
format: "po",
};
Using @lingui/macro
The macro transforms natural-language strings at compile time, so your source code reads like plain English while still being fully localizable:
// src/components/ProductCard.tsx
import React from "react";
import { Trans, Plural } from "@lingui/macro";
import { useLingui } from "@lingui/react";
interface ProductCardProps {
productName: string;
price: number;
reviewCount: number;
}
export function ProductCard({ productName, price, reviewCount }: ProductCardProps) {
const { i18n } = useLingui();
const formattedPrice = i18n.number(price, {
style: "currency",
currency: "USD",
});
return (
<article>
<h2>
<Trans>Buy {productName} today</Trans>
</h2>
<p>{formattedPrice}</p>
<p>
<Plural
value={reviewCount}
one="# customer review"
other="# customer reviews"
/>
</p>
<p>
<Trans>
Free shipping on orders over <strong>$50</strong>.
</Trans>
</p>
</article>
);
}
Extraction Workflow
After writing your components, run the CLI to extract strings:
# Extract all strings from source files into catalog
npx lingui extract
# Translate the .po files (or send them to your translation service)
# Then compile them for production
npx lingui compile
The compiled catalogs are tiny and load fast. LinguiJS is particularly attractive for teams that want a translator-friendly PO file workflow and the smallest possible runtime payload.
Full documentation is at lingui.dev.
Better i18n React SDK
Better i18n is a newer entrant in the React i18n space. Rather than competing purely as a translation-rendering library, it bundles a full workflow platform: AI-powered translations with brand context awareness, automatic key discovery via AST scanning, and CDN delivery so translation updates go live without a rebuild.
Be honest about the trade-offs before choosing it: The community is a fraction of the size of react-intl or i18next. Stack Overflow answers are sparse. If you hit an edge case, you are more likely to rely on the official docs or support channel than on community solutions. The library is well-maintained and actively developed, but it does not yet have the years of battle-testing behind it that the established options do.
Installation
npm install @better-i18n/react
Setup
// src/providers/I18nProvider.tsx
import React, { type ReactNode } from "react";
import { BetterI18nProvider } from "@better-i18n/react";
interface I18nProviderProps {
children: ReactNode;
locale: string;
}
export function I18nProvider({ children, locale }: I18nProviderProps) {
return (
<BetterI18nProvider
projectId={process.env.VITE_BETTER_I18N_PROJECT_ID!}
locale={locale}
>
{children}
</BetterI18nProvider>
);
}
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { I18nProvider } from "./providers/I18nProvider";
const locale = navigator.language.split("-")[0] ?? "en";
ReactDOM.createRoot(document.getElementById("root")!).render(
<I18nProvider locale={locale}>
<App />
</I18nProvider>
);
useTranslations Hook
// src/components/HeroSection.tsx
import React from "react";
import { useTranslations } from "@better-i18n/react";
interface HeroSectionProps {
userName: string;
planName: string;
}
export function HeroSection({ userName, planName }: HeroSectionProps) {
const t = useTranslations("hero");
return (
<section>
<h1>{t("title", { name: userName })}</h1>
<p>{t("subtitle", { plan: planName })}</p>
<a href="/signup">{t("cta")}</a>
</section>
);
}
Translation keys are defined in the Better i18n dashboard or discovered automatically from your source code by the CLI scanner. When a translator approves a change, it is delivered to your app via Cloudflare's CDN edge network — no rebuild or redeploy required.
The Better i18n documentation is at better-i18n.com/docs.
When to choose Better i18n over the alternatives: Your team wants AI-assisted translations out of the box, you are comfortable with a managed platform rather than a fully self-contained library, and workflow automation (automatic key discovery, CDN delivery, GitHub PR-based sync) is worth more to you than ecosystem breadth.
When to choose something else: You need the largest possible community, years of Stack Overflow answers, or a purely self-hosted solution with no external service dependency. In that case, react-i18next or react-intl are the safer choice.
Key Implementation Patterns
Language Switching
Language switching should be stateful and reflected in the URL so users can share localized links. The simplest approach stores the locale in state and passes it to your provider, but a URL-based approach is more robust for SSR and deep linking.
// src/hooks/useLocale.ts
import { useState, useCallback } from "react";
const SUPPORTED_LOCALES = ["en", "fr", "de", "es"] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
function getInitialLocale(): SupportedLocale {
const fromUrl = window.location.pathname.split("/")[1] as SupportedLocale;
if (SUPPORTED_LOCALES.includes(fromUrl)) return fromUrl;
const fromBrowser = navigator.language.split("-")[0] as SupportedLocale;
if (SUPPORTED_LOCALES.includes(fromBrowser)) return fromBrowser;
return "en";
}
export function useLocale() {
const [locale, setLocale] = useState<SupportedLocale>(getInitialLocale);
const switchLocale = useCallback((next: SupportedLocale) => {
setLocale(next);
document.documentElement.lang = next;
// Optionally: push to router history for URL-based routing
}, []);
return { locale, switchLocale, supportedLocales: SUPPORTED_LOCALES };
}
Lazy Loading Translations
Loading all locale bundles upfront wastes bandwidth. Load only the active locale and fetch others on demand:
// src/i18n/loader.ts
const localeCache = new Map<string, Record<string, string>>();
export async function loadLocale(locale: string): Promise<Record<string, string>> {
if (localeCache.has(locale)) {
return localeCache.get(locale)!;
}
// Dynamic import — bundler splits this into a separate chunk per locale
const messages = await import(`./locales/${locale}.json`);
const resolved = messages.default as Record<string, string>;
// Return a new map entry rather than mutating in place
const updated = new Map(localeCache);
updated.set(locale, resolved);
localeCache.clear();
updated.forEach((v, k) => localeCache.set(k, v));
return resolved;
}
SSR / SSG Considerations
When rendering on the server, locale detection moves from navigator.language to HTTP headers (Accept-Language) or URL segments. The locale must be resolved before rendering begins so the server and client produce identical HTML.
// src/app/[locale]/layout.tsx (Next.js App Router example)
import { notFound } from "next/navigation";
import { type ReactNode } from "react";
const SUPPORTED_LOCALES = ["en", "fr", "de"];
interface LocaleLayoutProps {
children: ReactNode;
params: { locale: string };
}
export default function LocaleLayout({ children, params }: LocaleLayoutProps) {
if (!SUPPORTED_LOCALES.includes(params.locale)) {
notFound();
}
return (
<html lang={params.locale} dir={params.locale === "ar" ? "rtl" : "ltr"}>
<body>{children}</body>
</html>
);
}
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
URL-Based Locale Routing
Search engines treat example.com/fr/produits and example.com/en/products as separate, indexable pages. URL-based routing is strongly preferred over cookie-based or header-based detection for SEO. Structure your routes with a [locale] segment at the root and add hreflang link tags in the <head> for each supported locale.
Pluralization
Every library covered here handles pluralization differently, but the underlying rules come from the CLDR (Common Locale Data Repository). English has two plural forms. Arabic has six. Never hard-code pluralization logic yourself.
With react-i18next (using _one / _other suffixes):
{
"notification": "{{count}} notification",
"notification_other": "{{count}} notifications"
}
t("notification", { count: 1 }); // "1 notification"
t("notification", { count: 5 }); // "5 notifications"
Date and Number Formatting
Use the library's built-in formatting utilities or fall back to Intl directly. Never concatenate formatted values as strings — the order of units differs by locale.
// Using Intl directly (works with any library)
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: "EUR",
});
// "€1,234.56" in en-US, "1 234,56 €" in fr-FR — same number, different format
formatter.format(1234.56);
Common Mistakes
String concatenation. Never build sentences by joining translated fragments with + or template literals. Word order varies by language. "Hello " + t("world") breaks in languages where the greeting comes after the noun. Always pass interpolation variables into the translation function.
Forgetting context. The English word "File" can mean the noun (a document) or the verb (to file a complaint). Translators need context to choose the right word. Most libraries support a context parameter or description field — use it.
Not handling loading states. When translations load asynchronously, there is a window where your component renders before strings are available. Render a skeleton, a spinner, or nothing — but never render raw translation keys like "dashboard.title" to the user.
Bundling all locales together. Shipping French, German, Spanish, and Japanese translations to a user who only reads English wastes bandwidth and increases your Time to Interactive. Use dynamic imports or a CDN-based delivery strategy to load only what is needed.
Hardcoding locale-specific formatting. Do not hardcode date formats like MM/DD/YYYY. German users expect DD.MM.YYYY. Always delegate formatting to Intl or your library's formatting utilities.
Rendering translated HTML as raw markup. Injecting translated strings as raw HTML is an XSS risk if your translation source is ever compromised. Use your library's safe rich-text components — FormattedMessage with element values in react-intl, or the Trans component in react-i18next — rather than rendering HTML strings directly.
FAQ
Q: Do I need ICU message format support?
ICU is the standard for complex messages involving plurals, gender selection, and conditional text. If your app has sentences like "John and 3 others liked this", ICU makes expressing those rules clean and portable. If your app only needs simple string replacement, ICU is optional overhead. react-intl and LinguiJS support ICU natively. react-i18next requires the i18next-icu plugin.
Q: Which library works best with Next.js App Router?
react-i18next and react-intl both support App Router with some configuration. The Next.js ecosystem has converged around next-intl for App Router projects due to its first-class Server Component support. Better i18n also ships a dedicated Next.js SDK. LinguiJS works but requires careful handling of the compile step in the build pipeline.
Q: How should I structure my translation files?
For small apps, a single JSON file per locale (en.json, fr.json) is fine. For larger apps, split by feature or domain (namespaces in i18next, separate catalogs in LinguiJS). Avoid deeply nested objects — flat or one-level-deep keys are easier to manage at scale and less prone to merge conflicts.
Q: How do I handle translations that include markup like bold text or links?
Most libraries support rich text via a render prop or slot system. In react-intl, pass a JSX element as a value to FormattedMessage. In react-i18next, use the Trans component. These approaches keep HTML in React where it can be properly escaped and sanitized, rather than mixing markup into your string catalog.
Q: When should I start thinking about i18n? Earlier than you think. Retrofitting i18n into an existing codebase means hunting down every hardcoded string, every date format, every plural assumption, and every layout that breaks with longer German words. If there is any chance your app will reach non-English speakers, add i18n infrastructure in your initial setup even if you only ship English on day one.
Conclusion
React i18n does not have a single correct answer. The right library is the one that fits your team's workflow, your app's complexity, and your tolerance for ecosystem risk.
If you are starting a new project and want the most community support and the deepest ecosystem, react-i18next is the safe default. Its namespace system scales well, its documentation is thorough, and you will find answers to most edge cases on Stack Overflow.
If your app has genuinely complex message formatting requirements — conditional gender, multi-level plurals, rich inline markup — react-intl is the most complete implementation of the ICU standard in the React ecosystem.
If you want the smallest runtime bundle and a compiler-enforced extraction workflow, LinguiJS offers a compelling alternative, particularly for teams comfortable adding a build step.
If you want AI-assisted translations, CDN delivery without a rebuild cycle, and a managed platform that automates the translation pipeline, Better i18n is worth evaluating — with the honest caveat that you are betting on a younger ecosystem with a smaller community.
Whatever you choose, commit to it early, keep your translation files organized, and treat i18n as a first-class feature rather than an afterthought. Your international users will notice the difference.
References
- react-intl (FormatJS): formatjs.io/docs/react-intl
- react-i18next: react.i18next.com
- i18next core: www.i18next.com
- LinguiJS: lingui.dev
- Better i18n React SDK: better-i18n.com/docs
- Unicode CLDR Plural Rules: cldr.unicode.org/index/cldr-spec/plural-rules
- MDN Intl API reference: developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl