Featured image of post Vite Plugin Development: From Zero to Production

Vite Plugin Development: From Zero to Production

Complete guide to developing Vite plugins covering the plugin API, Rollup compatibility, virtual modules, HMR integration, config resolution, and publishing to npm.

Vite’s plugin system is one of its most powerful features, allowing developers to extend and customize the build pipeline. Whether you need to transform file types, inject build-time constants, or integrate with other tools, the Vite plugin API gives you full control.

Understanding Vite’s Plugin System

Vite plugins are objects with hook functions that execute at specific points in the build lifecycle. Plugins operate in three phases: serve (dev server), build (production), and SSR. A basic plugin looks like this:

import type { Plugin } from 'vite';

export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    config(config) {
      return config;
    },
    transform(code, id) {
      if (id.endsWith('.custom')) {
        return { code: `export default ${JSON.stringify(code)}`, map: null };
      }
    },
  };
}

The name property is required for debugging. enforce: 'pre' or 'post' controls execution order relative to other plugins.


Rollup Compatibility

Vite’s plugin interface extends Rollup’s, meaning most Rollup plugins work with Vite out of the box. Shared hooks include resolveId, load, transform, and buildEnd. Vite-specific hooks add dev-server capabilities:

HookPurposePhase
configModify Vite config before resolutionServe + Build
configResolvedRead the final resolved configServe + Build
configureServerExtend the dev serverServe only
handleHotUpdateCustomize HMR behaviorServe only

When porting a Rollup plugin to Vite, ensure it does not use Rollup-specific APIs like this.emitFile incorrectly in the dev server context.


Virtual Modules

Virtual modules exist only at build time, generated by plugin code rather than file system files:

const VIRTUAL_MODULE = 'virtual:config';

export function configPlugin(): Plugin {
  return {
    name: 'config-plugin',
    resolveId(id) {
      if (id === VIRTUAL_MODULE) return '\0' + VIRTUAL_MODULE;
    },
    load(id) {
      if (id === '\0' + VIRTUAL_MODULE) {
        const config = { apiUrl: process.env.API_URL };
        return `export default ${JSON.stringify(config)}`;
      }
    },
  };
}

The \0 prefix is a Rollup convention to mark resolved virtual modules. Use cases include injecting polyfills, providing build-time constants, and generating code from configuration files.


HMR Integration

Hot Module Replacement keeps the development experience fast. The handleHotUpdate hook lets you customize which modules are invalidated:

handleHotUpdate({ server, modules, timestamp }) {
  if (modules.every(m => m.url.includes('node_modules'))) {
    return [];
  }
  server.ws.send({
    type: 'custom',
    event: 'custom-update',
    data: { timestamp },
  });
  return modules;
}

Return an empty array to suppress HMR, or return a filtered list for granular updates instead of full reloads.


Testing and Publishing

Integration test your plugin with Vite’s programmatic API:

import { build } from 'vite';

const result = await build({
  plugins: [testPlugin({ option: 'value' })],
  logLevel: 'silent',
});

When publishing, follow the vite-plugin-* naming convention, list vite as a peer dependency, and include TypeScript declarations. Test across multiple Vite versions in CI to catch breaking changes early.


Vite’s plugin API is well-designed, straightforward, and powerful. Start with simple transformations, explore virtual modules for build-time code generation, and leverage HMR hooks for a polished development experience.