Skip to main content

unplugin vs Rollup Plugin vs Vite Plugin: Universal Build Plugins (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.