Skip to content

i18n

A second language is not a feature you add to a page. It reaches the controllers, the views of whichever engine renders them, and the mails — and the mails are the ones people forget, because the language of a mail is the recipient’s and there is often no request to ask.

henri.i18n is the lookup, and config/locales is where the strings live:

config/locales/en.json
{
"nav": { "notes": "Notes", "settings": "Settings" },
"notes": {
"count": {
"=0": "No notes yet",
"one": "1 note",
"other": "{count} notes"
},
"greeting": "Hello, {name}!"
}
}
app/controllers/notes.js
module.exports = {
index: async (req, res) => {
const notes = await Note.find();
return res.render('/notes/index', {
data: { notes, title: req.t('notes.count', { count: notes.length }) },
});
},
};

Nothing else is needed. A file in config/locales is what turns this on; an application with one language has none, and pays for none of it.

What an application with one language pays

Section titled “What an application with one language pays”

Nothing at request time, and 5 µs at boot.

With no config/locales directory and no i18n block, the module is inert: no catalogue is read, no middleware is mounted (not one that returns early — one that is not in the stack), req.locale, req.localeSource, req.t and req.setLocale are not set, res.render() puts no i18n in the view options, nothing reaches the client, and the boot prints no line. The whole cost is the fs.existsSync that found no directory.

That is the same rule the call log follows, and it is deliberate: a feature that is present and quiet still costs a property read on every request, forever, in every application that never wanted it.

With two locales and a thousand strings, the boot reads them in about 0.8 ms, and the middleware costs 465 ns per request when it has to negotiate Accept-Language and 59 ns when a query parameter already said. One t() is about 270 ns.

config/locales/<locale>.json is one locale. Past a few hundred strings, config/locales/<locale>/<namespace>.json is the same locale with the file name as a prefix — config/locales/fr/nav.json holding { "home": "Accueil" } answers nav.home.

Keys are nested in the file and dotted in a call; a list becomes numbered keys (days.0). A value is a string or a set of plural forms, and nothing else: a number, a boolean or a null in a locale file fails the boot with HENRI_LOCALE_CATALOGUE_INVALID, rather than rendering as 2 somewhere.

config.i18n.locales names the locales the application has. Without it, henri reads the directory — which is what you want in development and is not always what you want in production, where a half-finished de.json sitting in the repository would start answering requests. Naming them is how you keep one out.

The order is fixed, and each step can be renamed or turned off in config.i18n.from:

  1. an explicit callreq.setLocale('fr'), or { locale } on a t();
  2. the user’s own settingfrom.user names a column of the user model. henri has no opinion about that column existing; it reads the one you name;
  3. the query?locale=fr (from.query, locale by default);
  4. the cookiefrom.cookie, henri.locale by default. henri reads it and never writes it: a language switcher is an action of your application, and a framework that sets a cookie nobody asked for is a framework that quietly broke somebody’s cache;
  5. Accept-Language — negotiated by q value, with fr-CA matching a catalogue named fr and fr matching one named fr-CA;
  6. the defaultconfig.i18n.default.

The decision is on the request, where you can read it, log it and assert it:

req.locale; // 'fr'
req.localeSource; // 'query'

and on the wire, as Content-Language. The response also varies on whichever header decided — Vary: Accept-Language for step 5, Vary: Cookie for steps 2 and 4 — so a cache in front of the application does not hand one visitor’s language to the next.

A view gets the same thing: {{@i18n.locale}} in Handlebars, useHenri().i18n in Inertia and React.

/fr/notes is a routing decision, and henri’s route table is the source of both the url and the helper that prints it. Stripping a prefix at the edge would make notes_path() lie to every page that renders it, and half a path prefix is worse than none.

What to write instead, in two lines:

config/routes.js
'namespace fr': { 'resources notes': { controller: 'notes' } },
app/controllers/fr/notes.js
module.exports = {
before: { all: (req) => void req.setLocale('fr') },
index: async (req, res) => res.render('/notes/index', { data: {} }),
};

The helpers stay true (index_fr/notes_path), the locale is explicit, and req.localeSource says explicit so nobody has to guess where it came from.

It answers the key. t('nav.settings') renders nav.settings, never Settings.

That is the whole design decision on this page worth arguing. A humanized key reads like a translation, ships like a translation, and is invisible in a review — which is exactly how an application ends up half translated in production, with nobody able to point at the half. The key is ugly on purpose, and it is greppable.

On top of that, config.i18n.missing says what else happens:

Mode What it does
auto (default) warn outside production, key in it
warn one pen.warn per key, however many requests reach it
key nothing; the key is the answer
throw raises HENRI_LOCALE_TRANSLATION_MISSING

Every mode records it, and that is where a missing key is found at runtime:

henri.i18n.missing();
// [{ key: 'nav.settings', locale: 'fr', why: 'no key' }]
henri.i18n.misses(); // 412

throw is what a test suite sets. It is the only setting that makes a missing key fail a build:

config/test.json
{ "i18n": { "missing": "throw" } }

A key found in a fallback locale is not missing — it is answered, silently, because that is what a fallback is for. config.i18n.fallback is true (fall back to i18n.default), false (a key missing in fr is missing whatever en holds), or a locale, or a list of them. A regional locale always falls back to its language first: fr-CA, then fr, then the rest of the chain.

henri.i18n.missing() only knows about keys something asked for. The other half is henri doctor, which reads the files and compares them:

warning i18n.incomplete config/locales/fr.json is missing 12 keys that en has: nav.settings, …
warning i18n.orphan config/locales/fr.json has 1 key that en has not: nav.setings
warning i18n.placeholders config/locales/fr.json fills different values than en in 1 key: greeting
error i18n.catalogue config/locales/de.json: Unexpected token } in JSON

i18n.placeholders is the one that reaches a person: a key whose {name} values differ between locales prints a literal {count} on somebody’s page, because a value nobody passed is left as its own placeholder rather than blanked. henri doctor --json answers the same list under problems, so a CI job can gate on it.

A translation carries {name}, and the values fill it:

{ "greeting": "Hello, {name}!", "literal": "Use {{name}} for a brace" }
req.t('greeting', { name: user.name }); // "Hello, Ada!"

{{ and }} are the literal braces. A value nobody passed is left as {name} — visible on the page, and recorded — rather than blanked into a sentence with a hole in it.

Then the part that matters:

A translation is written by a developer and lives in the repository. The values interpolated into it come from the application, which means they can come from a person.

So henri never escapes a translation, and escapes the values wherever there is markup to escape them into:

Where The translation The values Why
henri.i18n.t(), req.t() as written not escaped It answers a plain string. A controller putting one in a JSON body would otherwise ship &amp; to a client that is not a browser. Escaping happens where it is rendered.
{{t}} in a page or a mail’s html as written HTML-escaped The one place henri escapes. It answers a SafeString, which is what lets a translation carry <strong> while a name carries nothing.
{{t}} in a <view>.text.hbs as written not escaped There is no markup in text/plain. Escaping there would print &amp; to the reader instead of hiding a tag from a parser.
t() in an Inertia or React page as written not escaped by henri React escapes every string it renders as a child, so {t('greeting', { name })} is safe whatever the name holds.

That last row has a consequence worth stating rather than hiding: a translation carrying <strong> shows as text in a React or Inertia page, and as markup in a Handlebars one. The engines differ, because React’s escaping is not henri’s to turn off. A page that wants markup writes it in JSX around two keys:

<p>
{t('read.before')} <strong>{t('read.guide')}</strong>
</p>

henri ships no dangerouslySetInnerHTML helper for this, deliberately: an escape hatch there is one refactor away from a translation file becoming an injection point.

Intl.PluralRules decides, because it knows the categories of every locale ICU knows and a hand-written n === 1 does not know Polish:

{ "notes": { "=0": "No notes yet", "one": "1 note", "other": "{count} notes" } }
req.t('notes', { count: 0 }); // "No notes yet"
req.t('notes', { count: 1 }); // "1 note"
req.t('notes', { count: 7 }); // "7 notes"

count selects the form and is interpolated like any other value. The categories are zero, one, two, few, many and other; other has to be there, and it is also what tells a plural entry from a namespace that happens to have a key called one. An exact form ("=0", "=1") wins over the category, because “No notes yet” is a sentence and not a plural of “note”, and no plural rule in any locale would produce it. { ordinal: true } selects ordinal forms instead.

French makes one of zero and English does not; both come out right without a line of henri knowing that. A plural entry asked for without a count is missing, not a guess at other.

<h1>{{t 'notes.greeting' name=user.name}}</h1>
<p>{{t 'notes.count' count=notes.length}}</p>
<p>{{number total style='currency' currency='EUR'}}</p>
<time>{{date note.createdAt dateStyle='long'}}</time>

{{number}} and {{date}} exist for one reason: Handlebars has no expressions, so a template cannot call Intl itself. Their hash is the options object of Intl.NumberFormat and Intl.DateTimeFormat, passed through unchanged with the locale of the render — henri invents no option name and adds no behaviour. A .jsx page calls Intl directly and gets nothing from henri here.

import { useTranslation } from '@usehenri/inertia';
// or: import { useTranslation } from '@usehenri/react/i18n';
export default function Index({ notes }) {
const { t, locale } = useTranslation();
return (
<>
<h1>{t('notes.count', { count: notes.length })}</h1>
<p>{new Intl.NumberFormat(locale).format(notes.length)}</p>
</>
);
}

How the strings get to the browser, which is the payload question and the reason there is a client setting at all:

  • a document — a full page load, and the server-side render behind it — carries the catalogue, once;
  • every Inertia visit and every client-side navigation after it carries { locale, source, url } and no strings, because the browser asking for it loaded a document to get here and already has them;
  • when the locale changes mid-session, the props name a url whose digest is in the file name (/_henri/locales/fr.9f2a1c8e.json), so the answer is immutable and the browser fetches it once, ever.

A thousand strings weigh about 23 kB. Carrying them on every Inertia visit would be 23 kB per click; carrying them once per document is 23 kB per session, and the url is the seam that keeps a language switch from needing a full reload. That is client: "auto", the default.

The other two answers are there when that trade is wrong for you. client: "always" puts the catalogue in every answer — simpler, no second request ever, and right for a small catalogue. client: false sends the locale and no strings at all, for an application that only translates on the server.

serverOnly (default ["mailers"]) is the other bound: those key prefixes never leave the server, because the strings of a mail are written for a recipient and shipping them to a reader is a payload nobody reads. They still work in t().

The locale of a mail is the recipient’s, and it is never the request’s.

That is not pedantry. An administrator acting on somebody else’s account, a nightly digest and a job retrying a delivery an hour later all produce a mail whose reader is not whoever made the request — and two of those have no request at all. A framework that quietly used req.locale would be right most of the time and invisibly wrong the rest, which is the worst of both.

So a message carries its own locale, in this order:

  1. locale in what the mailer action returned;
  2. locale in the mailer’s defaults;
  3. for — the recipient’s record, whose i18n.from.user column henri reads;
  4. i18n.default.
app/mailers/notes.js
module.exports = {
digest(user, notes) {
const locale = henri.i18n.forUser(user) || henri.i18n.fallback;
return {
data: { name: user.name, notes },
for: user, // the record: what makes the view and the fallback agree
subject: henri.i18n.t('mailers.digest.subject', {}, { locale }),
to: user.email,
};
},
};

for and locale are henri’s own keys: neither reaches nodemailer, and for in particular is a record, which is exactly what a mail payload must not carry into a queue row. The view reads the locale like a page does:

<h1>{{t 'mailers.digest.greeting' name=name}}</h1>

