The Tree
How Intl-T models translations — the one page that makes everything click
The mental model
Your JSON is the API.
{
"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 pathThis 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)| Field | Meaning |
|---|---|
base | The node's own text. Without it, the node is a container only. |
values | Default variables, inherited by this node and everything under it. |
children | An 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" }); // sameDeclare 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 stayedOn 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 messagesRendering 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 });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 demandfork 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:
| Expression | Loads |
|---|---|
await t with preload: true | all allowedLocales (once; later awaits are instant) |
await t with preload: false | only the current locale |
await t.es | only es |
no await at all | nothing 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:
| Reserved | Why |
|---|---|
base, values, children | node format fields |
default | produced by import * as messages from "./en.json" |
parent, node, global, settings, current, t, variables, path, key | node internals |
then, catch | the 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)
| Option | Type | Default | |
|---|---|---|---|
locales | Record<Locale, Tree | Loader> | — | Required. Your messages, or loaders. |
allowedLocales | readonly Locale[] | keys of locales | The locales your app supports. Use as const. |
mainLocale | Locale | first allowed | The locale other locales fall back to. |
defaultLocale | Locale | mainLocale | The initial locale. |
variables | Values | {} | Global default variables. |
id | string | — | Names this instance so providers resolve it across the server/client boundary — required for multiple translations. |
preload | boolean | false | With loaders: await t completes every locale (true) or only the current one (default). Nothing loads without an access or an await. |
pathSeparator / ps | string | "." | Separator for the path form. |
formatOptions | Intl options + onMissingVariable | — | Defaults for number/date formatting, and onMissingVariable: "keep" | "empty" | fn for variables you didn't pass. |
Node members
| Member | |
|---|---|
String(node) / node.base | the 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.current | the node at the active locale |
node.global / node.g | the tree root |
node.children / node.keys | child key names |
node.toJSON() | the plain object back |