DEV Community

Cover image for next-intl vs react-i18next: Which React i18n Library Should You Use?
Mohit Sapat for Babelize

Posted on Originally published at babelize.co

next-intl vs react-i18next: Which React i18n Library Should You Use?

TL;DR: Use next-intl for Next.js App Router apps: it translates in Server Components, ships locale routing and middleware, and uses ICU MessageFormat out of the box. Use react-i18next (i18next) when you need React Native, other frameworks, or a shared Node.js backend, or already rely on the i18next plugin ecosystem.

If you're adding internationalization to a React or Next.js app, you will hit the same fork in the road almost immediately: next-intl or react-i18next? Both are mature, actively maintained, and used in production by thousands of teams. They just make very different assumptions about where your app runs and who renders your text.

The Short Answer

Use next-intl if your app is a Next.js App Router project. It was designed around Server Components, locale-aware routing, and ICU MessageFormat, so the common path needs very little glue code.

Use react-i18next if your app is not Next.js-only — a Vite SPA, React Native, Remix, an Electron app, or a monorepo that shares translations across web and mobile — or if your team already runs on i18next and its plugin ecosystem.

The rest of this guide explains why, with real setup code for both, a feature-by-feature comparison, the pitfalls that catch teams in production, and what a migration looks like.

At a Glance (October 2026)

Current releases and npm downloads, pulled from the npm registry on October 13, 2026. Download counts include installs pulled in by other packages, so read them as a measure of reach, not of how many apps use each library directly.

next-intl react-i18next + i18next
Latest version 4.14.9 react-i18next 17.0.16 · i18next 26.4.4
Weekly npm downloads ~7.1M ~17.9M (react-i18next) · ~24.1M (i18next)
Runs on Next.js 12–16 only Any React 16.8+ app, React Native, Node.js
Message format ICU MessageFormat i18next JSON (ICU via i18next-icu)
Server Components Built in (getTranslations) Per-request i18next instance; hooks are client-only
Locale routing Built in (defineRouting, proxy.ts) Bring your own

What Are next-intl and react-i18next?

react-i18next

react-i18next is the official React binding for i18next, a framework-agnostic internationalization core that has been around for over a decade. i18next itself knows nothing about React; react-i18next adds hooks (useTranslation), a Trans component for rich text, and a provider. Around that core sits a large ecosystem: language detectors, HTTP and filesystem backends, caching layers, an ICU plugin, and integrations with most translation management systems.

Because the core is framework-agnostic, the same i18next configuration and the same translation files can power a React web app, a React Native app, and a Node.js backend that sends localized emails.

next-intl

next-intl is built specifically for Next.js. It supports both the Pages Router and the App Router, but its design centers on the App Router: translations work in async Server Components, Client Components, Server Actions, metadata, and route handlers, and it ships its own locale-aware routing, middleware, and navigation helpers. Messages use ICU MessageFormat out of the box, so plurals, selects, and number and date formatting live inside the translation string.

Feature Comparison

Feature next-intl react-i18next
Framework scope Next.js only Any React renderer (web, React Native, Electron)
App Router & Server Components First-class: getTranslations and useTranslations in server code Works, but hooks are client-only; server code needs a per-request i18next instance
Pages Router Supported Supported (commonly via next-i18next)
Message syntax ICU MessageFormat, built in i18next JSON format; ICU via the i18next-icu plugin
Plurals {count, plural, one {…} other {…}} Suffixed keys: key_one, key_other
Routing & middleware Built in: defineRouting, middleware, createNavigation Not included; you write your own
Type safety Module augmentation of AppConfig Module augmentation of CustomTypeOptions
Namespaces & lazy loading Nested message objects; load what you need in request.ts Namespaces are a core concept; lazy loading via backends
Language detection Middleware: URL prefix, cookie, Accept-Language i18next-browser-languagedetector or custom detector
Plugin ecosystem Small and focused Extensive
Learning curve Gentle for Next.js developers Moderate: more configuration options and concepts

Setting Up next-intl in the App Router

