TanStack i18n

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:

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

Responsibility split

LayerOwnsPackage
URL locale, redirects, cookie, setLocaleRouting + persist@Wadiou/tanstack-i18n
Message catalogs, t(), dates/numbersTranslationuse-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 → loadMessagesIntlProvider gets new locale and messages.

Install use-intl

pnpm add use-intl

Keep @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

ConcernStays in app codeProvided by TanStack i18n
JSON message filesYes
loadMessages / mergeYes
Server entry redirectscreateServerEntry()
getLocale()locale.getLocale()
setLocale switchcreateLocaleProvider
IntlProvider / useTranslationsuse-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 + LocaleProvider

API reference

IntlProvider (use-intl)

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

Hooks (use-intl)

HookRole
useTranslations(namespace)t('key') in components
useLocale()Read active locale — pass to createLocaleProvider
useFormatter()Dates, numbers, lists

createLocaleProvider (@Wadiou/tanstack-i18n/react)

DepRole
runtimeBound LocaleRuntime
useLocaleRead-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
Edit on GitHub