Extract and translate
The i18n-extract CLI and the PO workflow
i18n-extract --locales en,zh # writes messages/messages.pot + zh.po
i18n-extract --locales en,zh --check # CI: exit 1 when stale or untranslated
i18n-compile --locales en,zh --outdir out --format json # optional JSON/JS exportThe .po files are the source of truth — the bundler plugins read them
directly, no compile step in between. Editing a message file in dev triggers a
full reload. An extract run never reduces the number of translations; if it
would, it refuses and asks for --force.
Fuzzy and validation
Fuzzy translations do not build — standard gettext behaviour. A carried-over
translation stays in the catalog marked fuzzy, the message falls back to the
base locale, and the build reports it as missing until someone reviews it. A
translation whose {0}/<0> placeholders do not match the source is a build
error naming the file, locale and message; i18n-extract reports the same
mismatch when it merges, so a bad TMS import is visible before anyone builds.
Comments, flags, plural entries and headers written by translators or a TMS
survive a rewrite untouched.
Config file
The CLIs and the bundler plugins take the same facts - locales, the catalog
directory, re-exported macro modules. Write them once in i18n.config.ts and
every one of them reads it:
// i18n.config.ts
import { defineConfig } from 'best-i18n/config'
export default defineConfig({
locales: ['en', 'zh'], // the first is the base locale unless baseLocale says
messagesDir: 'messages',
src: ['src'],
hookFrom: ['~/i18n'],
split: { depth: 2 },
})i18n-extract # no flags neededAn option passed explicitly - a flag, a plugin option - wins over the file,
and without a file everything works from options as before. Paths are
relative to the file. It is looked up in the working directory as
i18n.config.{ts,mts,js,mjs}, or named with --config. A .ts config is
loaded by Node itself, so it needs Node 22.18+ (or 23.6+) and erasable
TypeScript only; i18n.config.mjs works on any version.
This file is build-time only. What the running app needs - cookie, proxy,
exclude - stays in defineI18nConfig.
Splitting a large catalog
One .po per locale stops scaling once a project has thousands of messages:
the file is hard to review and every team edits the same one. Pass
--split-depth and the catalog is sharded by source directory instead.
i18n-extract --locales en,zh --src src --split-depth 2messages/
messages.pot zh.po # messages shared across shards
components/
messages.pot zh.po
features/billing/
messages.pot zh.po- A file's messages go to its directory under
--split-root(default: the--srcroot), cut after--split-depthlevels. Route segments -[param],(group),@slot- are not counted, soapp/[locale]/about/page.tsxat depth 2 isapp/[locale]/about. - A message used from several shards lives in their deepest common directory, so shared texts rise toward the root on their own.
- The merge still runs over the whole catalog: a reworded message keeps its translation even when it moves shard, a deleted page's translations stay as obsolete entries in the nearest shard above it, and empty shards are removed.
- Nothing else is configured: the bundler plugins and
i18n-compileread every shard undermessagesDir. Run without--split-depth(orsplitin the config file) and the shards fold back into one catalog.
Re-exported macros
from/hookFrom match import specifiers as written in the source, so if you
re-export the macros, list your module path in the plugin options. The
extractor has the matching --hook/--hook-from flags.