Skip to main content

Guide

unplugin vs Rollup Plugin vs Vite Plugin 2026

Compare unplugin, Rollup plugins, and Vite plugins for writing build tool plugins. Universal plugins that work across Vite, Rollup, webpack, esbuild, and.

·PkgPulse Team·
0

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 — 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

FeatureunpluginRollup PluginVite 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~8Mbundledbundled

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.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.