Intl-T

The Tree

How Intl-T models translations — the one page that makes everything click

The mental model

Your JSON is the API.

en.json
{
  "homepage": {
    "title": "Homepage",
    "welcome": "Welcome, {user}!",
  },
}
t.homepage.title; // "Homepage"
t.homepage.welcome({ user: "Ada" }); // "Welcome, Ada!"

There is no key registry, no namespace concept, no t("a.b.c") string to keep in sync with a file. The shape you wrote is the shape you walk, and TypeScript knows all of it.

Every node is three things at once

Each key in your tree becomes a node. A node behaves as a string, an object, and a function — whichever you need at that moment.

t.homepage.title; //  as object: walk deeper
String(t.homepage.title); //  as string: "Homepage"
t.homepage.welcome({ user: "Ada" }); //  as function: inject variables
t("homepage.title"); //  or address it by path

This is why a node renders directly in JSX (<h1>{t.homepage.title}</h1>) and still lets you keep walking (t.homepage.title.length is not a thing — but t.homepage.hero.cta is).

Property access (t.homepage.title) is the idiomatic form — it is the one that gives you autocomplete and variable inference. Use the path form (t("homepage.title")) when the key is dynamic, or when you're migrating from a library that used strings. Both resolve to the same node.

Anatomy of a node

A node is either a plain string, or an object with reserved fields:

{
  "hero": {
    "base": "Welcome, {user}!", //  the node's own text
    "cta": "Get started", //  a child
    "values": { "user": "World" }, //  default variables for this node and its children
  },
}
String(t.hero); //  "Welcome, World!"   (base, with its default values)
t.hero.cta; //  "Get started"       (child)
t.hero({ user: "Ada" }); //  "Welcome, Ada!"     (base, with your values)
FieldMeaning
baseThe node's own text. Without it, the node is a container only.
valuesDefault variables, inherited by this node and everything under it.
childrenAn explicit child list (rarely needed — keys are children by default).

Arrays work too, and are typed by index:

{ "features": ["First {name}", "Second {name}", { "base": "Third", "title": "tooltip" }] }
t.features[0]({ name: "Ada" });
t.features.map(f => <li key={f.id}>{f}</li>);

Variables

Pass variables where you render:

t.homepage.welcome({ user: "Ada" }); //  "Welcome, Ada!"
t("homepage.welcome", { user: "Ada" }); //  same

Declare defaults in the JSON, and they flow down the tree:

{
  "values": { "brand": "Acme" }, //  root defaults
  "footer": { "copy": "© {brand}" }, //  inherits {brand}
}

Variables are written into the node

Passing variables mutates the node you passed them to — they stick, so that children can inherit them and so chained calls work:

t.account({ id: 7 }).options.change; //  {id} reaches the child
t("welcome", { user: "Ada" });
String(t.welcome); //  still "Welcome, Ada!" — the value stayed

On the client this is what you want. On a long-lived server with concurrent requests, two requests sharing one tree will overwrite each other's variables. Give each request its own copy:

const requestT = t.fork(locale); //  ~6µs, isolated — shares settings and loaded messages

Rendering on the server and sending strings to the client (RSC, getServerSideProps) is unaffected — by then the text is already a string.

ICU messages

Full ICU support, and it composes with the tree.

{
  "items": "{count, plural, =0 {No items} one {# item} other {# items}}",
  "invite": "{gender, select, female {She} male {He} other {They}} invited you",
  "price": "{amount, number, currency}",
  "when": "{date, date, long}",
}
t.items({ count: 0 }); //  "No items"
t.items({ count: 3 }); //  "3 items"
t.invite({ gender: "female" }); //  "She invited you"
t.price({ amount: 1234.5 }); //  "$1,234.50"

Formatting follows the node's locale, not the app's: with the app in English, t.price({ amount: 1234.5 }) gives $1,234.50 while t.es.price({ amount: 1234.5 }) gives 1234,50 US$. Reading another locale never means formatting it in the wrong one.

A variable you never pass is left as-is ({user} renders literally) rather than throwing. To change that, set formatOptions: { onMissingVariable: "empty" } (or a function) in your settings — the option lives inside formatOptions, not at the top level.

Locales

..; //  current locale
.("es"); //  switch the active locale
.; //  the tree at the active locale

