Intl-T

Node & Servers

Express, Hono, Fastify — per-request locale without shared state

Intl-T's core has no framework in it. Import intl-t on any server and you get the same typed tree.

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

export const t = createTranslation({ locales: { en, es }, mainLocale: "en" });

Resolving the locale per request

intl-t/utils gives you the two pieces you need, with no framework coupling.

import { match, negotiator } from "intl-t/utils";

const locale = match(negotiator({ headers: req.headers }), ["en", "es"], "en");
  • negotiator({ headers }) parses Accept-Language into an ordered list of preferences.
  • match(preferences, allowed, fallback) picks the best supported locale, handling regional codes (es-MXes).

Rendering for that request

Never call setLocale on a shared tree

t.setLocale(locale) mutates the tree for the whole process. Two concurrent requests will overwrite each other's locale, and a request can render in another user's language. The same is true of variables: t("welcome", { user }) writes user into the node.

This is not a bug you can catch in local testing — it only appears under concurrency.

Use a locale branch instead. t[locale] is the same tree in another language, and reading it changes nothing globally:

server.ts
import express from "express";
import { match, negotiator } from "intl-t/utils";
import { t } from "./i18n/translation";

const app = express();

app.get("/", (req, res) => {
  const locale = match(negotiator({ headers: req.headers }), ["en", "es"], "en");
  const tl = t[locale]; //  this request's tree

  res.send(String(tl.homepage.title));
});

If the request needs its own variables (a user name, a count), fork it. A fork shares the settings and the loaded messages, so it costs microseconds:

app.get("/welcome", (req, res) => {
  const locale = match(negotiator({ headers: req.headers }), ["en", "es"], "en");
  const rt = t.fork(locale); //  isolated copy, ~6µs

  res.send(String(rt.homepage.welcome({ user: req.query.name })));
});

t.checkout.with({ user }) is the one-liner when a single branch needs the variables — a fork with them already attached.

PatternIsolated?CostUse it when
t[locale]Locale: yes. Variables: nofreeYou only read messages, no per-user variables
t.fork(locale) / branch.with(vars)Yes~6µsThe request injects its own variables — pass the locale to fork, never through [locale] on the fork (that returns the shared branch)
t.setLocale(locale)No — never on a serverSingle-user contexts only (CLI, tests, the browser)

In Next.js you don't need any of this: the App Router integration isolates the locale per request for you (getRequestLocale, setRequestLocale), and rendering happens before the response is sent. This page is for servers you run yourself.

Everything else works the same

const tl = t[locale];

tl.homepage.items({ count: 3 }); //  ICU plurals
tl.cart.total({ amount: 42.5 }); //  "{amount, number, currency}" → formats in `locale`
tl.homepage.hero.cta; //  nested access
String(tl("homepage.title")); //  path form, for dynamic keys

Formatting follows the branch's locale, so t.es.cart.total({ amount: 42.5 }) formats in Spanish even while another request is reading t.en.

Loading messages on demand

On a server, preload every locale at boot — one cost, then zero per request. Loading is lazy by default, so say so explicitly:

const t = createTranslation({
  locales: {
    en: () => import("./messages/en.json"),
    es: () => import("./messages/es.json"),
  },
  allowedLocales: ["en", "es"] as const,
  preload: true, //  `await t` means *all* locales
});

await t; //  loads everything, once

To load lazily instead, await only the branch you need — await t.es fetches Spanish and nothing else. See the tree for the full contract.

On this page