esbuild is fast because it does very little by default. It’s a JavaScript bundler, so it knows how to handle .js, .jsx, .ts, and .tsx files. Anything else? It treats it as raw data, just copying it over. But you can teach it new tricks with plugins.

Let’s say you have some custom data files, like .mydata files, and you want esbuild to parse them into JavaScript objects.

Here’s a .mydata file:

name: My Item
version: 1.2.3
enabled: true
tags:
  - alpha
  - beta

You want to transform this into a JavaScript module that exports the parsed data.

// Inside your esbuild build configuration
const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  plugins: [
    {
      name: 'my-data-transformer',
      setup(build) {
        // This is where the magic happens
      }
    }
  ]
}).catch(() => process.exit(1));

The setup function is where you define the plugin’s behavior. It receives a build object that exposes methods to interact with esbuild’s build process. The most important method here is onLoad.

onLoad lets you intercept files based on their path and extension. You provide a filter and a loader function.

// Inside the setup function
build.onLoad({ filter: /\.mydata$/, namespace: 'file' }, async (args) => {
  const fs = require('fs');
  const path = require('path');

  // Read the file content
  const fileContent = await fs.promises.readFile(args.path, 'utf-8');
  const fileName = path.basename(args.path);

  // Here's where you'd parse your custom format
  // For this example, let's do a very simple line-by-line parse
  const lines = fileContent.split('\n');
  let data = {};
  let currentKey = null;
  let currentValue = [];

  for (const line of lines) {
    if (line.trim() === '') continue;

    const match = line.match(/^([^:]+):\s*(.*)$/);
    if (match) {
      if (currentKey !== null) {
        data[currentKey] = currentValue.length > 0 ? currentValue : currentValue[0];
      }
      currentKey = match[1].trim();
      const value = match[2].trim();
      currentValue = value ? [value] : [];
    } else if (currentKey !== null && line.startsWith('  ')) {
      currentValue.push(line.trim());
    }
  }
  if (currentKey !== null) {
    data[currentKey] = currentValue.length > 0 ? currentValue : currentValue[0];
  }

  // The returned object defines how esbuild should treat this file
  return {
    contents: `export default ${JSON.stringify(data, null, 2)};`,
    loader: 'js', // Tell esbuild to treat the output as JavaScript
  };
});

When esbuild encounters a file ending in .mydata, it will call your onLoad function. It reads the file, parses it (in this simplified example), and then returns a JavaScript string that esbuild will use as the module’s content. We explicitly set loader: 'js' so esbuild knows to process the generated string as JavaScript.

Now, in your src/index.js:

import myData from '../data/config.mydata';

console.log(myData);
// Expected output:
// {
//   name: 'My Item',
//   version: '1.2.3',
//   enabled: 'true',
//   tags: [ 'alpha', 'beta' ]
// }

The output bundle.js will contain:

// ... other esbuild output ...
var my_data_transformer_data = {
  "name": "My Item",
  "version": "1.2.3",
  "enabled": "true",
  "tags": [
    "alpha",
    "beta"
  ]
};
// ... rest of bundle ...

This pattern is incredibly flexible. You can use it for CSS preprocessors (like Sass or Less, though esbuild has built-in support for some now), template engines, custom configuration formats, or even embedding binary assets. The onLoad function can return contents as a string or a Uint8Array, and you can specify any loader esbuild supports (js, jsx, ts, tsx, css, json, wasm, text, base64, file, dataurl, binary).

The namespace: 'file' in the onLoad filter is important. It tells esbuild that this is a standard file path that should be resolved and loaded. Other namespaces exist for virtual modules or internal esbuild operations.

A common pitfall is forgetting to set the loader in the onLoad return value. If you don’t, esbuild might default to treating your transformed output as raw text or binary, which isn’t what you want if you’re generating JavaScript or CSS.

The next thing you’ll run into is wanting to transform files before they are loaded, or wanting to transform files that aren’t directly imported but are part of the build process, which is what onResolve is for.

Want structured learning?

Take the full Esbuild course →