react-intl for the Next.js App Router — no build plugin required.
react-intl/FormatJS is great, but using it with the App Router means re-solving the same two problems in every project:
- No Server Component story.
useIntlneeds React context, so it can't run in RSC. You end up hand-rolling agetIntl()for the server and anIntlProviderfor the client every time. - The extracted-key workflow fights Next. FormatJS's best feature — author an inline
defaultMessage, get a hashedidinjected at build time — normally needs a Babel plugin. Next uses SWC. The@swc/plugin-formatjsWASM plugin exists but is ABI-locked to a specific Next/swc_coreversion, so a Next bump can silently break your build.
next-react-intl solves both:
- App Router glue, server + client, from one config.
- It computes the FormatJS message
idat runtime instead of at build time — byte-for-byte the same hash@formatjs/cliwrites into your catalog (verified by test). So you keep the extract → compile workflow with no SWC/Babel plugin, and it works under Turbopack and any bundler. If the build plugin is present, ids are already injected and the runtime hashing is skipped — it composes, it doesn't conflict.
The id hash is
sha512overdefaultMessage(+#description), base64, first 6 chars — memoized, so each unique message hashes once.
npm install next-react-intl react-intl @formatjs/intl
# and, for extracting/compiling catalogs:
npm install -D @formatjs/cliPeer deps: react >=18, react-intl >=6, @formatjs/intl >=2, next >=14 (optional — only the cookieLocaleResolver helper imports next/headers).
This package is ESM-only ("type": "module") — which is the norm for the App Router. Import it; don't require() it.
// src/i18n.ts (server module — no "use client")
import { createI18n, cookieLocaleResolver } from "next-react-intl/server"
const SUPPORTED = ["en", "es"] as const
export const { getIntl, getProviderProps, I18nProvider, resolveLocale } = createI18n({
// The language your inline defaultMessages are authored in (react-intl's fallback).
sourceLocale: "en",
// Where the active locale comes from — app-owned, a single source of truth.
// Use the helper, or pass any () => string | Promise<string>.
resolveLocale: cookieLocaleResolver({
cookieName: "locale",
supportedLocales: SUPPORTED,
defaultLocale: "es",
}),
// Load the compiled catalog for a locale (see the catalog workflow below).
loadMessages: async (locale) => (await import(`./i18n/compiled/${locale}.json`)).default,
})// src/app/layout.tsx
import { I18nProvider, getProviderProps, resolveLocale } from "@/i18n"
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await resolveLocale()
const { messages } = await getProviderProps()
return (
<html lang={locale}>
<body>
<I18nProvider locale={locale} messages={messages}>
{children}
</I18nProvider>
</body>
</html>
)
}import { getIntl } from "@/i18n"
export default async function AccountPage() {
const intl = await getIntl()
return <h1>{intl.formatMessage({ defaultMessage: "Account" })}</h1>
}Import the hook/component from the client entry (next-react-intl), not from your server config module — that keeps server code out of the client bundle. They read the active locale and id pattern from the provider you mounted in the layout.
"use client"
import { useIntl, FormattedMessage } from "next-react-intl"
export function CartButton({ count }: { count: number }) {
const intl = useIntl()
return (
<button aria-label={intl.formatMessage({ defaultMessage: "Cart ({count})" }, { count })}>
<FormattedMessage defaultMessage="Cart ({count})" values={{ count }} />
</button>
)
}No ids anywhere — just defaultMessage. ICU syntax ({count}, plurals, etc.) works as usual.
English (defaultMessage) is canonical and lives inline in your source. The other locales are translated JSON catalogs, keyed by the hashed id. Drive it with @formatjs/cli:
The compiled src/i18n/compiled/*.json files are what loadMessages returns at runtime — commit them.
If you change
idInterpolationPatternincreateI18n, pass the same--id-interpolation-patterntoformatjs extract. The default ([sha512:contenthash:base64:6]) matches FormatJS's own default, so you usually don't touch it.
createI18n(config)→{ getIntl, getProviderProps, resolveLocale, I18nProvider, useIntl, FormattedMessage }. One-stop wiring from a single config.createGetIntl(config)→() => Promise<IntlShape>. Just the RSCgetIntlif you don't want the rest.cookieLocaleResolver({ cookieName, supportedLocales, defaultLocale })→ aresolveLocalebacked by a cookie (readsnext/headers). Optional — pass any resolver you like.
config (I18nConfig): sourceLocale, loadMessages, resolveLocale, optional idInterpolationPattern, optional onError (default ignores MISSING_TRANSLATION and logs the rest).
<I18nProvider locale messages [defaultLocale] [idInterpolationPattern] [onError]>— wraps react-intl'sIntlProviderand publishes the id pattern via context.useIntl()— like react-intl's, butformatMessageauto-injects the id.<FormattedMessage defaultMessage ... />— like react-intl's, butidis computed when absent.
messageId(descriptor, pattern?)/withId(descriptor, pattern?)— the runtime id computation, if you need it directly.DEFAULT_ID_INTERPOLATION_PATTERN.
This package only resolves and applies a locale; it's agnostic about where the locale comes from. Cookie-based (the cookieLocaleResolver) or a custom resolver reading a URL segment / header both work — keep the locale in one place so you don't end up with two diverging stores.
Runtime glue only. It does not wrap @formatjs/cli in a custom binary — you call extract/compile yourself (snippets above). A compatibility checker for the SWC plugin and a CLI wrapper are possible future additions.
MIT