Intl-T

React

Provider, hooks, rich-text injection — the React surface of Intl-T

intl-t/react gives you a provider, hooks, and JSX rendering for the tree. It is the surface for React SPAs and the Next.js Pages Router; the App Router adds its own layer on top (Next.js).

i18n/translation.ts
import { createTranslation } from "intl-t/react";
import en from "./messages/en.json";
import es from "./messages/es.json";

export const { Translation, useTranslation, t } = createTranslation({ locales: { en, es } });

Provider

main.tsx
<Translation>
  <App />
</Translation>

Without props, the provider manages the locale itself: it reads the persisted choice from localStorage, falls back to browser detection, and re-renders the app when it changes. Pass locale to control it yourself:

const { locale, setLocale } = useLocale(); // or your own state
<Translation locale={locale} onLocaleChange={setLocale}>
  {children}
</Translation>;
  • Uncontrolled (no props) — persistence and detection are handled for you. Set the initial locale via defaultLocale in createTranslation, not as a prop.
  • Controlled (locale given) — the prop rules. onLocaleChange fires on switch requests; storage is never written unless you write it.

Providers nest: an inner provider with its own translation instance follows the outer provider's locale unless you pass it an explicit locale. That is the client half of namespaces.

Hooks

const { t, locale, setLocale } = useTranslation("common");

useTranslation(path?) returns the node at path — and the return is the node, so everything a node does works directly: useTranslation("home").title, useTranslation().es.footer, destructure { t, locale, setLocale } as you prefer. useTranslations is the same hook, aliased for muscle memory.

In client components, read through the hook — not the module-level `t`

The shared t is anchored to its construction locale; it does not follow the provider. t.title in a client component renders the default language no matter what the provider says. Read via useTranslation() (or t.current) — they resolve the active locale. The direct form is for servers and scripts, where you pick the branch yourself (t[locale], t.fork(locale)).

useLocale manages locale state on its own — with or without a provider:

const { locale, setLocale } = useLocale();

// controlled: the given locale rules; changes are reported, storage untouched
useLocale({ locale: preview, onLocaleChange: report });

// scoped: its own storage key, independent from the app's locale
useLocale({ key: "preview:locale", defaultLocale: "en" });
OptionEffect
locale + onLocaleChangeControlled mode — the value you pass wins, changes are notified
defaultLocaleInitial value when nothing is persisted
keyThe localStorage key (default "locale") — scope it to isolate a widget's locale

Components

Every node exposes its own Translation component (aliases: TranslationProvider, Tr, Trans). With children it acts as a provider; without, it renders the node:

<Translation path="hello" />
<Translation.hello />
<Trans.hello variables={{ name: "Ivan" }} />

Rich text — chunk injection

Messages can carry HTML-like tags, and you decide what they render as:

{ "welcome": "Welcome to <b>{site}</b>. <Link href='/start'>Get started</Link>!" }
t.welcome({
  site: "My Site",
  Link: props => <a href={props.href}>{props.children}</a>,
});

Three kinds of values resolve a tag, in order of what you pass:

You passIt renders
nothingthe tag as a real HTML element — attributes, children and nesting included
a componentyour component, called with the chunk props below
a JSX elementyour element, cloned: its own props survive, the tag's attributes win on conflicts, the translated children are injected
// element form: <icon className="from-tag">label</icon> in the message
t.msg({ icon: <Icon className="mine" data-kept="yes" /> });
// → <Icon data-kept="yes" className="from-tag">label</Icon>

Custom chunk components receive:

interface ReactChunkProps {
  children?: ReactNode; //  translated content inside the tag
  tagName: string;
  tagProps?: Record<string, unknown>; //  the tag's attributes, parsed into a clean bag
  tagAttributes?: string; //  the raw attribute string
  tagContent?: string; //  the raw inner content
  [attribute: string]: unknown; //  each parsed attribute, spread
}

Injected content renders as a Fragment with positional children — no key props needed, no key warnings, chunks nest freely.

Messages are trusted content

Chunk tags in messages behave like JSX you wrote. If translations come from an untrusted source, treat them like any other user-supplied markup — sanitize before they reach the tree. The same stance applies to expression evaluation.

Rendering nodes in JSX

Nodes are function objects, not strings, and they still render directly as children and attributes:

const { t } = useTranslation();
return <h1 title={t.description}>{t.title}</h1>;

That works because creating a translation teaches the JSX runtime to accept nodes — it happens on createTranslation, never as an import side effect (sideEffects: false is real). Where you control the bundler, wiring the runtime yourself is the cleaner form of the same thing:

vite.config.ts
export default defineConfig({
  plugins: [react({ jsxImportSource: "intl-t" })],
  optimizeDeps: { exclude: ["intl-t"] },
});

optimizeDeps.exclude matters: without it Vite's optimizer can duplicate the library and break node identity. The wiring is also per-React-instance, so in test runners that snapshot modules per file (bun test, some vitest setups) render nodes via String(node) or React.createElement.

Contexts that stringify on their own (`${t.title}`, .join, DOM APIs) work; APIs that check typeof x === "string" need the explicit form: String(node), node.base, or node.toString(). All three are type-safe and equivalent.

If you would rather not touch the JSX runtime at all, t.toObject() returns the same tree as an object-typed view that React renders on its own — the same shape non-callable mode uses, where .use() is the call form.

On this page