| | |

esbuild 24 ⚡ Writing a Plugin — Virtual modules & custom loaders

⏱️ Estimated read: 14 min | |

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

PartWherePurpose
Custom namespaceonResolve returnMarks the module as virtual
onLoad filteronLoad registrationMatches the custom namespace
contentsonLoad returnThe generated content
loaderonLoad returnThe content type
resolveDironLoad returnThe directory for imports

The loaders

LoaderContent typeUse case
jsJavaScriptGenerated code
jsonJSONEnvironment variables, config
textRaw stringTemplates, text content
binaryUint8ArrayWasm, images
cssCSSGenerated styles

The namespace rules

NamespacePath typeRead from disk
fileFile system pathYes
CustomArbitrary stringNo

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

PitfallWhy It HappensFix
onLoad is never calledThe namespace does not matchCheck the namespace string
The content is not parsed correctlyThe loader is missing or wrongSet the correct loader
The generated imports failresolveDir is not setSet the directory for resolution
The error “Expected onLoad() callback to return an object”A raw string was returnedWrap it in { contents, loader }
The virtual module collides with a fileThe namespace is fileUse 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

ItemValue
Virtual module mechanismCustom namespace + onLoad
onResolve return{ path, namespace }
onLoad return{ contents, loader, resolveDir? }
Default loaderjs
JSON loaderjson
Binary loaderbinary
Text loadertext
Two-stage patternStub namespace + binary namespace
ErroronLoad 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 onResolve hook returns the namespace, and the onLoad hook filters on it to provide the content .
  • The loader tells esbuild how to parse the content. The js loader is the default, but a virtual module that exports JSON uses json, a raw string uses text, and a binary buffer uses binary .
  • The resolveDir is required for generated content with imports. If the content contains import statements, the resolveDir must 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 onResolve hook distinguishes the two by checking args.namespace .
  • The onLoad callback 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.

Was this helpful?


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!