Virtual Modules in esbuild with onLoad
A virtual module is source that esbuild bundles as if it were a file but that only exists in memory — a generated manifest, an aggregated barrel, a config baked from environment. This guide builds one with onResolve plus onLoad, gets the namespace and resolveDir right so nested imports work, and verifies the result. It is the onLoad companion to writing an onResolve path-alias plugin, both drawn from esbuild plugin patterns.
The problem this solves is that a large class of build-time artifacts have no natural home on disk. A route manifest is derived from the shape of a pages/ directory; a feature-flag module is derived from process.env; an icon barrel is derived from a glob of SVG files. Writing those artifacts to a real file before the build starts means a separate codegen step, a temp file that has to be gitignored, a stale-output hazard when the generator and the bundler disagree about when to run, and a watch-mode race where esbuild reads the file a beat before the generator finishes rewriting it. Virtual modules collapse that whole side-channel into the bundler itself: the same process that resolves and loads real files also produces the synthetic ones, in the correct order, inside the same content-addressed cache.
Mechanically, esbuild’s resolution pipeline is the leverage point. Every import specifier esbuild encounters is first offered to registered onResolve callbacks; whatever they return short-circuits the default on-disk resolver. A resolve result carries a path and, critically, a namespace. The default namespace is file, which is what routes a path to the filesystem. Return any other namespace and esbuild stops treating the path as a filename — it will never stat it, never read it, never apply the default loaders keyed on file extension. Instead it looks for an onLoad callback registered for that namespace and asks it for the bytes. That single indirection — a non-file namespace — is the entire mechanism behind a virtual module; everything else in this guide is getting the details around it right.
Where this sits in the pipeline matters for reasoning about failures. onResolve runs during graph construction, once per unique specifier per build; onLoad runs when esbuild first needs the contents of a resolved path, and its result is cached by (path, namespace) for the lifetime of an incremental context. So the generator that produces your source string runs at most once per distinct virtual path, and getting a rebuild to pick up new contents means either changing the path or tearing down the context — a caching cadence covered below.
onLoad instead of the filesystem.Prerequisites & reproducible setup
# esbuild 0.25.x, Node 18+
mkdir virtual-demo && cd virtual-demo && npm init -y
npm install --save-dev esbuild@0.25.0
echo "import { routes } from 'virtual:manifest'; console.log(routes);" > entry.ts
There is no virtual:manifest file — the plugin generates it. Building entry.ts without the plugin fails with Could not resolve "virtual:manifest", which is the signal that the specifier needs a namespace claim.
Pin the esbuild version rather than floating it. esbuild is pre-1.0 and the plugin API, while stable in shape, has changed the semantics of onLoad results across minor versions (the handling of pluginData, the watch-mode invalidation rules). A virtual-module plugin is exactly the kind of code that breaks quietly on an unpinned bump — the build still succeeds, but the synthesized contents go stale or the loader guess changes — so the @0.25.0 pin in the install line is load-bearing, not decoration. The entry.ts here uses a TypeScript extension only so the default ts loader is exercised for the entry; the virtual module’s loader is chosen explicitly and is independent of that.
Diagnosis workflow
- Pick a scheme prefix. A
virtual:(or@generated/) prefix keeps virtual specifiers visually distinct and gives youronResolvefilter a cheap anchor. The prefix is not magic to esbuild — it is just the leading characters your regex matches on — but the:character has a practical benefit: it never appears in a real relative or bare specifier, so a filter anchored on^virtual:cannot collide with an application file. TheonResolvefilter is a Go regular expression evaluated in the native layer before any JavaScript callback runs, so making it as specific as possible is a genuine performance win, not just tidiness: a filter of/^virtual:manifest$/rejects every unrelated specifier in native code and never pays the cost of crossing into your JS callback. - Decide what generates the contents. A static object, a filesystem glob resolved at build start, or values from
process.env— all fine, as long as the result is deterministic for a given input. Determinism is the constraint that everything else hangs off: the same inputs must produce byte-identical output within a build and, ideally, across builds. If your generator reads a directory listing, sort it, because filesystem enumeration order is not guaranteed and an unsorted glob produces a manifest whose bytes churn between machines, defeating content hashing and cache reuse. Compute the source string once, at build start, and close over it — do not recompute it inside theonLoadcallback, where it would run per resolved path and could observe a moving target. - Check whether the generated code imports anything. If it does, you must set
resolveDiron theonLoadresult so those imports resolve against a real directory. This is the single most common failure in virtual-module plugins and it is worth understanding why it happens: a real file has a location on disk, so esbuild resolves that file’s relative imports against the file’s own directory. A virtual module has no location, so esbuild has nothing to resolve./helpersorreactagainst unless you tell it.resolveDiris that base directory — the folder esbuild pretends the virtual module lives in for the purpose of resolving its imports. If the generated code is fully self-contained (only literal exports, noimport/require), you can omitresolveDirentirely.
resolveDir.Annotated solution config
Read the two callbacks as a contract. onResolve answers “where does virtual:manifest live?” with “in the virtual namespace, at path manifest” — deliberately dropping the virtual: scheme from the returned path so the onLoad filter can match a clean manifest rather than the full specifier. onLoad answers “what are the bytes at manifest in the virtual namespace?” with the generated source, an explicit loader, and a resolveDir. The namespace string is the join key between them: if the string in the onResolve return does not exactly equal the string in the onLoad registration, esbuild resolves the path but finds no loader for that namespace and falls back to the filesystem, which fails because there is no such file. That silent mismatch is the number-one reason a virtual-module plugin “does nothing.”
// build.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';
// Generate the virtual source deterministically at build start.
const routes = ['/', '/about', '/contact'];
const manifestSource = `export const routes = ${JSON.stringify(routes)};`;
const virtualManifest = {
name: 'virtual-manifest',
setup(build) {
// 1. Claim the specifier into a private namespace.
build.onResolve({ filter: /^virtual:manifest$/ }, () => ({
path: 'manifest',
namespace: 'virtual',
}));
// 2. Supply contents for that path + namespace. Nothing hits disk.
build.onLoad({ filter: /^manifest$/, namespace: 'virtual' }, () => ({
contents: manifestSource,
loader: 'js',
// resolveDir only needed if manifestSource itself imports modules.
resolveDir: process.cwd(),
}));
},
};
await esbuild.build({
entryPoints: ['entry.ts'],
bundle: true,
outfile: 'dist/out.js',
plugins: [virtualManifest],
metafile: true,
});
A few details in that config are easy to get subtly wrong. The path returned from onResolve does not have to look like a filename — it can be any opaque token that uniquely identifies the module within its namespace. When one plugin manages several virtual modules, encode the discriminator into the path (manifest, routes, flags) and dispatch on it inside a single onLoad handler, rather than registering a separate onLoad per module. The filter on onLoad still runs in native code, so a broad /.*/ filter combined with the namespace restriction is cheap and idiomatic when one handler serves the whole namespace. Finally, note that bundle: true is required: without it esbuild treats every import as external and never resolves virtual:manifest at all, so the plugin’s hooks never fire.
Worked example: a virtual barrel that imports real files
The manifest above is self-contained, which is why resolveDir was almost incidental. The case that forces you to understand resolveDir is a virtual barrel — a synthesized module that re-exports a glob of real files. Here the generated source contains export ... from './...' statements that must resolve against a real directory.
// build-barrel.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';
import { globSync } from 'node:fs';
import path from 'node:path';
// Resolve the glob once, at build start, and sort for determinism.
const iconsDir = path.resolve('src/icons');
const files = globSync('*.svg', { cwd: iconsDir }).sort();
// Synthesize a barrel: each icon becomes a named export.
const barrelSource = files
.map((f) => {
const name = path.basename(f, '.svg');
return `export { default as ${name} } from './${f}';`;
})
.join('\n');
const virtualBarrel = {
name: 'virtual-icon-barrel',
setup(build) {
build.onResolve({ filter: /^virtual:icons$/ }, () => ({
path: 'icons',
namespace: 'virtual-icons',
}));
build.onLoad({ filter: /.*/, namespace: 'virtual-icons' }, () => ({
contents: barrelSource,
loader: 'js',
// Without this, every './icon.svg' fails with Could not resolve.
resolveDir: iconsDir,
}));
},
};
await esbuild.build({
entryPoints: ['entry.ts'],
bundle: true,
outfile: 'dist/icons.js',
plugins: [virtualBarrel],
loader: { '.svg': 'text' },
metafile: true,
});
The resolveDir: iconsDir line is what makes the ./foo.svg specifiers inside barrelSource resolve — esbuild treats them as relative to iconsDir, reads the real SVG files through the configured .svg loader, and pulls them into the graph. Delete that line and the build fails at the first re-export with Could not resolve "./foo.svg", because esbuild has no directory to anchor the relative path against. This is the mechanism the earlier decision branch was pointing at, made concrete: the virtual module is the index, the real files are the leaves, and resolveDir is the bridge between them.
Verification
# esbuild 0.25.x — build and run the bundle.
node build.mjs
node dist/out.js # prints: [ '/', '/about', '/contact' ]
The bundle logs the generated routes, proving the virtual module was loaded. In the metafile, inputs contains a virtual:manifest entry with no corresponding file on disk — confirmation that the bytes came from onLoad, not a stray real file.
The metafile is the authoritative check because runtime output alone can lie. A console.log that prints the right routes could, in principle, be reading a real virtual:manifest.ts that someone committed by accident, in which case your plugin is doing nothing and you would never know. The metafile settles it: esbuild records every input in the graph keyed by its namespace-qualified path, so a synthesized module shows up as virtual:manifest (namespace-prefixed), whereas a real file shows up under its filesystem path. If you see the filesystem path, a real file shadowed your plugin; if you see the namespace-prefixed key with a non-zero byte count, the synthesis worked. Write that assertion into a test rather than eyeballing it — parse the metafile JSON and assert the expected key is present and the unexpected filesystem path is absent — so a stray committed file can never silently replace your generator.
// verify.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';
import assert from 'node:assert';
// ...construct `virtualManifest` as above...
const result = await esbuild.build({
entryPoints: ['entry.ts'],
bundle: true,
outfile: 'dist/out.js',
plugins: [virtualManifest],
metafile: true,
});
const keys = Object.keys(result.metafile.inputs);
assert(keys.some((k) => k.includes('virtual:manifest')), 'virtual module missing');
assert(!keys.some((k) => k.endsWith('manifest.ts')), 'a real file shadowed the plugin');
console.log('virtual module verified');
Gotchas & edge cases
resolveDir is the one that breaks the moment a virtual module imports anything real.- Missing
resolveDir. Symptom: the build fails withCould not resolve "./x"orCould not resolve "react"pointing at your virtual module, even though those imports are fine everywhere else. Root cause: the virtual module has no on-disk location, so esbuild has no base directory to resolve its relative or bare imports against. Fix: returnresolveDirfromonLoad, set to the directory those imports should resolve against —iconsDirfor a barrel of real siblings,process.cwd()orpath.dirname(fileURLToPath(import.meta.url))when the imports are bare packages that should resolve against your project’snode_modules. Confirm: the error disappears and the imported files show up in the metafile inputs. - Wrong loader. Symptom: a parse error like
Unexpected "<"orExpected ";" but found "export"on contents you know are valid. Root cause: esbuild does not infer the loader foronLoadcontents from thepaththe way it does for real files — thepathis an opaque token, not a filename with an extension. The default isjs. Fix: setloaderexplicitly ('js','ts','jsx','json','css','text') to match the bytes you return; returning JSON or TypeScript under the defaultjsloader is the usual trigger. Confirm: the parse error clears and the module’s exports are what you expect. - Namespace collisions. Symptom: two plugins are installed and one intermittently swallows the other’s paths, or an
onLoadreturns contents meant for a different module. Root cause: namespaces are a flat global string space within a build; a generic name likevirtualis easy for two plugins to both claim, and whichever registered itsonLoadfirst wins for overlapping paths. Fix: namespace with the plugin’s own name (virtual-manifest,virtual-icon-barrel) so the strings cannot alias. Confirm: each plugin’s modules appear in the metafile under its own namespace prefix. - Non-deterministic generation. Symptom: incremental rebuilds serve stale contents, or content hashes churn between builds that should be identical. Root cause:
onLoadresults are cached by(path, namespace), so a generator that readsDate.now(), a random value, or a network response either gets frozen at its first value (stale) or, if the path also changes, invalidates the cache constantly (churn). Fix: compute the source once at build start from stable inputs and close over it; if the underlying data genuinely changes, encode a version into thepathso a new path forces a fresh load. Confirm: two clean builds of unchanged inputs produce identical output bytes.
Caching and watch-mode cadence
The (path, namespace) cache key is the fact that governs how virtual modules behave under context() and watch mode. On the first build, esbuild calls your onLoad for each resolved virtual path and stores the result. On a rebuild triggered by a real file change elsewhere, esbuild does not call onLoad again for a virtual path whose key is unchanged — it reuses the cached contents. That is correct and fast when your generated source is a pure function of stable inputs, and it is a footgun when the inputs it depends on are themselves changing on disk.
Concretely, the icon barrel above will not notice a newly added src/icons/new.svg during a watch session, because barrelSource was computed once at process start and the virtual path icons never changed. There are two honest fixes. The first is to recompute the glob and rebuild the context when the watched directory changes, using esbuild’s own watch to trigger a fresh context.rebuild() after regenerating the source — but note that the closed-over string must actually be reassigned, so the generator has to live somewhere the onLoad closure reads on each call. The second, cleaner for content that is cheap to compute, is to move the generation inside onLoad and accept the per-load cost, then invalidate by varying the path when inputs change. Pick based on generation cost: expensive-but-stable belongs at build start, cheap-but-volatile belongs in the callback. What you must not do is generate inside onLoad from a volatile source and expect the cache to both serve fast and stay fresh — those goals are in tension and the cache resolves it in favor of staleness.
When not to use this
Virtual modules are the wrong tool when a real file would serve just as well, because a real file is inspectable, greppable, and debuggable in ways synthetic bytes are not. If a human ever needs to open the generated module, set a breakpoint in it, or diff it in review, write it to disk in a committed or explicitly-gitignored path and import it normally. They are also the wrong tool when the “generation” is really a transform of one existing file — that is an onLoad on the file namespace, not a virtual module, and reaching for a fake namespace there just hides the real input from the graph. And if the only goal is to inject a few constants, esbuild’s define option substitutes them at parse time with no plugin at all and no module boundary, which is lighter than a virtual module for that narrow case. Reserve virtual modules for genuinely synthesized source with no single on-disk origin: aggregations, manifests derived from directory shape, and config assembled from the environment.
Comparison with Rollup and Vite virtual modules
The pattern is portable but the spelling differs. Rollup (and therefore Vite, whose plugin API is Rollup-compatible in dev) expresses the same idea through resolveId and load. The convention there is to return an id prefixed with a NUL byte (\0virtual:manifest) from resolveId; the \0 marks the id as synthetic so that other plugins and the module resolver leave it alone, which is Rollup’s equivalent of esbuild’s non-file namespace. Then load returns the source string for that id. esbuild splits the same two responsibilities across onResolve/onLoad and uses a structured namespace field instead of a NUL-prefix convention, but the shape — claim the id, then supply the bytes — is identical.
The practical differences are in resolution of nested imports and in caching. Rollup resolves a virtual module’s relative imports through the same resolveId chain, so you handle them by resolving ids yourself or returning absolute paths; esbuild handles them declaratively through resolveDir. And Vite’s dev server has its own module graph and hot-update semantics layered on top of Rollup’s, so a virtual module that must participate in HMR needs a handleHotUpdate hook in Vite that has no esbuild analogue — esbuild’s story ends at the bundle. If you are writing a plugin that must run in both toolchains, keep the source-generation function pure and framework-agnostic, and write two thin adapters: one mapping onResolve/onLoad to it, one mapping resolveId/load. The generator is the reusable part; the hook wiring is not.
Related
- esbuild plugin patterns — the resolution/loading/namespace model this builds on.
- Writing an esbuild onResolve plugin for path aliases — the onResolve-only counterpart.
- Writing an esbuild plugin for inline SVG imports — an onLoad plugin that transforms real files.