# unplugin: write a build tool plugin once and ship it to ten bundlers

> A small TypeScript layer that turns one Rollup-shaped plugin object into plugins for Vite, Webpack, esbuild, Rspack, Rolldown, Farm and Bun, so library authors stop maintaining four forks of the same hook.

**unjs/unplugin** — Unified plugin system for Vite, Rollup, Webpack, esbuild, Rolldown, and more

- Repository: https://github.com/unjs/unplugin
- Website: https://unplugin.unjs.io
- Stars: 3,620 · Forks: 167
- Language: TypeScript
- License: MIT
- Published: 2026-10-07 · Updated: 2026-10-07 · Language: en
- Canonical page: https://hysenlabs.com/projects/unjs-unplugin

## One plugin definition, ten build tools behind it

The pitch fits in three lines of README text: a unified plugin system for build tools, a list of supported targets, and a link to documentation. The supported list is the interesting part.

```text
- [Vite](https://vite.dev/)
- [Rollup](https://rollupjs.org/)
- [Webpack](https://webpack.js.org/)
- [esbuild](https://esbuild.github.io/)
- [Rspack](https://www.rspack.dev/)
- [Rsbuild](https://rsbuild.rs/)
- [Rolldown](https://rolldown.rs/)
- [Farm](https://www.farmfe.org/)
- [Bun](https://bun.com/)
```

That is eight named targets plus a catch-all line about every framework built on top of them, which covers the toolchain-layer projects like Rsbuild rather than needing their own adapter logic. Rsbuild is the clearest example of why the framing matters: Rsbuild sits above Rspack, so supporting the underlying bundler gets the framework most of the way there.

The repository is MIT licensed, credited to Nuxt Contrib from 2021 onward, which places it in the same ecosystem as the unplugin-auto-import and unplugin-icons families that a great many Vue and Nuxt projects already depend on. GitHub reports 3,612 stars, 158 forks, 45 open issues, and a last push on 2026-09-18, with no archive flag. The issue count is low relative to the star count, which for infrastructure of this kind usually indicates that most consumers get it transitively through a framework preset and never file against it directly.

## The export map shows where the platforms genuinely differ

The most informative file in the repository is `package.json`, because the exports field lists the paths that exist and, just as usefully, the paths that do not:

```json
  "exports": {
    ".": "./dist/index.mjs",
    "./rspack/loaders/load": "./dist/rspack/loaders/load.mjs",
    "./rspack/loaders/transform": "./dist/rspack/loaders/transform.mjs",
    "./webpack/loaders/load": "./dist/webpack/loaders/load.mjs",
    "./webpack/loaders/transform": "./dist/webpack/loaders/transform.mjs",
    "./package.json": "./package.json"
  },
```

Vite, Rollup, esbuild, Rolldown, Farm and Bun get nothing beyond the root entry, because each of them accepts a plugin object directly and unplugin's job is to reshape it in memory. Webpack and Rspack get four extra subpaths exporting `load` and `transform` loaders, because their plugin architecture routes file transformation through webpack loaders rather than through a hook on the plugin object.

So the adapters are not all doing the same amount of work. For esbuild, Farm and Bun the work is mapping hook names and translating options, with Bun needing the most translation of any of them. For Webpack and Rspack the work includes shipping loaders, which is why those files exist as separate entry points rather than being bundled into the main export.

Two smaller details in the same file matter for consumers. `"sideEffects": false` lets bundlers drop the import entirely when nothing is used from it, which matters for a package that many projects import at config load time. And `"files": ["dist"]` confirms that only the build output ships, with `src/`, `test/` and `docs/` staying out of the tarball.

## Bun gets special handling the others do not

The v3.2.0 release, published 2026-06-24, is almost entirely Bun work, and reading its four entries together explains what the Bun adapter is doing. One change sanitizes plugin names for namespaces, which suggests plugin identifiers end up in namespace strings inside Bun's bundler and invalid characters break that. Another consumes a `plugin.bun` extension point, meaning a plugin can carry Bun-native configuration rather than being forced through the universal shape. A third makes the loader returned from the load hook respected, and the fourth adds `plugin.bun.loader` for per-plugin loader resolution.

Three separate concerns in one release, all about the same target. That is the honest shape of a compatibility layer: not a thin adapter but a place where target-specific extensions accumulate. It also explains the later v3.4.0 fix adding missing optional peer deps, which is a symptom of the same thing. Every new target adds a surface that has to be declared so the right types resolve without forcing consumers to install every bundler.

The peer dependency block is where that inventory lives, and it is where a mismatch shows up:

```json
  "peerDependencies": {
    "@farmfe/core": "*",
    "@rsbuild/core": "*",
    "@rspack/core": "*",
    "bun-types-no-globals": "*",
    "esbuild": "*",
    "rolldown": "*",
    "rollup": "*",
    "unloader": "*",
    "vite": "*",
    "webpack": "*"
  },
```

Bun itself is not in that list even though the README names Bun as a supported target; what appears is `bun-types-no-globals`, a types package. Treat the declarations as a floor rather than a complete inventory, and let the adapter detect the host at runtime.

## Node version requirements are stricter than most build tooling

The engines field is not the permissive `>=18` that most build infrastructure carries:

```json
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  },
```

Both floors are specific point releases, which is a signal about how the package is developed rather than an arbitrary gate. Node 20.19 and 22.12 are where the require(esm) and module syntax features the project relies on became reliably available. The practical consequence is real: a project on Node 20.11 or 22.9 is outside the supported range and will get an engine warning on install.

