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
Locale Routing and URL Strategy
URL structure for internationalized sites is an underappreciated complexity that the three libraries handle very differently, and the right choice significantly affects SEO and user experience.
next-intl includes first-class locale routing built into its middleware. It supports both sub-path routing (/en/about, /es/about) and domain-based routing (example.com/about for English, example.es/about for Spanish), configured in a single createMiddleware call. The middleware handles locale detection from Accept-Language headers, cookie-stored preferences, and URL prefixes — and redirects appropriately. Importantly, next-intl integrates with Next.js's static generation to pre-render all locale variants at build time when using setRequestLocale(), producing zero-JavaScript locale routing for static pages.
react-i18next and Lingui have no built-in locale routing. For Next.js Pages Router projects, next-i18next (a separate wrapper package) adds locale routing by hooking into Next.js's built-in i18n configuration. For Next.js App Router or non-Next.js projects, locale routing is typically implemented manually with a middleware function or a routing library like i18next-browser-languagedetector. This is flexible but requires more boilerplate — and getting locale routing right (avoiding redirect loops, handling locale persistence across navigation, generating correct hreflang tags for SEO) takes non-trivial effort.
For SEO-critical content sites, hreflang tags are mandatory for multi-locale indexing. next-intl's locale routing integration makes it straightforward to generate correct hreflang metadata from the Next.js App Router generateMetadata function. With react-i18next, generating hreflang tags is a user-land responsibility that requires knowing the current page's URL structure and all available locales at render time.
Translation File Management at Scale
The practical day-to-day workflow for a team maintaining translations across multiple languages is where the ergonomic differences between the three libraries become most concrete.
react-i18next with key-based JSON translation files is the most common pattern in existing React codebases, but it has a silent correctness problem at scale: when you rename a translation key in the code, the corresponding keys in every translation file still exist. The old keys become dead weight in your bundles, and no tooling tells you about them automatically. Over years, i18n JSON files accumulate hundreds of unused keys. Conversely, when you add new UI strings without adding them to all translation files, users in non-English locales see the untranslated key string rendered instead of a fallback.
Lingui's extract command solves both sides of this problem. It parses your source code for <Trans> and t() macro calls, generates the authoritative list of message IDs from what is actually used, and surfaces deletions and additions clearly in the diff of the extracted catalog files. Running lingui extract in CI and failing on untranslated strings is a standard workflow for Lingui teams — it makes incomplete translations a build failure rather than a production surprise. The dead message detection ensures that when you remove a UI element, its translation string is automatically flagged for removal from all locale catalogs.
next-intl addresses this differently: by generating TypeScript types from your primary locale's message file, missing keys in the application code are TypeScript errors. But missing keys in secondary-locale message files (Spanish, Japanese, German) are not caught at compile time — they surface as missing translations at runtime, where next-intl falls back to the primary locale string. This is acceptable for rapidly evolving products where translation lag is expected, but teams with professional translation workflows often combine next-intl with a CI step that checks for missing keys across all locale files.
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.
The practical recommendation for 2026: if you are starting a Next.js App Router project, next-intl is the default choice and its Server Component support provides a genuine architectural advantage — translations happen at the server layer with no hydration cost. If your project predates the App Router or targets non-Next.js environments, react-i18next's breadth of framework support and plugin ecosystem make it the most flexible option. Lingui is worth the higher setup investment specifically for teams with professional translators, PO-file workflows, and strict requirements around translation completeness — its extract command makes missing translations a build-time failure rather than a runtime surprise, which is a meaningful reliability improvement at scale.
Compare localization and i18n packages on PkgPulse →
See also: React vs Vue and React vs Svelte, better-auth vs Lucia vs NextAuth 2026.