next-intl and react-intl are both libraries designed to handle internationalization (i18n) in React applications, but they target different architectural needs. react-intl is the industry-standard, framework-agnostic library built on top of the ECMA-402 Intl API. It provides a robust set of React components and hooks for formatting dates, numbers, and messages, making it ideal for Single Page Applications (SPAs) or projects where the framework might change. next-intl is a specialized wrapper built specifically for the Next.js ecosystem. It leverages Next.js features like Server Components, Middleware, and file-based routing to provide seamless server-side rendering (SSR) of translated content, automatic locale detection, and optimized bundle splitting without the boilerplate often required when integrating react-intl manually into Next.js.
Choosing the right internationalization (i18n) library can make or break the scalability of a global application. While react-intl has long been the gold standard for React i18n, next-intl has emerged as a powerful, opinionated alternative specifically for the Next.js ecosystem. Let's dive into how they differ in architecture, server-side rendering, and daily developer workflow.
react-intl is designed to work anywhere React runs. It relies on a top-level <IntlProvider> to inject formatting context into your component tree. You are responsible for managing the locale state, loading message bundles, and handling the initial HTML hydration yourself. This flexibility is great for SPAs but requires significant setup for Server-Side Rendering (SSR).
// react-intl: Manual provider setup
import { IntlProvider } from 'react-intl';
import messages from './locales/en.json';
function App({ locale, children }) {
return (
<IntlProvider locale={locale} messages={messages}>
{children}
</IntlProvider>
);
}
next-intl is built for Next.js. It abstracts the provider pattern into a NextIntlClientProvider and leverages Next.js Middleware to handle locale detection and routing before the request even hits your page components. It treats locales as a first-class citizen in your routing structure, automatically managing the locale prefix in URLs.
// next-intl: Integrated provider in layout (App Router)
import { NextIntlClientProvider } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
export default function LocaleLayout({ children, params: { locale } }) {
const messages = (await import(`./locales/${locale}.json`)).default;
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
}
react-intl requires you to manually extract the current locale on the server, load the corresponding JSON bundle, and pass it to the client. In Next.js Pages Router, this often means wrapping every page in getStaticProps or getServerSideProps. In the App Router, you must manually coordinate data fetching to ensure messages are available before the client hydrates, which can lead to hydration mismatches if not handled perfectly.
// react-intl: Manual SSR data fetching in Next.js Pages Router
export async function getStaticProps({ locale }) {
const messages = (await import(`../locales/${locale}.json`)).default;
return { props: { messages, locale } };
}
// Then pass these props to the IntlProvider in your _app.js
next-intl solves this by integrating directly with React Server Components. You can asynchronously import messages directly inside your Server Components or Layouts. The library ensures that the server-rendered HTML matches the client state automatically, eliminating hydration warnings related to missing translations.
// next-intl: Async messages in Server Components (App Router)
import { getTranslations } from 'next-intl/server';
export default async function HomePage() {
const t = await getTranslations('HomePage');
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
);
}
react-intl does not handle routing. You must implement your own logic to detect the user's locale (from cookies, headers, or subdomains) and redirect them to the correct URL structure. You also need to configure your router (e.g., Next.js next.config.js) to recognize locale prefixes manually.
// react-intl: Manual locale detection in Middleware
import { NextResponse } from 'next/server';
export function middleware(request) {
const locale = detectLocaleFromCookie(request); // You write this logic
if (!request.nextUrl.pathname.startsWith(`/${locale}`)) {
return NextResponse.redirect(new URL(`/${locale}${request.nextUrl.pathname}`, request.url));
}
}
next-intl provides a pre-built middleware that handles locale detection, prefixing, and redirection out of the box. It supports path strategies (e.g., /en/about), domain strategies, and subpath strategies with minimal configuration. It automatically strips or adds locale prefixes based on your config.
// next-intl: Built-in middleware configuration
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
localePrefix: 'always' // Ensures /en/about, /de/about
});
export const config = { matcher: ['/((?!api|_next|_vercel|.*\..*).*)'] };
react-intl relies heavily on the useIntl hook for accessing formatting functions and the FormattedMessage component for rendering text. While powerful, using these in Server Components requires careful handling since hooks only work on the client. You often end up creating wrapper components just to access translations in mixed environments.
// react-intl: Client-side hook usage
'use client';
import { useIntl } from 'react-intl';
function ClientComponent() {
const intl = useIntl();
return <span>{intl.formatDate(new Date())}</span>;
}
next-intl offers a dual approach. It provides server-side helpers like getTranslations for use in Server Components and standard hooks like useTranslations for Client Components. The API is consistent, but the loading mechanism is optimized for the Next.js rendering pipeline.
// next-intl: Server and Client usage
// Server Component
const t = await getTranslations('Index');
<h1>{t('title')}</h1>
// Client Component
'use client';
import { useTranslations } from 'next-intl';
function ClientComp() {
const t = useTranslations('Index');
return <h1>{t('title')}</h1>;
}
react-intl requires manual configuration to split locale bundles. If not configured correctly, you risk shipping all languages to every user, bloating the initial JavaScript bundle. Developers often need to set up complex Webpack or Vite plugins to dynamically import only the necessary locale file.
// react-intl: Often requires custom babel/webpack config for extraction
// and dynamic imports to avoid shipping all locales
const messages = await import(`../locales/${locale}.json`);
next-intl handles bundle splitting automatically. Because it leverages the Next.js module system and server components, it only sends the active locale's messages to the client. The server renders the content, and the client only hydrates with the specific JSON needed for that language, resulting in smaller bundles by default.
Despite the conveniences of next-intl, there are valid reasons to choose react-intl:
react-intl provides a consistent API everywhere.react-intl is the core implementation of the FormatJS project. If you need very specific, low-level control over the Intl API that next-intl hasn't wrapped yet, react-intl gives you direct access.react-intl, the cost of migrating to next-intl might outweigh the benefits of cleaner SSR setup.| Feature | next-intl | react-intl |
|---|---|---|
| Primary Focus | Next.js Integration | Framework-Agnostic React |
| SSR Setup | Automatic (Server Components) | Manual (GetInitialProps/Loader) |
| Routing | Built-in Middleware | DIY Implementation |
| Bundle Splitting | Automatic per locale | Manual Configuration |
| API Style | getTranslations (Server) + Hooks | useIntl Hook + Components |
| Learning Curve | Low (for Next.js devs) | Medium (requires i18n concepts) |
If you are starting a new project on Next.js, next-intl is almost always the superior choice. It removes the friction of setting up SSR, handles routing edge cases automatically, and ensures your application is performant out of the box. It feels like a native part of the framework rather than a third-party add-on.
However, if you are building a design system, a library intended for multiple frameworks, or a complex SPA where server-side rendering is not the primary concern, react-intl remains the robust, battle-tested standard. It offers maximum flexibility at the cost of initial configuration effort.
For most teams shipping Next.js applications today, next-intl allows you to focus on content and logic rather than the plumbing of internationalization.
Choose next-intl if your project is built exclusively on Next.js (App Router or Pages Router) and you need deep integration with server-side rendering. It is the best fit when you want automatic locale detection via middleware, server-component-first translation loading, and zero-config routing for localized paths (e.g., /en/about, /de/about). It significantly reduces boilerplate for SSR scenarios.
Choose react-intl if you are building a framework-agnostic React application (like Vite, Create React App, or Remix) or if you need a solution that is not tied to a specific meta-framework's lifecycle. It is also the better choice if your team requires strict adherence to the standard FormatJS ecosystem across multiple different projects or if you need granular control over the provider setup that next-intl abstracts away.
Internationalization for Next.js.
Internationalization (i18n) is an essential part of the user experience, therefore next-intl gives you all the parts you need to get language nuances right.
// UserProfile.tsx
import {useTranslations} from 'next-intl';
export default function UserProfile({user}) {
const t = useTranslations('UserProfile');
return (
<section>
<h1>{t('title', {firstName: user.firstName})}</h1>
<p>{t('membership', {memberSince: user.memberSince})}</p>
<p>{t('followers', {count: user.numFollowers})}</p>
</section>
);
}
// en.json
{
"UserProfile": {
"title": "{firstName}'s profile",
"membership": "Member since {memberSince, date, short}",
"followers": "{count, plural, β΅
=0 {No followers yet} β΅
=1 {One follower} β΅
other {# followers} β΅
}"
}
}
As an app grows, messages may drift and become inconsistent.
A companion tool, eloqnt/cli, catches this by analyzing your source code and messages statically:
$ eloqnt lint
messages/de.json
β
β "UserProfile.title": "Dein Profil"
β ββ¬ββββββββββ
β β°β Inconsistent ICU arguments: missing {firstName} (inconsistent-args)
β
β "UserProfile.membership": "Member since {memberSince, date, short}"
β ββ¬ββββββββββββββββββββββ
β β°β Missing translation for de (missing-translation)
β 1 error
! 1 warning
β Rule details: https://cli.eloqnt.dev/docs/lint-rules/<rule>
β Run `eloqnt translate` to fill in 1 missing translation
It can optionally also fill in missing translations with eloqnt translate, using your source code as context.