Creating Virtual Modules in a Vite Plugin

A virtual module lets a Vite plugin serve import config from 'virtual:app-config' from generated code that has no file on disk — useful for build-time config, route manifests, or icon sets. The mechanism is a resolveId/load hook pair plus the \0 prefix convention that keeps other plugins from touching your synthetic id. This guide implements one with HMR. It applies the advanced Vite plugin configuration hook model; the parent overview is Vite Configuration Ecosystem.

The problem virtual modules solve is that a lot of what a bundler needs to import does not exist as a source file and should not. A route manifest is derived from the directory tree; an icon sprite is assembled from dozens of SVGs; a feature-flag object is read from an environment file or a remote service at build time. Writing those artifacts to disk before every build means a codegen step that runs out of band, a generated file checked into or gitignored out of the repo, and a staleness window where the file on disk disagrees with the inputs it was derived from. The generated file also has to live somewhere in the module graph that both the dev server and the production build agree on, and it has to be regenerated on the right cadence. Every one of those is a place where the build silently serves yesterday’s data.

A virtual module removes the file entirely. Instead of writing src/generated/routes.ts and importing it, the consumer imports virtual:routes and the plugin manufactures the source inside the load hook the moment the bundler asks for it. There is no file to go stale, no codegen script to wire into predev and prebuild, and no generated artifact to gitignore. The trade-off is that the code now lives in a plugin rather than in a file a developer can open, so the plugin has to be correct about three things: claiming the specifier so Vite routes the import to your load hook, marking the resolved id so no other plugin mistakes it for a real path, and invalidating the module when the inputs it was derived from change. Get any of the three wrong and the failure is quiet — a resolution error, a type error, or a stale value that survives a save.

Where this sits in the pipeline matters. resolveId and load run during module resolution, before transform and long before bundling. That means a virtual module is a first-class node in Vite’s module graph: it can be imported by real files, it can import real files itself, it participates in the dependency graph that drives HMR, and in a production build Rollup tree-shakes and inlines it exactly like any other module. You are not injecting a global or patching window; you are adding a node the rest of the graph can depend on normally.

resolveId claims the id, load returns the code A virtual import is claimed by the plugin's resolveId hook which returns a null-byte-prefixed id so other plugins skip it, then the load hook returns the generated source; Vite bundles it like any module. Claim the id, then serve the code virtual:app-configimport resolveId→ \0virtual:app-config loadreturn source module the \0 prefix signals "this is virtual" so other plugins leave it alone
Figure: the \0 prefix is the convention that keeps other plugins from trying to read your virtual id from disk.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+
npm create vite@latest vmod-demo -- --template vanilla-ts
cd vmod-demo && npm install
# We will import a generated config module with no backing file.
echo "import cfg from 'virtual:app-config'; console.log(cfg);" > src/main.ts

Vite’s plugin hooks are Rollup-compatible: resolveId maps an import specifier to an id, load returns the code for an id. The \0 (null byte) prefix on the resolved id is the ecosystem convention marking it virtual.

The virtual: namespace on the specifier and the \0 byte on the resolved id do two different jobs, and conflating them is the first mistake people make. The virtual: prefix is a human- and tooling-facing convention on the string a developer types in an import — it makes the specifier cheap to match in resolveId and unmistakable in a stack trace. The \0 prefix is machine-facing: it is applied to the id you return, not the specifier you match, and it is what the rest of the build system reads to decide whether an id points at a real path. Rollup and Vite both treat a leading null byte as “this id is synthetic, do not try to read it from the filesystem, do not resolve it relative to anything, and do not emit it as a chunk name verbatim.” The two prefixes travel together but they are not the same string and they are checked at different times.

The demo above writes a single-line src/main.ts that imports the virtual specifier and logs it. That is deliberately the smallest possible consumer: if the import resolves and the log fires, the resolve/load pair is wired correctly, and you can layer HMR and TypeScript declarations on top of a known-good baseline rather than debugging three things at once.

resolveId and load are the two Rollup-compatible hooks Vite reuses Rollup's plugin hooks, so resolveId maps a specifier to an id and load returns the code for that id; a virtual module implements both, using the null-byte prefix to mark the resolved id as synthetic. resolveId(source)specifier → id load(id)id → code the same Rollup hook pair Vite exposes
Figure: the two hooks mirror esbuild's onResolve/onLoad — resolve the id, then supply the code.

