Skip to main content
Bun provides a universal plugin API that can be used to extend both the runtime and bundler. Plugins intercept imports and perform custom loading logic: reading files, transpiling code, etc. They can be used to add support for additional file types, like .scss or .yaml. In the context of Bun’s bundler, plugins can be used to implement framework-level features like CSS extraction, macros, and client-server code co-location.

Lifecycle hooks

Plugins can register callbacks that execute at various stages of the bundling lifecycle:
  • onStart(): Runs once when the bundler starts a build
  • onResolve(): Runs before a module is resolved
  • onLoad(): Runs before a module is loaded
  • onBeforeParse(): Runs zero-copy native plugins in the parser thread, before a file is parsed
  • onEnd(): Runs after bundling completes

Reference

A rough outline of the types (refer to Bun’s bun.d.ts for complete type definitions):
type PluginBuilder = {
  onStart(callback: () => void): void;
  onResolve: (
    args: { filter: RegExp; namespace?: string },
    callback: (args: { path: string; importer: string }) => {
      path: string;
      namespace?: string;
    } | void,
  ) => void;
  onLoad: (
    args: { filter: RegExp; namespace?: string },
    defer: () => Promise<void>,
    callback: (args: { path: string }) => {
      loader?: Loader;
      contents?: string;
      exports?: Record<string, any>;
    },
  ) => void;
  onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;
  config: BuildConfig;
};

type Loader =
  | "js"
  | "jsx"
  | "ts"
  | "tsx"
  | "json"
  | "jsonc"
  | "toml"
  | "yaml"
  | "file"
  | "napi"
  | "wasm"
  | "text"
  | "css"
  | "html";

Usage

Plugins are defined as simple JavaScript objects containing a name property and a setup function.
import type { BunPlugin } from "bun";

const myPlugin: BunPlugin = {
  name: "Custom loader",
  setup(build) {
    // implementation
  },
};
This plugin can be passed to Bun.build in the plugins array.
await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./out",
  plugins: [myPlugin],
});

Plugin lifecycle

Namespaces

onLoad and onResolve accept an optional namespace string. What is a namespace? Each module has a namespace. Namespaces are used to prefix import paths in transpiled code; for instance, a loader with filter: /\.yaml$/ and namespace: "yaml:" would convert an import ./myfile.yaml to yaml:./myfile.yaml. The default namespace is "file", which usually doesn’t need to be explicitly specified, e.g.: import myModule from "./my-module.ts" is equivalent to import myModule from "file:./my-module.ts". Other common namespaces:
  • "bun": Bun-specific modules (e.g. "bun:test", "bun:sqlite")
  • "node": Node.js modules (e.g. "node:fs", "node:path")

onStart

onStart(callback: () => void): Promise<void> | void;
Register a callback to be executed when the bundler starts a new build.
import { plugin } from "bun";

plugin({
  name: "onStart example",

  setup(build) {
    build.onStart(() => {
      console.log("Bundle started!");
    });
  },
});
The callback can return a Promise. After the bundler initializes, it will wait for all onStart() callbacks to complete before proceeding. For example:
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  sourcemap: "external",
  plugins: [
    {
      name: "Sleep for 10 seconds",
      setup(build) {
        build.onStart(async () => {
          await Bun.sleep(10_000);
        });
      },
    },
    {
      name: "Log bundle time to a file",
      setup(build) {
        build.onStart(async () => {
          const now = Date.now();
          await Bun.$`echo ${now} > bundle-time.txt`;
        });
      },
    },
  ],
});
In the example above, Bun will wait for both the first onStart() (sleep 10 seconds) and the second onStart() (write bundle time to file) to complete before continuing.
onStart() callbacks (and all lifecycle callbacks) cannot modify the build.config object. To modify build.config, you must do so directly in the setup() function.

onResolve

