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

i18n-fs

v0.9.0

Published

Folder-based i18n for Next.js, with a Rust core compiled to WebAssembly.

Readme

i18n-fs

Folder-based internationalisation for Next.js, with a Rust core compiled to WebAssembly.

npm install i18n-fs

Next.js 14.2+ · Node 22.18+ · App Router

Documentation · Getting started · API reference

What makes it different

Your file tree is the namespace. public/locales/en/home/hero.json is home/hero — no registry, no import map, no central module that has to know about every message file you own.

const t = await getTranslation('home/hero', 'hero');

Move a file and one string changes.

Missing translations fail the build, not the page. i18n-fs check compares every locale against the default one — by key and by shape, so a key that is a string in one language and a list in another is caught before t.array breaks for the readers of exactly one language.

npx i18n-fs check --strict     # exits non-zero; put it in CI

Set compareLocales: false when the locales are not translations of one another — a German site written for a German audience has different keys by design, and that is not a defect to report.

Types generated from the files you actually have. i18n-fs build writes the namespaces, the scopes inside them, and which keys are text and which are lists. You write no types yourself, and a mistyped or renamed key is a compile error — as is a list key passed to t, or a scope that does not exist.

Plurals that are the translator's to fix. {count, plural, one {# file} other {# files}} — plus selectordinal and select — so the grammar lives in the message rather than in your TSX, where a Russian translator cannot reach it. The categories come from the runtime's own Intl.PluralRules, so CLDR's tables are not compiled into anything you download.

{ "files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}" }

Dates, money and lists, for nothing. getFormatter() and useFormatter() wrap Intl, which the runtime already has — a page that formats but does not translate on the client downloads no WebAssembly at all. The Persian, Arabic, Hebrew and Japanese calendars come free with it, so a Jalali date needs no date library.

Pages ship only the messages they use. Namespaces live under public/ and are fetched per namespace with a content hash for immutable caching. Nothing puts your whole message tree into every page's payload.

Routing that provably does not loop. Canonicalisation is idempotent and redirects preserve the locale that was asked for — both asserted over thousands of generated cases per test run, and again end-to-end against a real Next.js server. Three real bugs were found this way that example-based tests missed.

One implementation, both sides. Locale negotiation, route canonicalisation, message resolution and rich-text parsing are Rust, compiled once and used by the server, the browser and the proxy. A lookup cannot behave one way during SSR and another after hydration, and a diagnostic cannot say two different things.

Three routing strategies. Path, domain or cookie, with the locale prefix always, as-needed, or hidden entirely — changed in one config field, touching no link in your application.

What it costs a visitor

Bundle size is a feature here, so it is measured rather than claimed. Nothing below is typed by hand: npm run measure reads the binaries and the built example app and writes the tables.

Measured on 2026-08-31, from i18n-fs 0.9.0. Every figure below comes from a real build. CI re-measures on each pull request and fails if this table and the binaries disagree, so a stale number cannot survive here.

| what | when it is downloaded | gzip | brotli | | --- | --- | --- | --- | | Client Components — the WebAssembly binary | only when a Client Component calls useTranslation | 62.8 KB | 53.4 KB | | Server Components only | never — nothing is sent to the browser | 0 KB | 0 KB | | The proxy | never — it runs on the server | 0 KB | 0 KB |

Sizes of every binary, including the ones a visitor never receives:

| binary | used by | downloaded? | raw | gzip | brotli | | --- | --- | --- | --- | --- | --- | | edge | the proxy, on every request | server-side — never downloaded | 65.7 KB | 37.6 KB | 33.4 KB | | browser | Client Components | downloaded, and only when one is used | 139.0 KB | 62.8 KB | 53.4 KB | | node | Server Components and the CLI | read from disk — never downloaded | 210.1 KB | 100.3 KB | 85.2 KB |

Taken from the built examples/next-15-middleware, which uses client translations. The JavaScript that instantiates the binary is a couple of kilobytes and is folded into a chunk the page loads anyway.

Translating in a Server Component costs the browser nothing. The WebAssembly lives on the server and only HTML crosses the wire. That is the default path and the one to prefer.

A Client Component is what changes this. useTranslation needs the core in the browser, so the binary is fetched — once, lazily, as its own chunk, cached immutably. It is not part of your initial JavaScript bundle, and a page that never calls it never asks for it.

Message files are separate again: each namespace is its own JSON, fetched per namespace with a content hash, so a page carries the messages it reads and not your whole translation tree. Anything listed in <I18nProvider namespaces> is already in the payload and is not fetched at all.

Expect the last decimal to move: the Rust toolchain is not byte-identical across operating systems, so the same commit built on Linux and on Windows differs by a few tenths of a kilobyte.

You pay for what you import

The package is marked free of side effects, so a bundler keeps only what you actually use. Measured by bundling one import at a time:

| import | cost | | --- | --- | | ErrorCode, VERSION, defineConfig from i18n-fs | under 1 KB | | Link, useRouter, usePathname, useLocaleSwitcher | 1–2 KB, no WebAssembly | | useLocale, useI18nContext | ~2 KB, no WebAssembly | | useTranslation | the core, because resolving a message needs it |

Navigation carries no binary because addLocale and stripLocale are mirrored in TypeScript — the reason <Link> can stay synchronous is also the reason it costs nothing. An example app that navigates and switches locale on the client but translates only on the server emits no .wasm at all.

