TL;DR
unplugin is the universal plugin system — write ONE plugin that works in Vite, Rollup, webpack, esbuild, and rspack simultaneously. Rollup plugins use Rollup's native hook API — the foundation that Vite extends, widely adopted, well-documented. Vite plugins extend Rollup's API with Vite-specific hooks — dev server middleware, HMR handling, and config resolution. In 2026: unplugin if you're publishing a plugin for the ecosystem, native Rollup/Vite plugins for project-specific needs.
Key Takeaways
- unplugin: ~8M weekly downloads — one plugin API → Vite + Rollup + webpack + esbuild + rspack
- Rollup plugins: native to Rollup — Vite uses the same API in build mode
- Vite plugins: extends Rollup — adds
configureServer,handleHotUpdate,confighooks - Vite plugins ARE Rollup plugins + extra hooks — any Rollup plugin works in Vite
- unplugin is used by Vue ecosystem (unplugin-vue-components, unplugin-auto-import) and many others
- Write Rollup-compatible hooks when possible — they work in both Rollup and Vite
Plugin Architecture
Rollup Plugin API (foundation):
resolveId → load → transform → buildEnd → generateBundle
Vite Plugin API (extends Rollup):
config → configResolved → configureServer → resolveId → load → transform
→ handleHotUpdate → buildEnd → generateBundle
unplugin (universal wrapper):
Same hooks → compiles to Rollup, Vite, webpack, esbuild, rspack plugins
Rollup Plugin
Rollup plugins — the foundation:
Basic plugin structure
import type { Plugin } from "rollup"
function myPlugin(): Plugin {
return {
name: "my-plugin",
// Called for each import — resolve module paths:
resolveId(source, importer) {
if (source === "virtual:my-module") {
return source // Handle this module ourselves
}
return null // Let other plugins/default handle it
},
// Called after resolveId — provide module content:
load(id) {
if (id === "virtual:my-module") {
return `export const version = "1.0.0"`
}
return null
},
// Transform module source code:
transform(code, id) {
if (!id.endsWith(".ts")) return null
// Example: inject build metadata
return code.replace(
"__BUILD_TIME__",
JSON.stringify(new Date().toISOString())
)
},
}
}
Virtual modules
function virtualEnvPlugin(): Plugin {
const PREFIX = "\0virtual:env"
return {
name: "virtual-env",
resolveId(id) {
if (id === "virtual:env") return PREFIX
},
load(id) {
if (id === PREFIX) {
return `
export const NODE_ENV = ${JSON.stringify(process.env.NODE_ENV)}
export const BUILD_TIME = ${JSON.stringify(new Date().toISOString())}
export const GIT_SHA = ${JSON.stringify(process.env.GIT_SHA ?? "dev")}
`
}
},
}
}
// Usage in app:
import { NODE_ENV, BUILD_TIME } from "virtual:env"
Code transformation
import MagicString from "magic-string"
function replacePlugin(replacements: Record<string, string>): Plugin {
return {
name: "replace",
transform(code, id) {
let hasReplacement = false
const s = new MagicString(code)
for (const [search, replace] of Object.entries(replacements)) {
const index = code.indexOf(search)
if (index !== -1) {
s.overwrite(index, index + search.length, replace)
hasReplacement = true
}
}
if (!hasReplacement) return null
return {
code: s.toString(),
map: s.generateMap({ hires: true }),
}
},
}
}
Vite Plugin
Vite plugins — Rollup + Vite-specific hooks:
Vite-specific hooks
import type { Plugin } from "vite"
function myVitePlugin(): Plugin {
return {
name: "my-vite-plugin",
// Modify Vite config:
config(config, { command }) {
if (command === "build") {
return { define: { __PROD__: "true" } }
}
return { define: { __PROD__: "false" } }
},
// Access resolved config:
configResolved(config) {
console.log("Root:", config.root)
console.log("Mode:", config.mode)
},
// Add dev server middleware:
configureServer(server) {
server.middlewares.use("/api/health", (req, res) => {
res.end(JSON.stringify({ status: "ok" }))
})
},
// Handle HMR:
handleHotUpdate({ file, server }) {
if (file.endsWith(".mdx")) {
console.log("MDX changed, triggering full reload")
server.ws.send({ type: "full-reload" })
return [] // Prevent default HMR
}
},
// Standard Rollup hooks still work:
resolveId(source) {
if (source === "virtual:app-info") return "\0virtual:app-info"
},
load(id) {
if (id === "\0virtual:app-info") {
return `export const name = "PkgPulse"`
}
},
}
}
Enforce order
function earlyPlugin(): Plugin {
return {
name: "early-plugin",
enforce: "pre", // Run BEFORE other plugins
transform(code, id) {
// Pre-processing: runs before Vite's own transforms
},
}
}
function latePlugin(): Plugin {
return {
name: "late-plugin",
enforce: "post", // Run AFTER other plugins
transform(code, id) {
// Post-processing: runs after Vite's transforms
},
}
}
Build-only vs serve-only
function devOnlyPlugin(): Plugin {
return {
name: "dev-only",
apply: "serve", // Only active during vite dev
configureServer(server) {
// Mock API endpoints in dev
},
}
}
function buildOnlyPlugin(): Plugin {
return {
name: "build-only",
apply: "build", // Only active during vite build
generateBundle(options, bundle) {
// Add build metadata to output
},
}
}
unplugin
unplugin — universal plugin framework:
Write once, use everywhere
import { createUnplugin } from "unplugin"
// Define the plugin ONCE:
const myUnplugin = createUnplugin((options: { prefix?: string }) => {
return {
name: "my-universal-plugin",
// Standard hooks (work in all bundlers):
resolveId(id) {
if (id === "virtual:config") return id
},
load(id) {
if (id === "virtual:config") {
return `export const prefix = ${JSON.stringify(options?.prefix ?? "app")}`
}
},
transformInclude(id) {
return id.endsWith(".ts") // Only transform .ts files
},
transform(code, id) {
return code.replace("__PREFIX__", options?.prefix ?? "app")
},
}
})
// Export for each bundler:
export const vite = myUnplugin.vite
export const rollup = myUnplugin.rollup
export const webpack = myUnplugin.webpack
export const esbuild = myUnplugin.esbuild
export const rspack = myUnplugin.rspack
Usage in each bundler
// vite.config.ts:
import { vite as myPlugin } from "my-universal-plugin"
export default { plugins: [myPlugin({ prefix: "pkgpulse" })] }
// rollup.config.js:
import { rollup as myPlugin } from "my-universal-plugin"
export default { plugins: [myPlugin({ prefix: "pkgpulse" })] }
// webpack.config.js:
const { webpack: MyPlugin } = require("my-universal-plugin")
module.exports = { plugins: [new MyPlugin({ prefix: "pkgpulse" })] }
// esbuild:
import { esbuild as myPlugin } from "my-universal-plugin"
esbuild.build({ plugins: [myPlugin({ prefix: "pkgpulse" })] })
Real-world example: auto-import
import { createUnplugin } from "unplugin"
import { readFileSync } from "node:fs"
// Auto-import components without manual import statements:
const autoImport = createUnplugin((options: { imports: Record<string, string> }) => {
const importMap = options.imports
return {
name: "unplugin-auto-import",
transformInclude(id) {
return /\.[jt]sx?$/.test(id)
},
transform(code, id) {
const usedImports: string[] = []
for (const [name, source] of Object.entries(importMap)) {
if (code.includes(name) && !code.includes(`import.*${name}`)) {
usedImports.push(`import { ${name} } from "${source}"`)
}
}
if (usedImports.length === 0) return null
return usedImports.join("\n") + "\n" + code
},
}
})
Feature Comparison
| Feature | unplugin | Rollup Plugin | Vite Plugin |
|---|---|---|---|
| Vite support | ✅ | ✅ | ✅ (native) |
| Rollup support | ✅ | ✅ (native) | ❌ |
| webpack support | ✅ | ❌ | ❌ |
| esbuild support | ✅ | ❌ | ❌ |
| rspack support | ✅ | ❌ | ❌ |
| Dev server hooks | ❌ | ❌ | ✅ |
| HMR hooks | ❌ | ❌ | ✅ |
| Virtual modules | ✅ | ✅ | ✅ |
| Code transform | ✅ | ✅ | ✅ |
| transformInclude | ✅ | ❌ (manual) | ❌ (manual) |
| Weekly downloads | ~8M | bundled | bundled |
When to Use Each
Write an unplugin if:
- Publishing a plugin for the ecosystem (users on different bundlers)
- Need the plugin to work in Vite, webpack, esbuild, AND rspack
- Building a universal code transform (auto-import, macros, etc.)
- Vue/UnJS ecosystem plugin
Write a Rollup plugin if:
- Plugin is for build-time only (no dev server hooks needed)
- Want compatibility with both Rollup and Vite automatically
- Standard code transformation, virtual modules, or asset generation
Write a Vite plugin if:
- Need Vite-specific hooks (
configureServer,handleHotUpdate) - Dev server middleware or HMR behavior
- Vite-only project (no need for cross-bundler support)
- Extending Vite's config or environment handling
Community Adoption in 2026
unplugin reaches approximately 8 million weekly downloads, driven by its adoption as the foundational layer for popular ecosystem plugins that need cross-bundler support. unplugin-vue-components, unplugin-auto-import, unplugin-icons, and unplugin-vue-router are all built on unplugin and together account for tens of millions of additional weekly downloads. The pattern has become the standard way to publish a Vite ecosystem plugin that should also work in webpack, rspack, and esbuild environments — particularly important for Vue and Nuxt plugin authors whose user bases span multiple bundlers.
Rollup (as a plugin host) sees hundreds of millions of weekly downloads as a bundler, though individual plugin download numbers vary widely. Rollup's plugin API, formalized in Rollup v3, has become the canonical cross-bundler plugin contract — Vite adopted it deliberately so that most Rollup plugins work in Vite unchanged. Writing a Rollup plugin gives you compatibility with both Rollup (library bundling) and Vite (application bundling) for the standard hook surface.
Vite as a plugin host serves the same hundreds-of-millions-scale download base as Rollup, as Vite includes both a dev server and a Rollup-based production build. The Vite-specific plugin API (hooks like configureServer, handleHotUpdate, and enforce) adds capabilities that pure Rollup plugins cannot use. For plugins that need dev server integration — mock API middleware, WebSocket communication, HMR custom handling — a Vite-native plugin is the only option.
Migration Guide
Converting a Vite-only plugin to unplugin
If a Vite plugin only uses standard Rollup hooks (resolveId, load, transform), converting to unplugin adds cross-bundler support with minimal changes:
// Vite plugin (before)
import type { Plugin } from "vite"
function myPlugin(options: Options): Plugin {
return {
name: "my-plugin",
resolveId(id) { if (id === "virtual:foo") return id },
load(id) { if (id === "virtual:foo") return `export const foo = "${options.value}"` },
transform(code, id) { return id.endsWith(".ts") ? transform(code) : null },
}
}
// unplugin (after) — works in Vite, Rollup, webpack, esbuild
import { createUnplugin } from "unplugin"
const unpluginFactory = createUnplugin((options: Options) => ({
name: "my-plugin",
resolveId(id) { if (id === "virtual:foo") return id },
load(id) { if (id === "virtual:foo") return `export const foo = "${options.value}"` },
transformInclude(id) { return id.endsWith(".ts") },
transform(code) { return transform(code) },
}))
export const vite = unpluginFactory.vite
export const rollup = unpluginFactory.rollup
export const webpack = unpluginFactory.webpack
Note: if your plugin uses configureServer or handleHotUpdate, those are Vite-only and cannot be expressed in unplugin — keep a Vite-specific wrapper that uses the unplugin base and adds the Vite-specific hooks on top.
Testing and Debugging Plugin Implementations
Plugin development and debugging requires a different workflow than application development, and each approach has distinct testing strategies.
Testing Rollup plugins uses the rollup API programmatically in tests. A test creates a Rollup build with the plugin under test, bundles a fixture input, and asserts on the output. The rollup() function's output includes transformed modules, generated assets, and any warnings. This integration test approach catches issues that unit testing individual hook functions would miss — particularly issues with hook ordering, transform chaining, and the interaction between load and transform hooks.
Testing Vite plugins is more complex because Vite's development server mode and build mode have different code paths. Vite's createServer() and build() APIs enable integration testing but require proper server lifecycle management (server.close() in test teardown). The vite-plugin-inspect plugin (developed by Anthony Fu) is invaluable during development: it adds a Vite inspector UI at http://localhost:5173/__inspect/ showing each module's transform chain — which plugin transformed it, the input and output at each stage, and the final output. Using vite-plugin-inspect in development of a custom plugin catches transform issues immediately.
unplugin's testing approach leverages the fact that the same plugin code runs through multiple adapters. Testing against Vite (the most developer-friendly environment) catches most issues; then verifying against Rollup (the most spec-compliant environment) catches hooks that work in Vite but rely on Vite-specific behavior not in the spec. Running the same test fixture through all adapters — unplugin.vite, unplugin.rollup, unplugin.webpack — is unplugin's primary value for plugin authors: one codebase, multi-adapter validation.
Debugging with source maps requires plugins to either pass through source maps from upstream transforms or generate correct source maps for their own transforms. A plugin that transforms code without updating source maps breaks debugger line mapping in the browser's DevTools. The Rollup source map API (this.parse(), magic-string's MagicString) handles source map generation for text-manipulation transforms. Most plugin bugs in production are source map issues that are invisible in development but break stack traces and breakpoints in production builds.
TypeScript Authoring and Type Safety in Plugin Development
TypeScript types for Rollup and Vite plugins are comprehensive and well-maintained. Rollup's Plugin type from rollup (not a separate types package) fully types all hook parameters and return values. Vite's Plugin type from vite extends Rollup's Plugin and adds types for all Vite-specific hooks. Using these types provides IDE autocomplete for hook names, parameter types, and the context object (this) available inside hooks — particularly useful for this.emitFile(), this.resolve(), and this.addWatchFile().
unplugin's TypeScript experience has improved significantly in v1.x. The createUnplugin generic accepts a type parameter for the plugin options, providing full type propagation from the factory options to each hook. The per-adapter exports (unpluginFactory.vite, unpluginFactory.rollup) return correctly typed Plugin objects for each framework, so consumers get the right types in their vite.config.ts or rollup.config.js without manual casting. One area where unplugin's types are less complete than native Vite types is the Vite-specific hook signatures — since unplugin doesn't expose configureServer or handleHotUpdate, those Vite-specific parameter types are not included.
Plugin versioning and backwards compatibility are operational concerns for published plugins. A Rollup plugin that depends on a specific Rollup version's hook behavior may break when users upgrade Rollup. The peerDependencies field in the plugin's package.json should specify the supported Rollup and Vite version ranges. For unplugin-based plugins, specifying the supported unplugin version range and testing against multiple bundler versions in CI is the recommended approach to catching compatibility regressions before they reach users. The unplugin ecosystem has benefited from the UnJS organization's consistent release cadence, which makes version management more predictable than maintaining adapter compatibility across independently released bundlers.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on unplugin v1.x, Rollup v4.x, and Vite v6.x.
Compare build tools and bundler plugins on PkgPulse →
See also: Vite vs webpack and Turbopack vs Vite, recast vs jscodeshift vs ts-morph.