Skip to content

Web Development · Frontend

React i18n in 2026: react-i18next vs next-intl vs Lingui

Choosing between react-i18next, next-intl, and Lingui comes down to your framework: next-intl for Next.js App Router, react-i18next elsewhere.

Anurag Verma

Anurag Verma

8 min read

Internationalizing a React App in 2026

Sponsored

Share

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

i18n libraries solve four problems a raw key-value translation object can’t: language-specific pluralization rules (English has two forms, Arabic has six), locale-aware number and date formatting, right-to-left layout for languages like Arabic and Hebrew, and a consistent fallback strategy for missing translations.

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.56 in English is 1.234,56 in 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 hreflang and 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; see our technical SEO guide for JavaScript apps for how hreflang and locale routing fit into the broader crawlability picture.

With Next.js App Router, locale is handled through the directory structure (our App Router guide covers the routing conventions this builds on):

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-i18nextnext-intlLingui
Next.js App RouterNeeds extra workBuilt for itNeeds extra work
Plain React (Vite)Best choiceWorksWorks
Translator-friendlyMediumMediumHigh
Translation key managementManualManualExtracted from code
Bundle impactMediumLow (server)Low (compiled)
Ecosystem/pluginsLargeGrowingMedium

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. If you’re picking i18n as part of a broader stack decision, that fits into the same tradeoff framework as our React 19 guide walks through for the rest of the app.

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-scanner and fails if any keys are missing from any locale
  • next-intl: compare the key sets of your messages/en.json and all other locale files
  • Lingui: lingui extract followed by lingui compile --strict fails on incomplete catalogs

Adding this check to CI takes 15 minutes and prevents the embarrassing release where half your German UI shows English strings.

Frequently asked questions

Which React i18n library should I use with the Next.js App Router?
next-intl. It's built specifically for Next.js and works correctly with Server Components, including proper server-side rendering of pre-translated content. react-i18next is a client-side library and needs extra configuration to pass translations from server to client in the App Router, so it's a better fit outside Next.js.
How does Lingui differ from react-i18next and next-intl?
Lingui extracts translatable strings directly from your source code with `lingui extract`, so translators see the actual source-language text as the key instead of an opaque identifier like `greeting_with_name`. react-i18next and next-intl both require you to manage translation keys manually, which is simpler across repos but less translator-friendly.
Do these libraries handle pluralization automatically?
Yes, all three build on the JavaScript `Intl.PluralRules` API, which defines the correct plural forms for every language, from English's two forms to Arabic's six. i18next exposes this through key suffixes like `key_one` and `key_other`; next-intl and Lingui use inline ICU message format instead.
How do I catch untranslated strings before shipping?
Add a CI check specific to your library: an `i18next-scanner` step that fails on missing keys for react-i18next, a script comparing your `messages/en.json` key set against other locale files for next-intl, or `lingui compile --strict` for Lingui, which fails on incomplete catalogs. It takes about 15 minutes to set up.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored