Skip to content

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

Anurag Verma

8 min read

Internationalizing a React App in 2026: react-i18next, next-intl, and Lingui Compared

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

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.

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-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.

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.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored