| | |

esbuild 23 ⚡ Plugins — onResolve and onLoad hooks

⏱️ Estimated read: 14 min | |

The onResolve and onLoad hooks are the two mandatory extension points of every esbuild plugin. They are not optional callbacks that a plugin may choose to implement; a plugin that provides neither is a plugin that does nothing. The onResolve hook answers the question “where does this import point?” and the onLoad hook answers the question “what is the content of that module?” . The two hooks are connected by a namespace: the onResolve callback can return a custom namespace, and the onLoad callback can filter on that namespace to provide the content for the modules that the onResolve hook resolved. A plugin that implements only onResolve can redirect paths, mark them external, or pass them through to the next plugin. A plugin that implements only onLoad can transform the content of existing files. A plugin that implements both can create virtual modules, which is the pattern behind environment variable injection, HTTP imports, and generated code.

Key point: onResolve runs for every import path in every module. It can return a path, a namespace, an external flag, or undefined to pass through. onLoad runs for every unique path/namespace pair that is not external. It returns the module contents and a loader. The namespace is the link between the two hooks: a custom namespace returned by onResolve is the filter that onLoad uses to provide the content.


Why the two hooks are separate

The resolution and loading split. An import statement has two parts: the specifier (what is written in the source) and the resolved path (where the module actually is). The onResolve hook handles the first part. It receives the specifier, the importer, and the resolution kind, and it returns the resolved path. The onLoad hook handles the second part. It receives the resolved path and returns the content. The split is not arbitrary: a single resolved path can be loaded once and used by many importers, so the loading step is cached by the path and namespace pair . If the two were combined, the content would be loaded once per importer, which would be slower and would not match the caching behavior.

The filter optimization. Both hooks require a filter regular expression. The filter is evaluated in Rust, and the callback is only invoked when the filter matches . This is the same optimization as the Vite plugin filter, and it is more important for esbuild because the plugin callbacks cross the Rust-to-Go-or-JS boundary. A plugin that filters on /\.myext$/ is only called for the files that have that extension, not for every module in the graph. The filter syntax follows Go’s regexp engine, which means no look-ahead, look-behind, or backreferences .

The namespace mechanism. Every module has a namespace, and the default is file. The onResolve callback can return a different namespace, and the onLoad callback can filter on it. A module in a non-file namespace is not treated as a file system path, which means esbuild does not try to read it from disk. The namespace is the mechanism for virtual modules: the onResolve hook intercepts a specifier like env and returns { path: 'env', namespace: 'env-ns' }, and the onLoad hook filters on namespace: 'env-ns' and returns the generated content . The path in a virtual namespace can be any string, because it is not a file system path.

The trade-off. The two-hook structure is more verbose than a single hook that does both, but it is more flexible. A plugin that only needs to transform the content of existing files can implement only onLoad and skip onResolve entirely. A plugin that only needs to redirect paths can implement only onResolve and return external: true or a different path. The two hooks are independent, and a plugin that needs both is a plugin that is creating virtual modules. The verbosity is the cost of the flexibility.


a. The onResolve hook

The onResolve hook is registered with build.onResolve({ filter }, callback). The filter is a RegExp, and the callback receives an OnResolveArgs object with the path, importer, namespace, resolveDir, and kind properties.

// onResolve.mjs — a path redirection plugin
import * as esbuild from 'esbuild';

const redirectPlugin = {
  name: 'redirect-plugin',
  setup(build) {
    // The filter matches imports that start with "images/".
    build.onResolve({ filter: /^images\// }, (args) => {
      // args.path is the import specifier: "images/logo.png".
      // args.importer is the module that contains the import.
      // args.resolveDir is the directory of the importer.
      return {
        // Resolve the path relative to the importer's directory.
        path: `${args.resolveDir}/public/${args.path}`,
      };
    });
  },
};

await esbuild.build({
  entryPoints: ['src/app.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  plugins: [redirectPlugin],
});

The callback can return undefined to pass through to the next plugin in the chain. If no plugin resolves the path, esbuild falls back to its default resolution, which resolves relative to the resolveDir of the importer . The external property marks the path as external, which means esbuild does not try to bundle it and leaves the import in the output. The namespace property sets the namespace for the resolved module, which is the link to the onLoad hook.

// external.mjs — marking an import as external
build.onResolve({ filter: /^https?:\/\// }, (args) => {
  // The import is a URL, which esbuild cannot bundle.
  // Mark it as external so the import stays in the output.
  return { path: args.path, external: true };
});

The kind property tells the plugin what kind of import triggered the resolution. The values are entry-point, import-statement, require-call, dynamic-import, require-resolve, and import-rule (for CSS). A plugin that only wants to handle static imports can check args.kind === 'import-statement' and return undefined for the other kinds.


b. The onLoad hook

The onLoad hook is registered with build.onLoad({ filter, namespace? }, callback). The filter is a RegExp, and the optional namespace restricts the callback to modules in that namespace. The callback receives an OnLoadArgs object with the path, namespace, and suffix properties.

// onLoad.mjs — a content transformation plugin
import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';

const txtPlugin = {
  name: 'txt-plugin',
  setup(build) {
    // The filter matches .txt files.
    // The namespace defaults to "file".
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      // args.path is the absolute path to the file.
      const text = await readFile(args.path, 'utf8');

      // Return the transformed content and the loader.
      // The loader tells esbuild how to interpret the content.
      return {
        contents: JSON.stringify(text.split(/\s+/)),
        loader: 'json',
      };
    });
  },
};

