Start
Migrate to Verbaly
Moving off another i18n library is mostly mechanical, and you do not have to do it all at once: the two runtimes coexist while you go file by file. The three sections after the first one apply whatever you are coming from; the first is for i18next, which is where most people arrive from.
Coming from i18next: what maps to what
The mental model carries over one-to-one, and syntax is the only thing that changes:
| i18next | Verbaly |
|---|---|
i18next.init({ … }) | npx verbaly init + one build plugin |
t('inbox.title') | t('inbox.title'): same call, keys now typed |
{{name}} | {name} |
messages_one / messages_other | {count | one: … | other: # …}: optional, the suffixed keys keep working as they are |
format: 'currency' (interpolation) | {price:currency/EUR} (native Intl) |
i18next-browser-languagedetector | resolveLocale() + persistLocale() |
i18next-http-backend (lazy load) | per-locale code-splitting, built into the plugin |
useTranslation() (react-i18next) | useT() (@verbaly/react) |
| Trans component | Trans component: values / components map over |
i18next.changeLanguage('es') | await setLocale('es') |
saveMissing, runtime fallbacks | npx verbaly check: CI fails instead |
Keep your catalogs: they port as they are
Your catalogs move without being reshaped. Nested groups keep their nesting, _one and _other keep working as the keys they are, and a namespace with a colon is still a key. One thing has to change, and migrate does it: {{name}} becomes {name}, and a lone brace i18next showed as text is doubled, because a doubled brace is how Verbaly writes a literal one.
It reports first, rewrites only the braces, and lists what it will not guess so you decide those yourself. This is what it does to the file:
Two upgrades you can take later, and neither is needed to run. Merging _one and _other into one message with variants ({count | one: … | other: # …}) is what buys automatic plural selection, and the # renders the count already localized, with no separate formattedCount. The second is smaller: dots are a naming convention, not a format. A key reads the same whether your file spells it "inbox.title" or as a title inside an inbox group.
Swap the runtime
Existing keys keep working through t('key', params), so migrated components barely change. In React:
If your components call a hook of your own, like a useTranslate around useTranslation, keep it and make it return useT(): nothing that calls it changes. A key type built from your JSON, like a NestedKeyOf, also lists the group paths, so make it VerbalyKey from virtual:verbaly. To wait for the visitor's language before the first paint, as a Suspense boundary did, render inside setLocale(locale).then(…): a top-level await in your entry splits the bundle into many chunks.
The bundle notices. A React app carrying i18next and react-i18next ships about 23 KB of runtime; the same app on Verbaly ships about 4. Without a framework it is 13.6 against 3.3. All four numbers are gzipped and measured, not estimated.
Measured on Memos, an open source app in 46 languages: 20 files changed by hand, its build and its tests as before, and about 11 KB less for every visitor on first load.
New copy skips the key ceremony entirely: write the text as a tagged template and let the compiler extract it, or keep readable ids with t.id. Vue and Svelte adapters follow the same pattern, and plain-HTML pages can bind with data-verbaly instead.
t`Hola ${name}`; // key + types generated
t.id('inbox.title')`Inbox`; // readable id, i18next-styleComing from no i18n at all? Let wrap do the boring part
If your app has hardcoded text instead of an i18n library, npx verbaly wrap scans your JSX, finds the user-visible strings and wraps them in t`…` for you. It only reports until you pass --write, and anything ambiguous (like a sentence mixed with markup) is listed for you to wrap by hand instead of guessed.
npx verbaly wrap- would wrap 37 texts in 12 files (report only, use
--writeto apply) - src/App.tsx:12 "Welcome back"
- src/Nav.tsx:8 aria-label → "Open menu"
Locale detection and lazy loading
The detector plugin and the HTTP backend both disappear. resolveLocale reads storage → navigator.languages → fallback (SSR-safe, BCP-47 narrowing included), persistLocale stores the choice and syncs <html lang>, and setLocale lazy-loads that locale's chunk on demand:
import { resolveLocale, persistLocale } from 'verbaly';
import { setLocale, getLocale } from 'virtual:verbaly';
await setLocale(resolveLocale({ supported: ['en', 'es', 'pt'] }));
persistLocale(getLocale());Migrate incrementally
No big-bang rewrite needed: the Verbaly runtime is ~3 KB, so both libraries can coexist while you move one page or namespace at a time. Once the catalogs are converted, give i18next interpolation: { prefix: '{', suffix: '}' } so it reads them too. A pragmatic order:
Run both side by side
npx verbaly initand the build plugin, so the toolchain runs alongside i18next from day one. It points the config at the catalogs you already have, wherever they live.Port the catalogs
npx verbaly migrate --write. It turns{{name}}into{name}, doubles a brace i18next showed as text so it stays text, and remembers which languages it converted, so running it again is safe.Swap hooks page by page
Replace
useTranslationwithuseTand delete each i18next namespace as it empties.Check it in CI
npx verbaly checkin CI, andnpx verbaly pseudoto catch hardcoded strings the migration missed.