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>

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>
  )
}

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() === '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.

On this page