Effortless i18n: write natural text, ship type-safe, tree-shakeable translations.
Most i18n tools make you maintain key files by hand: keys drift from the code, no type-safety, heavy setup. Verbaly inverts the flow: write the source text in your code, a build plugin extracts stable keys, types and per-locale modules. Compiler-grade safety, a 3.32 KB runtime, zero dependencies. That is createVerbaly on its own; a site with no framework, which also pulls the DOM interpreter and the url helpers, ships 6.14 KB. Both are measured on every release by pnpm --filter verbaly size, never estimated.
t`Hello ${name}, you have ${count} messages`;
t('usNGUet6', { name, count });
t.id('inbox.title')`Hello ${name}`;
Missing a translation? Every build says which ones, and verbaly check stops your CI on them, so a raw key never reaches a release. A translation that dropped your {name} or flattened a plural block fails check too: being filled in is not the same as working.
🚀 Try it in 30 seconds
pnpm add verbaly @verbaly/vite
import verbaly from '@verbaly/vite';
export default { plugins: [verbaly({ locales: ['en', 'es', 'pt'] })] };
import { t, setLocale } from 'virtual:verbaly';
t`Hello ${name}`;
await setLocale('es');
With the compiler, every t your project holds is typed by its catalog: the generated types fill in Register, so Translate and Locale (both exported here, types only, zero bytes) are your keys and your locales, and an instance's t checks keys and params too.
🎛️ Standalone runtime (no compiler)
verbaly also works on its own for dynamic/CMS content:
import { createVerbaly } from 'verbaly';
const v = createVerbaly({
locale: 'en',
messages: { en: { greeting: 'Hello {name}' } },
loaders: { es: () => import('./locales/es.json') },
});
v.t('greeting', { name: 'Aron' });
v.setLocale('es');
🌐 Plain HTML, no framework needed
<h1 data-verbaly="home.title"></h1>
<p data-verbaly="home.intro" data-verbaly-rich></p>
<p
data-verbaly="home.cta"
data-verbaly-rich
data-verbaly-links='{"repo":"https://github.com/x"}'
></p>
import { bindDom, createVerbaly, persistLocale, resolveLocale } from 'verbaly';
const v = createVerbaly({
locale: resolveLocale({ supported: ['en', 'es', 'pt'] }),
});
bindDom(v, {
richLinks: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } },
});
v.setLocale('es');
persistLocale('es');
import { localeDirection, localeName } from 'verbaly';
localeName('es');
localeName('de', 'en');
localeDirection('ar');
import { createVerbaly, resolveRequestLocale } from 'verbaly';
const locale = resolveRequestLocale({
supported: ['en', 'es', 'pt'],
path: url.pathname,
routing: 'prefix-except-source',
cookie: cookies.get('verbaly-locale'),
header: request.headers.get('accept-language'),
fallback: 'en',
});
const v = createVerbaly({ locale, fallback: 'en', messages });
import { alternateLinks, localePath, stripLocalePath } from 'verbaly';
localePath('pt', { supported, sourceLocale: 'en', path: '/es/docs' });
stripLocalePath({ supported, path: '/es/docs' });
alternateLinks({ supported, sourceLocale: 'en', path: '/docs', baseUrl: 'https://x.dev' });
✨ What you get
- Hybrid compiler + runtime: static text compiled (types + tree-shaking), dynamic content via a real runtime path.
- Type-safe params:
{name}, plurals, currency and dates inferred from the message; wrong/missing params fail to compile.
Intl-powered format: number/currency/date/time/relative/list/unit + CLDR plurals and select/gender, tiny surface. 'Updated {when:relative}' · '{langs:list}' · '{d:unit/kilometer}'.
- Plain, portable JSON catalogs: no proprietary format, no lock-in.
- A custom formatter knows which message it was called for: the fourth argument carries the param name and the message key, so its own errors can name them.
- ICU and relative time are weight you only carry when you use them: the compiler sees them in your catalogs and wires what is needed, so an app whose messages use neither never ships those 862 bytes. A message that arrives after the build with ICU syntax shows its own source and says why, rather than rendering something that looks almost right.
- DOM interpreter for framework-less HTML, with opt-in rich text (whitelist-based, XSS-safe) and named links (
richLinks / data-verbaly-links; hrefs come from the caller, javascript: blocked).
- Lazy catalogs:
loaders + loadLocale load per-locale JSON on demand, in the runtime itself.
- RTL and locale names built in:
localeDirection keeps <html dir> right when you add Arabic or Hebrew (applied by switchLocale/persistLocale and verbaly render), and localeName gives your locale switcher real language names via Intl.DisplayNames.
- Runtime devtools: opt-in
verbaly/devtools answers "what key is this text?" in the browser: hover to see any element's key/locale/source, plus a live missing-keys panel. Tree-shaken out of production (its own chunk). Or wire the onResolve hook yourself.
- Fast, with receipts: fully memoized hot path, benchmarked every release: 5-35× faster than i18next on lookup, interpolation and plurals.
🧩 Ecosystem
verbaly | Core runtime (this package) |
@verbaly/vite | Zero-config Vite plugin |
@verbaly/unplugin | webpack · Rollup · esbuild · Rspack |
@verbaly/compiler | Extraction + codegen + the verbaly CLI |
@verbaly/react · @verbaly/vue · @verbaly/svelte | Framework adapters (React also covers Preact) |
@verbaly/astro | Astro integration + per-locale static build |
@verbaly/next · @verbaly/nuxt · @verbaly/sveltekit | SSR: locale per request, hydration with no flash |
@verbaly/mcp | MCP server: the translation cycle as tools for an agent |
@verbaly/studio | Local review server over your catalogs |
All thirteen share one version number, so you never match compatible ranges.
📖 Docs: https://verbaly-web.vercel.app/docs/reference/api
🔁 Coming from i18next? Keep your keys and catalogs: the migration guide maps everything one-to-one.
⚠️ Early development (0.x): API not stable yet.
License
MIT © Aron Soto