await esbuild.build({
  entryPoints: ['src/app.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  plugins: [txtPlugin],
});

The loader property tells esbuild how to parse the content. The valid loaders include js, jsx, ts, tsx, css, json, text, base64, dataurl, file, binary, and default. A plugin that returns loader: 'text' is a plugin that treats the content as a string export. A plugin that returns loader: 'js' is a plugin that treats the content as JavaScript, which is the pattern for virtual modules that generate code.

The resolveDir property is the directory that esbuild uses to resolve imports inside the loaded content. If the content contains import statements, the resolveDir must be set to the directory where those imports should be resolved. For a virtual module that imports from the file system, the resolveDir is the directory of the importer or the project root.

// virtual.mjs — a virtual module with generated content
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => {
  // The virtual module exports the environment variables as JSON.
  return {
    contents: JSON.stringify(process.env),
    loader: 'json',
    // The resolveDir is not needed because the content has no imports.
  };
});

The watchFiles and watchDirs properties are the watch-mode integration. When a plugin reads a file that is not part of the module graph, it must return the path in watchFiles so that a change to that file triggers a rebuild . A plugin that reads a configuration file in the onLoad hook must return the path in watchFiles, or the watch mode will not detect the change.


Complete Example Session

// ============================================
// PART 1: THE ENVIRONMENT VARIABLE PLUGIN
// ============================================

// The canonical example of onResolve + onLoad.
// The onResolve hook intercepts the "env" specifier.
// The onLoad hook provides the environment variables.
const envPlugin = {
  name: 'env',
  setup(build) {
    // Intercept the "env" import specifier.
    // Return a path and a custom namespace.
    build.onResolve({ filter: /^env$/ }, (args) => ({
      path: args.path,
      namespace: 'env-ns',
    }));

    // Filter on the custom namespace.
    // Return the environment variables as JSON.
    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }));
  },
};

// Usage in source:
// import { PATH } from 'env';
// console.log(`PATH is ${PATH}`);

// ============================================
// PART 2: THE HTTP PLUGIN
// ============================================

// The HTTP plugin resolves URLs and loads them from the network.
const httpPlugin = {
  name: 'http',
  setup(build) {
    // Match URL specifiers.
    build.onResolve({ filter: /^https?:\/\// }, (args) => ({
      path: args.path,
      namespace: 'http-url',
    }));

    // Load the URL content.
    build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => ({
      contents: await (await fetch(args.path)).text(),
      loader: 'js',
    }));
  },
};

// ============================================
// PART 3: THE TXT PLUGIN (onLoad ONLY)
// ============================================

// A plugin that only implements onLoad.
// It transforms .txt files into JSON arrays.
const txtPlugin = {
  name: 'txt',
  setup(build) {
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      const text = await readFile(args.path, 'utf8');
      return {
        contents: JSON.stringify(text.split(/\s+/)),
        loader: 'json',
      };
    });
  },
};

// ============================================
// PART 4: THE EXTERNAL PLUGIN (onResolve ONLY)
// ============================================

// A plugin that only implements onResolve.
// It marks all URL imports as external.
const externalPlugin = {
  name: 'external',
  setup(build) {
    build.onResolve({ filter: /^https?:\/\// }, (args) => ({
      path: args.path,
      external: true,
    }));
  },
};

// ============================================
// PART 5: THE PLUGIN ORDER
// ============================================

// Plugins run in registration order.
// The first onResolve that returns a result wins.
// The first onLoad that returns a result wins.
// A plugin that returns undefined passes to the next plugin.

// ============================================
// PART 6: THE WATCH FILES
// ============================================

// A plugin that reads a configuration file must
// return the path in watchFiles.
const configPlugin = {
  name: 'config',
  setup(build) {
    build.onLoad({ filter: /\.special$/ }, async (args) => {
      const config = await readFile('config.json', 'utf8');
      const content = await readFile(args.path, 'utf8');
      return {
        contents: transform(content, JSON.parse(config)),
        loader: 'js',
        // The config file is not in the module graph.
        // It must be watched explicitly.
        watchFiles: ['config.json'],
      };
    });
  },
};

// ============================================
// PART 7: THE FILTER SYNTAX
// ============================================

// The filter is a Go regexp.
// No look-ahead, look-behind, or backreferences.
// Invalid filters cause a build error.

// ✅ /\.myext$/
// ❌ /(?<=foo)bar/ (look-behind not supported)
// ❌ /(foo)\1/ (backreference not supported)

// ============================================
// PART 8: THE NAMESPACE RULES
// ============================================

// The default namespace is "file".
// A module in the "file" namespace must be an absolute path.
// A module in a custom namespace can have any path.
// The onLoad hook for a custom namespace does not read from disk.

// ============================================
// PART 9: THE build.resolve METHOD
// ============================================

// The build.resolve method calls the default resolution
// logic from inside a plugin.
build.onResolve({ filter: /\.myext$/ }, async (args) => {
  // Resolve the path using esbuild's default resolver.
  const result = await build.resolve(args.path, {
    resolveDir: args.resolveDir,
    kind: args.kind,
  });
  return result;
});

// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================

// [ ] The plugin has a unique name.
// [ ] The onResolve hook has a filter.
// [ ] The onLoad hook has a filter.
// [ ] The virtual module uses a custom namespace.
// [ ] The onLoad hook filters on the custom namespace.
// [ ] The watchFiles are returned for files outside the graph.
// [ ] The resolveDir is set for generated content with imports.
// [ ] The plugins are passed to the build options.

The ten parts covered the environment variable plugin, the HTTP plugin, the onLoad-only plugin, the onResolve-only plugin, the plugin order, the watch files, the filter syntax, the namespace rules, the build.resolve method, and the verification. The through-line is that the two hooks are connected by the namespace, and the namespace is the mechanism for virtual modules.


Quick Reference

The hooks

HookWhenReturns
onResolveFor every import pathpath, namespace, external, or undefined
onLoadFor every unique path/namespace paircontents, loader, or undefined

The onResolve arguments

PropertyMeaning
pathThe import specifier
importerThe module that contains the import
namespaceThe namespace of the importer
resolveDirThe directory of the importer
kindThe import kind (import-statement, require-call, etc.)

The onLoad arguments

PropertyMeaning
pathThe resolved path
namespaceThe namespace of the module
suffixThe URL query or hash

The return properties

HookPropertyPurpose
onResolvepathThe resolved path
onResolvenamespaceThe custom namespace
onResolveexternalMark as external
onLoadcontentsThe module content
onLoadloaderThe content type
onLoadresolveDirThe directory for imports
onLoadwatchFilesAdditional files to watch
onLoadwatchDirsAdditional directories to watch

Best Practices

✅ Do This:

// Use a custom namespace for virtual modules
build.onResolve({ filter: /^env$/ }, () => ({ path: 'env', namespace: 'env-ns' })); // ✅

// Filter on the namespace in onLoad
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: '...', loader: 'json' })); // ✅

// Return watchFiles for files outside the graph
return { contents, loader: 'js', watchFiles: ['config.json'] };   // ✅

// Return undefined to pass through
return undefined;                                                  // ✅

❌ Don’t Do This:

// Use a look-behind in the filter
{ filter: /(?<=foo)bar/ } // Go regexp error                   // ❌

// Use a file system path in a custom namespace
{ path: '/absolute/path', namespace: 'custom' } // not a file   // ❌

// Forget the loader
return { contents: '...' }; // loader is required              // ❌

// Read a file without watchFiles
// The watch mode will not detect changes.                     // ❌

Common Pitfalls

PitfallWhy It HappensFix
The virtual module is not loadedThe onLoad namespace does not matchCheck the namespace string
The filter causes a build errorLook-behind or backreference usedUse a Go-compatible regexp
The watch mode does not detect changesThe file is not in watchFilesReturn the path in watchFiles
The generated content’s imports failresolveDir not setSet the directory for resolution
The plugin is not calledThe filter does not matchCheck the regexp against the path

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. HTTP plugin