A current next-intl (v4) App Router setup is split into a few small files. First, a single routing definition that the middleware and navigation helpers share:

// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
    locales: ["en", "fr", "de"],
    defaultLocale: "en",
});
Enter fullscreen mode Exit fullscreen mode

The middleware negotiates the locale and redirects or rewrites requests to the right prefix. (In Next.js 16 the middleware.ts convention was renamed to proxy.ts; the content is the same.)

// src/proxy.ts  (src/middleware.ts on Next.js 15 and earlier)
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

export default createMiddleware(routing);

export const config = {
    matcher: "/((?!api|_next|_vercel|.*\\..*).*)",
};
Enter fullscreen mode Exit fullscreen mode

Request configuration tells next-intl which messages to load for the current request:

// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
    const requested = await requestLocale;
    const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

    return {
        locale,
        messages: (await import(`../../messages/${locale}.json`)).default,
    };
});
Enter fullscreen mode Exit fullscreen mode

Then the [locale] layout wraps the tree in NextIntlClientProvider so Client Components can read messages, and calls setRequestLocale so pages can be statically rendered:

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

export function generateStaticParams() {
    return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }) {
    const { locale } = await params;
    if (!hasLocale(routing.locales, locale)) notFound();
    setRequestLocale(locale);

    return (
        <html lang={locale}>
            <body>
                <NextIntlClientProvider>{children}</NextIntlClientProvider>
            </body>
        </html>
    );
}
Enter fullscreen mode Exit fullscreen mode

Using translations looks the same everywhere. In an async Server Component you await getTranslations(); in a regular component — server or client — you call useTranslations():

// Server Component
import { getTranslations } from "next-intl/server";

export default async function Pricing() {
    const t = await getTranslations("Pricing");
    return <h1>{t("title")}</h1>;
}

// Client Component
"use client";
import { useTranslations } from "next-intl";

