Skip to content

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:

i18nextVerbaly
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-languagedetectorresolveLocale() + persistLocale()
i18next-http-backend (lazy load)per-locale code-splitting, built into the plugin
useTranslation() (react-i18next)useT() (@verbaly/react)
Trans componentTrans component: values / components map over
i18next.changeLanguage('es')await setLocale('es')
saveMissing, runtime fallbacksnpx 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.

npx verbaly migrate             # what it would port
npx verbaly migrate --write     # apply it

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:

i18nextlocales/en.json
Verbalylocales/en.json
{
{
"inbox": { "title": "Inbox" },
"inbox": { "title": "Inbox" },
"messages_one": "one message",
"messages_one": "one message",
"messages_other": "{{count}} messages"
"messages_other": "{count} messages"
}
}

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:

react-i18next
@verbaly/react
import { useTranslation } from 'react-i18next';
import { useT } from '@verbaly/react';
const { t } = useTranslation();
const t = useT();
t('inbox.title');
t('inbox.title');
t('messages', { count });
t('messages', { count }); // params now typed

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.

src/app.tsx
t`Hola ${name}`;                 // key + types generated
t.id('inbox.title')`Inbox`;      // readable id, i18next-style

Coming 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
  1. would wrap 37 texts in 12 files (report only, use --write to apply)
  2. src/App.tsx:12 "Welcome back"
  3. src/Nav.tsx:8 aria-label → "Open menu"
npx verbaly wrap --write   # then: add t to scope and run npx verbaly extract

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:

src/main.ts
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:

  1. Run both side by side

    npx verbaly init and 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.

  2. 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.

  3. Swap hooks page by page

    Replace useTranslation with useT and delete each i18next namespace as it empties.

  4. Check it in CI

    npx verbaly check in CI, and npx verbaly pseudo to catch hardcoded strings the migration missed.

↑↓to moveEnterto open
Copied to clipboard