Skip to main content

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

·PkgPulse Team

TL;DR

next-intl is the default for Next.js App Router projects — it's built around RSC, server components, and the app/ directory with excellent TypeScript inference. react-i18next is the most widely used React i18n library — works everywhere (React, Next.js, Remix, Vite), mature, and has a massive ecosystem. Lingui is the ICU-standards-first choice with compile-time message extraction and the smallest runtime bundle — preferred by teams with professional translators and strict localization workflows.

Key Takeaways

  • react-i18next: ~2.8M weekly downloads — most popular, works in any React setup, battle-tested
  • next-intl: ~900K weekly downloads — Next.js App Router native, server-first, TypeScript-first
  • Lingui: ~300K weekly downloads — ICU format, compile-time extraction, smallest bundle
  • next-intl is purpose-built for Next.js 13+ App Router — use it for new Next.js projects
  • react-i18next is universal — best for non-Next.js React apps or when migrating
  • Lingui has the best professional translator workflow (PO files, Crowdin/Lokalise integration)

PackageWeekly DownloadsFrameworkMessage Format
react-i18next~2.8MAny ReactKey-based JSON
next-intl~900KNext.jsICU + key-based
@lingui/react~300KAny ReactICU (compile-time)

Choosing a Message Format

Before picking a library, understand message format options:

Key-based (react-i18next default):

// en.json
{
  "welcome": "Welcome, {{name}}!",
  "items_count": "{{count}} package",
  "items_count_plural": "{{count}} packages"
}

ICU (next-intl, Lingui):

{
  "welcome": "Welcome, {name}!",
  "items_count": "{count, plural, one {# package} other {# packages}}"
}

ICU format handles pluralization, gender, and date/number formatting natively without per-language translation keys. For professional translation workflows, ICU is the industry standard.


next-intl

next-intl is the i18n library designed around Next.js App Router — it understands server components, streaming, and the Next.js middleware system.

Setup

// middleware.ts — locale detection and routing:
import createMiddleware from "next-intl/middleware"

