npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

best-i18n

v0.3.0

Published

Compile-time i18n: translations inlined at the call site, per-locale builds collapse to plain strings, PO-based workflow

Readme

best-i18n

Compile-time i18n. Write the source text inline; the compiler inlines every translation at the call site, so there is no runtime catalog, no lookup, and nothing to load. A per-locale build collapses to plain string literals.

import { useI18n } from 'best-i18n/react/macro'

function About() {
  const t = useI18n()
  return <h1>{t`A small starter with room to grow.`}</h1>
}

compiles (default build) to

// injected by the compiler
import { useLocale } from 'best-i18n/react'

function About() {
  const t = useLocale() // re-renders on locale change
  return (
    <h1>
      {t === 'zh'
        ? `一个小而可长的起始模板。`
        : `A small starter with room to grow.`}
    </h1>
  )
}

and with staticLocale: 'zh' (per-locale build) to

function About() {
  return <h1>{`一个小而可长的起始模板。`}</h1>
}

Why

  • No keys. The source text is the message; ids are content hashes managed for you in PO files.
  • No runtime. Messages compile to ternaries (single build) or literals (per-locale build). Unused messages tree-shake with the code that used them.
  • PO workflow. messages.pot + <locale>.po — the format translators, TMSes and LLMs already understand. Rewording a message carries its translation over as fuzzy instead of losing it; removed messages become #~ obsolete entries, never deleted.
  • SSR-safe. The server locale lives in AsyncLocalStorage per request; if the runtime cannot provide isolation it throws instead of silently sharing state between requests.

Setup

pnpm add best-i18n

Pick the integration for your framework. Everything below it - the macros, the PO workflow, the URL helpers - is the same either way.

| Framework | Integration | | ------------------------------------------------------------------------- | -------------------- | | Vite, and anything on it (TanStack Start, React Router, SvelteKit, Astro) | best-i18n/vite | | Next.js (App Router, Turbopack or webpack) | best-i18n/next | | Rolldown used directly, and tools built on it (tsdown, ...) | best-i18n/rolldown | | Nuxt 4 (Vue 3 alone uses best-i18n/vite with vue: true) | best-i18n/nuxt |

rolldown-vite keeps the Vite plugin API, so it takes best-i18n/vite unchanged; best-i18n/rolldown is for Rolldown without Vite around it.

Vite

// vite.config.ts
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { i18n } from 'best-i18n/vite'

export default defineConfig({
  plugins: [
    i18n({
      messagesDir: fileURLToPath(new URL('./messages', import.meta.url)),
      locales: ['en', 'zh'],
      baseLocale: 'en',
      // set (e.g. from an env var) to build a single locale as pure literals
      staticLocale: process.env.I18N_STATIC_LOCALE,
    }),
    // ...other plugins
  ],
})

Rolldown

The same options as the Vite plugin. The plugin declares Rolldown hook filters, so files that never name a macro module are skipped in Rust and the JavaScript plugin is not even called for them.

// rolldown.config.ts
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { i18n } from 'best-i18n/rolldown'

export default {
  input: 'src/main.ts',
  plugins: [
    i18n({
      messagesDir: fileURLToPath(new URL('./messages', import.meta.url)),
      locales: ['en', 'zh'],
      baseLocale: 'en',
      staticLocale: process.env.I18N_STATIC_LOCALE,
    }),
  ],
}

Next.js

Next.js does not run on Vite, so it gets its own loader and its own way of carrying the locale through a render.

// src/i18n.ts - one description of the languages and the URL shape
import { defineI18nConfig } from 'best-i18n/next/config'

export const i18n = defineI18nConfig({
  locales: ['en', 'zh'],
  baseLocale: 'en',
  exclude: '^/(api|_next)/',
})
// next.config.ts - the same description, imported rather than repeated
import process from 'node:process'
import { createI18nPlugin } from 'best-i18n/next'
import { i18n } from './src/i18n'

const withI18n = createI18nPlugin({
  ...i18n,
  messagesDir: fileURLToPath(new URL('./messages', import.meta.url)),
  staticLocale: process.env.I18N_STATIC_LOCALE,
})

export default withI18n({})

The locales are spread in rather than repeated: the compiler needs them at build time, and src/i18n.ts is where they are described once.

// src/proxy.ts - points a public URL at the [locale] segment
import { createProxy } from 'best-i18n/next/proxy'
import { i18n } from '@/i18n'

export const proxy = createProxy(i18n)
export const config = { matcher: ['/((?!_next|.*\\..*).*)'] }
// src/app/[locale]/layout.tsx
import { getLocale } from 'best-i18n/next/server'
import { LocaleProvider } from 'best-i18n/react'
import { i18n } from '@/i18n'

export function generateStaticParams() {
  return i18n.locales.map((locale) => ({ locale }))
}

export const dynamicParams = false

export default function LocaleLayout({ children }) {
  const locale = getLocale()

  return (
    <html lang={locale}>
      <body>
        <LocaleProvider locale={locale} config={i18n}>
          {children}
        </LocaleProvider>
      </body>
    </html>
  )
}

That is the whole setup. t then works in any Server Component - no per-file call, no await, static rendering intact - and useI18n in any Client Component.

Routes live under [locale], but the base locale's URLs stay unprefixed: /about is English, /zh/about is Chinese, and /en/about redirects to the canonical /about. Links are written unprefixed and localized as they render:

import { Link } from 'best-i18n/next/navigation'

// Renders href="/zh/about" while Chinese is active.
function Nav() {
  return <Link href='/about'>{t`About`}</Link>
}

usePathname and useRouter come from the same module, with the prefix stripped and applied respectively. All three read the URL layout from LocaleProvider, so the config is described once and travels once - and they are exported one by one, so an app that only links does not carry the other two.

The unprefixed base locale assumes a proxy is there to rewrite /about onto the [locale] segment. A deployment without one - a static export serves only the files that exist - has two options. prefixBase: true in the config gives up on the unprefixed form: /en/about becomes canonical, Link prefixes the base locale like any other, and a proxy (if one runs anyway) redirects unprefixed URLs out rather than stripping /en. Or keep the URLs and make the files exist: the staticExport() plugin from @best-i18n/next-unprefixed-locale mirrors app/[locale] into a generated route group with the base locale pinned, and its README says what that asks of generateStaticParams. It goes in plugins, the hook createI18nPlugin offers for steps that need the locales and the URL shape at config load; best-i18n itself does not depend on it.

// next.config.ts - static export, /about beside /zh/about
import { staticExport } from '@best-i18n/next-unprefixed-locale'

const withI18n = createI18nPlugin({
  ...i18n,
  messagesDir: fileURLToPath(new URL('./messages', import.meta.url)),
  plugins: [staticExport()],
})

export default withI18n({ output: 'export' })

Why the pieces are what they are:

  • getLocale() in the root layout, once. It is what pulls in the module that teaches the runtime where Next keeps the locale. Skip it and every message quietly renders in the base locale.
  • LocaleProvider even though the server already knows the locale. Client components render in a second module graph that cannot see the server's render state; passing the locale through React is what keeps the server's HTML and the first client render identical. Its config is required for the same reason: it is the channel Link and usePathname read the URL layout from, so keep it serializable (exclude as a string).
  • Server Components need no per-file setup. A layout and the page beneath it are separate renders in the App Router, so a locale stashed in one is not visible in the other. The locale is read from the route param instead, which is also why static rendering still works.

Plain JavaScript

No framework required: the Vite or Rolldown plugin plus the t macro is the whole system. best-i18n/runtime is the locale state the compiled messages read - it plays the role @lingui/core plays for Lingui, minus the catalog, because there is nothing to load. It is also, in its entirety, the runtime the "no runtime" pitch leaves standing.

import { t } from 'best-i18n/macro'
import { getLocale, setLocale, subscribeLocale } from 'best-i18n/runtime'

function render() {
  document.querySelector('h1')!.textContent =
    t`A small starter with room to grow.`
}

// A message is evaluated where it is called, so a locale change means
// re-running the code that renders - same as Lingui's activate-then-rerender.
subscribeLocale(render)
document.querySelector('select')!.onchange = (e) => {
  setLocale((e.target as HTMLSelectElement).value)
}
render()

setLocale is client-only, deliberately: on a server one shared locale would leak between concurrent requests. There the locale is bound per request or per scope instead - withRequestLocale(request, config, fn) in a fetch handler, withLocale(locale, fn) in a script - both from best-i18n/server, both feeding the same getLocale() the messages compile to.

import { t } from 'best-i18n/macro'
import { withLocale } from 'best-i18n/server'

for (const locale of ['en', 'zh']) {
  withLocale(locale, () => console.log(t`A small starter with room to grow.`))
}

Writing messages

Anywhere — loaders, server functions, plain modules:

import { t } from 'best-i18n/macro'

const title = t`A small starter with room to grow.`
const greeting = t`Hi ${name}, you have ${count} items`

Inside React components, reactive to locale changes:

import { useI18n } from 'best-i18n/react/macro'

function About() {
  const t = useI18n()
  return <p>{t`About`}</p>
}

Both are compile-time macros: the binding can only be used as a tagged template. Storing it, passing it around, destructuring useI18n() or shadowing the name is a build error with a file and offset, not a runtime surprise.

A line break inside a template is code formatting, not content: it collapses to a single space - the same rule JSX applies to <Trans> - so re-indenting a component never changes a message or orphans its translations. Write \n for a literal newline.

An interpolated identifier names its own placeholder, so the translator sees Hi {name}, you have {count} items rather than Hi {0}, you have {1}. Anything more complex than an identifier falls back to a number. A dropped or invented placeholder in a translation is a build error naming the file, the locale and the message.

Plurals

import { plural } from 'best-i18n/macro'

const label = plural(count, `One item`, `${count} items`)

The two forms are one gettext entry - msgid and msgid_plural - and each locale's .po supplies as many msgstr[n] forms as its Plural-Forms header declares (Russian three, Chinese one; there is a built-in table for catalogs that don't set the header). The compiler inlines that locale's selection formula at the call site, so what ships is a small arrow function per plural message - no ICU runtime, no Intl.PluralRules, and a one-form locale gets the bare string with no dispatch at all. The count is always available to a translation as a placeholder, interpolated or not.

Context

Two identical texts that must translate differently are different messages. ctx is gettext's msgctxt:

const verb = t.ctx('verb')`Open` // 打开
const sign = t.ctx('adjective')`Open` // 营业中
const markup = <Trans ctx='verb'>Open</Trans>

Naming the locale

A call site can name the locale it wants instead of reading the current one: an email in the recipient's language, a preview of another locale, a label that must stay in the source language. locale is the last modifier on t and plural, and a prop on <Trans>:

const zh = t.locale('zh')`Hello` // always 你好
const theirs = t.locale(user.locale)`Hello` // whatever they chose
const count = plural.locale(user.locale)(n, `One item`, `${n} items`)
const rich = (
  <Trans locale={user.locale}>
    Read the <a href={url}>docs</a>
  </Trans>
)

Modifiers chain in either order: t.ctx('verb').locale('zh') and t.locale('zh').ctx('verb') are the same message. A string literal compiles to that locale's text alone - no branch, no locale read - and a literal that is not one of the configured locales is a build error. Any other expression compiles to a branch over its value at runtime. Either way the message does not subscribe to the current locale, so it does not update when the user switches, and in a 'use client' module on Next.js it needs no hook and no generated component. A per-locale build leaves these branches in place: the build fixes the current locale, not the ones a call site asks for.

Comments for the translator

A // i18n: comment directly above (or on the line of) a message becomes a #. extracted comment in the catalogs:

// i18n: Button label on the home page, keep it short
const label = t`Save`

Messages with markup

A tagged template cannot hold JSX, so a sentence with a link or a bold run in it has nowhere to go. <Trans> is that place:

import { Trans } from 'best-i18n/react/macro'

function About() {
  return (
    <p>
      <Trans>
        Read the <a href={docsUrl}>documentation</a> to learn more.
      </Trans>
    </p>
  )
}

The catalog stores the markup as named placeholders - the tag's own name where it has one, a number where it does not - for the reason Lingui established: a translator moves the pieces, and never sees a JSX attribute:

msgid "Read the <a>documentation</a> to learn more."
msgstr "请阅读<a>文档</a>了解更多。"

Where this parts ways with Lingui is what runs. There is no component walking a message tree per render: each locale's version is reassembled into ordinary JSX at build time, so the above compiles to

// getLocale is injected by the compiler, from 'best-i18n/runtime'
getLocale() === 'zh' ? (
  <>
    请阅读<a href={docsUrl}>文档</a>了解更多。
  </>
) : (
  <>
    Read the <a href={docsUrl}>documentation</a> to learn more.
  </>
)

and, under staticLocale, to the one branch on its own.

Whitespace follows JSX's own rules, so the stored message matches what renders

  • including the space a line break swallows, which is why {' '} exists. <Trans> takes no props, key included: wrap it in the element that needs one.

Extract and translate

i18n-extract --locales en,zh          # writes messages/messages.pot + zh.po
i18n-extract --locales en,zh --check  # CI: exit 1 when stale or untranslated
i18n-compile --locales en,zh --outdir out --format json   # optional JSON/JS export

The .po files are the source of truth — the vite plugin reads them directly, no compile step in between. Editing a message file in dev triggers a full reload. An extract run never reduces the number of translations; if it would, it refuses and asks for --force.

Fuzzy translations do not build — standard gettext behaviour. A carried-over translation stays in the catalog marked fuzzy, the message falls back to the base locale, and the build reports it as missing until someone reviews it. A translation whose {0}/<0> placeholders do not match the source is a build error naming the file, locale and message; i18n-extract reports the same mismatch when it merges, so a bad TMS import is visible before anyone builds. Comments, flags, plural entries and headers written by translators or a TMS survive a rewrite untouched.

Locale resolution and URLs

On any server that owns its own request handler - a Vite-based framework, a Worker, a plain fetch handler - bind the locale per request. Next.js does this for you, through the proxy.

import { withRequestLocale } from 'best-i18n/server'

export default {
  async fetch(request: Request) {
    return withRequestLocale(request, I18N, () => handler.fetch(request))
  },
}

Resolution order: URL prefix → cookie → Accept-Language → base locale. The client mirrors the same order (resolveClientLocale) so hydration matches SSR. localizeUrl/deLocalizeUrl plug into router URL rewriting, with an exclude pattern for paths that must never be localized (/api/...).

Limitations

  • Messages must be statically visible — no dynamic message construction.
  • On Next.js, a Client Component has to take its locale from useI18n(). Plain t and a <Trans> with no useI18n() above it read getLocale(), and client components render in a module graph where nothing has bound the request's locale - so they would fall back to the base one, on the server only, and React patches the difference at hydration without a word. The loader refuses to compile that, naming the file, the line and the message. Server Components are the other way round: they cannot call a hook, and do not need to.
  • The macro cannot be a dependency. useMemo(() => t`About`, [t]) is refused: t is compiled away, and what the name holds by the time the array is evaluated is a locale rather than a translator. Depend on the locale instead — const locale = useLocale() alongside useI18n(), then useMemo(() => t`About`, [locale]) — which costs nothing, because the compiler turns the two reads into one. The build error explains it at the call site, and so does the error reference.
  • Plurals are gettext plurals (plural(count, one, other)), not ICU: there is no select/gender construct yet, and no number/date formatting - reach for Intl with getLocale() for those.
  • from/hookFrom match import specifiers as written in the source, so if you re-export the macros, list your module path in the plugin options.
  • On Next.js, best-i18n/next/server reads the locale out of Next's internal render storage, because t has to resolve synchronously while params and headers() are async. That is a private API, so a Next.js major version can break it. The field it reads, rootParams, arrived in 15.2, which is the peer floor.
  • In a Client Component prefer useI18n. Plain t there reads the ambient client locale, which LocaleProvider mirrors on the client - but only from the nearest provider, and without re-rendering on a locale change the way useI18n does.
  • best-i18n/server needs AsyncLocalStorage. Node and Bun ship it; on Cloudflare Workers it exists only behind the nodejs_compat (or nodejs_als) compatibility flag, and without the flag the import throws with an error saying exactly that, instead of silently sharing one locale between requests.
  • workerd does not implement AsyncLocalStorage.enterWith(). best-i18n only calls it in best-i18n/next/server - a route handler prerendered outside a React render - which by definition runs on Node, where it is fully supported (Bun too). On Workers the locale is bound with withRequestLocale from best-i18n/server, which uses .run() and is unaffected. (enterWith binds the remainder of the current synchronous frame, which is why Node's docs prefer run - here that frame is exactly the handler invocation, which is the intent.)

Thanks

best-i18n did not invent its best ideas, it inherited them:

  • oxc-parser — the parser under every transform. It is what makes parsing each file on every build cheap enough that the compiler needs no cache and no daemon.
  • GNU gettext — the PO workflow this package speaks: source text as the message, fuzzy instead of data loss, #~ instead of deletion. Decades of translator tooling work because these conventions are respected.
  • Lingui — the macro shape and the <0>...</0> placeholder convention for markup in messages, adopted here for the same reason it exists there: a translator should never see a JSX attribute.
  • Paraglide JS — the proof that compile-time i18n with per-locale tree-shaking is viable, and the bar for what a locale-strategy API can look like.
  • next-intl — the reference for what a complete Next.js App Router integration covers; its playground twin in this repo is what keeps the size claims honest.
  • gettext-parser — the PO codec underneath i18n-extract.

Svelte 5

Set svelte: true and place i18n(...) before svelte() / sveltekit() in Vite's plugins array. The extractor scans .svelte files, and the plugin compiles t, plural, and <Trans> in scripts, template expressions, and attributes. A full SvelteKit app is in playground/sveltekit.

<script lang="ts">
  import { t } from 'best-i18n/macro'
  import { locale, setLocale } from 'best-i18n/svelte'
  let { name } = $props<{ name: string }>()
  let title = $derived(t`Welcome`)
</script>

<h1>{title}</h1>
<p>{t`Hello ${name}`}</p>
<button disabled={locale.current === 'zh'} onclick={() => setLocale('zh')}>
  中文
</button>

Reactive language switching requires Svelte 5 runes mode. Use $derived for reactive text in scripts; ordinary initializers run once. locale.current from best-i18n/svelte is the reactive locale getter for templates; getLocale() is the same read as a function, for .svelte.ts modules. staticLocale compiles translations to literals without the locale runtime. Markup in a message uses <Trans> from best-i18n/svelte/macro. React's useI18n is not supported. For SSR, use withRequestLocale from best-i18n/server and initialize the client locale before hydration.

SolidStart v2

Enable solid: true on the Vite plugin before solidStart(). Import t and plural from best-i18n/macro, <Trans> from best-i18n/solid/macro, and reactive getLocale() / setLocale() from best-i18n/solid. Put translations in JSX, accessors or createMemo so they update when the locale changes.

Wrap SolidStart v2 H3 middleware’s next() with withRequestLocale from best-i18n/server for request isolation. See the complete SolidStart playground and integration guide.

Vue 3 and Nuxt 4

Enable vue: true on the Vite plugin before vue(). Import t and plural from best-i18n/macro, <Trans> from best-i18n/vue/macro, and the reactive useLocale() / getLocale() / setLocale() from best-i18n/vue. Template expressions update on their own; script text that should follow the locale goes in a computed.

<script setup lang="ts">
import { computed } from 'vue'
import { t } from 'best-i18n/macro'
import { useLocale } from 'best-i18n/vue'

const title = computed(() => t`Welcome`)
const locale = useLocale()
</script>

<template>
  <h1 :title="t`Welcome`">{{ title }}</h1>
  <p>{{ locale }}</p>
</template>

On Nuxt, list best-i18n/nuxt in modules and put the locales under bestI18n. The module adds the Vite plugin, a route per locale prefix, a Nitro plugin that binds the locale to each request through Nitro's async context, and a Nuxt plugin that stamps <html lang> and reads it back before hydration. See the complete Nuxt playground and integration guide.

License

MIT