next-react-intl
v0.1.1
Published
react-intl for the Next.js App Router — server + client glue, no build plugin required. Resolves FormatJS hashed message IDs at runtime so you don't need the ABI-fragile @swc/plugin-formatjs.
Maintainers
Readme
next-react-intl
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.
Install
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.
Quickstart
1. Configure once
// 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,
})2. Mount the provider in your root layout (Server Component)
// 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>
)
}3. Use it — Server Components
import { getIntl } from "@/i18n"
export default async function AccountPage() {
const intl = await getIntl()
return <h1>{intl.formatMessage({ defaultMessage: "Account" })}</h1>
}4. Use it — Client Components
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.
The catalog workflow
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:
// package.json
{
"scripts": {
// 1. Scan source → English catalog (hashed ids). The id pattern MUST match
// next-react-intl's default.
"i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --ignore='**/*.d.ts' --out-file lang/en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
// 2. Mirror lang/en.json → lang/locales/<locale>.json and translate each value
// (keep the same keys). One file per non-source locale.
// 3. Compile to the runtime catalogs your loadMessages() imports.
"i18n:compile-en": "formatjs compile lang/en.json --out-file src/i18n/compiled/en.json",
"i18n:compile-others": "formatjs compile-folder lang/locales src/i18n/compiled"
}
}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.
API
next-react-intl/server
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).
next-react-intl (client)
<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.
next-react-intl/hash
messageId(descriptor, pattern?)/withId(descriptor, pattern?)— the runtime id computation, if you need it directly.DEFAULT_ID_INTERPOLATION_PATTERN.
Locale routing
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.
Scope (v0)
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.
License
MIT
