Claude Code for Vite Plugins: Custom Build Transforms and Dev Server Extensions — Claude Skills 360 Blog
Blog / DevOps / Claude Code for Vite Plugins: Custom Build Transforms and Dev Server Extensions
DevOps

Claude Code for Vite Plugins: Custom Build Transforms and Dev Server Extensions

Published: February 12, 2027
Read time: 8 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free