next-intl vs react-i18next vs Lingui: React i18n in 2026
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)
Download Trends
| Package | Weekly Downloads | Framework | Message Format |
|---|---|---|---|
react-i18next | ~2.8M | Any React | Key-based JSON |
next-intl | ~900K | Next.js | ICU + key-based |
@lingui/react | ~300K | Any React | ICU (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
| Feature | next-intl | react-i18next | Lingui |
|---|---|---|---|
| Next.js App Router | ✅ Native | ⚠️ Manual setup | ⚠️ Manual setup |
| React Server Components | ✅ | ❌ | ❌ |
| Works without Next.js | ⚠️ Possible | ✅ | ✅ |
| Message format | ICU | Key-based JSON | ICU (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.