onResolve(
  args: { filter: RegExp; namespace?: string },
  callback: (args: { path: string; importer: string }) => {
    path: string;
    namespace?: string;
  } | void,
): void;
To bundle a project, Bun traverses the dependency tree of all modules. For each imported module, Bun needs to find and read the module. This process of “finding” a module is called “resolution”. The onResolve() plugin lifecycle callback lets you customize the module resolution logic. The first argument to onResolve() is an object with a filter and optional namespace property. The filter is a regular expression used to match against import strings; in practice, it’s used to filter which modules should use the custom resolution logic. The second argument is a callback function that will be called for each imported module that matches the filter and namespace from the first argument. The callback receives the matched module path as input, and can return a new path for the module. Bun will read the contents of the new path and parse it as a module. For example, to redirect all imports of images/ to ./public/images/:
import { plugin } from "bun";

plugin({
  name: "onResolve example",
  setup(build) {
    build.onResolve({ filter: /.*/, namespace: "file" }, args => {
      if (args.path.startsWith("images/")) {
        return {
          path: args.path.replace("images/", "./public/images/"),
        };
      }
    });
  },
});

onLoad

onLoad(
  args: { filter: RegExp; namespace?: string },
  defer: () => Promise<void>,
  callback: (args: { path: string, importer: string, namespace: string, kind: ImportKind }) => {
    loader?: Loader;
    contents?: string;
    exports?: Record<string, any>;
  },
): void;
After the Bun bundler resolves a module, it needs to read the module contents and parse them. The onLoad() plugin lifecycle callback lets you modify the contents of a resolved module before Bun reads and parses them. Like onResolve(), the first argument to onLoad() is used to filter which modules this call should apply to. The second argument is a callback that will be called before the contents of each matched module are loaded. The callback receives the path of the matched module, the module’s importer, the module namespace, and the module kind. The callback can return a new contents string and a new loader for the module. For example:
import { plugin } from "bun";

const envPlugin: BunPlugin = {
  name: "env plugin",
  setup(build) {
    build.onLoad({ filter: /env/, namespace: "file" }, args => {
      return {
        contents: `export default ${JSON.stringify(process.env)}`,
        loader: "js",
      };
    });
  },
};

Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  plugins: [envPlugin],
});

// import env from "env"
// env.FOO === "bar"
This plugin will convert all imports of the form import env from "env" into a JavaScript module that exports the current environment variables.

.defer()

One of the arguments passed to the onLoad callback is a defer function. This function returns a Promise that only resolves after all other modules have finished loading. This allows you to defer the execution of the onLoad callback until all other modules are loaded. This is useful for returning module contents that depend on the contents of other modules.
import { plugin } from "bun";

plugin({
  name: "track imports",
  setup(build) {
    const transpiler = new Bun.Transpiler();

    let trackedImports: Record<string, number> = {};

    // For each module that goes through this onLoad callback,
    // record its imports into trackedImports
    build.onLoad({ filter: /\.ts/ }, async ({ path }) => {
      const contents = await Bun.file(path).arrayBuffer();

      const imports = transpiler.scanImports(contents);

      for (const i of imports) {
        trackedImports[i.path] = (trackedImports[i.path] || 0) + 1;
      }

      return undefined;
    });

    build.onLoad({ filter: /stats\.json/ }, async ({ defer }) => {
      // Wait until all files have been loaded, ensuring
      // the above onLoad callback has executed for each file and imports are tracked
      await defer();

      // Output a JSON with statistics for each import
      return {
        contents: `export default ${JSON.stringify(trackedImports)}`,
        loader: "json",
      };
    });
  },
});

onEnd

onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;
Register a callback to be executed after bundling completes.
import { plugin } from "bun";

plugin({
  name: "onEnd example",
  setup(build) {
    build.onEnd((result) => {
      console.log(`Build ${result.success ? "succeeded" : "failed"}`);
      console.log(`${result.outputs.length} files generated`);
    });
  },
});
The callback receives the build result, including outputs and logs.

Examples

YAML loader

