best-i18n

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-i18nLinguinext-intlParaglide JS
The message isthe source textthe source texta keya key (m.greeting())
Ships at runtimenothing — inlined ternaries/literalscatalog + lookupcatalog + ICU formatterone function per message
Catalog formatPOPOJSONinlang messages
Pluralsgettext, formula inlined per localeICU, at runtimeICU, at runtimematching/variants
select, dates, numbersno — reach for Intlyes (ICU)yes (ICU)no built-in formatting
Markup in messages<Trans>, rebuilt to JSX or Svelte markup at build<Trans>, tree walk per rendert.rich() at runtimestrings only
Per-locale buildstaticLocale — pure literalsnonoyes, per-locale bundles
Localized pathnames (/about → /de/ueber)nonoyesyes
One message in a named localet.locale(), <Trans locale>noserver 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 isa key ($_('greeting'))a key (t('greeting'))a key ($t('greeting'))
Ships at runtimeICU formatter (intl-messageformat) + storesflattened dictionary + {{ }} templatingvue-i18n message compiler + formatter
Catalog formatJSONJSON or any objectJSON / YAML per locale
PluralsICU, at runtimenone built in|-separated forms, at runtime
Markup in messagesstrings onlystrings only<i18n-t> component with slots, per render
Per-locale buildnonono (lazy per-locale chunks)
Locale resolution, routingnone — bring your ownnone — bring your ownyes — strategies, useLocalePath, detection
Per-request SSR bindingnone — one global storenone — a signal you provideyes — 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

variantclient JS (gzip)rawHTML /zh (gzip)HTML /zh/long (gzip)
no i18n at all173.4 kB562.0 kB1.9 kB-
best-i18n174.3 kB564.9 kB2.4 kB5.0 kB
best-i18n, I18N_STATIC_LOCALE=zh174.2 kB564.7 kB2.4 kB5.0 kB
next-intl187.4 kB607.4 kB4.9 kB5.5 kB

TanStack Start — against Paraglide

variantclient JS (gzip)raw
best-i18n99.1 kB310.3 kB
best-i18n, I18N_STATIC_LOCALE=zh98.8 kB309.7 kB
paraglide106.9 kB334.9 kB

SvelteKit — against Paraglide and svelte-i18n

variantclient JS (gzip)raw
best-i18n32.1 kB79.3 kB
best-i18n, I18N_STATIC_LOCALE=zh31.5 kB78.2 kB
paraglide39.3 kB102.8 kB
svelte-i18n49.4 kB136.7 kB

SolidStart v2 — against @solid-primitives/i18n and Paraglide

variantclient JS (gzip)raw
best-i18n16.7 kB41.9 kB
best-i18n, I18N_STATIC_LOCALE=zh16.4 kB41.3 kB
@solid-primitives/i18n18.2 kB44.8 kB
paraglide26.0 kB69.8 kB

Nuxt 4 — against @nuxtjs/i18n

variantclient JS (gzip)raw
best-i18n73.7 kB199.0 kB
best-i18n, I18N_STATIC_LOCALE=zh73.5 kB198.5 kB
@nuxtjs/i18n100.5 kB279.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.

On this page