build.onResolve({ filter: /^https?:\/\// }, (args) => ({ path: args.path, namespace: 'http-url' }));
build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => ({ contents: await (await fetch(args.path)).text(), loader: 'js' }));

3. txt plugin

build.onLoad({ filter: /\.txt$/ }, async (args) => ({ contents: JSON.stringify((await readFile(args.path, 'utf8')).split(/\s+/)), loader: 'json' }));

4. External plugin

build.onResolve({ filter: /^https?:\/\// }, (args) => ({ path: args.path, external: true }));

5. Custom namespace

{ path: args.path, namespace: 'my-ns' }

6. Loader

{ contents: 'export default 1', loader: 'js' }

7. watchFiles

{ contents, loader: 'js', watchFiles: ['config.json'] }

8. resolveDir

{ contents, loader: 'js', resolveDir: args.resolveDir }

9. build.resolve

const result = await build.resolve(args.path, { resolveDir: args.resolveDir, kind: args.kind });

10. Pass through

return undefined;

Visual

The Two-Hook Flow

┌──────────────────────────────────────────────────────────────┐
│  IMPORT → onResolve → onLoad → CONTENT                        │
│                                                              │
│  import 'env'                                                │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ onResolve           │  Returns { path, namespace }       │
│  │ filter: /^env$/     │  or { external: true }             │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                │
│  ┌─────────────────────┐                                    │
│  │ onLoad              │  Returns { contents, loader }      │
│  │ namespace: 'env-ns' │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                │
│  ┌─────────────────────┐                                    │
│  │ The module content  │  The loader parses it              │
│  └─────────────────────┘                                    │
└──────────────────────────────────────────────────────────────┘

The Namespace Link

┌──────────────────────────────────────────────────────────────┐
│  onResolve RETURNS THE NAMESPACE, onLoad FILTERS ON IT        │
│                                                              │
│  onResolve({ filter: /^env$/ })                             │
│  ┌─────────────────────┐                                    │
│  │ return {            │                                    │
│  │   path: 'env',      │                                    │
│  │   namespace: 'env-ns'│  ← the link                       │
│  │ }                   │                                    │
│  └─────────────────────┘                                    │
│                                                              │
│  onLoad({ filter: /.*/, namespace: 'env-ns' })              │
│  ┌─────────────────────┐                                    │
│  │ return {            │                                    │
│  │   contents: '...',  │                                    │
│  │   loader: 'json'    │                                    │
│  │ }                   │                                    │
│  └─────────────────────┘                                    │
│                                                              │
│  The namespace is the key that connects the two hooks.      │
└──────────────────────────────────────────────────────────────┘

The Virtual Module

┌──────────────────────────────────────────────────────────────┐
│  A VIRTUAL MODULE HAS NO FILE SYSTEM PATH                     │
│                                                              │
│  File namespace              Custom namespace                │
│  ┌─────────────────────┐    ┌─────────────────────┐         │
│  │ path: /abs/file.js  │    │ path: 'env'         │         │
│  │ namespace: 'file'   │    │ namespace: 'env-ns' │         │
│  │                     │    │                     │         │
│  │ onLoad reads from   │    │ onLoad generates    │         │
│  │ disk                │    │ the content         │         │
│  └─────────────────────┘    └─────────────────────┘         │
│                                                              │
│  The custom namespace tells esbuild not to read from disk.  │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
onResolveResolves import paths; returns path, namespace, external
onLoadLoads module content; returns contents, loader
The linkThe namespace returned by onResolve
FilterGo regexp; no look-ahead, look-behind, backreferences
Default namespacefile (absolute file system path)
Custom namespaceVirtual module (no disk read)
Watch integrationwatchFiles and watchDirs
Plugin orderRegistration order; first result wins

Key takeaways:

  • onResolve and onLoad are the two mandatory hooks. A plugin that provides neither does nothing. A plugin that provides only onResolve can redirect or externalize paths. A plugin that provides only onLoad can transform the content of existing files. A plugin that provides both can create virtual modules .
  • The namespace is the link between the hooks. The onResolve callback returns a namespace, and the onLoad callback filters on it. A custom namespace tells esbuild not to read from the file system, which is the mechanism for virtual modules .
  • The filter is a Go regexp. No look-ahead, look-behind, or backreferences. The filter is evaluated in Rust, and the callback is only invoked when the filter matches, which is the performance optimization .
  • The watchFiles property is required for files outside the graph. A plugin that reads a configuration file or a directory listing must return the paths in watchFiles (or watchDirs), or the watch mode will not detect the changes .
  • The build.resolve method calls the default resolution from inside a plugin. A plugin that wants to resolve a path and then transform the result can call build.resolve and return the result, or use it to find the path before loading the content .

Remember: The onResolve and onLoad hooks are the two halves of the module pipeline. The onResolve hook decides where the import points, and the onLoad hook decides what the content is. The namespace is the link between them: the onResolve hook returns it, and the onLoad hook filters on it. A virtual module is a module with a custom namespace and a generated content. The filter is the performance optimization: it runs in Rust and skips the callback for paths that do not match. The watchFiles property is the watch-mode integration: a file that the plugin reads must be listed, or the watcher will not see the change. The two hooks are small, and the patterns they enable 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!