TanStack i18n

react-intl (React)

Pair TanStack i18n routing with react-intl. Set up FormatJS message formatting and synchronize the active language context in React.

Supported Frameworks: React

Runnable References:

react-intl (FormatJS) provides IntlProvider, useIntl, and FormattedMessage. TanStack i18n handles URL locale, server entry, persist adapters, and setLocale — react-intl handles catalogs and formatting for the active locale.

Because FormatJS expects a flat key-value map for message translation IDs (e.g., Common.switchLanguage), we flatten nested multi-level namespace objects into dot-separated paths during message loading.

Prerequisites

Responsibility split

LayerOwnsPackage
URL locale, redirects, cookie, setLocaleRouting + persist@Wadiou/tanstack-i18n
Message catalogs, intl.formatMessage(), formattingTranslationreact-intl

Critical rule: Switch language with setLocale from useLocaleContext — not a separate locale setter inside react-intl. After setLocale: persist + URL update + router.invalidate() → root beforeLoad re-runs → loadMessagesIntlProvider gets new locale and messages.

Install react-intl

pnpm add react-intl

Add message files

Organize copy per locale and namespace. For the marketing site, start with landing.json and common.json under your messages/ folder (e.g., src/messages/en/landing.json):

{
  "nav": {
    "homeLink": "Home",
    "aboutLink": "About"
  },
  "hero": "Welcome to our site"
}

FormatJS requires a flat catalog. Create helper utilities to flatten nested namespace structures and merge all modules into one:

// src/i18n/flat-messages.ts
export function flattenMessages(
  nestedMessages: unknown,
  prefix = ""
): Record<string, string> {
  if (nestedMessages === null || typeof nestedMessages !== "object") {
    return { [prefix]: String(nestedMessages) };
  }
  const messages: Record<string, string> = {};
  for (const [key, value] of Object.entries(
    nestedMessages as Record<string, unknown>
  )) {
    const prefixedKey = prefix ? `${prefix}.${key}` : key;
    if (typeof value === "object" && value !== null) {
      Object.assign(messages, flattenMessages(value, prefixedKey));
    } else {
      messages[prefixedKey] = String(value);
    }
  }
  return messages;
}
// src/i18n/merge-messages.ts
import type { LocaleMessageModules, Messages } from "@/types/i18n";
import { flattenMessages } from "./flat-messages";

export function mergeMessages(m: LocaleMessageModules): Messages {
  return {
    ...flattenMessages(m.common, "Common"),
    ...flattenMessages(m.landing, "Landing"),
  } as Messages;
}

Per-locale barrels import the nested JSON catalogs 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 });

Configure type definitions

FormatJS allows merging a global interface to provide type checking for message translation IDs. 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 LocaleMessageModules {
  common: typeof enCommon;
  landing: typeof enLanding;
}

type FlattenKeys<T, Prefix extends string = ""> = {
  [K in keyof T & string]: T[K] extends Record<string, unknown>
    ? FlattenKeys<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`;
}[keyof T & string];

export type Messages = {
  [K in keyof LocaleMessageModules as FlattenKeys<
    LocaleMessageModules[K],
    `${Capitalize<K & string>}.`
  >]: string;
};

declare global {
  namespace FormatjsIntl {
    interface Message {
      ids: keyof 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,
});

IntlProvider (outer) — pass loader data

RootComponent reads route context and wraps the tree. IntlProvider is outer; LocaleProvider is inner:

import { IntlProvider } from "react-intl";
import { Outlet } from "@tanstack/react-router";
import { LocaleProvider } from "@/i18n/provider";

function RootComponent() {
  const { locale: activeLocale, messages } = Route.useRouteContext();

  return (
    <div dir={activeLocale === "ar" ? "rtl" : "ltr"} lang={activeLocale}>
      <IntlProvider locale={activeLocale} messages={messages} timeZone="UTC">
        <LocaleProvider>
          <Outlet />
        </LocaleProvider>
      </IntlProvider>
    </div>
  );
}

createLocaleProvider (inner) — bridge hook

Pass a custom hook bridging the active locale from FormatJS:

// src/i18n/provider.tsx
import { createLocaleProvider } from "@Wadiou/tanstack-i18n/react";
import { useIntl } from "react-intl";
import { locale } from "@/locale";

export function useLocale() {
  return useIntl().locale;
}

export const { LocaleProvider, useLocaleContext } = createLocaleProvider({
  runtime: locale,
  useLocale,
});

Translate a page

Inject the useIntl hook and use the typed formatMessage function:

import { useIntl } from "react-intl";
import { LocalizedLink } from "@/i18n/routes";

function MarketingHeader() {
  const intl = useIntl();

  return (
    <header>
      <h1>{intl.formatMessage({ id: "Landing.hero" })}</h1>
      <LocalizedLink to="/about">
        {intl.formatMessage({ id: "Landing.nav.aboutLink" })}
      </LocalizedLink>
    </header>
  );
}

Language switcher

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>
  );
}

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

ConcernStays in app codeProvided by TanStack i18n
JSON message filesYes
loadMessages / mergeYes
Server entry redirectscreateServerEntry()
getLocale()locale.getLocale()
setLocale switchcreateLocaleProvider
IntlProvider / useIntlreact-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/
    flat-messages.ts
    merge-messages.ts
    load-messages.ts
    locales/en.ts
    locales/ar.ts
    provider.tsx                 # createLocaleProvider + useLocale from react-intl
    routes.ts                    # LocalizedLink from guides
  routes/__root.tsx              # beforeLoad + IntlProvider + LocaleProvider

API reference

IntlProvider (react-intl)

PropRole
localeActive locale — from beforeLoad / route context
messagesCatalog object — from loadMessages(locale)
timeZoneOptional — e.g. "UTC" for consistent dates

Hooks (react-intl)

HookRole
useIntl()Formatter object (intl.formatMessage())

createLocaleProvider (@Wadiou/tanstack-i18n/react)

DepRole
runtimeBound LocaleRuntime
useLocaleRead-only locale reader — typically useLocale that uses react-intl

Returns { LocaleProvider, useLocaleContext }. Use setLocale from useLocaleContext in UI.

What's next

  • Paraglide and use-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
Edit on GitHub