export default createMiddleware({
  locales: ["en", "es", "fr", "de", "ja"],
  defaultLocale: "en",
  localeDetection: true,  // Auto-detect from Accept-Language header
})

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
}
// i18n.ts — next-intl configuration:
import { getRequestConfig } from "next-intl/server"

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./messages/${locale}.json`)).default,
}))

Server Components (App Router)

next-intl's killer feature — translations in React Server Components with zero client-side bundle for translation logic:

// app/[locale]/packages/page.tsx — Server Component
import { useTranslations, useFormatter } from "next-intl"
import { setRequestLocale } from "next-intl/server"

export default function PackagesPage({ params: { locale } }: { params: { locale: string } }) {
  // Enable static rendering:
  setRequestLocale(locale)

  // useTranslations in RSC — runs at build/request time, no client JS:
  const t = useTranslations("PackagesPage")
  const format = useFormatter()

  return (
    <main>
      <h1>{t("title")}</h1>
      <p>{t("description", { count: 50000 })}</p>
    </main>
  )
}
// messages/en.json
{
  "PackagesPage": {
    "title": "Package Explorer",
    "description": "Browse {count, number} packages"
  }
}

Client Components

"use client"
import { useTranslations, useFormatter, useLocale } from "next-intl"

function PackageCard({ pkg }: { pkg: Package }) {
  const t = useTranslations("PackageCard")
  const format = useFormatter()
  const locale = useLocale()

  return (
    <div>
      <h3>{pkg.name}</h3>

      {/* ICU number formatting: */}
      <p>
        {t("downloads", {
          count: format.number(pkg.weeklyDownloads, { notation: "compact" }),
        })}
      </p>

      {/* Date formatting: */}
      <time>
        {format.dateTime(new Date(pkg.updatedAt), {
          year: "numeric",
          month: "short",
          day: "numeric",
        })}
      </time>

      {/* Plural-safe: */}
      <p>{t("contributors", { count: pkg.contributorCount })}</p>
    </div>
  )
}

TypeScript Type-Safety

next-intl generates types from your message files:

// types/next-intl.d.ts — auto-generated from messages/en.json:
// Gives you autocomplete and type errors for invalid message keys

const t = useTranslations("PackageCard")
t("downloads", { count: 5000 })  // ✅
t("doesNotExist")                 // ❌ TypeScript error — key doesn't exist
t("downloads")                    // ❌ TypeScript error — missing required 'count'

react-i18next

react-i18next is the React wrapper for i18next — the most battle-tested i18n library in JavaScript with plugins for every scenario.

Setup

// i18n.ts — configure once:
import i18n from "i18next"
import { initReactI18next } from "react-i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import HttpApi from "i18next-http-backend"

i18n
  .use(LanguageDetector)      // Auto-detect browser language
  .use(HttpApi)               // Load translations from /public/locales/
  .use(initReactI18next)
  .init({
    fallbackLng: "en",
    supportedLngs: ["en", "es", "fr", "de", "ja"],
    defaultNS: "common",
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    interpolation: {
      escapeValue: false,  // React already escapes
    },
  })

export default i18n
// public/locales/en/common.json
{
  "navigation": {
    "home": "Home",
    "packages": "Packages",
    "compare": "Compare"
  },
  "package": {
    "downloads": "{{count}} download",
    "downloads_other": "{{count}} downloads",
    "version": "v{{version}}"
  }
}

Using Translations

import { useTranslation, Trans } from "react-i18next"

function Navigation() {
  const { t, i18n } = useTranslation("common")

  return (
    <nav>
      <a href="/">{t("navigation.home")}</a>
      <a href="/packages">{t("navigation.packages")}</a>

      {/* Language switcher: */}
      <select
        value={i18n.language}
        onChange={(e) => i18n.changeLanguage(e.target.value)}
      >
        <option value="en">English</option>
        <option value="es">Español</option>
        <option value="fr">Français</option>
      </select>
    </nav>
  )
}

function PackageStats({ downloads, maintainerName }: { downloads: number, maintainerName: string }) {
  const { t } = useTranslation("common")

  return (
    <div>
      {/* Count-based pluralization: */}
      <p>{t("package.downloads", { count: downloads })}</p>

      {/* Trans component for JSX within translations: */}
      <Trans
        i18nKey="package.maintainedBy"
        values={{ name: maintainerName }}
        components={{ link: <a href={`/user/${maintainerName}`} /> }}
      >
        Maintained by <link>{{ name: maintainerName }}</link>
      </Trans>
    </div>
  )
}

react-i18next with Next.js (Pages Router or App Router):

// For App Router, use next-intl instead
// For Pages Router, use next-i18next:
import { serverSideTranslations } from "next-i18next/serverSideTranslations"

export const getStaticProps = async ({ locale }) => ({
  props: {
    ...(await serverSideTranslations(locale, ["common", "packages"])),
  },
})

react-i18next lazy loading namespaces:

// Load translations only when needed (code splitting):
const { t } = useTranslation(["common", "packages"], {
  useSuspense: false,  // Don't suspend — handle loading state manually
})

Lingui

Lingui takes a different approach — messages are extracted from your JSX at build time, compiled to optimized message catalogs, and the runtime is tiny.

Setup

npm install @lingui/react @lingui/core
npm install -D @lingui/cli @lingui/babel-plugin-react-jsx-source
// lingui.config.ts
export default {
  locales: ["en", "es", "fr"],
  sourceLocale: "en",
  catalogs: [{
    path: "src/locales/{locale}/messages",
    include: ["src"],
  }],
  format: "po",  // PO files — standard for professional translators
}

Writing Translatable Messages

Lingui extracts messages from your code — you write in English, messages are auto-extracted:

import { Trans, useLingui } from "@lingui/react"
import { t, plural, msg } from "@lingui/core/macro"

function PackagePage({ pkg }: { pkg: Package }) {
  const { i18n } = useLingui()

  return (
    <div>
      {/* JSX macro — extracted automatically: */}
      <h1><Trans>Package Explorer</Trans></h1>

      {/* t() macro for string context: */}
      <meta name="description" content={t`Browse ${pkg.name} on PkgPulse`} />

      {/* Plural macro: */}
      <p>
        {plural(pkg.weeklyDownloads, {
          one: "# weekly download",
          other: "# weekly downloads",
        })}
      </p>

      {/* Select (gender, etc.): */}
      <p>
        {i18n._("maintainer_pronoun", { gender: pkg.maintainer.gender })}
      </p>
    </div>
  )
}

Lingui message extraction workflow:

# Extract messages from source code:
npx lingui extract

# Creates: src/locales/en/messages.po
# msgid "Package Explorer"
# msgstr "Package Explorer"
#
# msgid "Browse {pkgName} on PkgPulse"
# msgstr "Browse {pkgName} on PkgPulse"

# Send .po files to translators (Crowdin, Lokalise, Phrase, etc.)
# Translators fill in msgstr for each language

# Compile .po → .js catalogs:
npx lingui compile

# Creates: src/locales/es/messages.js (optimized runtime format)
// _app.tsx — load compiled catalogs:
import { i18n } from "@lingui/core"
import { I18nProvider } from "@lingui/react"

async function loadCatalog(locale: string) {
  const { messages } = await import(`./locales/${locale}/messages`)
  i18n.loadAndActivate({ locale, messages })
}

await loadCatalog("es")

Lingui's advantages:

  • Smallest runtime — compiled catalogs are plain JS objects, no parsing overhead
  • No string keys — you write natural language, Lingui generates keys
  • PO format — industry standard that every translation tool supports
  • Type safety via macros — TypeScript knows your message signatures
  • Dead message detection — unused translations are flagged at extract time

Feature Comparison

Featurenext-intlreact-i18nextLingui
Next.js App Router✅ Native⚠️ Manual setup⚠️ Manual setup
React Server Components
Works without Next.js⚠️ Possible
Message formatICUKey-based JSONICU (extracted)
TypeScript inference✅ Excellent✅ Good✅ Macro-based
Pluralization✅ ICU✅ i18next rules✅ ICU
Lazy loading✅ Plugins✅ Dynamic imports
Professional translator workflow✅ PO files (best)
Bundle size (runtime)~30KB~40KB~5KB compiled
Locale routing✅ Built-in❌ (manual/plugins)❌ (manual)
CDN/edge cache friendly⚠️

When to Use Each

Choose next-intl if:

  • You're building a Next.js App Router application (the default choice)
  • You want server components to serve pre-translated HTML (no hydration cost)
  • TypeScript-first with auto-inferred message key types
  • Locale-based routing is required (domain-based or path-based)

Choose react-i18next if:

  • You're not using Next.js (Remix, Vite SPA, CRA migration)
  • Migrating an existing i18next project
  • You need the broad plugin ecosystem (ICU format, multiple backends, etc.)
  • Maximum framework flexibility and community support

Choose Lingui if:

  • Working with professional translation agencies or platforms (Crowdin, Lokalise)
  • PO format is required for your localization workflow
  • Smallest possible runtime bundle is critical
  • You prefer writing natural language over managing string keys

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on next-intl 3.x, react-i18next 15.x, and Lingui 4.x. Bundle sizes from bundlephobia.

Compare localization and i18n packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.