<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/unplugin-vs-rollup-plugin-vs-vite-plugin-universal-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/unplugin-vs-rollup-plugin-vs-vite-plugin-universal-2026/raw.md -->
<!-- Source path: content/guides/unplugin-vs-rollup-plugin-vs-vite-plugin-universal-2026.mdx -->

---
og_image: "/images/guides/unplugin-vs-rollup-plugin-vs-vite-plugin-universal-2026.webp"
title: "unplugin vs Rollup Plugin vs Vite Plugin 2026"
description: "Compare unplugin, Rollup plugins, and Vite plugins for writing build tool plugins. Universal plugins that work across Vite, Rollup, webpack, esbuild, and."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["javascript", "typescript", "developer-tools", "automation"]
---

## 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`, `config` hooks
- 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](https://rollupjs.org/plugin-development/) — the foundation:

### Basic plugin structure

```typescript
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

```typescript
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

```typescript
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](https://vite.dev/guide/api-plugin.html) — Rollup + Vite-specific hooks:

### Vite-specific hooks

```typescript
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

```typescript
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

```typescript
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](https://github.com/unjs/unplugin) — universal plugin framework:

### Write once, use everywhere

```typescript
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

```typescript
// 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

```typescript
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:

```typescript
// 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 →](https://www.pkgpulse.com)*

*See also: [Vite vs webpack](/compare/vite-vs-webpack) and [Turbopack vs Vite](/compare/turbopack-vs-vite), [recast vs jscodeshift vs ts-morph](/guides/recast-vs-jscodeshift-vs-ts-morph-codemods-code-2026).*
