esbuild 24 ⚡ Writing a Plugin — Virtual modules & custom loaders
A virtual module is a module that esbuild bundles as if it were a file on disk, but whose content is generated by a plugin at build time. It has no physical path in the file namespace. Instead, the plugin creates a custom namespace, intercepts the import specifier with onResolve, and supplies the content with onLoad. The namespace is the mechanism that tells esbuild “do not read this from disk; ask the plugin instead.” The custom loader is the second half of the pattern: the onLoad callback returns a loader string that tells esbuild how to interpret the generated content—'js' for JavaScript, 'json' for JSON, 'text' for a raw string, or 'binary' for a Uint8Array.
The pattern exists because some modules are not files. An environment variable object, a WebAssembly binary, a generated remote entry for module federation, or a GitHub releases list fetched at build time—none of these have a path in the source tree, but all of them need to be importable from the source code . The virtual module makes them importable without creating a file on disk. The plugin generates the content, the custom loader tells esbuild how to parse it, and the bundler treats it like any other module in the graph.
Key point: A virtual module has three parts: a custom namespace returned by onResolve, an onLoad callback filtered on that namespace, and a loader that tells esbuild how to interpret the generated content. The onResolve hook intercepts the specifier, the onLoad hook generates the content, and the loader parses it. Without the custom namespace, esbuild would try to read the path from disk and fail.
Why virtual modules require a custom namespace
The file namespace assumption. Every module in esbuild has a namespace, and the default is file. A module in the file namespace is treated as a file system path, and esbuild reads it from disk using the default loader for the file extension . A plugin that wants to generate content cannot use the file namespace, because esbuild would ignore the generated content and read the file instead. The custom namespace is the signal that the module is not a file. The onLoad callback for a custom namespace is the only source of the content.
The path in a custom namespace. A module in a custom namespace has a path, but the path is not a file system path. It is an identifier that the plugin uses to distinguish one virtual module from another. The env plugin uses the path 'env' and the namespace 'env-ns' . The WebAssembly plugin uses two namespaces: 'wasm-stub' for the JavaScript stub and 'wasm-binary' for the binary content . The path can be any string, and the plugin’s onLoad callback receives it as args.path.
The loader’s role. The loader property tells esbuild how to parse the contents string. The default is 'js', which means the content is parsed as JavaScript and its exports are available to the importer . A virtual module that exports a JSON object uses loader: 'json'. A virtual module that exports a raw string uses loader: 'text'. A virtual module that exports a Uint8Array of binary data uses loader: 'binary' . The loader is not optional for a virtual module; without it, esbuild assumes JavaScript, and a JSON string or a binary buffer would fail to parse.
The trade-off. The virtual module pattern is more verbose than a file-based module. The plugin needs the onResolve hook, the onLoad hook, and the namespace string. The namespace string must be unique enough that it does not collide with another plugin’s namespace, and the onLoad filter must match the namespace exactly. The verbosity is the cost of the flexibility: the plugin can generate content from any source—an environment variable, a network request, a generated string—and the source code imports it as if it were a normal module.
a. The environment variable virtual module
The canonical example is the environment variable plugin. The onResolve hook intercepts the specifier 'env', and the onLoad hook returns the environment variables as JSON.
// env-plugin.mjs — the environment variable virtual module
import * as esbuild from 'esbuild';
const envPlugin = {
name: 'env',
setup(build) {
// Intercept the "env" specifier.
// The custom namespace "env-ns" tells esbuild
// that this module is not a file.
build.onResolve({ filter: /^env$/ }, (args) => ({
path: args.path,
namespace: 'env-ns',
}));
// Load the content for modules in the "env-ns" namespace.
// The loader is "json" because the content is JSON.
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}));
},
};
await esbuild.build({
entryPoints: ['src/app.js'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [envPlugin],
});
The source code imports the virtual module as if it were a package:
// src/app.js — importing the virtual module
import { PATH, NODE_ENV } from 'env';
console.log(`PATH is ${PATH}`);
console.log(`Environment is ${NODE_ENV}`);
The bundler resolves 'env' through the plugin, generates the JSON content, and bundles it into the output. The process.env object is not available at runtime in the browser; the plugin has replaced the import with the build-time values. The virtual module is the mechanism that makes the replacement possible.
b. The WebAssembly virtual module with a binary loader
A virtual module that exports binary data uses the binary loader. The WebAssembly plugin is the canonical example: it generates a JavaScript stub that imports the binary, and the binary itself is a virtual module with the binary loader .
// wasm-plugin.mjs — the WebAssembly virtual module
import * as esbuild from 'esbuild';
import fs from 'node:fs/promises';
import path from 'node:path';
const wasmPlugin = {
name: 'wasm',
setup(build) {
// The .wasm import is intercepted in two stages.
// The first stage creates the JavaScript stub.
// The second stage loads the binary content.
build.onResolve({ filter: /\.wasm$/ }, (args) => {
// If the import is inside the stub module,
// resolve to the binary namespace.
if (args.namespace === 'wasm-stub') {
return { path: args.path, namespace: 'wasm-binary' };
}
// Otherwise, generate the stub module.
// The path is resolved to an absolute path
// so the binary namespace can read it from disk.
return {
path: path.isAbsolute(args.path)
? args.path
: path.join(args.resolveDir, args.path),
namespace: 'wasm-stub',
};
});
// The stub module exports the WebAssembly module.
// It imports the binary from the wasm-binary namespace.
build.onLoad({ filter: /.*/, namespace: 'wasm-stub' }, (args) => ({
contents: `import wasm from ${JSON.stringify(args.path)}
export default new WebAssembly.Module(wasm);`,
loader: 'js',
}));
// The binary module reads the file from disk
// and exports it with the binary loader.
build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({
contents: await fs.readFile(args.path),
loader: 'binary',
}));
},
};
The two-stage resolution is the pattern for a virtual module that wraps a real file. The wasm-stub namespace is the virtual module that the source code imports. The wasm-binary namespace is the real file that the stub imports. The onResolve hook distinguishes the two by checking args.namespace: if the import is inside the stub, it resolves to the binary; otherwise, it resolves to the stub . The binary loader is the mechanism that makes the Uint8Array available to the stub.
Complete Example Session
// ============================================
// PART 1: THE ENVIRONMENT VARIABLE VIRTUAL MODULE
// ============================================
// env-plugin.mjs
const envPlugin = {
name: 'env',
setup(build) {
build.onResolve({ filter: /^env$/ }, (args) => ({
path: args.path,
namespace: 'env-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}));
},
};
// ============================================
// PART 2: THE SOURCE IMPORT
// ============================================
// src/app.js
import { NODE_ENV } from 'env';
console.log(NODE_ENV);
// ============================================
// PART 3: THE BUILD CONFIGURATION
// ============================================
await esbuild.build({
entryPoints: ['src/app.js'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [envPlugin],
});
// ============================================
// PART 4: THE TEXT VIRTUAL MODULE
// ============================================
// A virtual module that exports a raw string.
const textPlugin = {
name: 'text',
setup(build) {
build.onResolve({ filter: /^virtual:text$/ }, () => ({
path: 'text',
namespace: 'text-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'text-ns' }, () => ({
contents: 'This is a raw string.',
loader: 'text',
}));
},
};
// ============================================
// PART 5: THE JAVASCRIPT VIRTUAL MODULE
// ============================================
// A virtual module that exports a JavaScript function.
const jsPlugin = {
name: 'js',
setup(build) {
build.onResolve({ filter: /^virtual:math$/ }, () => ({
path: 'math',
namespace: 'math-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'math-ns' }, () => ({
contents: 'export const add = (a, b) => a + b;',
loader: 'js',
}));
},
};
// ============================================
// PART 6: THE GENERATED MODULE WITH A RESOLVE DIR
// ============================================
// A virtual module that contains imports must specify resolveDir
// so that the imports inside the generated content resolve correctly.
const generatedPlugin = {
name: 'generated',
setup(build) {
build.onResolve({ filter: /^virtual:generated$/ }, () => ({
path: 'generated',
namespace: 'generated-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'generated-ns' }, () => ({
contents: `import { add } from './math.js'; export default add(1, 2);`,
loader: 'js',
// The resolveDir is the directory for the imports inside the content.
resolveDir: './src',
}));
},
};
// ============================================
// PART 7: THE TWO-STAGE WASM MODULE
// ============================================
// The stub namespace imports the binary namespace.
// The binary namespace reads the file from disk.
const wasmPlugin = {
name: 'wasm',
setup(build) {
build.onResolve({ filter: /\.wasm$/ }, (args) => {
if (args.namespace === 'wasm-stub') {
return { path: args.path, namespace: 'wasm-binary' };
}
return {
path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path),
namespace: 'wasm-stub',
};
});
build.onLoad({ filter: /.*/, namespace: 'wasm-stub' }, (args) => ({
contents: `import wasm from ${JSON.stringify(args.path)}; export default new WebAssembly.Module(wasm);`,
loader: 'js',
}));
build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({
contents: await fs.readFile(args.path),
loader: 'binary',
}));
},
};
// ============================================
// PART 8: THE MODULE FEDERATION REMOTE ENTRY
// ============================================
// The remote entry is a virtual module added to the entry points.
const federationPlugin = {
name: 'federation',
setup(build) {
build.onResolve({ filter: /^__federation_remote_entry__$/ }, () => ({
path: '__federation_remote_entry__',
namespace: 'federation-entry',
}));
build.onLoad({ filter: /.*/, namespace: 'federation-entry' }, () => ({
contents: 'export default { get: async () => {}, init: () => {} };',
loader: 'js',
}));
build.initialOptions.entryPoints = {
...build.initialOptions.entryPoints,
remoteEntry: '__federation_remote_entry__',
};
},
};
// ============================================
// PART 9: THE LOADER ERROR
// ============================================
// The onLoad callback must return an object with a contents field.
// Returning a raw string causes an error.
// ❌ build.onLoad({ filter: /.*/ }, () => 'export default 1');
// ✅ build.onLoad({ filter: /.*/ }, () => ({ contents: 'export default 1', loader: 'js' }));
// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================
// [ ] The onResolve hook returns a custom namespace.
// [ ] The onLoad hook filters on the custom namespace.
// [ ] The onLoad hook returns an object with contents and loader.
// [ ] The loader matches the content type.
// [ ] The resolveDir is set for generated content with imports.
// [ ] The namespace is unique enough to avoid collisions.
// [ ] The path in the custom namespace is not treated as a file path.
The ten parts covered the environment variable module, the source import, the build configuration, the text loader, the JavaScript loader, the resolve directory, the two-stage Wasm module, the module federation remote entry, the loader error, and the verification. The through-line is that the custom namespace is the mechanism, and the loader is the interpretation.
Quick Reference
The virtual module parts
| Part | Where | Purpose |
|---|---|---|
| Custom namespace | onResolve return | Marks the module as virtual |
onLoad filter | onLoad registration | Matches the custom namespace |
contents | onLoad return | The generated content |
loader | onLoad return | The content type |
resolveDir | onLoad return | The directory for imports |
The loaders
| Loader | Content type | Use case |
|---|---|---|
js | JavaScript | Generated code |
json | JSON | Environment variables, config |
text | Raw string | Templates, text content |
binary | Uint8Array | Wasm, images |
css | CSS | Generated styles |
The namespace rules
| Namespace | Path type | Read from disk |
|---|---|---|
file | File system path | Yes |
| Custom | Arbitrary string | No |
Best Practices
✅ Do This:
// Return a custom namespace from onResolve
build.onResolve({ filter: /^env$/ }, () => ({ path: 'env', namespace: 'env-ns' })); // ✅
// Filter on the namespace in onLoad
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: '...', loader: 'json' })); // ✅
// Set resolveDir for generated content with imports
build.onLoad({ filter: /.*/, namespace: 'ns' }, () => ({ contents, loader: 'js', resolveDir: './src' })); // ✅
// Use the binary loader for Uint8Array content
build.onLoad({ filter: /.*/, namespace: 'bin' }, async (args) => ({ contents: await fs.readFile(args.path), loader: 'binary' })); // ✅
❌ Don’t Do This:
// Use the file namespace for virtual content
build.onResolve({ filter: /^env$/ }, () => ({ path: '/abs/env.js' })); // ❌
// Return a raw string from onLoad
build.onLoad({ filter: /.*/ }, () => 'export default 1'); // ❌
// Forget the loader for JSON content
build.onLoad({ filter: /.*/ }, () => ({ contents: '{"a":1}' })); // ❌
// Use the same namespace for unrelated virtual modules
// Collisions cause the wrong onLoad to run. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
onLoad is never called | The namespace does not match | Check the namespace string |
| The content is not parsed correctly | The loader is missing or wrong | Set the correct loader |
| The generated imports fail | resolveDir is not set | Set the directory for resolution |
| The error “Expected onLoad() callback to return an object” | A raw string was returned | Wrap it in { contents, loader } |
| The virtual module collides with a file | The namespace is file | Use a custom namespace |
Real-World Examples
1. Environment plugin
build.onResolve({ filter: /^env$/ }, () => ({ path: 'env', namespace: 'env-ns' }));
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json' }));
2. Text module
build.onLoad({ filter: /.*/, namespace: 'text-ns' }, () => ({ contents: 'hello', loader: 'text' }));
3. JSON module
build.onLoad({ filter: /.*/, namespace: 'json-ns' }, () => ({ contents: '{"a":1}', loader: 'json' }));
4. JavaScript module
build.onLoad({ filter: /.*/, namespace: 'js-ns' }, () => ({ contents: 'export default 1', loader: 'js' }));
5. Binary module
build.onLoad({ filter: /.*/, namespace: 'bin-ns' }, async (args) => ({ contents: await fs.readFile(args.path), loader: 'binary' }));
6. Resolve directory
build.onLoad({ filter: /.*/, namespace: 'gen-ns' }, () => ({ contents: 'import "./x.js"', loader: 'js', resolveDir: './src' }));
7. Two-stage Wasm
build.onResolve({ filter: /\.wasm$/ }, (args) => args.namespace === 'wasm-stub' ? { path: args.path, namespace: 'wasm-binary' } : { path: args.path, namespace: 'wasm-stub' });
8. Module federation
build.onResolve({ filter: /^__federation_remote_entry__$/ }, () => ({ path: 'remote', namespace: 'federation' }));
9. The loader error
// ❌ build.onLoad({ filter: /.*/ }, () => 'export default 1')
// ✅ build.onLoad({ filter: /.*/ }, () => ({ contents: 'export default 1', loader: 'js' }))
10. Custom namespace
build.onResolve({ filter: /^virtual:x$/ }, () => ({ path: 'x', namespace: 'x-ns' }));
Visual
The Virtual Module Flow
┌──────────────────────────────────────────────────────────────┐
│ IMPORT → onResolve → onLoad → CONTENT │
│ │
│ import 'env' │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ onResolve │ Returns { path: 'env', │
│ │ filter: /^env$/ │ namespace: 'env-ns' } │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ onLoad │ Returns { contents: '...', │
│ │ namespace: 'env-ns' │ loader: 'json' } │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ The loader parses │ │
│ │ the content │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Two-Stage Wasm Module
┌──────────────────────────────────────────────────────────────┐
│ STUB AND BINARY │
│ │
│ import './module.wasm' │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ onResolve │ → wasm-stub namespace │
│ │ (file namespace) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ onLoad (wasm-stub) │ → exports the WebAssembly.Module │
│ │ │ → imports the binary │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ onResolve │ → wasm-binary namespace │
│ │ (wasm-stub ns) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ onLoad (wasm-binary)│ → reads the file from disk │
│ │ │ → loader: 'binary' │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Loader Types
┌──────────────────────────────────────────────────────────────┐
│ LOADER DECIDES THE PARSER │
│ │
│ contents: '{"a":1}' loader: 'json' → parsed as JSON │
│ contents: 'export x=1' loader: 'js' → parsed as JS │
│ contents: 'hello' loader: 'text' → string export │
│ contents: Uint8Array loader: 'binary' → binary export │
│ │
│ The wrong loader causes a parse error or a wrong export. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Virtual module mechanism | Custom namespace + onLoad |
onResolve return | { path, namespace } |
onLoad return | { contents, loader, resolveDir? } |
| Default loader | js |
| JSON loader | json |
| Binary loader | binary |
| Text loader | text |
| Two-stage pattern | Stub namespace + binary namespace |
| Error | onLoad must return an object, not a raw string |
Key takeaways:
- A virtual module has no file path. It has a custom namespace and a path that the plugin chooses. The
onResolvehook returns the namespace, and theonLoadhook filters on it to provide the content . - The loader tells esbuild how to parse the content. The
jsloader is the default, but a virtual module that exports JSON usesjson, a raw string usestext, and a binary buffer usesbinary. - The
resolveDiris required for generated content with imports. If the content containsimportstatements, theresolveDirmust be set to the directory where those imports should be resolved, or the imports will fail . - The two-stage pattern is for virtual modules that wrap a real file. The stub namespace is the virtual module that the source imports, and the binary namespace is the real file that the stub imports. The
onResolvehook distinguishes the two by checkingargs.namespace. - The
onLoadcallback must return an object. Returning a raw string causes the error “Expected onLoad() callback to return an object.” The content must be wrapped in{ contents, loader }.
Remember: A virtual module is a module that esbuild treats as if it were a file, but whose content is generated by a plugin. The custom namespace is the signal that the module is not a file. The onResolve hook intercepts the specifier and returns the namespace. The onLoad hook filters on the namespace and returns the content and the loader. The loader tells esbuild how to parse the content. The resolveDir tells esbuild where to resolve imports inside the content. The two-stage pattern is for virtual modules that wrap a real file: the stub namespace is the virtual module, and the binary namespace is the real file. The pattern is small, and the use cases are large.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!