export function SeatCounter({ count }: { count: number }) {
    const t = useTranslations("Pricing");
    return <p>{t("seats", { count })}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Finally, createNavigation(routing) gives you locale-aware Link, redirect, usePathname, and useRouter wrappers, so you never hand-build /fr/pricing URLs. For the full walkthrough from an empty project, see our step-by-step guide to i18n in Next.js with next-intl.

Setting Up react-i18next in the App Router

react-i18next was designed before Server Components existed, and its hooks rely on React context, which only exists in Client Components. In the App Router that leaves two layers to configure.

Server side: the common pattern is to create a fresh i18next instance per request, so concurrent requests in different languages never share state. i18next-resources-to-backend lets you lazy-load JSON files per language and namespace with dynamic imports:

// src/i18n/server.ts
import { createInstance } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";

export async function getT(locale: string, ns = "common") {
    const i18n = createInstance();
    await i18n
        .use(initReactI18next)
        .use(resourcesToBackend((lng: string, ns: string) =>
            import(`../locales/${lng}/${ns}.json`)))
        .init({ lng: locale, ns: [ns], defaultNS: ns, fallbackLng: "en" });

    return i18n.getFixedT(locale, ns);
}

// In a Server Component
const t = await getT(locale, "pricing");
return <h1>{t("title")}</h1>;
Enter fullscreen mode Exit fullscreen mode

Client side: a separate, long-lived i18next instance initialized in a client module, with useTranslation() in Client Components. You keep the client instance's language in sync with the locale segment of the URL, often by passing it down from the layout.

You also write locale routing yourself: a middleware that reads the cookie or Accept-Language header, redirects to a locale prefix, and a [lng] segment in your app directory. None of this is hard, but it is code you own and maintain. (If you are on the Pages Router, next-i18next wraps most of this for you; it does not target the App Router.)

Message Syntax: ICU vs i18next JSON

This is the difference that matters most to translators, and it is easy to overlook. next-intl uses ICU MessageFormat:

{
    "Pricing": {
        "seats": "{count, plural, =0 {No seats} one {# seat} other {# seats}}"
    }
}
Enter fullscreen mode Exit fullscreen mode

i18next uses its own JSON format, where interpolation uses double braces and plurals are separate keys with CLDR category suffixes:

{
    "seats_zero": "No seats",
    "seats_one": "{{count}} seat",
    "seats_other": "{{count}} seats"
}
Enter fullscreen mode Exit fullscreen mode

Both handle languages with complex plural rules correctly, because both resolve the category with Intl.PluralRules. The i18next format is easier to read for simple strings; ICU keeps the whole sentence in one key, which translators and translation platforms generally handle better for gender and nested selects. If you want ICU with i18next, add the i18next-icu plugin — but then you are using the ICU format, not the i18next one, so pick one per project. Our ICU MessageFormat guide covers plural and select syntax in depth.

Type Safety

Both libraries can type-check your translation keys, and both do it the same way: TypeScript module augmentation pointing at your source-language JSON file. Neither generates code.

// next-intl: global.d.ts
import { routing } from "@/i18n/routing";
import messages from "./messages/en.json";

declare module "next-intl" {
    interface AppConfig {
        Locale: (typeof routing.locales)[number];
        Messages: typeof messages;
    }
}

// react-i18next: i18next.d.ts
import common from "./locales/en/common.json";

declare module "i18next" {
    interface CustomTypeOptions {
        defaultNS: "common";
        resources: { common: typeof common };
    }
}
Enter fullscreen mode Exit fullscreen mode

With either setup, a typo like t("pricng.title") becomes a compile error. next-intl can additionally type ICU arguments in recent versions, so a missing count is caught as well.

Bundle Size and Performance

Avoid picking either library based on a headline bundle number; what dominates in practice is how many messages you ship to the browser, not the size of the runtime.

With next-intl, text rendered in Server Components is resolved on the server and arrives as HTML. The message catalog itself does not need to be sent at all for those components. For Client Components, NextIntlClientProvider passes messages down; on large apps you can pass only the namespaces a subtree needs (for example with pick(messages, ["Pricing"])) instead of the entire file.

With react-i18next, server-rendered text works the same way when you use a per-request instance. Client Components need the resources loaded into the client instance, and the ecosystem's answer is namespaces plus a backend (HTTP or dynamic import) that loads pricing.json only when a component asks for the pricing namespace.

Both approaches can produce lean pages. next-intl makes the lean path the default in the App Router; react-i18next makes it a configuration choice.

Diagram comparing where translation runs in the Next.js App Router. next-intl: createMiddleware handles locale detection, i18n/request.ts loads messages/${locale}.json per request, Server Components call getTranslations and render text to HTML, and NextIntlClientProvider passes picked messages to Client Components using useTranslations. react-i18next: your own middleware and (lng) segment, createInstance per request with resourcesToBackend loading locales/${lng}/${ns}.json and getT on the server, and a separate long-lived client instance using useTranslation with a backend that loads namespaces on demand.
Where translation runs: next-intl resolves text per request on the server; react-i18next uses a per-request server instance plus a client instance.

Which One Should You Choose?

Choose next-intl if:

  • The app is Next.js, and you have no plans to share translations with a non-Next.js client
  • You use the App Router and want translations in Server Components without extra glue
  • You want locale routing, middleware, and localized navigation handled for you
  • You prefer ICU MessageFormat, or your translation vendor expects it

Choose react-i18next if:

  • You ship React Native, Electron, Vite, or Remix apps alongside (or instead of) Next.js
  • You want one translation setup shared by the frontend and a Node.js backend
  • Your team already uses i18next and its file format, detectors, or backends
  • You need a specific plugin, such as a CMS or translation-platform backend that loads strings at runtime

A useful tiebreaker: whoever owns the most code that renders text wins. If 90% of your UI lives in a Next.js app, use next-intl there even if a small React Native app uses i18next — sharing raw JSON between two formats is easier than fighting the wrong library in your main app.

Decision flowchart. Q1: Is the app a Next.js App Router project? No leads to react-i18next. Yes leads to Q2: do the same translations also need to run in React Native, Electron, Vite, Remix or Node.js? Yes leads to react-i18next. No leads to Q3: does your team already rely on i18next, its file format, detectors, backends or a plugin? Yes leads to react-i18next, No leads to next-intl. Tiebreaker: whoever owns the most code that renders text wins.
Decision guide: next-intl for Next.js App Router apps, react-i18next for cross-platform setups or existing i18next teams.

Migrating from react-i18next to next-intl

Teams moving an existing Next.js app to the App Router often make this switch. The work splits into three parts:

  • Convert the message files. Merge namespaces into nested objects (or keep one file per namespace and merge them in request.ts), turn {{name}} into {name}, and collapse key_one/key_other pairs into a single ICU plural. Script this; do not do it by hand.
  • Replace the calls. useTranslation("pricing") becomes useTranslations("Pricing"), and <Trans> becomes t.rich() with tag callbacks.
  • Delete your routing glue. Custom locale middleware and link helpers get replaced by defineRouting, createMiddleware, and createNavigation. Keep your existing URL structure so you do not lose rankings; see our hreflang and multilingual SEO guide before changing locale prefixes.

Migrate route by route behind a feature branch, and run a pseudo-locale through the converted pages to catch any string that slipped through untranslated.

Common Pitfalls

  • Calling useTranslation() in a Server Component. With react-i18next, hooks need a Client Component. Use a per-request instance on the server instead of adding "use client" everywhere.
  • Sharing one global i18next instance on the server. Changing its language for one request can leak into another concurrent request. Create an instance per request.
  • Forgetting setRequestLocale with next-intl. Without it, pages that use translations opt into dynamic rendering, and you lose static generation.
  • Passing the whole catalog to the client. Wrapping the root in a provider with every message for every feature is convenient, but it ships text the page never shows.
  • Mixing message formats. ICU strings in an i18next project without the ICU plugin render literally, braces and all.
  • Inconsistent locale codes. en_US in file names, en-US in the URL, en in the HTML lang attribute. Pick BCP 47 tags once; our locale codes guide explains the rules.
  • Hand-formatting numbers and dates. Both libraries expose locale-aware formatting; pass raw values, never toFixed(2) strings.

FAQ

What is the difference between next-intl and react-i18next?

next-intl is built specifically for Next.js, with first-class App Router and Server Component support, built-in locale routing, and ICU MessageFormat. react-i18next is the React binding for i18next, a framework-agnostic library that also runs in React Native, Vite, Remix, and Node.js, with a large plugin ecosystem.

Which library works best with the Next.js App Router?

next-intl. Its getTranslations and useTranslations APIs work in Server Components, and it provides routing, middleware, and navigation helpers. react-i18next works in the App Router too, but its hooks are client-only, so server rendering requires creating an i18next instance per request.

Can I use react-i18next with React Native?

Yes. react-i18next supports React Native, which makes it a strong choice for projects that share translations between web and mobile. next-intl only targets Next.js, so a React Native app would need a different library.

Does next-intl support ICU MessageFormat?

Yes. next-intl uses ICU MessageFormat natively for plurals, selects, and number and date formatting. i18next uses its own JSON format with suffixed plural keys such as key_one and key_other, and supports ICU only through the i18next-icu plugin.

Is next-intl or react-i18next faster?

Neither runtime is a meaningful bottleneck. What matters is how many messages reach the browser. next-intl renders Server Component text on the server by default, while react-i18next achieves similar results with per-request server instances and lazily loaded namespaces on the client.

The Short Version

next-intl is the specialist: if your product is a Next.js App Router app, it gives you Server Component translations, routing, and ICU with the least code. react-i18next is the generalist: if your translations need to work across React Native, other frameworks, or a backend, its framework-agnostic core and ecosystem are hard to beat. Pick based on where most of your text renders, keep your message files in one format, and type your keys either way. If you are still deciding how to structure those files, start with our explanation of JSON translation files.

Written with AI assistance and reviewed by the Babelize team.


Originally published on the Babelize blog. Babelize is an AI localization tool for Next.js, React and Vue apps: it generates and syncs your JSON locale files from your repo.

Top comments (0)