Diagnosis workflow

  1. Pick a namespaced specifier. Use a virtual: prefix so the import is unmistakably synthetic and your resolveId filter is a cheap string check. The comparison in resolveId runs on every unresolved import in the graph, so it needs to be a bare === against a constant, not a regex or a filesystem probe. A namespaced specifier also keeps you from colliding with a real package: virtual:app-config can never be mistaken for a node_modules entry, whereas a bare app-config could shadow a dependency or be shadowed by one, and the resolution order that decides which wins is exactly the kind of thing you do not want to reason about at 2am.
  2. Return the \0-prefixed id. Resolving to \0 + the specifier is what tells Vite and other plugins this id has no file; skipping the prefix invites another plugin to try reading it. Concretely, when your resolveId returns a bare virtual:app-config, resolution continues down the plugin chain and eventually reaches a plugin — often Vite’s own filesystem resolver — that interprets the string as a path, calls fs.stat on it, gets ENOENT, and throws “failed to resolve import.” The null byte short-circuits that: every well-behaved plugin, including Vite’s internals, checks for the leading \0 and returns early, so your load hook is the only thing that ever sees the id.
  3. Decide the HMR story. A virtual module derived from a watched file should invalidate when that file changes, so its consumers update. This is a decision, not a default: if the generated source is a pure constant with no external inputs, you can skip HMR entirely and the module simply never changes during a session. But the moment the source is derived from a file, an env var, or a glob of the filesystem, you have to tell Vite which real files feed the virtual one, because Vite’s watcher only knows about files it can see on disk — it has no way to guess that editing app.config.json should invalidate \0virtual:app-config.
The null-byte prefix protects the virtual id Returning the id with a null-byte prefix marks it virtual so Vite and other plugins do not try to read it from disk, whereas returning a bare specifier lets another plugin attempt a filesystem lookup and fail. return \0 + idmarked virtualothers skip it return bare idanother plugin reads diskresolution error
Figure: the null byte is not decoration — omit it and another plugin will try to stat your virtual id.

Annotated solution config

The plugin below is deliberately complete but minimal: it claims one specifier, generates one object, and wires HMR to one watched file. Read it as three cooperating hooks rather than three independent ones — resolveId decides the id, load is the only place that id is ever honoured, and handleHotUpdate is what keeps the pair from going stale. The VIRTUAL_ID and RESOLVED constants are computed once and closed over so the string comparison in every hook is against the same value; defining them inline in each hook is how you end up with a resolveId that returns one prefix and a load that checks for another, and the module silently never loads.

// vite.config.ts — Vite 5.4.x, a virtual config module with HMR.
import { defineConfig, type Plugin } from 'vite';

function appConfigPlugin(): Plugin {
  const VIRTUAL_ID = 'virtual:app-config';
  const RESOLVED = '\0' + VIRTUAL_ID;   // \0 marks it synthetic
  return {
    name: 'app-config',
    resolveId(source) {
      if (source === VIRTUAL_ID) return RESOLVED;   // claim the specifier
    },
    load(id) {
      if (id === RESOLVED) {
        // Generate the module source deterministically.
        const cfg = { name: 'demo', builtAt: 'build-time' };
        return `export default ${JSON.stringify(cfg)};`;
      }
    },
    // Invalidate the virtual module when a watched source changes.
    handleHotUpdate({ file, server }) {
      if (file.endsWith('app.config.json')) {
        const mod = server.moduleGraph.getModuleById(RESOLVED);
        if (mod) server.reloadModule(mod);
      }
    },
  };
}

export default defineConfig({ plugins: [appConfigPlugin()] });

Two details in that code do more work than they look like. First, resolveId returns undefined for any specifier that is not exactly VIRTUAL_ID — it does not return null or false and it does not throw. Returning undefined is how a Rollup-style hook says “not mine, keep asking the other plugins”; returning null in some hooks means “definitively unresolvable” and short-circuits the chain, so the difference is load-bearing. The same is true of load: fall through to undefined for anything you did not generate, and Vite reads the id from disk as usual. A plugin that unconditionally returns a string from load will hijack every module in the graph.

