esbuild plugins are essentially just functions that hook into esbuild’s build process, letting you intercept and transform files before they’re bundled.

Let’s see it in action. Imagine you have a bunch of .txt files you want to include directly in your JavaScript bundle, perhaps for some sort of in-app documentation or configuration. esbuild, by default, wouldn’t know what to do with these.

Here’s a simple example of a plugin that handles .txt files:

// plugins/text-plugin.js
const fs = require('fs');
const path = require('path');

module.exports = function textPlugin() {
  return {
    name: 'text-plugin', // A unique name for your plugin
    setup(build) {
      // This is the core of the plugin: an onResolve hook.
      // It intercepts import paths.
      build.onResolve({ filter: /\.txt$/ }, async (args) => {
        // If the path matches our filter, we've found a .txt file.
        // We return the absolute path to the file. esbuild will then
        // pass this path to the onLoad hook.
        const resolvedPath = path.resolve(__dirname, args.path);
        return { path: resolvedPath };
      });

      // This is the onLoad hook. It gets the actual file content.
      build.onLoad({ filter: /\.txt$/ }, async (args) => {
        // args.path is the absolute path we resolved earlier.
        const textContent = await fs.promises.readFile(args.path, 'utf8');
        // We return the content and specify the loader.
        // 'js' means esbuild will treat this as JavaScript code.
        // We're wrapping the text content in a JS string literal.
        return {
          contents: `export default ${JSON.stringify(textContent)};`,
          loader: 'js',
        };
      });
    },
  };
};

Now, to use this plugin, you’d include it in your esbuild configuration:

// esbuild.config.js
const esbuild = require('esbuild');
const textPlugin = require('./plugins/text-plugin');

esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  plugins: [
    textPlugin(), // Simply call the function that returns the plugin object
  ],
}).catch(() => process.exit(1));

And in your src/index.js:

// src/index.js
import myText from './data.txt';

console.log(myText); // This will log the content of data.txt

When you run esbuild, it will process src/index.js. When it sees import myText from './data.txt';, the onResolve hook in text-plugin will catch the .txt extension. It resolves the path and tells esbuild to load it. The onLoad hook then reads the file, converts its content into a JavaScript string, and exports it as a default import. The final bundle.js will contain the text content directly embedded.

This simple example demonstrates the core mechanism: intercepting paths (onResolve) and transforming content (onLoad). You can use this for much more complex tasks: transpiling custom languages, injecting environment variables, optimizing assets, or even modifying existing JavaScript code.

The setup function is where all the magic happens. It receives a build object which exposes various methods to hook into the build process. The most common are:

  • onResolve(options, callback): This hook allows you to control how import paths are resolved. The filter option (a regular expression) determines which import paths this hook will intercept. The callback receives an args object containing information about the import, including path and importer. You can return an object with a path to tell esbuild where to find the file, or an object with external: true to mark it as an external dependency.
  • onLoad(options, callback): This hook allows you to provide the content for a file that esbuild needs to load. Again, the filter specifies which files this hook applies to. The callback receives args with path and namespace. You return an object with contents (the transformed code) and optionally a loader (like 'js', 'css', 'file', etc.) to tell esbuild how to process that content.

The namespace property in onResolve and onLoad is a powerful, often overlooked, feature. By default, all files share the same namespace. However, you can assign a custom namespace to resolved paths. This allows plugins to create isolated environments for their files. For example, a plugin that generates virtual files might use namespace: 'my-virtual-files'. Then, its onLoad hook would only trigger for files in that specific namespace, preventing conflicts with other plugins or esbuild’s default behavior. This is crucial for complex build systems where multiple plugins might otherwise interfere with each other’s file handling.

Beyond onResolve and onLoad, esbuild offers other hooks like onEnd (to run code after the build is complete) and onStart (to run code before the build begins), which are useful for tasks like cleaning up directories or generating reports.

The filter in onResolve and onLoad can also target specific namespaces. For instance, build.onLoad({ filter: /\.json$/, namespace: 'my-custom-json' }, ...) would only apply to .json files within the my-custom-json namespace. This granular control is key to building robust and maintainable plugins.

The next step is understanding how to create virtual modules that don’t exist on the filesystem.

Want structured learning?

Take the full Esbuild course →