test/tree-shaking.test.ts bundles each import and fails if one of them starts pulling the core, because the only symptom otherwise is a larger download.

And what the messages cost

Three things decide how a namespace reaches a Client Component, and they trade document size against a round trip. Measured on a page reading three namespaces of 13.5 KB each — 41 KB of JSON — by requesting the page from next start and recording what came back:

| | HTML, uncompressed | over the wire | when the browser has it | | --- | --- | --- | --- | | <I18nProvider namespaces> | 47.0 KB | 13.0 KB | before any JavaScript runs | | <I18nProvider prefetch> | 6.4 KB | 2.9 KB | in parallel with the JavaScript | | neither | 5.6 KB | 2.7 KB | after hydration, on demand |

Both columns are the same response: what the server sent with Accept-Encoding: gzip, and what it sent without. Next.js serves gzip; a CDN doing brotli takes the first row to about 10.4 KB.

Inlining 41 KB of JSON therefore costs about 10 KB more on the wire than sending none of it, because HTML compresses well. That is the number worth reasoning about — the uncompressed figure is what a devtools DOM panel shows, and not what anyone downloads.

Minifying the JSON is not worth doing: stripping the indentation makes the files 3% smaller and, after gzip, 0.1 KB different, since compression already collapses repeated whitespace.

The two levers that do move it are choosing prefetch over namespaces, and putting a provider on the route that needs the messages rather than at the root. Both are in translating.

Quick look

// i18n-fs.config.ts
import { defineConfig } from 'i18n-fs/config';

export default defineConfig({
	locales: ['fa', 'en'],
	defaultLocale: 'fa',
	strategy: 'path',
	prefix: 'as-needed',
});
// app/[locale]/page.tsx
import { getTranslation } from 'i18n-fs/server';

export default async function Page() {
	const t = await getTranslation('home/hero', 'hero');

	return (
		<main>
			<h1>{t('title')}</h1>
			<p>{t('greeting', { name: 'Ali' })}</p>
			<ul>
				{t.array('bullets').map((item) => (
					<li key={item}>{item}</li>
				))}
			</ul>
			{t.rich('terms', { link: (chunk) => <a href="/terms">{chunk}</a> })}
		</main>
	);
}

Full walkthrough: Getting started.

About fallbacks

A missing translation falls back to the string you supply, or to the key. It never falls back to another language, and there is no way to enable that.

Cross-language fallback produces a page that looks fine to whoever built it and is broken for the reader — a Persian page with three English sentences passes every review by someone who reads English, and the gap never surfaces because nothing is missing any more.

That rule is only survivable because i18n-fs check turns the gap into a build failure first. The two go together. See errors.

Development

Requires Rust (stable, with wasm32-unknown-unknown), wasm-pack, Node 22.18+.

npm run bootstrap

Installs, builds the three WebAssembly targets, syncs them into the package and builds it. Plain npm workspaces — nothing to install or learn beyond the Node version in .nvmrc.

Both Rust feature sets matter. The proxy compiles the core with --no-default-features, so a change that only builds with full breaks it:

cargo test --workspace --all-features
cargo test -p i18n-fs-core --no-default-features
npm run typecheck && npm run build && npm test

The example apps are part of the test suite, not samples — they are the only place the real matcher, the real Next.js runtime and the real WebAssembly binary meet. There are two because the file convention changed between Next.js majors, and both run the same assertions, so behaving identically across them is proven rather than assumed:

npm run example && npm run example:test

| | | | | | --- | --- | --- | --- | | examples/next-16-proxy | Next.js 16 | React 19 | proxy.ts | | examples/next-15-middleware | Next.js 15 | React 19 | middleware.ts | | examples/next-14-react-18 | Next.js 14.2 | React 18 | middleware.ts |

The third is the floor of peerDependencies, and it earns its place: it is the only one that tests react@18.

Contributing

Work happens on branches and lands through pull requests; main is never pushed to directly. Every pull request that changes published behaviour needs a changeset (npm run changeset).

The version pull request re-measures itself. changeset:version bumps the version, refreshes the lockfile and regenerates the table below, so the release carries figures taken from the build it describes. To do it by hand:

npm run bootstrap && npm run example && npm run measure

That rewrites the table above and stamps it with today's date and the version it was taken from. CI runs npm run measure:check on every pull request, so the documented sizes cannot drift from the binaries that were built — a change that grows the download fails the build while the README still advertises the old figure. npm run release runs the same check before publishing.

Publishing, once the version pull request is merged:

npm run release

That builds the three binaries, checks the documented sizes against them, publishes to npm, and pushes the tagchangeset publish writes the tag locally and nothing else does. Five versions once reached npm with no tag on the remote, so there was no way to tell which commit 0.6.1 was.

Afterwards, confirm every published version can be found from the repository:

npm run releases:check     # tags and releases, compared against the registry
npm run releases:create    # create any missing release, with the changelog as its notes

It asks the remote, not the local clone. That is the distinction that let the problem last five versions: every tag was present locally the whole time, unpushed, so anything looking at git tag --list would have seen nothing wrong.

Architecture decisions live in docs/adr/. If you are changing how something fundamental works, the ADR is part of the change.

License

MIT