vite-mastery

3.1 · difficulty 2/4 · 16 min read

Vite Plugins vs Rollup Plugins

How Vite, Rollup, and Rolldown plugin hooks overlap, where they differ, and how to decide which plugin shape to author.

Vite 8.1Stable

The Short Version

A Vite plugin is Rollup/Rolldown-compatible hooks plus Vite-only hooks plus dev server semantics.

That means:

  1. resolveId, load, transform, and generateBundle come from the Rollup plugin model.
  2. Vite 8 builds with Rolldown, while keeping Rollup-style plugin compatibility.
  3. config, configureServer, transformIndexHtml, and handleHotUpdate are Vite-specific.
  4. Dev mode does not perform a full bundle, so build-only hooks do not behave like production.

Most pure transform Rollup plugins can be used in Vite. A Vite plugin that depends on the dev server, HTML handling, or HMR cannot be dropped into Rollup unchanged.

The Three-Layer Model

text
Vite plugin
  ├─ Vite-only hooks: config / configureServer / transformIndexHtml / handleHotUpdate
  ├─ Universal build hooks: resolveId / load / transform / renderChunk / generateBundle
  └─ Rolldown/Rollup compatibility layer
ShapeTargetTypical capabilitiesWorks directly in Vite
Rollup pluginRollup/Rolldown build pipelineresolution, loading, transforms, output hooksusually
Rolldown pluginRolldown build pipelinebuild-specific Rolldown extension pointsfor build
Vite pluginVite dev + build lifecycleconfig, dev server, HTML, HMR, buildyes

Vite plugins are not a replacement for Rollup plugins. They place the same build hooks inside a broader dev server model.

Portable Hooks

Hooks that only care about resolving, loading, transforming, and outputting modules are easier to reuse:

HookPurposeCommon use
optionsadjust build optionsdefaults
buildStartinitialize workcaches, file scans
resolveIdresolve import IDsaliases, virtual modules
loadprovide module sourcevirtual modules, custom files
transformtransform sourceDSL compilation, code injection
renderChunkpost-process chunkswrappers
generateBundleinspect final bundlereports, emitted files
writeBundleafter writing outputsourcemap upload

If your plugin uses only these hooks and does not touch Vite's server, config, or module graph, author it as a Rollup/Rolldown-compatible plugin.

ts
import type { Plugin } from "vite"

export function virtualVersionPlugin(version: string): Plugin {
  const virtualId = "virtual:app-version"
  const resolvedId = "\0" + virtualId

  return {
    name: "virtual-version",
    resolveId(id) {
      if (id === virtualId) return resolvedId
    },
    load(id) {
      if (id === resolvedId) {
        return `export const version = ${JSON.stringify(version)}`
      }
    },
  }
}

This does not rely on Vite-only hooks, so it is cheap to reuse elsewhere.

Vite-Only Hooks

HookWhy it is Vite-specific
configVite owns config merging, mode, and env loading
configResolvedreads Vite's final ResolvedConfig
configureServerVite owns the dev server and middleware stack
configurePreviewServervite preview is a Vite server
transformIndexHtmlVite treats HTML as an entry
handleHotUpdateHMR, module graph, and websocket are Vite dev concepts

Example:

ts
import type { Plugin } from "vite"

export function devHealthPlugin(): Plugin {
  return {
    name: "dev-health",
    apply: "serve",
    configureServer(server) {
      server.middlewares.use("/__health", (_req, res) => {
        res.setHeader("Content-Type", "application/json")
        res.end(JSON.stringify({ ok: true }))
      })
    },
  }
}

Rollup cannot run this plugin because it has no dev server middleware.

Dev vs Build Hook Behavior

Vite dev is request-driven. Build scans and bundles the full graph.

QuestionDevBuild
When is a module processed?when requested by the browserwhile traversing the full graph
Is a bundle generated?no full output bundleyes
generateBundlenot usefulcore output hook
configureServercallednot called
transformper requested moduleper bundled module
HMRyesno

Use apply when the plugin only makes sense in one phase:

ts
export function reportBundleSize(): Plugin {
  return {
    name: "report-bundle-size",
    apply: "build",
    generateBundle(_, bundle) {
      console.log(Object.keys(bundle).length)
    },
  }
}

Ordering

Vite plugin order is grouped:

text
user pre plugins
Vite core plugins
user normal plugins
Vite build plugins
user post plugins

enforce: "pre" and "post" control where a plugin sits in Vite's queue. This is separate from hook-level order.

If an MDX plugin must run before React, use enforce: "pre". If an analyzer needs final output, use apply: "build" and generateBundle.

Decision Flow

text
Need dev server / middleware / websocket?
  ├─ yes -> Vite plugin
  └─ no
      Need to transform index.html?
        ├─ yes -> Vite plugin
        └─ no
            Need custom HMR boundaries?
              ├─ yes -> Vite plugin
              └─ no
                  Only resolve/load/transform/output hooks?
                    ├─ yes -> Rollup/Rolldown-compatible plugin
                    └─ no -> clarify the runtime first
RequirementRecommended shape
Convert .md to JS modulesRollup/Rolldown-compatible plugin
Add /api/mock to dev serverVite plugin
Inject script into HTMLVite plugin
Analyze build output sizebuild-only Vite plugin or Rolldown plugin
Implement HMR for .yamlVite plugin
Publish to multiple bundlersdepend on universal hooks only

Common Misconceptions

A Rollup plugin fully runs during Vite dev

Not always. Dev does not produce a full output bundle, so output hooks like generateBundle are not useful there.

A Vite plugin is automatically Rollup-compatible

If it uses a Vite-only hook, it is not Rollup-compatible.

All logic belongs in transform

It does not. Put path resolution in resolveId, virtual source in load, and output analysis in generateBundle. Overusing transform makes plugins slow and hard to debug.

Check Yourself

  1. Why is transformIndexHtml Vite-only?
  2. Why are resolveId / load / transform easier to reuse across tools?
  3. Why is generateBundle not a good dev-mode debugging entry point?
  4. What plugin shape should a dev mock API use?
ts
// Pick the plugin shape and explain why:
// 1. Convert .txt files into JS string modules
// 2. Add /__debug that returns the current module graph
// 3. Emit bundle-size.json after build
// 4. Inject an analytics script into index.html