Here’s a simple plugin that adds support for .yaml files:
import type { BunPlugin } from "bun";
import { parse } from "yaml";

const yamlPlugin: BunPlugin = {
  name: "YAML loader",
  setup(build) {
    build.onLoad({ filter: /\.ya?ml$/ }, async (args) => {
      const text = await Bun.file(args.path).text();
      const data = parse(text);
      
      return {
        contents: `export default ${JSON.stringify(data)}`,
        loader: "json",
      };
    });
  },
};

await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./out",
  plugins: [yamlPlugin],
});

Inline SVG as React components

import type { BunPlugin } from "bun";

const svgPlugin: BunPlugin = {
  name: "SVG loader",
  setup(build) {
    build.onLoad({ filter: /\.svg$/ }, async (args) => {
      const svg = await Bun.file(args.path).text();
      
      return {
        contents: `
          import React from 'react';
          export default function SVG(props) {
            return (
              <svg {...props}>${svg}</svg>
            );
          }
        `,
        loader: "tsx",
      };
    });
  },
};

Environment variable injection

import type { BunPlugin } from "bun";

const envPlugin: BunPlugin = {
  name: "Environment variables",
  setup(build) {
    build.onResolve({ filter: /^env$/ }, () => {
      return { path: "env", namespace: "env" };
    });

    build.onLoad({ filter: /.*/, namespace: "env" }, () => {
      return {
        contents: `export default ${JSON.stringify(process.env)}`,
        loader: "json",
      };
    });
  },
};

// Usage:
// import env from "env";
// console.log(env.NODE_ENV);

Build-time code generation

import type { BunPlugin } from "bun";

const buildInfoPlugin: BunPlugin = {
  name: "Build info",
  setup(build) {
    build.onResolve({ filter: /^build-info$/ }, () => {
      return { path: "build-info", namespace: "build-info" };
    });

    build.onLoad({ filter: /.*/, namespace: "build-info" }, () => {
      return {
        contents: `
          export const buildTime = ${Date.now()};
          export const buildId = "${crypto.randomUUID()}";
          export const version = "${process.env.npm_package_version || "unknown"}";
        `,
        loader: "js",
      };
    });
  },
};

// Usage:
// import { buildTime, buildId, version } from "build-info";
// console.log(`Built at ${new Date(buildTime)}`);

Plugin ordering

Plugins are executed in the order they are defined in the plugins array:
await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./out",
  plugins: [
    firstPlugin,  // executes first
    secondPlugin, // executes second
    thirdPlugin,  // executes third
  ],
});
If multiple plugins register callbacks for the same lifecycle hook and filter:
  • For onResolve: The first plugin that returns a value wins
  • For onLoad: The first plugin that returns a value wins
  • For onStart and onEnd: All callbacks execute

Best practices

Use specific filters

Use precise regex patterns to avoid unnecessary callback invocations:
// Good: specific pattern
build.onLoad({ filter: /\.scss$/ }, callback);

// Bad: too broad
build.onLoad({ filter: /.*/ }, callback);

Cache expensive operations

Cache results when possible to avoid redundant work:
const cache = new Map();

build.onLoad({ filter: /\.md$/ }, async (args) => {
  if (cache.has(args.path)) {
    return cache.get(args.path);
  }
  
  const result = await expensiveTransform(args.path);
  cache.set(args.path, result);
  return result;
});

Return early

Return undefined or void from callbacks when no transformation is needed:
build.onLoad({ filter: /.*/ }, (args) => {
  if (!shouldTransform(args.path)) {
    return; // Let other plugins or default loader handle it
  }
  
  return { contents: transform(args.path), loader: "js" };
});

Handle errors gracefully

Catch and log errors instead of letting them crash the build:
build.onLoad({ filter: /\.custom$/ }, async (args) => {
  try {
    return await transform(args.path);
  } catch (error) {
    console.error(`Failed to transform ${args.path}:`, error);
    throw error; // Re-throw to fail the build
  }
});

Build docs developers (and LLMs) love