@k8ordo/i18n

How it works

How @k8ordo/i18n works underneath. None of it is needed to use the package, but knowing why it is written this way makes the edge cases easier to reason about.

On this page

A message is a function with no side effect

message only returns a function holding the text it was given. At the declaration it registers nothing, checks nothing, and throws nothing.

save-button.tsx
'use client';

import * as m from '../messages';

export function SaveButton() {
  return <button type="submit">{m.form.save()}</button>;
The only message that reaches the browser
}

So a bundler can drop a message nothing calls, as dead code. What a browser bundle carries is exactly the messages that 'use client' modules, and the modules they import, name — each with its text in every locale.

Text a Server Component renders adds nothing to the browser bundle, whichever module declares it. That is why messages are not entries in one dictionary object: a dictionary is kept or dropped whole.

A missing locale is not checked at the declaration for the same reason: a declaration that could throw looks like a side effect to the bundler, which could then no longer drop unused messages. A gap that gets past the types becomes a TypeError where the message is read instead.

message does not take the locale set as an argument for the same reason. Were it a method such as locales.message(…), the bundler would not look inside a function another call returned, and unused messages would stay.

Information

Note

After a build, search the JavaScript in dist/client/assets/ to confirm that text only a Server Component renders is not there.

Where the locale is read

A message is never handed the locale; it reads it when called. Where it reads it differs between the server and the browser.

text
server   paramsSchema accepts 'en'    → AsyncLocalStorage
         nav.home()                   → reads the storage → 'Home'

browser  location.pathname '/en/ui'   → first segment 'en'
         nav.home()                   → reads the URL     → 'Home'

On the server

On the server, the locale paramsSchema accepted is kept in AsyncLocalStorage, so however many requests are rendering at once, their locales stay apart.

AsyncLocalStorage is reached through process.getBuiltinModule rather than an import of node:async_hooks, so the same build loads in a browser unchanged.

The RSC environment and the SSR environment, where Client Components become HTML, are separate module graphs in one process. The storage lives on globalThis so that both read the same locale.

A locale is accepted while the framework is still deciding which pattern answers. It runs each pattern’s schemas in a flow of their own and starts the render in the flow of the pattern that answered, so a locale accepted for a pattern a later schema refused is dropped with it.

In the browser

In the browser, a message reads the first segment of location.pathname below Vite’s base every time it is called. That agrees with the server’s HTML because the HTML was rendered for the same URL; a 404.html rendered for another one is rendered afresh instead of hydrated.

Which of the two is used is decided once, when the module loads, by whether document is defined.

Until the module defining the set has run in the browser, there is no telling whether a segment is a locale. Meanwhile, a segment the message has no text for counts as no locale, and the first text written is used. That is why a 404 page on /fr/… does not throw.

The last set defined is the one used

defineLocales does more than return a value: it registers the default locale and the membership check on globalThis. message takes no set, so this is where it reads the default from.

The last definition wins, so that when a dev server evaluates i18n.ts again after a locale is added, the next message called reads the new set.

Warning

Pitfall

The flip side: define another set inside a test or a helper, and every message after it reads that set. Keep one set per app, defined and exported from one module and imported everywhere else.

Why there is no provider and no hook

The locale is in the URL, and a message reads it where it is called. So there is no provider to pass the locale down the tree, and no hook to read it with.

  • Only a Client Component can read a provider’s value, so a Server Component could not get the locale from one. A message that reads the locale itself is the same line in a Server Component and in a Client Component.
  • Not being a hook, a message can be called in an event handler, inside another message’s function, or in the function handed to bindParams.
  • Changing the locale is a navigation. The browser holds no state to keep in sync: the page renders again, and its messages read the new URL’s locale.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2