Guides
Migrate from i18next
Both tools speak JSON, so this is easier than it looks: your keys and translations port almost directly, the plural suffixes merge into one message, and the runtime swap is mostly find-and-replace. You also drop ~20 KB of runtime on the way.
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: # …} |
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: the build fails instead |
Keep your keys: catalogs port almost directly
Verbaly catalogs are flat JSON with no proprietary format. Flatten nested groups into dotted keys, and merge the _one/_other plural suffixes into a single message with variants, and every existing translation survives:
{
"inbox": { "title": "Inbox" },
"messages_one": "one message",
"messages_other": "{{count}} messages"
}{
"inbox.title": "Inbox",
"messages": "{count | one: one message | other: # messages}"
}Namespaces (ns:key) become part of the dotted key (ns.key). The # inside a variant renders the count already localized, with no separate formattedCount interpolation needed.
Swap the runtime
Existing keys keep working through t('key', params), so migrated components barely change. In React:
import { useTranslation } from 'react-i18next';
const { t } = useTranslation();
t('inbox.title');
t('messages', { count });import { useT } from '@verbaly/react';
const t = useT();
t('inbox.title');
t('messages', { count }); // params now typedNew 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 --write to apply)
# src/App.tsx:12 "Welcome back"
# src/Nav.tsx:8 aria-label → "Open menu"
npx verbaly wrap --write # then: add t to scope and run npx verbaly extractLocale 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. A pragmatic order:
npx verbaly init+ the build plugin, so the toolchain runs alongside i18next from day one.- Port the catalogs: flatten keys, merge plural suffixes, drop the interpolation braces from four to two.
- Swap hooks page by page (
useTranslation→useT) and delete each i18next namespace as it empties. - Turn on the gate:
npx verbaly checkin CI,npx verbaly pseudoto catch hardcoded strings the migration missed.