Comparison
How best-i18n relates to Lingui, next-intl, Paraglide, svelte-i18n and @solid-primitives/i18n — with measured numbers
best-i18n inherited its best ideas from the libraries it gets compared to, so
this page is less a contest than a map of trade-offs. The numbers come from
twin playgrounds in the repo — the same app, the same two pages, the same
messages, once per library — rebuilt with pnpm bench, so they are
reproducible rather than remembered.
At a glance
| best-i18n | Lingui | next-intl | Paraglide JS | |
|---|---|---|---|---|
| The message is | the source text | the source text | a key | a key (m.greeting()) |
| Ships at runtime | nothing — inlined ternaries/literals | catalog + lookup | catalog + ICU formatter | one function per message |
| Catalog format | PO | PO | JSON | inlang messages |
| Plurals | gettext, formula inlined per locale | ICU, at runtime | ICU, at runtime | matching/variants |
select, dates, numbers | no — reach for Intl | yes (ICU) | yes (ICU) | no built-in formatting |
| Markup in messages | <Trans>, rebuilt to JSX or Svelte markup at build | <Trans>, tree walk per render | t.rich() at runtime | strings only |
| Per-locale build | staticLocale — pure literals | no | no | yes, per-locale bundles |
Localized pathnames (/about → /de/ueber) | no | no | yes | yes |
| One message in a named locale | t.locale(), <Trans locale> | no | server only | { locale } option |
The Svelte, Solid and Nuxt families add three runtime libraries that have no compile step of their own:
| svelte-i18n | @solid-primitives/i18n | @nuxtjs/i18n (vue-i18n) | |
|---|---|---|---|
| The message is | a key ($_('greeting')) | a key (t('greeting')) | a key ($t('greeting')) |
| Ships at runtime | ICU formatter (intl-messageformat) + stores | flattened dictionary + {{ }} templating | vue-i18n message compiler + formatter |
| Catalog format | JSON | JSON or any object | JSON / YAML per locale |
| Plurals | ICU, at runtime | none built in | |-separated forms, at runtime |
| Markup in messages | strings only | strings only | <i18n-t> component with slots, per render |
| Per-locale build | no | no | no (lazy per-locale chunks) |
| Locale resolution, routing | none — bring your own | none — bring your own | yes — strategies, useLocalePath, detection |
| Per-request SSR binding | none — one global store | none — a signal you provide | yes — per app instance |
Two of these are best-i18n's direct ancestors: the macro shape and the
<0>...</0> markup convention are Lingui's, and Paraglide proved that
compile-time i18n with per-locale tree-shaking is viable at all. The deeper
difference from Lingui is what runs: Lingui looks a message up in a catalog and
<Trans> walks a message tree on every render; here each locale's version is
ordinary JSX or Svelte markup by the time the browser sees it.
Measured size
The apps in a family are measured the same way — on Next.js, every
/_next/static/*.js the HTML of /zh, /zh/about and /zh/long references;
on TanStack Start, the emitted client assets; on SvelteKit, everything under
.svelte-kit/output/client; on SolidStart, everything under
.output/public/_build; on Nuxt, everything under .output/public/_nuxt. /zh/long is the text-heavy
case: a deliberately long article of ~30 server-rendered messages — full
paragraphs, plurals, context, markup — mirrored across both Next twins.
Next.js — against next-intl
| variant | client JS (gzip) | raw | HTML /zh (gzip) | HTML /zh/long (gzip) |
|---|---|---|---|---|
| no i18n at all | 173.4 kB | 562.0 kB | 1.9 kB | - |
| best-i18n | 174.3 kB | 564.9 kB | 2.4 kB | 5.0 kB |
best-i18n, I18N_STATIC_LOCALE=zh | 174.2 kB | 564.7 kB | 2.4 kB | 5.0 kB |
| next-intl | 187.4 kB | 607.4 kB | 4.9 kB | 5.5 kB |
TanStack Start — against Paraglide
| variant | client JS (gzip) | raw |
|---|---|---|
| best-i18n | 99.1 kB | 310.3 kB |
best-i18n, I18N_STATIC_LOCALE=zh | 98.8 kB | 309.7 kB |
| paraglide | 106.9 kB | 334.9 kB |
SvelteKit — against Paraglide and svelte-i18n
| variant | client JS (gzip) | raw |
|---|---|---|
| best-i18n | 32.1 kB | 79.3 kB |
best-i18n, I18N_STATIC_LOCALE=zh | 31.5 kB | 78.2 kB |
| paraglide | 39.3 kB | 102.8 kB |
| svelte-i18n | 49.4 kB | 136.7 kB |
SolidStart v2 — against @solid-primitives/i18n and Paraglide
| variant | client JS (gzip) | raw |
|---|---|---|
| best-i18n | 16.7 kB | 41.9 kB |
best-i18n, I18N_STATIC_LOCALE=zh | 16.4 kB | 41.3 kB |
| @solid-primitives/i18n | 18.2 kB | 44.8 kB |
| paraglide | 26.0 kB | 69.8 kB |
Nuxt 4 — against @nuxtjs/i18n
| variant | client JS (gzip) | raw |
|---|---|---|
| best-i18n | 73.7 kB | 199.0 kB |
best-i18n, I18N_STATIC_LOCALE=zh | 73.5 kB | 198.5 kB |
| @nuxtjs/i18n | 100.5 kB | 279.3 kB |
The two HTML columns of the Next table tell the story. Both home pages render the same handful
of messages, yet /zh reads 2.4 kB against 4.9 kB: the difference is the
catalog, which next-intl ships in every page's payload whether the page
renders those messages or not. best-i18n's pages carry only the text they
render — the ~30 long-page messages exist as HTML on /zh/long and nowhere
else, client JS included. The long page itself is close on both (5.0 kB
against 5.5 kB): a page that actually renders the text pays for the text,
whoever compiled it.
What the gaps are made of
They are not the same kind of gap, and the difference matters more than the numbers.
next-intl's ~13 kB is a message runtime — an ICU formatter, the catalog
and the lookup. It buys plurals, select, dates, numbers and rich text.
best-i18n has since grown plurals of its own — gettext plurals, compiled to an
inlined per-locale formula rather than an ICU runtime — but select, dates
and numbers it still does not do. next-intl's catalog also travels in every
page's HTML by default: /zh renders none of the /long page's ~30 messages
and still carries all of them — the 4.9 kB against best-i18n's 2.4 kB in the
table above.
Paraglide's ~8 kB is a URL router — a URLPattern matcher, cookie
handling, preferredLanguage detection — not message lookup. On the message
side both libraries inline and tree-shake, so the gap is fixed rather than
growing with the catalog. What it buys is real: Paraglide can translate the
path itself, /about becoming /de/ueber, which best-i18n cannot do at all.
How much the router weighs depends on the bundler around it: the same chunk is
8.7 kB inside SvelteKit's shared chunks and 15.8 kB in the SolidStart build.
svelte-i18n's ~17 kB is an ICU runtime — intl-messageformat with the
formatjs parser, parsing and formatting every message in the browser — plus
one dictionary chunk per locale. It is the same trade next-intl makes, made
on the client: plurals, select, dates and numbers, paid for on every page.
It also brings no locale resolution, no routing and no per-request binding;
the playground adds all three by hand, and on the server the library's one
global store is written per request, which its own SvelteKit guide does and
which concurrent requests in different languages can race on.
@nuxtjs/i18n's ~27 kB is vue-i18n — the message compiler and formatter
that turn $t('key', { count }) into text in the browser, plus the module's
routing strategies and browser-language detection. It is the largest gap
measured, and the most complete alternative: it brings the routing and the
per-request binding the other runtime libraries leave to you, and its
<i18n-t> component handles markup in a message — at runtime, per render,
the way Lingui's <Trans> does. best-i18n's Nuxt module covers the routing
and the binding without a message runtime.
@solid-primitives/i18n's ~1.5 kB is barely a runtime — a flattened
dictionary and a {{ name }} template fill — which is why it lands closest to
best-i18n of anything measured. The two lazy dictionary chunks are counted
even though a visitor downloads one. Like svelte-i18n, it stops at
translation: the locale signal, the URL prefix, the middleware and the
context provider in the playground are all the app's own code. It has no
plurals.
Svelte and Solid
The runtime libraries in those two ecosystems are honest about their scope:
they format text and leave everything around it to you. What best-i18n brings
to Svelte and Solid, beyond the compile step, is the part they leave out — the
resolution chain, the URL helpers, withRequestLocale for SSR — and one thing
none of the four alternatives has: markup inside a message. Every one of them
splits Read the <a>README</a> to learn more. into three strings by hand,
which fixes the word order for every language; <Trans> keeps it one message
and rebuilds it as Svelte markup or Solid JSX per locale at build time.
Reactivity is the other axis. Paraglide's setLocale() navigates, so nothing
has to be reactive. svelte-i18n is a store — $_ and $locale — and
@solid-primitives/i18n is a signal you pass in. best-i18n compiles to a
locale read that is wired into each framework's reactivity —
createSubscriber in Svelte, a signal in Solid — so t`...` in a
template updates in place, and switchLocale still navigates when the URL
should change too.
Where best-i18n costs more
Messages are inlined at each call site. Within one module, repeats collapse: the call sites of a repeated message share one hoisted module-level function — Paraglide's shape — so its translations are emitted once per module. Across modules each module carries its own copy, because the transform is per-file by design; gzip flattens that repetition (measured at +0.5 kB gzip for one message used 100 times, inlined or hoisted alike), so over the wire it is a wash and what remains is parse time.
Why there is no cross-module dedup. It is possible — a virtual module
holding one function per message, imported by every call site, roughly the
shape of Paraglide's generated messages/ directory. Three things argue
against it here.
The transform is handed one file at a time. Vite's and Rolldown's transform
hooks and the Next.js loader all pass a single module with no view of the
graph, so a shared module means either a codegen pass that writes files before
the build, or a virtual module every message-using file has to import. Both
give up what keeps the current design simple: a compiled module is
self-contained, and nothing has to run before the bundler does.
It also would not dedup across the boundary that matters. On the App Router the server and client module graphs are separate, so a message rendered on both sides gets its own copy in each of them either way.
And the win is not really there. Repeats within a module already collapse into one hoisted function; across modules gzip flattens the rest. What is left is parse time on duplicated string literals — not obviously worth a build-graph edge into every module in the app.
The hoisted function is already the shape such a module would export, so if an app turns up where this costs something measurable, it is a contained change rather than a redesign.
Message-side scaling was measured too, at 300 extra messages: rendered from a Server Component they cost best-i18n no client JS at all, and from a Client Component about 6 bytes each gzipped for both languages.
The rest of the honest list — no ICU select, the Next.js private API, where
AsyncLocalStorage has to come from — lives in
Limitations. The playgrounds
(playground/nextjs-intl,
playground/tanstack-start-paraglide,
playground/sveltekit-paraglide,
playground/sveltekit-svelte-i18n,
playground/solid-start-paraglide,
playground/solid-start-primitives,
playground/nuxt-i18n)
carry the full numbers and the caveats.