DevToolBoxFREE
Blog

Vite Plugin Development: Build Custom Plugins from Scratch 2026

13 min readby DevToolBox

Vite Plugin Development: Building Custom Transforms and Dev Tools

Vite's plugin API is a thin layer over Rollup's, extended with dev-server hooks. Understanding it unlocks the ability to build custom transforms, virtual modules, dev-server middleware, and HMR logic — without ejecting from Vite.

Plugin Anatomy: Hooks and Lifecycle

A Vite plugin is a plain object (or a factory function returning one) with a name and hook methods. Hooks run at specific points in the build/dev pipeline.

// Minimal Vite plugin structure
// A plugin is just an object with a name and hooks
export default function myPlugin(options = {}) {
  return {
    name: 'vite-plugin-my-plugin',   // required, shown in warnings/errors
    enforce: 'pre',                   // 'pre' | 'post' | undefined

    // Called once when Vite starts
    configResolved(config) {
      console.log('Vite mode:', config.mode);
    },

    // Modify Vite config before it resolves
    config(config, { command }) {
      if (command === 'build') {
        return { build: { sourcemap: true } };
      }
    },

    // Transform file contents
    transform(code, id) {
      if (!id.endsWith('.vue')) return null; // skip non-Vue files
      return { code: transformedCode, map: sourceMap };
    },

    // Resolve module imports to file paths
    resolveId(source, importer) {
      if (source === 'virtual:my-module') {
        return 'virtual:my-module';  // '' prefix = virtual module
      }
    },

    // Load virtual module content
    load(id) {
      if (id === 'virtual:my-module') {
        return 'export const message = "Hello from virtual module!"';
      }
    }
  };
}

transform Hook: Modifying Source Files

The transform hook is the most commonly used hook. It receives raw source code and a module ID (file path), returning new code and an optional source map.

// Real-world transform: inject build metadata into source files
// Similar to how vite-plugin-inspect or vite-plugin-svgr work
import { readFileSync } from 'fs';
import { createHash } from 'crypto';

export default function buildMetaPlugin() {
  let config;
  const buildTime = new Date().toISOString();

  return {
    name: 'vite-plugin-build-meta',
    enforce: 'post',

    configResolved(resolvedConfig) {
      config = resolvedConfig;
    },

    transform(code, id) {
      // Only process files that import the meta module
      if (!code.includes('__BUILD_META__')) return null;

      const hash = createHash('sha256')
        .update(code)
        .digest('hex')
        .slice(0, 8);

      const meta = JSON.stringify({
        buildTime,
        mode: config.mode,
        hash,
        file: id.replace(config.root, ''),
      });

      const transformed = code.replace(
        /__BUILD_META__/g,
        meta
      );

      return {
        code: transformed,
        // Return null map to inherit original sourcemap
        map: null,
      };
    },
  };
}

// Usage in your app:
// import meta from 'virtual:build-meta';
// console.log(meta.buildTime);

resolveId Hook: Custom Module Resolution

The resolveId hook lets you intercept import paths and redirect them to different files or virtual modules. Return null to defer to other resolvers.

// Advanced resolveId: path aliases + conditional resolution
// Replaces tsconfig paths in runtime (Vite handles build, but not always HMR)
import path from 'path';
import fs from 'fs';

export default function aliasPlugin(aliases = {}) {
  // aliases = { '@': './src', '~utils': './src/utils' }
  const resolvedAliases = Object.fromEntries(
    Object.entries(aliases).map(([key, value]) => [
      key,
      path.resolve(process.cwd(), value),
    ])
  );

  return {
    name: 'vite-plugin-advanced-alias',

    resolveId(source, importer, options) {
      for (const [alias, target] of Object.entries(resolvedAliases)) {
        if (source === alias || source.startsWith(alias + '/')) {
          const resolved = source.replace(alias, target);

          // Try multiple extensions
          for (const ext of ['', '.ts', '.tsx', '.js', '/index.ts']) {
            const candidate = resolved + ext;
            if (fs.existsSync(candidate)) {
              return candidate;
            }
          }
        }
      }

      // Return null to let other resolvers handle it
      return null;
    },
  };
}

// vite.config.ts usage:
// plugins: [aliasPlugin({ '@': './src', '~hooks': './src/hooks' })]

configureServer: Dev Server Middleware

The configureServer hook gives you full access to the Vite dev server — Connect middleware, WebSockets, the watcher, and the module graph.

// Custom dev server middleware: mock API + WebSocket HMR notifications
export default function devApiPlugin(routes = {}) {
  return {
    name: 'vite-plugin-dev-api',
    apply: 'serve',   // Only active in dev server, not build

    configureServer(server) {
      // Add middleware BEFORE Vite's internal middlewares
      server.middlewares.use('/api', (req, res, next) => {
        const handler = routes[req.url];
        if (!handler) return next();

        res.setHeader('Content-Type', 'application/json');
        res.setHeader('Access-Control-Allow-Origin', '*');

        // Support async handlers
        Promise.resolve(handler(req))
          .then(data => res.end(JSON.stringify(data)))
          .catch(err => {
            res.statusCode = 500;
            res.end(JSON.stringify({ error: err.message }));
          });
      });

      // Hook into Vite's WebSocket for custom HMR events
      server.ws.on('custom:ping', (data, client) => {
        client.send('custom:pong', { timestamp: Date.now() });
      });

      // Send custom event when a file changes
      server.watcher.on('change', (file) => {
        if (file.endsWith('.mock.ts')) {
          server.ws.send({ type: 'custom', event: 'mock-updated', data: { file } });
        }
      });
    },
  };
}

// Usage:
// plugins: [devApiPlugin({
//   '/users': () => [{ id: 1, name: 'Alice' }],
//   '/auth/me': () => ({ id: 1, role: 'admin' }),
// })]

HMR: Hot Module Replacement Integration

Plugins can inject HMR boundary code and handle custom update events, enabling module-level state preservation across hot reloads.

// Plugin with HMR (Hot Module Replacement) support
// Allows modules to opt-in to custom update logic
export default function statePlugin() {
  return {
    name: 'vite-plugin-state-preserve',

    // Inject HMR handling code into every JS/TS file
    transform(code, id) {
      if (!/.(js|ts|jsx|tsx)$/.test(id)) return null;
      if (id.includes('node_modules')) return null;

      // Append HMR boundary acceptance
      const hmrCode = `
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // Module updated - newModule has the fresh exports
    console.log('[HMR] Module updated:', import.meta.url);
  });

  import.meta.hot.dispose((data) => {
    // Clean up side effects before module is replaced
    // data is passed to the next module version
    data.savedState = window.__APP_STATE__;
  });

  // Receive data from old module instance
  if (import.meta.hot.data.savedState) {
    window.__APP_STATE__ = import.meta.hot.data.savedState;
  }
}
`;

      return { code: code + hmrCode, map: null };
    },

    handleHotUpdate({ file, server, modules }) {
      // Custom logic: invalidate dependent modules when a .schema file changes
      if (file.endsWith('.schema.json')) {
        const affectedModules = server.moduleGraph.getModulesByFile(file);
        return [...(affectedModules || []), ...modules];
      }
    },
  };
}

Complete Plugin Configuration Example

A production-ready vite.config.ts combining multiple plugins, manual code splitting, and server proxy configuration.

// Complete vite.config.ts with multiple plugins
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import myPlugin from './plugins/my-plugin';

export default defineConfig(({ command, mode }) => ({
  plugins: [
    react(),
    myPlugin({ debug: mode === 'development' }),

    // Bundle analyzer - only in build mode
    command === 'build' && visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
    }),
  ].filter(Boolean),

  resolve: {
    alias: { '@': '/src' },
    extensions: ['.tsx', '.ts', '.jsx', '.js'],
  },

  build: {
    rollupOptions: {
      output: {
        // Manual chunk splitting for better caching
        manualChunks: {
          vendor: ['react', 'react-dom'],
          router: ['react-router-dom'],
          query: ['@tanstack/react-query'],
        },
      },
    },
  },

  server: {
    port: 5173,
    proxy: {
      '/api': { target: 'http://localhost:3000', changeOrigin: true },
    },
    hmr: {
      overlay: true,    // Show errors as overlay
      port: 24678,
    },
  },
}));

Vite Hooks Quick Reference

HookWhen CalledTypical UseApplies
configBefore config resolveModify Vite configBoth
configResolvedAfter config resolveRead final configBoth
configureServerBefore server startsAdd middlewareDev only
transformOn each file requestModify source codeBoth
resolveIdOn import resolutionRedirect importsBoth
loadAfter resolveIdSupply file contentBoth
handleHotUpdateOn file change (HMR)Custom HMR logicDev only
buildStartBuild beginsInitialize stateBuild only
generateBundleBundle generatedModify output assetsBuild only

Frequently Asked Questions

When should I use enforce: "pre" vs "post"?

Use "pre" when your plugin must run before other transforms (e.g., transpilers). Use "post" for plugins that need the fully transformed output. Omitting enforce places your plugin between pre and post plugins.

How do I create a virtual module in Vite?

Use resolveId to return a module ID prefixed with \0 (null byte), then use the load hook to return its content. The \0 prefix tells Rollup to treat it as virtual and not look for it on disk.

Can Vite plugins work in both dev and build mode?

Yes. Use the apply property: "serve" for dev-only, "build" for build-only, or omit it (default) to run in both. You can also use a function: apply(config, { command }) { return command === "build"; }.

How do I debug a Vite plugin?

Add DEBUG=vite:* to your environment to see Vite internals. Use this.warn() and this.error() inside hooks for plugin-specific messages. The vite-plugin-inspect plugin visualizes plugin transforms in the browser.

Related Tools

𝕏 Twitterin LinkedIn
Was this helpful?

Stay Updated

Get weekly dev tips and new tool announcements.

No spam. Unsubscribe anytime.

Try These Related Tools

{ }JSON FormatterB→Base64 Encode Online

Related Articles

Bun Package Manager: The Fastest JavaScript Runtime and Package Manager in 2026

Complete guide to Bun in 2026: installation, workspace management, scripts, and why it's faster than npm/yarn/pnpm. Benchmarks, migration guide, and real-world usage.

Monorepo Tools 2026: Turborepo vs Nx vs Lerna vs pnpm Workspaces Compared

Complete comparison of monorepo tools in 2026: Turborepo, Nx, Lerna, and pnpm workspaces. Choose the right tool for your team with benchmarks and real-world use cases.

Webpack vs Vite in 2026: Which Build Tool Should You Choose?

A comprehensive comparison of Webpack and Vite in 2026. Performance benchmarks, ecosystem support, migration strategies, and when to use each build tool.