Web Development · Frontend
Internationalizing a React App in 2026: react-i18next, next-intl, and Lingui Compared
Adding multi-language support to a React app is straightforward when you pick the right library for your setup. Here's how react-i18next, next-intl, and Lingui differ, and which fits each project type.
Anurag Verma
8 min read
Sponsored
Internationalization gets added at two points in a project’s life: before launch, when the team planned for it from the start, or long after launch, when someone notices the product is leaking users in markets that don’t speak English.
The second path is harder, but both paths end up in the same place: picking a library, wrapping strings, managing translation files, and figuring out how to handle pluralization, dates, and numbers. The fundamentals haven’t changed much in recent years. What has changed is which library fits which project type.
The Core Problem i18n Libraries Solve
A raw i18n implementation without a library works like this:
const translations = {
en: { greeting: "Hello, {name}!" },
es: { greeting: "¡Hola, {name}!" },
};
function t(key, vars) {
const template = translations[currentLocale][key];
return template.replace(/{(\w+)}/g, (_, k) => vars[k] ?? "");
}
This handles the simple case. It breaks on:
- Pluralization. “1 item” vs “2 items” requires language-specific rules. English is simple (1 vs many). Arabic has six plural forms. Russian has four.
- Number and date formatting.
1,234.56in English is1.234,56in German. - RTL languages. Arabic and Hebrew require layout mirroring in addition to translated text.
- Missing translations. The fallback strategy (what to show when a key is missing in the target language) needs to be consistent.
Good i18n libraries handle all of this. Which one you reach for depends on your framework.
react-i18next
react-i18next is the React wrapper around i18next, which is probably the most widely deployed JavaScript i18n library. It works in plain React (CRA, Vite), Next.js, Remix, and anywhere else.
Setup:
npm install react-i18next i18next
Configuration:
// src/i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import enTranslations from './locales/en/translation.json';
import esTranslations from './locales/es/translation.json';
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslations },
es: { translation: esTranslations },
},
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
});
export default i18n;
Import it once in your app entry point, then use the useTranslation hook anywhere:
import { useTranslation } from 'react-i18next';
function WelcomeMessage({ name }) {
const { t } = useTranslation();
return <p>{t('greeting', { name })}</p>;
}
Translation file:
// src/locales/en/translation.json
{
"greeting": "Hello, {{name}}!",
"items": "{{count}} item",
"items_other": "{{count}} items"
}
The _other key is i18next’s plural suffix. The library selects the right key based on the count and the rules for the active language.
When to use react-i18next:
- Non-Next.js React projects (Vite, CRA, Remix without Next)
- Projects that need lazy-loading of large translation files (i18next has excellent chunking support)
- Teams that want a mature, heavily documented solution with a large ecosystem of plugins
When it’s awkward:
- Next.js App Router. react-i18next is a client-side library and doesn’t play well with Server Components without extra configuration. You can make it work, but it requires passing translations from server to client explicitly.
next-intl
next-intl is built for Next.js. It works correctly with both the Pages Router and the App Router, including Server Components.
npm install next-intl
Setup for App Router:
// i18n.ts
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default,
}));
// next.config.js
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
export default withNextIntl({ /* your next config */ });
Directory structure:
messages/
en.json
de.json
ja.json
In a Server Component:
import { useTranslations } from 'next-intl';
export default function ProductPage() {
const t = useTranslations('ProductPage');
return <h1>{t('title')}</h1>;
}
In a Client Component:
'use client';
import { useTranslations } from 'next-intl';
export default function AddToCartButton() {
const t = useTranslations('Cart');
return <button>{t('addItem')}</button>;
}
The same hook works in both contexts. next-intl handles the server/client boundary, passing translations through React’s context.
next-intl uses the ICU message format for plurals and interpolation:
{
"items": "{count, plural, =0 {No items} =1 {One item} other {{count} items}}",
"greeting": "Hello, {name}!"
}
ICU is more verbose than i18next’s format but handles complex cases (gender, ordinals, select statements) without custom plugins.
When to use next-intl:
- Next.js projects, particularly those using the App Router
- Teams who want proper SSR/SSG with pre-rendered translations (no flash of untranslated content)
- Projects that need SEO per locale (next-intl handles
hreflangand locale-specific URLs cleanly)
Lingui
Lingui takes a different approach. Instead of managing translation keys manually, it extracts translatable strings from your code and manages translation catalogs as separate files.
npm install @lingui/react @lingui/core
npm install --save-dev @lingui/cli @lingui/macro
In your components, you write the source language directly:
import { t, Trans } from '@lingui/macro';
function Greeting({ name }) {
return (
<Trans>Hello, <strong>{name}</strong>!</Trans>
);
}
function DeleteButton({ count }) {
return (
<button>
{t`Delete ${count} ${count === 1 ? 'item' : 'items'}`}
</button>
);
}
Then extract:
npx lingui extract
This generates catalog files:
// locales/es/messages.json
{
"Hello, <0>{name}</0>!": "¡Hola, <0>{name}</0>!",
"Delete {0} {1}": "" // untranslated, flagged for translator
}
Translators see the original string as the key, not an opaque identifier like greeting_with_name. This is a real advantage: translators understand context without reading the source code.
The compile step converts catalogs to optimized JavaScript before build:
npx lingui compile
When to use Lingui:
- Projects with frequent changes to UI copy, where keeping key names in sync with copy is painful
- Teams working with professional translators who need context
- Applications where translation coverage needs to be measured and enforced (Lingui reports untranslated strings clearly)
When it’s awkward:
- Projects where translation keys are shared across multiple repos or services (key-based libraries are better for this)
- Teams who want minimal build tooling: Lingui’s macro system requires Babel or SWC configuration
Handling Locale in the URL
Most internationalized apps put the locale in the URL: /en/about, /de/about. This is important for SEO and for sharing links that open in the right language.
With Next.js App Router, locale is handled through the directory structure:
app/
[locale]/
page.tsx
about/
page.tsx
With next-intl, the middleware handles locale detection and routing:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'de', 'ja'],
defaultLocale: 'en',
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
The middleware reads Accept-Language headers and cookies, redirects to the appropriate locale prefix, and sets the locale for the request chain.
Plural Rules Across Languages
Pluralization is where most hand-rolled i18n solutions fail. The JavaScript Intl.PluralRules API defines the forms for every language:
const pr = new Intl.PluralRules('ar'); // Arabic
pr.select(0); // 'zero'
pr.select(1); // 'one'
pr.select(2); // 'two'
pr.select(5); // 'few'
pr.select(11); // 'many'
pr.select(100); // 'other'
All three libraries use Intl.PluralRules under the hood. What differs is how they expose plural forms in translation files.
i18next uses key suffixes: key, key_one, key_other, key_few, etc. next-intl and Lingui use ICU format inline. Either works; pick the one that’s readable to your translators.
Formatting Dates and Numbers
Localization is more than translated strings. Numbers and dates differ by locale too.
All three libraries wrap the Intl.NumberFormat and Intl.DateTimeFormat APIs:
// next-intl
import { useFormatter } from 'next-intl';
function Price({ amount }) {
const format = useFormatter();
return <span>{format.number(amount, { style: 'currency', currency: 'USD' })}</span>;
}
// en-US: $1,234.56
// de-DE: 1.234,56 $
// ja-JP: $1,234.56
This is less configuration than it looks. The locale determines the format automatically. You specify what the data means (style: 'currency'), not how to format it.
Choosing Between the Three
| react-i18next | next-intl | Lingui | |
|---|---|---|---|
| Next.js App Router | Needs extra work | Built for it | Needs extra work |
| Plain React (Vite) | Best choice | Works | Works |
| Translator-friendly | Medium | Medium | High |
| Translation key management | Manual | Manual | Extracted from code |
| Bundle impact | Medium | Low (server) | Low (compiled) |
| Ecosystem/plugins | Large | Growing | Medium |
If you’re on Next.js App Router: next-intl. If you’re on anything else: react-i18next unless you have a translator workflow that benefits from Lingui’s extraction approach.
The One Thing Most Teams Skip
Translation coverage checks. It’s common to ship a release with some strings untranslated in one language because a developer added copy without adding the translation.
Each library has tooling to catch this:
- react-i18next: use a CI step that runs
i18next-scannerand fails if any keys are missing from any locale - next-intl: compare the key sets of your
messages/en.jsonand all other locale files - Lingui:
lingui extractfollowed bylingui compile --strictfails on incomplete catalogs
Adding this check to CI takes 15 minutes and prevents the embarrassing release where half your German UI shows English strings.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored