Vite plugins extend the build pipeline and dev server with a Rollup-compatible hook API. resolveId intercepts module resolution — virtual modules start with \0 to avoid filesystem conflicts. load provides module content for virtual or transformed IDs. transform rewrites file source — parse with @babel/parser or oxc-parser, transform the AST, generate code. configureServer adds middleware to the Vite dev server. handleHotUpdate filters and customizes hot module replacement. Plugins return an object with a name and any subset of hooks. The Vite plugin API is a superset of Rollup’s — plugins work in both contexts. Claude Code generates custom Vite plugins, virtual module providers, code transform pipelines, dev server middleware, and the testing patterns for Vite plugin libraries.
CLAUDE.md for Vite Plugins
## Vite Plugin Stack
- Version: vite >= 6.0, rollup >= 4
- Structure: export default function myPlugin(opts): Plugin { return { name, hooks... } }
- Virtual: resolveId returns "\0virtual:name" → load returns content
- Transform: receives code + id, returns { code, map } — use magic-string for source maps
- Dev Server: configureServer hook — add express-style middleware
- HMR: handleHotUpdate — filter which files trigger full reload vs module update
- Testing: vitest with createServer() or InlineConfig for integration tests
Basic Plugin Structure
// vite-plugin-order-routes/src/index.ts — auto-generate routes from files
import type { Plugin, ResolvedConfig } from "vite"
import { readdir, readFile } from "fs/promises"
import { join, relative, extname } from "path"
import MagicString from "magic-string"
export interface OrderRoutesOptions {
routesDir?: string // Directory to scan for route files
outputModule?: string // Virtual module name
}
export default function orderRoutesPlugin(
options: OrderRoutesOptions = {}
): Plugin {
const {
routesDir = "src/routes",
outputModule = "virtual:order-routes",
} = options
let config: ResolvedConfig
return {
name: "vite-plugin-order-routes",
configResolved(resolvedConfig) {
config = resolvedConfig
},
// Intercept virtual module imports
resolveId(id) {
if (id === outputModule) {
return "\0" + outputModule // Prefix with \0 to mark as virtual
}
},
// Provide virtual module content
async load(id) {
if (id !== "\0" + outputModule) return
const routesPath = join(config.root, routesDir)
const routes = await scanRoutes(routesPath, config.root)
// Generate route registry module
const imports = routes.map((r, i) =>
`import route${i} from "${r.importPath}"`
)
const exports = routes.map((r, i) => ({
path: r.routePath,
component: `route${i}`,
}))
return `
${imports.join("\n")}
export const routes = [
${exports.map(e => `{ path: "${e.path}", component: ${e.component} }`).join(",\n ")}
]
export default routes
`
},
// Watch route files for HMR
configureServer(server) {
const routesPath = join(config.root, routesDir)
server.watcher.add(routesPath)
server.watcher.on("add", invalidateRoutes)
server.watcher.on("unlink", invalidateRoutes)
function invalidateRoutes() {
const mod = server.moduleGraph.getModuleById("\0" + outputModule)
if (mod) {
server.moduleGraph.invalidateModule(mod)
server.ws.send({ type: "full-reload" })
}
}
},
}
}
async function scanRoutes(dir: string, root: string) {
const files = await readdir(dir, { recursive: true })
const routeFiles = files.filter(f =>
[".tsx", ".jsx", ".ts", ".js"].includes(extname(f)) &&
!f.includes("__")
)
return routeFiles.map(file => {
// Convert file path to route path
const routePath = "/" + file
.replace(/\.(tsx?|jsx?)$/, "")
.replace(/\/index$/, "")
.replace(/\[([^\]]+)\]/g, ":$1")
return {
routePath,
importPath: "/" + relative(root, join(dir, file)),
}
})
}
Code Transform Plugin
// vite-plugin-env-types/src/index.ts — transform .env to TypeScript types
import type { Plugin } from "vite"
import MagicString from "magic-string"
// Rewrites process.env.FOO → import.meta.env.FOO in Node.js-style code
export function envCompatPlugin(): Plugin {
return {
name: "env-compat",
enforce: "pre",
transform(code, id) {
// Only transform JS/TS files
if (!/\.(t|j)sx?$/.test(id)) return
// Skip node_modules
if (id.includes("node_modules")) return
// Only transform if process.env is used
if (!code.includes("process.env")) return
const s = new MagicString(code)
const regex = /process\.env\.([A-Z_][A-Z0-9_]*)/g
let match
while ((match = regex.exec(code)) !== null) {
const [full, key] = match
s.overwrite(match.index, match.index + full.length, `import.meta.env.${key}`)
}
return {
code: s.toString(),
map: s.generateMap({ hires: true }),
}
},
}
}
Dev Server Middleware Plugin
// vite-plugin-mock-api/src/index.ts — mock API middleware in dev
import type { Plugin, ViteDevServer } from "vite"
import type { IncomingMessage, ServerResponse } from "http"
interface MockRoute {
method?: string
path: string
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
delay?: number
}
export function mockApiPlugin(routes: MockRoute[]): Plugin {
return {
name: "vite-plugin-mock-api",
apply: "serve", // Only in dev server, not build
configureServer(server: ViteDevServer) {
server.middlewares.use(async (req, res, next) => {
const method = req.method?.toUpperCase() ?? "GET"
const url = req.url?.split("?")[0]
const route = routes.find(r =>
(r.method ?? "GET").toUpperCase() === method &&
matchPath(r.path, url ?? "")
)
if (!route) return next()
// Inject path params
;(req as any).params = extractParams(route.path, url ?? "")
// Parse JSON body
if (["POST", "PUT", "PATCH"].includes(method)) {
await new Promise<void>(resolve => {
let body = ""
req.on("data", chunk => body += chunk)
req.on("end", () => {
try {
;(req as any).body = JSON.parse(body)
} catch {
;(req as any).body = {}
}
resolve()
})
})
}
// Simulate network delay
if (route.delay) {
await new Promise(r => setTimeout(r, route.delay))
}
res.setHeader("Content-Type", "application/json")
try {
await route.handler(req, res)
} catch (e) {
res.statusCode = 500
res.end(JSON.stringify({ error: (e as Error).message }))
}
})
},
}
}
// Usage in vite.config.ts
// import { mockApiPlugin } from './vite-plugin-mock-api'
//
// plugins: [
// mockApiPlugin([
// {
// method: "GET",
// path: "/api/orders",
// handler: (req, res) => {
// res.end(JSON.stringify({ orders: mockOrders }))
// },
// },
// ])
// ]
HMR Custom Handler
// vite-plugin-translations/src/index.ts — HMR for translation files
import type { Plugin, HmrContext } from "vite"
import { readFile } from "fs/promises"
export function translationsPlugin(): Plugin {
return {
name: "vite-plugin-translations",
resolveId(id) {
if (id === "virtual:translations") return "\0virtual:translations"
},
async load(id) {
if (id !== "\0virtual:translations") return
const en = JSON.parse(await readFile("./src/i18n/en.json", "utf-8"))
const es = JSON.parse(await readFile("./src/i18n/es.json", "utf-8"))
return `
export const translations = ${JSON.stringify({ en, es }, null, 2)}
export default translations
`
},
handleHotUpdate({ file, server, modules }: HmrContext) {
// When any i18n JSON file changes...
if (!file.includes("/i18n/")) return
// Invalidate the virtual:translations module
const translationsMod = server.moduleGraph.getModuleById(
"\0virtual:translations"
)
if (translationsMod) {
server.moduleGraph.invalidateModule(translationsMod)
}
// Tell the client to re-import this specific module
server.ws.send({
type: "custom",
event: "translations-updated",
data: { file },
})
// Return undefined to let Vite handle HMR normally
// Return [] to suppress default HMR for these modules
return []
},
}
}
Testing Plugins
// tests/plugin.test.ts — test Vite plugins with vitest
import { describe, test, expect } from "vitest"
import { build, createServer } from "vite"
import { envCompatPlugin } from "../src"
describe("envCompatPlugin", () => {
test("transforms process.env.* to import.meta.env.*", async () => {
const result = await build({
root: new URL("./fixtures/env-test", import.meta.url).pathname,
plugins: [envCompatPlugin()],
build: {
write: false,
rollupOptions: {
input: "./src/main.ts",
},
},
logLevel: "silent",
})
const output = Array.isArray(result) ? result[0] : result
const chunk = output.output[0]
expect("code" in chunk && chunk.code).toContain("import.meta.env.API_URL")
expect("code" in chunk && chunk.code).not.toContain("process.env.API_URL")
})
test("does not transform process.env in node_modules", async () => {
// Verify node_modules are excluded
const plugin = envCompatPlugin()
const transform = (plugin as any).transform as Function
// Simulate node_modules path
const result = transform(
"const x = process.env.FOO",
"/project/node_modules/some-lib/index.js"
)
expect(result).toBeUndefined()
})
})
For the Webpack plugin API that provides similar transform hooks but with a more complex tapable event system for webpack-based projects still in your stack, the webpack guide covers custom loader and plugin development. For the Rollup plugin API subset that Vite plugins are built on — useful when building plugins that work in both Rollup standalone and Vite contexts, the Rollup documentation covers the hook lifecycle. The Claude Skills 360 bundle includes Vite plugin skill sets covering virtual modules, transforms, and dev server middleware. Start with the free tier to try Vite plugin generation.