3.4 · difficulty 2/4 · 18 min read
In Practice: Virtual Module Plugins
Implement a virtual module plugin from scratch using resolveId + load, understand the \0 prefix convention, add HMR support, and learn how to publish it as a reusable npm package.
What is a virtual module
A normal import corresponds to a file that actually exists on disk:
import utils from "./utils.ts" // maps to src/utils.ts
import logo from "./logo.svg" // maps to src/logo.svgA virtual module is a module that does not exist on the filesystem — its content is generated dynamically by a plugin at runtime:
import meta from "virtual:site-meta"
// ↑ no real file corresponds to this path
// ↑ content is computed on the fly by a pluginWhen virtual modules are the right tool
- Injecting build-time information: current timestamp, git commit,
package.jsonversion - Exposing config file contents to runtime code without a raw JSON import (with type-safe wrappers)
- Generating route tables, i18n locale bundles, icon sprites, and other data that requires scanning the filesystem
import.meta.globis itself implemented on top of virtual modules
The \0 prefix convention
Implementing a virtual module requires two hooks working together: resolveId and load.
const VIRTUAL_ID = "virtual:site-meta"
const RESOLVED_ID = "\0" + VIRTUAL_ID // = "\0virtual:site-meta"
export function virtualSiteMeta(): Plugin {
return {
name: "vite-plugin-virtual-site-meta",
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID
return null
},
load(id) {
if (id !== RESOLVED_ID) return null
// Return the module content (an ES Module as a string)
return `export const buildTime = "${new Date().toISOString()}"`
},
}
}Why must you add \0?
When resolveId returns an ID, Vite/Rolldown checks whether that ID is a real file path. Without the \0 prefix, Rolldown tries to find a file named virtual:site-meta on the filesystem — it won't find one, and will throw an error.
\0 (the null byte) is Rolldown/Rollup's internal convention for "this is a virtual module": an ID prefixed with \0 skips the filesystem lookup and is handed directly to the load hook.
Full implementation: injecting build information
import type { Plugin } from "vite"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
const VIRTUAL_ID = "virtual:site-meta"
const RESOLVED_ID = "\0" + VIRTUAL_ID
export interface SiteMetaOptions {
/** Additional fields to inject */
extra?: Record<string, unknown>
}
export function virtualSiteMeta(options: SiteMetaOptions = {}): Plugin {
return {
name: "vite-plugin-virtual-site-meta",
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID
return null
},
load(id) {
if (id !== RESOLVED_ID) return null
// Read the version from package.json
const pkg = JSON.parse(readFileSync(resolve("package.json"), "utf-8")) as { version?: string; name?: string }
const meta = {
buildTime: new Date().toISOString(),
version: pkg.version ?? "0.0.0",
name: pkg.name ?? "unknown",
...options.extra,
}
// Return valid ES Module source code as a string
return `
export const buildTime = ${JSON.stringify(meta.buildTime)}
export const version = ${JSON.stringify(meta.version)}
export const name = ${JSON.stringify(meta.name)}
export default ${JSON.stringify(meta)}
`
},
}
}Usage:
import { defineConfig } from "vite"
import { virtualSiteMeta } from "./src/plugin"
export default defineConfig({
plugins: [
virtualSiteMeta({
extra: {
repo: "https://github.com/my/repo",
},
}),
],
})import meta from "virtual:site-meta"
console.log(meta.buildTime) // "2026-06-26T12:00:00.000Z"
console.log(meta.version) // "1.0.0"Type declaration file
Importing a virtual module directly will cause TypeScript errors. You need to provide type declarations:
declare module "virtual:site-meta" {
export const buildTime: string
export const version: string
export const name: string
const meta: {
buildTime: string
version: string
name: string
[key: string]: unknown
}
export default meta
}Alternatively, use generateBundle inside the plugin to auto-generate a .d.ts file (see step 4 in the examples/plugin-auto-import hands-on project).
Multiple virtual modules
A single plugin can manage multiple virtual modules:
const MODULES: Record<string, () => string> = {
"virtual:config": () => `export default ${JSON.stringify(loadConfig())}`,
"virtual:routes": () => `export default ${JSON.stringify(scanRoutes())}`,
"virtual:i18n": () => `export default ${JSON.stringify(loadLocale())}`,
}
export function multiVirtual(): Plugin {
return {
name: "vite-plugin-multi-virtual",
resolveId(id) {
if (id in MODULES) return "\0" + id
return null
},
load(id) {
const originalId = id.slice(1) // strip the \0 prefix
const factory = MODULES[originalId]
if (!factory) return null
return factory()
},
}
}Adding HMR support
The content of a virtual module often depends on external data (config files, locale files, route files). When those files change, the virtual module should trigger HMR.
import type { Plugin } from "vite"
import { watch } from "node:fs"
export function virtualConfig(): Plugin {
const configPath = resolve("config.json")
return {
name: "vite-plugin-virtual-config",
resolveId(id) {
if (id === "virtual:config") return "\0virtual:config"
return null
},
load(id) {
if (id !== "\0virtual:config") return null
// Tell Vite: this virtual module depends on config.json
this.addWatchFile(configPath)
return `export default ${readFileSync(configPath, "utf-8")}`
},
// When a file changes in the dev server, invalidate the virtual module
handleHotUpdate({ file, server }) {
if (file === configPath) {
const module = server.moduleGraph.getModuleById("\0virtual:config")
if (module) {
server.moduleGraph.invalidateModule(module)
// Tell the browser to reload this module
return [module]
}
}
},
}
}Companion hands-on project
examples/plugin-virtual-modules is the complete hands-on project for this section.
After entering the project directory:
pnpm install
pnpm dev # see buildTime and other info in the browserThe full walkthrough is split into steps under steps/:
steps/01-bare-plugin— minimal skeleton with onlynamesteps/02-resolveId-load— implementing the two core hookssteps/03-hmr— adding HMR supportfinal/— the complete version with type declarations
Self-check
- Why does
resolveIdreturn an ID with the\0prefix instead of returning the original"virtual:xxx"string directly? - Why can a virtual module's
loadhook return content without reading any file? - What happens when you call
this.addWatchFile(path)insideload? - If the external config file that a virtual module depends on changes but there is no
handleHotUpdate, what happens?
// Implement a virtual:env plugin
// import env from "virtual:env"
// env should contain:
// - NODE_ENV: the current environment name
// - APP_VERSION: read from package.json
// - BUILD_TIME: the build timestamp (number)
//
// Also generate correct TypeScript type declarations for virtual:env
import type { Plugin } from "vite"
export function virtualEnv(): Plugin {
// TODO
}