best-i18n

Comparison

How best-i18n relates to Lingui, next-intl and Paraglide — 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 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

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 by the time the browser sees it.

Measured size

Both 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. /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

The two HTML columns 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 two 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.

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.

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) carry the full numbers and the caveats.

On this page