..cart.({ : 42 });
cart: Translation<TranslationSettings<"en" | "es", "en" | "es", {
    readonly en: {
        readonly cart: {
            readonly total: "Total: {amount}";
        };
    };
    readonly es: {
        readonly cart: {
            readonly total: "Total: {amount}";
        };
    };
}, Values, ".", {
    readonly cart: {
        readonly total: "Total: {amount}";
    };
}>, {
    readonly total: "Total: {amount}";
}, Values, "es", ["cart"]>

t.es is a sibling node — the same tree, another language, fully typed like the root. It is how you render two locales side by side, or resolve a locale per request without touching global state.

setLocale moves the active locale: t.current and the React hooks follow it. A branch you already hold keeps pointing at its own language — const en = t.en stays English after t.setLocale("es"). Outside React, read through t.current (or a locale branch) after switching.

Copies: fork, with, load

Three methods for when one shared tree is not enough:

const copy = t.fork(); //  isolated copy at the same position — own variables, own locale
const es = t.fork("es"); //  the copy, in another language
const bound = t.checkout.with({ user }); //  fork + set in one call: sticky variables, zero effect on the original
const loaded = await t.load("es"); //  load and return a locale branch on demand

fork shares the settings and the already-loaded messages — it costs microseconds and never re-fetches. It is the primitive for request-scoped state on servers (recipes), and with is the ergonomic form when what you want is a copy with variables attached:

app.get("/welcome", (req, res) => {
  const rt = t.fork(locale); //  this request's tree
  res.send(String(rt.homepage.welcome({ user: req.query.name })));
});

`fork` takes a locale, not a path

t.fork("es") switches language. To scope by content, fork from the branch: t.checkout.fork() — position is preserved.

Loading locales on demand

Give locales a loader instead of an object and Intl-T will fetch what it needs.

const t = createTranslation({
  locales: {
    en: () => import("./messages/en.json"),
    es: () => import("./messages/es.json"),
  },
  allowedLocales: ["en", "es"] as const,
});

The contract, exactly:

ExpressionLoads
await t with preload: trueall allowedLocales (once; later awaits are instant)
await t with preload: falseonly the current locale
await t.esonly es
no await at allnothing yet — unloaded messages render as undefined until a load resolves

A loader that rejects makes await reject (it does not hang), and the next access retries — a transient network failure heals itself.

Reserved keys

These names have meaning inside a node, so they cannot be message keys:

ReservedWhy
base, values, childrennode format fields
defaultproduced by import * as messages from "./en.json"
parent, node, global, settings, current, t, variables, path, keynode internals
then, catchthe async loading contract
any locale code (en, es, …)locale siblings live there

Reserved names apply at every depth — nesting does not free them. If your content needs one (settings is a common section name), rename the key: "settings""preferences", "current""active". A reserved key does not throw; it simply renders as undefined, which is the fastest way to spot one.

Path separator

t.settings.ps = "/";
t("homepage/title");

API reference

createTranslation(settings)

OptionTypeDefault
localesRecord<Locale, Tree | Loader>Required. Your messages, or loaders.
allowedLocalesreadonly Locale[]keys of localesThe locales your app supports. Use as const.
mainLocaleLocalefirst allowedThe locale other locales fall back to.
defaultLocaleLocalemainLocaleThe initial locale.
variablesValues{}Global default variables.
idstringNames this instance so providers resolve it across the server/client boundary — required for multiple translations.
preloadbooleanfalseWith loaders: await t completes every locale (true) or only the current one (default). Nothing loads without an access or an await.
pathSeparator / psstring"."Separator for the path form.
formatOptionsIntl options + onMissingVariableDefaults for number/date formatting, and onMissingVariable: "keep" | "empty" | fn for variables you didn't pass.

Node members

Member
String(node) / node.basethe rendered text
node(variables)render with variables
node(path, variables?)resolve a path from this node
node.set(variables)attach variables without rendering
node.fork(locale?)isolated copy at the same position — own variables and locale, shared messages
node.with(variables)fork().set(variables) — a copy with sticky variables
await node.load(locale?)load a locale's messages and return that branch
node[locale]the same node in another locale
node.currentthe node at the active locale
node.global / node.gthe tree root
node.children / node.keyschild key names
node.toJSON()the plain object back

On this page