React i18n in 2026: react-intl vs i18next vs LinguiJS
A comparative analysis of the three main React internationalization libraries, with code examples, bundle size analysis, and recommendations.
Three libraries dominate React i18n: react-intl (FormatJS), react-i18next, and LinguiJS. They all solve the same problem, but they make different tradeoffs around API design, bundle size, and extraction tooling. Here's how they compare in practice.
react-intl (FormatJS)
react-intl is the oldest of the three. It's built on the ICU Message Format standard, which means your translation strings look like this:
{
"greeting": "Hello, {name}!",
"items": "You have {count, plural, one {# item} other {# items}} in your cart.",
"lastSeen": "Last seen {date, date, medium}"
}
Setup
// App.tsx
import { IntlProvider } from "react-intl";
import messages_en from "./locales/en.json";
import messages_es from "./locales/es.json";
const messages: Record<string, Record<string, string>> = {
en: messages_en,
es: messages_es,
};
function App() {
const locale = getUserLocale(); // "en", "es", etc.
return (
<IntlProvider locale={locale} messages={messages[locale]}>
<MainContent />
</IntlProvider>
);
}
Usage
import { FormattedMessage, useIntl } from "react-intl";
function CartSummary({ count }: { count: number }) {
const intl = useIntl();
// Component-based
return (
<div>
<FormattedMessage id="items" values={{ count }} />
</div>
);
// Or imperative
const text = intl.formatMessage({ id: "items" }, { count });
}
Pros
- ICU Message Format is a real standard. Translators know it.
- Built-in date, number, and relative time formatting via the browser's
IntlAPI. - Strong TypeScript support with message type generation.
- No external runtime dependency for formatting — uses browser-native
Intl.
Cons
- ICU syntax is hard to read for developers and translators who haven't seen it before.
{count, plural, one {# item} other {# items}}is not intuitive. - Bundle size: ~13KB gzipped for the core library.
- Message extraction requires the
@formatjs/clitool, which is a separate install and configuration step. - No built-in namespace support. All messages live in one flat object per locale.
react-i18next
react-i18next is the React binding for i18next, which is the most popular i18n framework across all JavaScript ecosystems. It's been around since 2014 and has a massive plugin ecosystem.
Setup
// i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
i18n.use(initReactI18next).init({
resources: {
en: {
translation: {
greeting: "Hello, {{name}}!",
items_one: "You have {{count}} item in your cart.",
items_other: "You have {{count}} items in your cart.",
},
},
es: {
translation: {
greeting: "Hola, {{name}}!",
items_one: "Tienes {{count}} artículo en tu carrito.",
items_other: "Tienes {{count}} artículos en tu carrito.",
},
},
},
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false, // React already escapes
},
});
export default i18n;
Usage
import { useTranslation } from "react-i18next";
function CartSummary({ count }: { count: number }) {
const { t } = useTranslation();
return <div>{t("items", { count })}</div>;
}
Pros
- The
t()function is simple and familiar. No special syntax to learn. - Namespace support built-in — split translations by feature/page.
- Massive plugin ecosystem: backend loaders, language detectors, caching plugins, ICU format plugin.
- Works identically in React, React Native, Node.js, and vanilla JS.
- Lazy loading of translation files is a first-class feature.
Cons
- Plural handling uses key suffixes (
_one,_other,_zero,_few,_many) which is implicit and error-prone. Miss a suffix and you get the wrong plural form. - The plugin architecture means the actual bundle size depends heavily on what you install. Core is ~8KB gzipped, but add a backend loader, language detector, and ICU plugin and you're at 20KB+.
- Configuration is verbose. The
init()options object has dozens of options. - TypeScript support exists but requires a separate declaration file and is less ergonomic than react-intl.
LinguiJS
LinguiJS is the newest of the three and takes a different approach: it uses a compiler. You write messages inline using tagged template literals or a macro, and the Lingui compiler extracts them into catalog files.
Setup
npm install @lingui/core @lingui/react @lingui/macro
npm install -D @lingui/cli @lingui/vite-plugin # or @lingui/swc-plugin
// lingui.config.ts
export default {
locales: ["en", "es", "de"],
sourceLocale: "en",
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"],
},
],
};
Usage
import { Trans, Plural } from "@lingui/react/macro";
function CartSummary({ count }: { count: number }) {
return (
<div>
<Plural
value={count}
one="You have # item in your cart."
other="You have # items in your cart."
/>
</div>
);
}
// Or with the t macro for imperative use
import { t } from "@lingui/core/macro";
function getCartText(count: number): string {
return tYou have ${count} items in your cart.;
}
Then extract messages:
npx lingui extract
# Creates src/locales/en/messages.po
# Creates src/locales/es/messages.po (with empty translations)
Pros
- Messages are co-located with components. No jumping between files.
- Automatic extraction — no manual message IDs to manage.
- PO file format is an industry standard that professional translators prefer.
- Smallest runtime bundle of the three: ~3KB gzipped.
- The compiler strips out the macro syntax, so there's zero runtime overhead for message extraction.
Cons
- Requires a build plugin (SWC or Babel macro). Not a drop-in.
- The macro API takes some getting used to. It looks like magic, and the mental model of "this gets compiled away" is unfamiliar.
- Smaller community than i18next. Fewer Stack Overflow answers, fewer tutorials.
- PO files are great for professional translators but awkward for developers used to JSON.
Bundle Size Comparison
| Library | Core bundle (gzipped) | With common plugins | | ----------------------- | --------------------- | -------------------------------- | | react-intl | ~13KB | ~13KB (no plugins needed) | | react-i18next + i18next | ~8KB | ~15-20KB | | LinguiJS | ~3KB | ~3KB (compiler handles the rest) |
If bundle size is a hard constraint (mobile web, slow connections), LinguiJS wins.
Developer Experience Comparison
Adding a new string:
- react-intl: Add key to JSON file, reference by ID in component. Two files touched.
- react-i18next: Add key to JSON file, reference by ID in component. Two files touched.
- LinguiJS: Write the string inline in the component, run
lingui extract. One file touched.
- react-intl: Run
@formatjs/cli extract. Outputs JSON. - react-i18next: Manual. You manage the JSON files yourself (or use i18next-scanner).
- LinguiJS: Run
lingui extract. Outputs PO files.
- react-intl: Import the JSON file. You handle code splitting.
- react-i18next: Use the
i18next-http-backendplugin for lazy loading. Works out of the box. - LinguiJS: Dynamic import of compiled message catalogs.
useLinguihandles loading states.
My Recommendation
Pick react-i18next if you want the largest ecosystem, need to share i18n logic between React and non-React code, or your team is already familiar with it. It's the safe choice.
Pick LinguiJS if you care about bundle size, want co-located messages, or your translators prefer PO files. It's the modern choice.
Pick react-intl if you need strict ICU Message Format compliance, your translators already use ICU syntax, or you want rock-solid date/number formatting.
For all three, you still need to actually translate the message files. That's where the real work is. Tools like auto18n can auto-translate your i18n message files (whether JSON for react-intl/i18next or PO for LinguiJS) in CI, so you don't have to manually manage translation for every new string.
Whichever library you pick, the important thing is to set up i18n early. Retrofitting i18n into an existing React app means touching every component that renders text. Starting with i18n from day one costs almost nothing in developer time and saves weeks of refactoring later.