A mail from a job works because the record is what is asked, not a request. henri.i18n.forUser(user) is that call, and it is the whole of it.

One detail about the queue: deliverLater() renders before it enqueues, so the strings are already in the row. A worker needs no catalogue, and editing a translation does not rewrite a mail that is already waiting to go out.

POST /signup, the password reset and the address confirmation mail through the built-in auth mailer, and henri passes it the user’s record — so those mails follow whatever i18n.from.user names on that person.

A brand-new account whose record says nothing gets i18n.default. The way to make a signup follow the visitor is to accept the locale as a signup field:

{ "user": { "signup": { "fields": ["name", "locale"] } } }

with a hidden locale input carrying req.locale. That is one line, and it also makes every later mail to that person right — which a per-request guess would not.

Dates, numbers and currency are Intl’s. henri hands you req.locale and stops:

new Intl.NumberFormat(req.locale, {
currency: 'EUR',
style: 'currency',
}).format(total);
new Intl.DateTimeFormat(req.locale, { dateStyle: 'long' }).format(
note.createdAt
);
new Intl.RelativeTimeFormat(req.locale).format(-3, 'day');

A henri.i18n.number() would be a second option vocabulary in front of one that is already standard, versioned by ICU, better documented than anything henri would write, and knows more about currencies than henri ever will. The two Handlebars helpers above are the single exception, and they exist because Handlebars cannot call a function with named arguments — their hash is forwarded to Intl unchanged.

Model attribute names and validation messages are not translated. henri.model.errors(error) normalizes what three ORMs raise into { field: message }, and those messages come from Sequelize, Mongoose or Drizzle, in English, from a library henri does not control and whose wording changes between versions. A catalogue keyed by that wording would break on an upgrade, silently, in the language it was written for.

What henri gives you instead is a default, so the composition is one line:

const errors = henri.model.errors(error);
const said = Object.fromEntries(
Object.entries(errors).map(([field, message]) => [
field,
req.t(`errors.note.${field}`, {}, { default: message }),
])
);

A key you have written answers in the reader’s language; a key you have not falls through to the ORM’s own words — and is still recorded in henri.i18n.missing(), so default never turns into a translation by being invisible. The same pattern covers HENRI_PARAMS_INVALID (base/params-schema.js), whose messages are henri’s own: they are not translated either, for the mirror-image reason — henri shipping catalogues of its own strings in N languages is a product, and an application that overrode half of them would ship a mix.

henri translates none of its own output. Error pages, boom bodies, log lines and CLI messages are in English. They are for you, not for your reader.

{
"i18n": {
"path": "config/locales",
"locales": ["en", "fr"],
"default": "en",
"fallback": true,
"from": {
"user": "locale",
"query": "locale",
"cookie": "henri.locale",
"header": true
},
"missing": "auto",
"client": "auto",
"serverOnly": ["mailers"]
}
}

Every key is described in configuration. "i18n": false turns it off whatever is on disk.

On the instance:

Call Answers
henri.i18n.enabled whether this application has any catalogue
henri.i18n.locales the locales it has
henri.i18n.fallback i18n.default
henri.i18n.t(key, values, options) the translation; options takes locale, default and ordinal
henri.i18n.has(key, locale) whether the chain translates it
henri.i18n.supports(locale) whether it is one of the locales
henri.i18n.forUser(record) the locale that record says, or null
henri.i18n.catalogue(locale) the flat catalogue, without serverOnly
henri.i18n.url(locale) where a browser fetches it, digest and all
henri.i18n.missing() every key that was asked for and nothing translates

On a request: req.locale, req.localeSource, req.t(key, values, options) and req.setLocale(locale), which refuses a locale the application has not (HENRI_LOCALE_UNKNOWN).

The codes are HENRI_LOCALE_CATALOGUE_INVALID, HENRI_LOCALE_KEY_INVALID, HENRI_LOCALE_TRANSLATION_MISSING and HENRI_LOCALE_UNKNOWN; each is explained in the error reference.