Development is pinned harder still. The `devEngines` field names pnpm and pins it to version 12.5.0 with `onFail: download`, meaning a contributor without that pnpm gets it fetched rather than an error. The presence of `pnpm-workspace.yaml` and `pnpm-lock.yaml` in the tree confirms a workspace setup, which is consistent with the `docs:dev`, `docs:build` and `docs:gen-files` scripts all delegating with `pnpm -C docs`.

Publishing is configured with `"publishConfig": { "tag": "beta" }`. That is worth pausing on. It means releases land under the npm `beta` dist-tag rather than `latest`, so the version you get from a plain install is not necessarily the version in the README badge or the changelog. If you depend on unplugin directly rather than transitively, pin an exact version, or install from the tag you actually want. The `CHANGELOG.md` at the repository root is generated with bumpp, per the release script, so tags are the more reliable record.

## Tests compile real fixtures against every supported bundler

The test setup is compact and tells you what the project considers its own responsibility:

```json
    "test": "nr test:build && vitest run",
    "test:build": "node scripts/buildFixtures.ts"
```

A fixture generation step runs first, then vitest. That ordering is the design: rather than mocking a bundler, the suite builds actual fixture projects and asserts on the output. For a compatibility layer, that is the only test approach with any credibility, since the whole risk is behavioural divergence between real bundlers. `vitest.config.ts` and `test/` at the top level are the two places to look, and `scripts/buildFixtures.ts` is where the supported targets get enumerated.

The build itself uses tsdown, configured through `tsdown.config.ts`, with a watch mode that points at `src`. Type checking uses `tsgo --noEmit` rather than the stock tsc invocation, which is a deliberate speed choice for a package whose type surface is its main deliverable. Linting is eslint with a cache, configured through `eslint.config.js`, which places this repository on the flat config style rather than the older `.eslintrc` format.

The tree also carries a `docs/` directory that is part of the workspace rather than a separate site checkout, and a `docs:gen-files` script that generates content into it. unplugin.unjs.io is therefore built from inside the same repository and pull request, which is the arrangement that keeps adapter documentation from drifting behind the code.

## Where to start and what to watch

The README is short enough to read in full and then sends you to unplugin.unjs.io, which is where the actual plugin authoring guide lives. That is a reasonable split: the README's job here is establishing scope and compatibility, not teaching the API, and there is not enough surface here to need a longer readme.

For a concrete working reference rather than a synthetic example, the ecosystem plugins built on top of this layer are more instructive than anything the repository ships. `unplugin-auto-import` is the best-known consumer because auto-import is a genuinely cross-bundler feature that has to intercept resolution in every target, which is the hard case. `unplugin-icons` is a good second, since it also has to deal with virtual modules, which stress a different set of hooks than plain transforms.

The release history is worth a skim too. v3.3.0 in June 2026 added an Rsbuild adapter as a single feature release, and v3.4.0 in September 2026 was two fixes. That cadence, roughly one meaningful change every two months with a minor version bump each time, is what to expect from infrastructure that other people's build depends on: few large releases, many small corrections.

The thing to actually decide early is whether you need unplugin at all. If your plugin only ever runs in one bundler, the adapter layer costs you an indirection and an extra dependency for no benefit, and writing the Rollup-shaped plugin directly is simpler. The moment the second bundler appears, that math reverses quickly.

## Conclusion

unplugin earns its place when you publish a build tool plugin for more than one bundler, because the alternative is maintaining near-duplicate plugin definitions whose only difference is which hooks the target exposes. What it does not do is make the platforms equivalent, and the export map is the proof: Webpack and Rspack get loader subpaths precisely because their plugin model cannot express a transform hook directly. If you only target Rollup or only Vite, this layer is overhead and you should skip it. Start with the `unplugin.autoImport` family of plugins as a working reference, and pin your install rather than tracking the beta dist-tag.

## FAQ

### What is unplugin and which build tools does it support?

unplugin is a unified plugin system that lets you write one plugin shape and run it across Vite, Rollup, Webpack, esbuild, Rspack, Rsbuild, Rolldown, Farm and Bun. For Rollup-style targets it reshapes the plugin object in memory, and for Webpack and Rspack it additionally ships load and transform loaders.

### Do I need unplugin if I only use Vite or only use Rollup?

Probably not. Both accept a plugin object directly, so writing that plugin yourself removes an indirection and a dependency. unplugin earns its cost from the second bundler onward, when the alternative is maintaining near-duplicate plugin definitions whose only difference is which hooks each target exposes.

### Why does unplugin ship separate loader entries for webpack and rspack?

Because their plugin architecture routes file transformation through webpack loaders rather than through a hook on the plugin object. The package exports unplugin/rspack/loaders/load, unplugin/rspack/loaders/transform and the matching webpack paths so those targets get a real loader. The other targets accept plugin objects directly and need no extra entry point.

### Which Node versions does unplugin support?

The package declares engines of node ^20.19.0 || >=22.12.0, so both branches start at a specific point release rather than a major version. Anything below those floors, such as Node 20.11 or 22.9, sits outside the supported range and will produce an engine warning on install.

## Sources

- [License: MIT](https://github.com/unjs/unplugin/blob/main/LICENSE)
- [Project website](https://unplugin.unjs.io)
- [README](https://github.com/unjs/unplugin/blob/main/README.md)
- [Releases](https://github.com/unjs/unplugin/releases)
- [unjs/unplugin on GitHub](https://github.com/unjs/unplugin)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/unjs-unplugin
