Vite 23 ⚡ Plugin API — Writing custom transforms & hooks
A Vite plugin is a factory function that returns an object with a name and a set of hook methods. The hooks are the extension points, and they run at specific moments in the build and dev-server lifecycle. The object can also carry enforce and apply modifiers that control when the plugin runs relative to Vite’s core plugins and whether it runs at all in a given mode . The plugin API is inherited from Rollup, which means most Rollup plugins work unchanged, and the hook names—resolveId, load, transform, buildStart—are the same ones Rollup users already know .
The critical detail in Vite 8 is the hook filter. In previous versions, a transform hook was called for every module, and the first line of the hook was typically an if statement checking the file extension. That check ran in JavaScript, which meant a Rust-to-JS call for every module in the graph. The filter property moves the check to the Rust side: Rolldown evaluates the pattern, and the handler is only invoked when the pattern matches . The difference is invisible in the source code, but it is measurable in the build time for large projects.
Key point: A Vite plugin is a factory function returning an object with name, optional enforce/apply modifiers, and hook methods. In Vite 8, the transform hook accepts a filter property that moves the match check to Rust, avoiding a JS call for modules that do not match. The enforce modifier controls ordering ('pre' before core, 'post' after), and apply restricts the plugin to 'serve' or 'build'.
Why the hook filter exists
The Rust-to-JS overhead problem. Rolldown is a Rust bundler, and Vite’s plugin hooks are JavaScript functions. Every time Rolldown needs to invoke a JS hook, it crosses the language boundary. For a transform hook that is called for every module, that boundary crossing happens thousands of times, and most of those calls return immediately because the module does not match the plugin’s condition. The filter property eliminates the boundary crossing for the non-matching modules. Rolldown evaluates the filter in Rust, and only the matching modules trigger the JS call .
The filter properties. The filter object accepts id, moduleType, and code properties. The id filter is a string (glob pattern) or a RegExp. The moduleType filter matches the module type ('js', 'tsx', 'json', etc.). The code filter matches the source code content. If multiple properties are specified, all must match. If include and exclude are both specified, exclude takes precedence . The filter is only available on resolveId, load, transform, and renderChunk hooks .
The composable filters. For more complex matching, @rolldown/pluginutils exports functions like and, or, not, id, moduleType, and code that can be composed into filter expressions . A filter that matches .ts files containing a specific string, and not in node_modules, can be expressed as a composition rather than a handler that checks three conditions. The composable filters are Rolldown-specific; they are not yet supported in Vite or unplugin .
The trade-off. The filter syntax is a new API surface, and a plugin that uses it is not compatible with older Vite versions that do not support it. The filter property is supported in Rollup 4.38.0+ and Vite 6.3.0+, so a plugin that targets those versions can use it. A plugin that needs to support older versions must use the if check inside the handler . The filter is an optimization, not a requirement.
a. The plugin structure and the modifiers
The factory function returns an object. The name is required and must be unique. The enforce modifier is optional and accepts 'pre', 'post', or nothing. The apply modifier is optional and accepts 'serve', 'build', or a function that receives the config and the environment.
// vite-plugin-timestamp.ts — a basic plugin structure
import type { Plugin } from 'vite';
export default function timestampPlugin(options: { prefix?: string } = {}): Plugin {
return {
// The name is required and must be unique.
name: 'vite-plugin-timestamp',
// enforce: 'pre' runs before Vite's core plugins.
// enforce: 'post' runs after the build plugins.
// No enforce runs after the core plugins.
enforce: 'pre',
// apply: 'serve' restricts the plugin to the dev server.
// apply: 'build' restricts it to the build.
// A function receives the config and environment for finer control.
apply: 'build',
// The transform hook receives the source code and the module id.
// Returning null or undefined means "no transformation".
transform(code, id) {
// Without the filter property, this check runs in JavaScript
// for every module in the graph.
if (!id.endsWith('.ts')) return null;
const prefix = options.prefix ?? '__TIMESTAMP__';
return {
code: code.replace(new RegExp(prefix, 'g'), String(Date.now())),
map: null,
};
},
};
}
The enforce modifier is the ordering mechanism. A 'pre' plugin runs before Vite’s core plugins, which means it sees the source code before the TypeScript stripping or the JSX transform. A 'post' plugin runs after the build plugins, which means it sees the bundled output. A plugin with no enforce runs after the core plugins but before the build plugins . The ordering matters for plugins that depend on the syntax being in a specific state.
The apply modifier is the mode restriction. A plugin that only configures the dev server’s middleware should have apply: 'serve'. A plugin that only runs during the production build should have apply: 'build'. The function form is for cases where the condition is more complex than a single mode, such as a plugin that runs in build mode but not in test mode .
b. The transform hook with the filter property
The filter property is the Vite 8 optimization. It is an object on the hook, and the handler is called only when the filter matches. The filter object uses the same shape as the createFilter utility from @rollup/pluginutils, but it is evaluated in Rust.
// vite-plugin-data.ts — a transform with a filter
import type { Plugin } from 'vite';
export default function dataPlugin(): Plugin {
return {
name: 'vite-plugin-data',
transform: {
// The filter is evaluated in Rust before the handler is called.
// The id pattern is a RegExp; a string is treated as a glob.
filter: {
id: /\.data$/,
// The code filter matches the source content.
// Both conditions must match for the handler to be called.
code: {
include: 'process.env.DATA',
exclude: '// @skip-data',
},
},
handler(code, id) {
// This handler is only called for .data files
// that contain "process.env.DATA" and do not contain
// "// @skip-data".
return {
code: code.replace(/process\.env\.DATA/g, JSON.stringify(process.env.DATA)),
map: null,
};
},
},
};
}
The filter is only available on resolveId, load, transform, and renderChunk . The id filter in resolveId must be a RegExp, because the value is the exact text from the import statement, not an absolute path . The id filter in transform can be a glob string or a RegExp. The moduleType filter matches the type ('js', 'tsx', 'css'), and the code filter matches the source content.
The filter is not the only way to narrow the hook. The createFilter utility from @rollup/pluginutils (re-exported by Vite) is the older mechanism. It returns a function that the handler calls with the id. The filter property is the newer mechanism, and it is faster because the check runs in Rust. A plugin that supports both old and new Vite versions can use createFilter in the handler and the filter property as an additional optimization .
Complete Example Session
// ============================================
// PART 1: THE BASIC PLUGIN STRUCTURE
// ============================================
// vite-plugin-basic.ts
import type { Plugin } from 'vite';
export default function basicPlugin(): Plugin {
return {
// Required.
name: 'vite-plugin-basic',
// Optional. 'pre' runs before Vite core plugins.
enforce: 'pre',
// Optional. 'serve' restricts to the dev server.
apply: 'serve',
// The transform hook. Returns null to skip.
transform(code, id) {
if (!id.endsWith('.special')) return null;
return { code: code.toUpperCase(), map: null };
},
};
}
// ============================================
// PART 2: THE ENFORCE ORDERING
// ============================================
// The order of execution:
// 1. enforce: 'pre' plugins
// 2. Vite core plugins
// 3. plugins with no enforce
// 4. Vite build plugins
// 5. enforce: 'post' plugins
//
// A plugin that needs to see the TypeScript source
// before it is stripped should use enforce: 'pre'.
// A plugin that needs to see the bundled output
// should use enforce: 'post'.
// ============================================
// PART 3: THE APPLY MODIFIER
// ============================================
export default function modePlugin(): Plugin {
return {
name: 'vite-plugin-mode',
// A function form for finer control.
apply(config, { command, mode }) {
// Run only in build mode, not test mode.
return command === 'build' && mode !== 'test';
},
// ...
};
}
// ============================================
// PART 4: THE FILTER PROPERTY
// ============================================
export default function filteredPlugin(): Plugin {
return {
name: 'vite-plugin-filtered',
transform: {
// The filter is evaluated in Rust.
// The handler is called only when the filter matches.
filter: {
id: /\.css$/,
code: { include: '--theme' },
},
handler(code, id) {
return {
code: code.replace(/--theme/g, '--brand'),
map: null,
};
},
},
};
}
// ============================================
// PART 5: THE VIRTUAL MODULE
// ============================================
const VIRTUAL_ID = 'virtual:my-data';
const RESOLVED_ID = '\0' + VIRTUAL_ID;
export default function virtualPlugin(data: Record<string, unknown>): Plugin {
return {
name: 'vite-plugin-virtual',
resolveId(id) {
// The resolveId hook maps the public id to the internal id.
// The \0 prefix tells other plugins not to process the module.
if (id === VIRTUAL_ID) return RESOLVED_ID;
return null;
},
load(id) {
// The load hook provides the module content.
if (id === RESOLVED_ID) {
return `export default ${JSON.stringify(data)}`;
}
return null;
},
};
}
// ============================================
// PART 6: THE CONFIGURE SERVER HOOK
// ============================================
export default function middlewarePlugin(): Plugin {
return {
name: 'vite-plugin-middleware',
apply: 'serve',
configureServer(server) {
// Add a custom middleware to the dev server.
server.middlewares.use('/health', (req, res) => {
res.end('OK');
});
},
};
}
// ============================================
// PART 7: THE TRANSFORM INDEX HTML HOOK
// ============================================
export default function htmlPlugin(): Plugin {
return {
name: 'vite-plugin-html',
transformIndexHtml: {
// order: 'pre' runs before Vite's HTML transforms.
// order: 'post' runs after.
order: 'pre',
handler(html, ctx) {
return {
html,
tags: [
{
tag: 'script',
attrs: { type: 'module' },
children: `window.__BUILD_TIME__ = "${new Date().toISOString()}"`,
injectTo: 'head',
},
],
};
},
},
};
}
// ============================================
// PART 8: THE HOT UPDATE HOOK
// ============================================
export default function hmrPlugin(): Plugin {
return {
name: 'vite-plugin-hmr',
apply: 'serve',
hotUpdate(ctx) {
// The hotUpdate hook is called per environment.
// this.environment is the current environment.
if (ctx.file.endsWith('.config.json')) {
// Send a full reload for config changes.
this.environment.hot.send({ type: 'full-reload' });
return [];
}
// Return undefined to use the default HMR behavior.
},
};
}
// ============================================
// PART 9: THE FILTER WITH COMPOSABLE FUNCTIONS
// ============================================
// The composable filters are Rolldown-specific.
// They are not yet supported in Vite or unplugin.
import { and, id, moduleType } from '@rolldown/pluginutils';
export default function composablePlugin(): Plugin {
return {
name: 'vite-plugin-composable',
transform: {
filter: [include(and(id(/\.ts$/), moduleType('ts')))],
handler(code) {
return { code: code.replace(/PLACEHOLDER/g, 'REPLACED'), map: null };
},
},
};
}
// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================
// [ ] The plugin has a unique name.
// [ ] The enforce modifier is set if ordering matters.
// [ ] The apply modifier is set if the plugin is mode-specific.
// [ ] The transform hook has a filter if it targets a subset of modules.
// [ ] The virtual module uses resolveId + load, not transform.
// [ ] The virtual id is prefixed with \0 after resolution.
// [ ] The configureServer hook is used for dev middleware.
// [ ] The hotUpdate hook is used for custom HMR behavior.
The ten parts covered the plugin structure, the enforce ordering, the apply modifier, the filter property, the virtual module, the configure server hook, the HTML transform, the hot update hook, the composable filters, and the verification checklist. The through-line is that the plugin is a factory function, and the hooks are the extension points.
Quick Reference
The plugin object
| Property | Type | Required | Purpose |
|---|---|---|---|
name | string | Yes | Unique plugin identifier |
enforce | 'pre' | 'post' | No | Ordering relative to core plugins |
apply | 'serve' | 'build' | function | No | Mode restriction |
transform | function | object | No | Code transformation |
resolveId | function | No | Module resolution |
load | function | No | Module content |
configureServer | function | No | Dev server middleware |
transformIndexHtml | object | No | HTML transformation |
hotUpdate | function | No | Custom HMR |
The filter properties
| Property | Available on | Type |
|---|---|---|
id | resolveId, load, transform, renderChunk | Glob string or RegExp |
moduleType | transform | 'js', 'tsx', 'css', etc. |
code | transform | String or RegExp |
The hook ordering
| Order | Plugin type |
|---|---|
| 1 | enforce: 'pre' plugins |
| 2 | Vite core plugins |
| 3 | Plugins with no enforce |
| 4 | Vite build plugins |
| 5 | enforce: 'post' plugins |
Best Practices
✅ Do This:
// Use the filter property for targeted transforms
transform: { filter: { id: /\.data$/ }, handler(code) { /* ... */ } } // ✅
// Use enforce: 'pre' for syntax-level transforms
enforce: 'pre' // ✅
// Use apply: 'serve' for dev-only plugins
apply: 'serve' // ✅
// Use resolveId + load for virtual modules
resolveId(id) { if (id === VIRTUAL) return '\0' + VIRTUAL; } // ✅
❌ Don’t Do This:
// Check the extension inside the handler without a filter
transform(code, id) { if (!id.endsWith('.data')) return; } // ❌ (slower in Vite 8)
// Use transform for virtual modules
transform(code, id) { if (id === 'virtual:x') return '...'; } // ❌
// Forget the \0 prefix on resolved virtual ids
return VIRTUAL_ID; // other plugins may process it // ❌
// Use enforce: 'post' for a transform that needs the source
enforce: 'post' // sees the bundled output, not the source // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Plugin runs in the wrong order | No enforce modifier | Add 'pre' or 'post' |
| Plugin runs in production but should be dev-only | No apply modifier | Add apply: 'serve' |
| Transform hook is slow | No filter property | Add the filter to move the check to Rust |
| Virtual module is processed by other plugins | No \0 prefix on the resolved id | Prefix the resolved id with \0 |
| HMR is not triggered | hotUpdate not implemented | Add the hook and send full-reload or custom events |
Real-World Examples
1. Basic transform plugin
export default function p(): Plugin { return { name: 'p', transform(code, id) { /* ... */ } }; }
2. Enforce pre
enforce: 'pre'
3. Apply serve
apply: 'serve'
4. Filter property
transform: { filter: { id: /\.ts$/ }, handler(code) { /* ... */ } }
5. Virtual module
resolveId(id) { if (id === 'virtual:x') return '\0virtual:x'; }
6. Load hook
load(id) { if (id === '\0virtual:x') return 'export default 1'; }
7. Configure server
configureServer(server) { server.middlewares.use('/api', handler); }
8. Transform HTML
transformIndexHtml: { order: 'pre', handler(html) { return { html, tags: [] }; } }
9. Hot update
hotUpdate(ctx) { if (ctx.file.endsWith('.json')) { this.environment.hot.send({ type: 'full-reload' }); return []; } }
10. Composable filter
filter: [include(and(id(/\.ts$/), moduleType('ts')))]
Visual
The Plugin Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ HOOK EXECUTION ORDER │
│ │
│ config │
│ configResolved │
│ configureServer (dev only) │
│ buildStart │
│ │ │
│ ▼ │
│ resolveId ──▶ load ──▶ transform │
│ │ │
│ ▼ │
│ buildEnd / closeBundle │
│ │
│ The transform hook is called for every module │
│ in the graph, unless a filter narrows it. │
└──────────────────────────────────────────────────────────────┘
The Filter Optimization
┌──────────────────────────────────────────────────────────────┐
│ WITHOUT FILTER vs WITH FILTER │
│ │
│ Without filter With filter │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Rust calls JS │ │ Rust checks filter │ │
│ │ for every module │ │ in Rust │ │
│ │ │ │ │ │
│ │ JS checks the id │ │ Only matching │ │
│ │ and returns early │ │ modules call JS │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ The filter removes the boundary crossing │
│ for non-matching modules. │
└──────────────────────────────────────────────────────────────┘
The Virtual Module Flow
┌──────────────────────────────────────────────────────────────┐
│ VIRTUAL MODULE RESOLUTION │
│ │
│ import 'virtual:my-data' │
│ │ │
│ ▼ │
│ resolveId('virtual:my-data') │
│ │ │
│ ▼ │
│ return '\0virtual:my-data' │
│ │ │
│ ▼ │
│ load('\0virtual:my-data') │
│ │ │
│ ▼ │
│ return 'export default { ... }' │
│ │
│ The \0 prefix tells other plugins not to process it. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Plugin structure | Factory function returning an object with name and hooks |
enforce | 'pre' (before core), 'post' (after build) |
apply | 'serve', 'build', or a function |
transform filter | id, moduleType, code properties |
| Virtual module hooks | resolveId + load |
| Virtual id prefix | \0 on the resolved id |
| Dev middleware | configureServer |
| HTML transform | transformIndexHtml with order |
| HMR | hotUpdate (replaces handleHotUpdate) |
| Composable filters | Rolldown-specific, not yet in Vite |
Key takeaways:
- A Vite plugin is a factory function returning an object. The
nameis required. Theenforcemodifier controls ordering ('pre'before core,'post'after build). Theapplymodifier restricts the plugin to'serve'or'build'. - The filter property moves the match check to Rust. In Vite 8, the
transformhook accepts afilterobject withid,moduleType, andcodeproperties. The handler is only called when the filter matches, which avoids a Rust-to-JS call for non-matching modules . - Virtual modules use
resolveId+load. TheresolveIdhook maps the public id to a\0-prefixed internal id, and theloadhook provides the content. Thetransformhook cannot create modules; it only modifies existing ones . - The
hotUpdatehook replaceshandleHotUpdate. It is environment-aware, called per environment, and accesses the current environment viathis.environment. The old hook is deprecated . - Most Rollup plugins work unchanged. The plugin API is inherited from Rollup, and the hook names are the same. A plugin that does not rely on
moduleParsedor the output hooks usually works with Vite 8 without modification .
Remember: A Vite plugin is a factory function, and the hooks are the extension points. The enforce modifier controls when the plugin runs, the apply modifier controls whether it runs, and the filter property controls which modules trigger the handler. The filter is the Vite 8 optimization: it moves the match check to Rust, which eliminates the boundary crossing for modules that do not match. The virtual module pattern is resolveId + load, and the \0 prefix is the convention that tells other plugins to stay out. The hot update hook is hotUpdate, not handleHotUpdate. The plugin API is stable, but the filter property is the new performance surface.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!