use-intl (React)
Wire use-intl with TanStack i18n. Configure message catalogs, wrap routes in IntlProvider, and set up locale state syncing via beforeLoad.
Supported Frameworks: React
Runnable References:
- React Start (SSR): react-start-use-intl
- React Router (SPA): react-router-use-intl
use-intl adds message catalogs and useTranslations while TanStack i18n keeps owning locale in the URL. This guide explains how to add JSON message files, load them in root beforeLoad, wrap the app with IntlProvider outside LocaleProvider, and translate the marketing header. Language switches reload messages through router invalidation — same setLocale flow as before.
Prerequisites
- Guides through Change locale
- Locale runtime exports
locale - TanStack Start server entry wraps
handler.fetch
Responsibility split
| Layer | Owns | Package |
|---|---|---|
URL locale, redirects, cookie, setLocale | Routing + persist | @Wadiou/tanstack-i18n |
Message catalogs, t(), dates/numbers | Translation | use-intl |
Critical rule: Switch language with setLocale from useLocaleContext — not a separate locale setter inside use-intl. After setLocale: persist + URL update + router.invalidate() → root beforeLoad re-runs → loadMessages → IntlProvider gets new locale and messages.
Install use-intl
pnpm add use-intlKeep @Wadiou/tanstack-i18n and subpaths from the guides. use-intl is the core library — APIs are IntlProvider and use-intl, not NextIntlClientProvider.
Add message files
Organize copy per locale and namespace. For the marketing site, start with Landing and Common under your messages/ folder (e.g., src/messages/en/landing.json):
{
"hero": "Welcome to our site",
"nav": {
"homeLink": "Home",
"aboutLink": "About us",
"programmaticHome": "Programmatic Home"
}
}// src/messages/ar/landing.json
{
"hero": "مرحبًا بكم في موقعنا",
"nav": {
"homeLink": "الرئيسية",
"aboutLink": "من نحن",
"programmaticHome": "الرئيسية برمجياً"
}
}Merge namespaces into one object use-intl expects:
// src/i18n/merge-messages.ts
import type { LocaleMessageModules, Messages } from "@/types/i18n";
export function mergeMessages(m: LocaleMessageModules): Messages {
return {
Common: m.common,
Landing: m.landing,
};
}Per-locale barrels import JSON and call mergeMessages:
// src/i18n/locales/en.ts
import common from "@/messages/en/common.json";
import landing from "@/messages/en/landing.json";
import { mergeMessages } from "../merge-messages";
export default mergeMessages({ common, landing });Message loading stays app code — TanStack i18n does not ship catalogs.
Configure type definitions
Augment use-intl for autocomplete on namespace keys. Create a type declaration file:
// src/types/i18n.d.ts
import type enCommon from "@/messages/en/common.json";
import type enLanding from "@/messages/en/landing.json";
export type SupportedLocale = "en" | "ar";
export interface Messages {
Common: typeof enCommon;
Landing: typeof enLanding;
}
export type LocaleMessageModules = {
[K in keyof Messages as Lowercase<K>]: Messages[K];
};
declare module "use-intl" {
interface AppConfig {
Locale: SupportedLocale;
Messages: Messages;
}
}Implement loadMessages
Dynamic import per locale keeps bundles split:
// src/i18n/load-messages.ts
import type { SupportedLocale, Messages } from "@/types/i18n";
const loaders = {
en: () => import("./locales/en"),
ar: () => import("./locales/ar"),
} as const satisfies Record<
SupportedLocale,
() => Promise<{ default: Messages }>
>;
export async function loadMessages(locale: SupportedLocale): Promise<Messages> {
const { default: messages } = await loaders[locale]();
return messages;
}Root beforeLoad — locale + messages
Resolve locale with the runtime, then load messages. On __root.tsx:
import { createRootRouteWithContext } from "@tanstack/react-router";
import { getLocale } from "@/locale";
import { loadMessages } from "@/i18n/load-messages";
import type { SupportedLocale, Messages } from "@/types/i18n";
interface RouterContext {
locale: SupportedLocale;
messages: Messages;
}
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async () => {
const active = await getLocale();
const messages = await loadMessages(active);
return { locale: active, messages };
},
component: RootComponent,
});getLocale() (imported from src/locale.ts) uses URL segment → persist adapters → defaultLocale (Behavior contract). Prefer root beforeLoad over a nested-only loader so every route shares one message load.
IntlProvider (outer) — pass loader data
RootComponent reads route context and wraps the tree. IntlProvider is outer; LocaleProvider is inner:
import { IntlProvider } from "use-intl";
import { Outlet } from "@tanstack/react-router";
import { LocaleProvider } from "@/i18n/provider";
function RootComponent() {
const { locale, messages } = Route.useRouteContext();
return (
<div dir={locale === "ar" ? "rtl" : "ltr"} lang={locale}>
<IntlProvider locale={locale} messages={messages} timeZone="UTC">
<LocaleProvider>
<Outlet />
</LocaleProvider>
</IntlProvider>
</div>
);
}Why outer? LocaleProvider calls useLocale from use-intl inside its render — that hook requires an IntlProvider ancestor. The locale prop on IntlProvider is the source of truth; use-intl's useLocale() is read-only.
createLocaleProvider (inner) — bridge hook
Pass useLocale from use-intl directly:
// src/i18n/provider.tsx
import { createLocaleProvider } from "@Wadiou/tanstack-i18n/react";
import { useLocale } from "use-intl";
import { locale } from "@/locale";
export const { LocaleProvider, useLocaleContext } = createLocaleProvider({
runtime: locale,
useLocale,
});This replaces hand-rolled switch logic (cookie + URL rewrite + invalidate) with setLocale from Change locale. Mount LocaleProvider inside IntlProvider, both under the router.
Translate a page
import { useTranslations } from "use-intl";
import { LocalizedLink } from "@/i18n/routes";
function MarketingHeader() {
const t = useTranslations("Landing");
return (
<header>
<h1>{t("hero")}</h1>
<LocalizedLink to="/about">{t("nav.aboutLink")}</LocalizedLink>
</header>
);
}Navigation stays on TanStack Router helpers — use-intl only replaces string content.
Language switcher — unchanged API
Keep the switcher from Change locale:
function LanguageSwitcher() {
const { locales, locale, setLocale } = useLocaleContext();
return (
<select
aria-label="Language"
value={locale}
onChange={(e) => void setLocale(e.target.value)}
>
{locales.map((code) => (
<option key={code} value={code}>
{code === "en" ? "English" : "العربية"}
</option>
))}
</select>
);
}Do not add a second locale setter from use-intl — TanStack i18n owns the switch gesture.
SSR vs SPA
For Full-stack (SSR): On the server, beforeLoad runs with the incoming request — getLocale() resolves from URL and cookies the same way as on the client. Wrap the document with html and body tags in the root route component, and pass the resolved locale and direction to them.
For Single Page Applications (SPA): You do not need an isomorphic helper. You can call locale.getLocale() directly from the runtime object inside beforeLoad or any other client-side code, relying on the localStorage adapter to persist settings. Wrap with a root div container instead of html and body tags.
Custom vs package
| Concern | Stays in app code | Provided by TanStack i18n |
|---|---|---|
| JSON message files | Yes | — |
loadMessages / merge | Yes | — |
| Server entry redirects | — | createServerEntry() |
getLocale() | — | locale.getLocale() |
setLocale switch | — | createLocaleProvider |
IntlProvider / useTranslations | use-intl | — |
Complete example (file list)
src/
locale.ts # createLocaleRuntime export & helpers
messages/ # JSON message catalogs
en/
landing.json
common.json
ar/
landing.json
common.json
i18n/
merge-messages.ts
load-messages.ts
locales/en.ts
locales/ar.ts
provider.tsx # createLocaleProvider + useLocale from use-intl
routes.ts # LocalizedLink from guides
routes/__root.tsx # beforeLoad + IntlProvider + LocaleProviderAPI reference
IntlProvider (use-intl)
| Prop | Role |
|---|---|
locale | Active locale — from beforeLoad / route context |
messages | Catalog object — from loadMessages(locale) |
timeZone | Optional — e.g. "UTC" for consistent dates |
Hooks (use-intl)
| Hook | Role |
|---|---|
useTranslations(namespace) | t('key') in components |
useLocale() | Read active locale — pass to createLocaleProvider |
useFormatter() | Dates, numbers, lists |
createLocaleProvider (@Wadiou/tanstack-i18n/react)
| Dep | Role |
|---|---|
runtime | Bound LocaleRuntime |
useLocale | Read-only locale reader — typically useLocale from use-intl |
Returns { LocaleProvider, useLocaleContext }. Use setLocale from useLocaleContext in UI.
What's next
- Paraglide and react-intl — same split of concerns; full recipes planned
- Behavior contract — locale resolution guarantees
- Migration from libs that owned URL routing — coming in the Migration section
Integrations
Integrate TanStack i18n routing with message and translation libraries. Explore guides for react-intl, use-intl, react-i18next, and Paraglide.
react-i18next (React)
Integrate react-i18next with TanStack i18n routing. Keep translation catalogs and routing state synchronized in React applications.