best-i18n

Writing messages

The t and useI18n macros, plurals, context and markup

Anywhere — loaders, server functions, plain modules:

import { t } from 'best-i18n/macro'

const title = t`A small starter with room to grow.`
const greeting = t`Hi ${name}, you have ${count} items`

Inside React components, reactive to locale changes:

import { useI18n } from 'best-i18n/react/macro'

function About() {
  const t = useI18n()
  return <p>{t`About`}</p>
}

Both are compile-time macros: the binding can only be used as a tagged template. Storing it, passing it around, destructuring useI18n() or shadowing the name is a build error with a file and offset, not a runtime surprise.

A line break inside a template is code formatting, not content: it collapses to a single space — the same rule JSX applies to <Trans> — so re-indenting a component never changes a message or orphans its translations. Write \n for a literal newline.

An interpolated identifier names its own placeholder, so the translator sees Hi {name}, you have {count} items rather than Hi {0}, you have {1}. Anything more complex than an identifier falls back to a number. A dropped or invented placeholder in a translation is a build error naming the file, the locale and the message.

Plurals

import { plural } from 'best-i18n/macro'

const label = plural(count, `One item`, `${count} items`)

The two forms are one gettext entry — msgid and msgid_plural — and each locale's .po supplies as many msgstr[n] forms as its Plural-Forms header declares (Russian three, Chinese one; there is a built-in table for catalogs that don't set the header). The compiler inlines that locale's selection formula at the call site, so what ships is a small arrow function per plural message — no ICU runtime, no Intl.PluralRules, and a one-form locale gets the bare string with no dispatch at all. The count is always available to a translation as a placeholder, interpolated or not.

Context

Two identical texts that must translate differently are different messages. ctx is gettext's msgctxt:

const verb = t.ctx('verb')`Open` // 打开
const sign = t.ctx('adjective')`Open` // 营业中
const markup = <Trans ctx='verb'>Open</Trans>

Naming the locale

A call site can name the locale it wants instead of reading the current one: an email in the recipient's language, a preview of another locale, a label that must stay in the source language. locale is the last modifier on t and plural, and a prop on <Trans>:

const zh = t.locale('zh')`Hello` // always 你好
const theirs = t.locale(user.locale)`Hello` // whatever they chose
const count = plural.locale(user.locale)(n, `One item`, `${n} items`)
const rich = (
  <Trans locale={user.locale}>
    Read the <a href={url}>docs</a>
  </Trans>
)

Modifiers chain in either order: t.ctx('verb').locale('zh') and t.locale('zh').ctx('verb') are the same message. A string literal compiles to that locale's text alone - no branch, no locale read - and a literal that is not one of the configured locales is a build error. Any other expression compiles to a branch over its value at runtime. Either way the message does not subscribe to the current locale, so it does not update when the user switches, and in a 'use client' module on Next.js it needs no hook and no generated component. A per-locale build leaves these branches in place: the build fixes the current locale, not the ones a call site asks for.

Comments for the translator

A // i18n: comment directly above (or on the line of) a message becomes a #. extracted comment in the catalogs:

// i18n: Button label on the home page, keep it short
const label = t`Save`

Messages with markup

A tagged template cannot hold JSX, so a sentence with a link or a bold run in it has nowhere to go. <Trans> is that place:

import { Trans } from 'best-i18n/react/macro'

function About() {
  return (
    <p>
      <Trans>
        Read the <a href={docsUrl}>documentation</a> to learn more.
      </Trans>
    </p>
  )
}

Svelte uses the same component and the same catalog placeholders, from best-i18n/svelte/macro. The compiler emits {#if} around the locale branches instead of a JSX fragment — Svelte cannot put elements inside {...}. See Svelte.

The catalog stores the markup as named placeholders — the tag's own name where it has one, a number where it does not — for the reason Lingui established: a translator moves the pieces, and never sees a JSX attribute:

msgid "Read the <a>documentation</a> to learn more."
msgstr "请阅读<a>文档</a>了解更多。"

Where this parts ways with Lingui is what runs. There is no component walking a message tree per render: each locale's version is reassembled into ordinary JSX at build time, so the above compiles to

// getLocale is injected by the compiler, from 'best-i18n/runtime'
getLocale() === 'zh' ? (
  <>
    请阅读<a href={docsUrl}>文档</a>了解更多。
  </>
) : (
  <>
    Read the <a href={docsUrl}>documentation</a> to learn more.
  </>
)

and, under staticLocale, to the one branch on its own.

Whitespace follows JSX's own rules, so the stored message matches what renders — including the space a line break swallows, which is why {' '} exists. <Trans> takes no props, key included: wrap it in the element that needs one.

<Trans> in a Client Component

A Client Component is rendered from two module graphs, and only the browser's has an ambient locale — so a message there has to read the locale through React, the one channel both halves share. useI18n() is that channel, and a <Trans> sitting in JSX gets it without asking:

'use client'
import { Trans } from 'best-i18n/react/macro'

// No hook in this file, and none needed.
export function Notice({ href }: { href: string }) {
  return (
    <p>
      <Trans>
        A Client Component needs no hook for <a href={href}>this sentence</a>.
      </Trans>
    </p>
  )
}

The message becomes a component of its own, which is a legal place to call a hook from, and the markup stays where it was — href is a local, so the element is passed in rather than moved:

const __i18nM1 = (l, c0) =>
  l === 'zh' ? <>{c0(`这句话`)}{`不需要 hook。`}</> : <>{`… for `}{c0(`this sentence`)}{`.`}</>
const __i18nT1 = (p) => __i18nM1(useLocale(), p.c0)

// at the call site
<__i18nT1 c0={(child) => <a href={href}>{child}</a>} />

Nothing about the markup is deferred to runtime: the body is the same locale ternary as everywhere else, one level down. Repeated messages share one component, and a render prop that names nothing local (<b>, <br/>) is hoisted to module scope and allocated once.

Two cases still need the hook. A t template has no component boundary to put one behind, so plain t in a Client Component is a build error naming the file and line. And a <Trans> used where a string is wanted — alt={...}, a template literal — cannot become a component, because a component renders to an element; the compiler only wraps a <Trans> in a position that takes one (JSX children, or returned as it is). Calling useI18n() in the component solves both, and is worth preferring anyway when the file already has other messages in it.

On this page