Second, handleHotUpdate reaches into server.moduleGraph.getModuleById(RESOLVED) using the prefixed id, not the specifier. The module graph is keyed by resolved id, so getModuleById('virtual:app-config') returns undefined and the reload silently does nothing — one of the most common “HMR just doesn’t fire” reports. Calling server.reloadModule(mod) invalidates that node and pushes the update to every consumer that imported it, which is exactly the behaviour you want: edit the source file, and only the virtual module and its dependents re-execute.

Here is the ambient declaration the TypeScript compiler needs so the import type-checks, kept in a separate .d.ts so it is picked up by the project’s type roots:

// src/virtual.d.ts — Vite 5.4.x, make the virtual import type-check.
declare module 'virtual:app-config' {
  const config: { name: string; builtAt: string };
  export default config;
}

Without this file the import runs correctly but tsc reports “Cannot find module ‘virtual:app-config’”, which fails CI type-check gates even though the browser is happy. The declaration is the only place TypeScript learns the shape of the generated object, so keep it in sync with what load actually returns.

Three hooks: resolveId, load, handleHotUpdate resolveId claims the specifier and returns the prefixed id, load returns the generated source for that id, and handleHotUpdate reloads the virtual module when a watched source file changes so consumers stay current. resolveIdclaim + prefix loadgenerate source handleHotUpdatereload on change the third hook is what makes it live
Figure: handleHotUpdate is the difference between a static virtual module and one that reflects source changes.

Verification

# Vite 5.4.x — run the dev server and confirm the import resolves.
npx vite
# Browser console prints: { name: 'demo', builtAt: 'build-time' }
# Edit app.config.json and confirm the module reloads without a full refresh.

The import resolves to the generated object with no file on disk, and editing the watched source triggers a hot reload of just that module. A build (vite build) inlines the generated code into the bundle exactly as a real module would be.

Verify all three surfaces, not just the first. In dev, confirm the console log fires — that proves resolveId and load agree on the prefixed id. Then edit app.config.json and watch the terminal: Vite should print an hmr update line naming the virtual module, and the browser should update without the full-page reload flash. If you see “page reload” instead of “hmr update,” handleHotUpdate either did not match the file or fetched the wrong id. Finally run vite build and grep the output bundle for the generated string; if the object is present, tree-shaking kept it, and if it is absent, confirm the consumer actually references it, because Rollup will drop a virtual module whose export is unused just as it drops a real one. A quick way to confirm the build path independent of the browser:

# Vite 5.4.x — prove the virtual module is inlined in the production bundle.
npx vite build
grep -r "builtAt" dist/assets/*.js   # the generated object is inlined, no separate chunk

If grep finds builtAt inside a hashed asset file, the virtual module went through the same chunking and minification path as any real module. There is no separate request for it at runtime and no file emitted for it, which is the whole point: one code path in dev and build, no generated artifact on disk in either.

Resolves in dev and inlines in build The virtual module resolves to its generated object in the dev server and hot-reloads on a watched change, and a production build inlines the same generated code into the bundle as if it were a real file. dev: resolves + HMRno file on disk build: inlinedlike a real module same behaviour in dev and build
Figure: the virtual module behaves identically in dev and build — resolved live, inlined at build.

How it works under the hood

When the browser requests src/main.ts, Vite parses its imports and finds virtual:app-config. That specifier is not a relative path and not a bare package name it can find in node_modules, so it enters the plugin resolution pipeline: Vite calls each plugin’s resolveId in order until one returns a non-undefined value. Your plugin returns \0virtual:app-config, resolution stops, and Vite records that id as a node in the module graph with an edge from main.ts. Crucially, because the id starts with a null byte, Vite does not attempt to normalise it, does not resolve it relative to the importer, and does not hand it to the filesystem resolver that would otherwise stat it.

Next Vite needs the source for that node, so it calls load for each plugin until one returns something. Your load sees the prefixed id, generates the string, and returns it. Vite then runs the returned source through the normal transform chain — so a virtual module written in TypeScript or JSX is compiled just like a file would be — and caches the result in the module graph keyed by the resolved id. In dev that cached module is served over the HMR transport; in build it becomes an input to Rollup’s bundling and tree-shaking. The id is the cache key throughout, which is why every hook has to use the identical prefixed string.

HMR closes the loop. Vite’s file watcher fires on a save; handleHotUpdate gets the changed file path and the dev server. You look up the virtual node by its resolved id and call reloadModule, which marks the node dirty and re-invokes load to regenerate the source, then propagates the update along the graph edges to main.ts and anything else that imported it. The consumer’s import.meta.hot accept boundary — or, absent one, the nearest boundary up the graph — decides whether the update is applied in place or escalates to a full reload. This is the same propagation algorithm Vite uses for real files; the virtual module gets it for free precisely because it is a real graph node.

Performance considerations

The resolveId hook runs for every unresolved specifier in the entire graph, on every cold resolution and on every HMR invalidation that touches an importer. Keep it to a constant-time string comparison. A regex, a path.resolve, or — worst — a filesystem check in resolveId multiplies across thousands of modules and shows up as dev-server startup lag. The load hook runs once per invalidation of the virtual id, which is rare, so it is the right place to do the expensive work: read the config file, glob the icon directory, call the codegen. Do not push that work up into resolveId to “have it ready,” because you will pay for it on every resolution rather than only when the module is actually loaded.

Determinism is also a performance concern, not just a correctness one. If load returns a different string each time it runs — a timestamp, a random id, a network fetch — Vite’s content-based caching cannot dedupe it, every build produces a different hash for the chunk, and long-term browser caching breaks because the asset filename changes on every deploy even when nothing meaningful did. Derive the source from stable inputs so identical inputs produce byte-identical output; if you genuinely need a build timestamp, inject it once from a single source rather than reading the clock inside load.

When not to use this

Reach for a virtual module when the imported code is genuinely derived — from the filesystem, config, or build-time computation — and there is no honest file to point at. If a real file already exists and is fine, importing it directly is simpler, debuggable in the editor, and does not require a plugin. If all you need is a compile-time constant, Vite’s define option replaces a token in the source text and is lighter than a whole resolve/load pair, though it only does literal substitution and cannot produce a module with exports. If you need to expose an environment variable to client code, import.meta.env and the envPrefix mechanism already do that and are understood by the whole ecosystem; a virtual module for a single env value is over-engineering. The virtual-module pattern earns its complexity when the output is a real module shape — multiple exports, imports of its own, participation in tree-shaking — assembled from inputs that have no single source file.

Gotchas & edge cases

Four Vite virtual-module edge cases Omitting the null-byte prefix lets other plugins misread the id; TypeScript needs an ambient declaration for the virtual specifier; a load hook that reads the clock breaks caching; and forgetting handleHotUpdate leaves the module stale after a source change. missing \0 prefix→ other plugins misread it no TS declaration→ declare module 'virtual:...' non-deterministic load→ generate from stable input no handleHotUpdate→ stays stale on change
Figure: the TypeScript declaration is easy to forget — without it, the virtual import is a type error even when it runs.
  • Missing \0 prefix. The symptom is a build-time “failed to resolve import ‘virtual:app-config’” even though your resolveId clearly returned something. The root cause is that you returned the bare specifier, so a downstream plugin — usually Vite’s filesystem resolver — treated it as a path and stated it into an ENOENT. The fix is to return '\0' + specifier; confirm it by logging the id inside load and checking the leading byte is present. Always prefix the resolved id, never the specifier you match.
  • No TypeScript declaration. The symptom is that the app runs fine in the browser but tsc (and the editor) reports “Cannot find module ‘virtual:app-config’ or its corresponding type declarations.” The root cause is that TypeScript resolves modules against the filesystem and type roots, and there is no file for it to find. The fix is an ambient declare module 'virtual:app-config' in a .d.ts that the project’s include picks up; confirm by running tsc --noEmit and seeing the error clear. Keep the declared type in sync with what load returns.
  • Non-deterministic load. The symptom is a chunk whose content hash changes on every build even when no source changed, breaking long-term caching and busting CDNs unnecessarily. The root cause is that load reads a non-stable input — the clock, a random value, or a network response — so identical inputs no longer produce identical output. The fix is to derive the source only from stable inputs and inject any genuinely time-varying value from a single controlled source; confirm by running vite build twice on an unchanged tree and diffing the emitted filenames.
  • Forgetting HMR. The symptom is that you edit the file the module is derived from and nothing updates until you hard-refresh. The root cause is that Vite’s watcher has no way to connect a real file to a synthetic id, so without handleHotUpdate the virtual node is never invalidated. The fix is to match the changed file, fetch the module by its resolved id, and call server.reloadModule; confirm by watching for an hmr update line naming the virtual module in the dev-server terminal after a save.