Bun provides a universal plugin API that works in both the bundler (Bun.build()) and the runtime (via preload in bunfig.toml). Plugins intercept imports and perform custom loading logic — reading files, transforming code, resolving modules to virtual paths, and more.
Creating a plugin
A plugin is a plain object with a name string and a setup function:
import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "my-plugin",
setup(build) {
// register lifecycle hooks here
},
};
Pass it to Bun.build():
await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./out",
plugins: [myPlugin],
});
SVG plugin example
A complete plugin that imports SVG files as string exports:
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: `export default ${JSON.stringify(svg)}`,
loader: "js",
};
});
},
};
await Bun.build({
entrypoints: ["./index.ts"],
outdir: "./out",
plugins: [svgPlugin],
});
Lifecycle hooks
onStart
Called once when the bundler starts a new bundle. Useful for setup, logging, or initializing shared state.
build.onStart(() => {
console.log("Build started!");
});
onStart callbacks can be async. The bundler waits for all onStart callbacks to resolve before continuing.
onStart callbacks cannot modify build.config. Mutate the config directly inside setup() if needed.
onResolve
Intercepts module resolution. Lets you redirect imports to different paths or virtual namespaces.
build.onResolve(
{ filter: /.*/, namespace: "file" },
(args) => {
if (args.path.startsWith("images/")) {
return {
path: args.path.replace("images/", "./public/images/"),
};
}
}
);
The callback receives { path, importer } and can return { path, namespace? } to override resolution, or undefined to let other resolvers handle it.
onLoad
Intercepts module loading. Lets you replace or transform a file’s contents before it is parsed.
build.onLoad({ filter: /env/, namespace: "file" }, (args) => {
return {
contents: `export default ${JSON.stringify(process.env)}`,
loader: "js",
};
});
The callback receives { path, importer, namespace, kind } and can return:
| Field | Type | Description |
|---|
contents | string | Replacement source code |
loader | Loader | How to interpret the returned contents |
exports | Record<string, any> | Directly export values (skips parsing) |
Available loaders: js, jsx, ts, tsx, json, jsonc, toml, yaml, file, napi, wasm, text, css, html
onEnd
Called after the bundle is complete. Receives the full BuildOutput object.
build.onEnd((result) => {
console.log(`Build completed: ${result.outputs.length} output files`);
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
}
});
onEnd callbacks can be async. The promise returned by Bun.build() does not resolve until all onEnd callbacks have completed — useful for post-build tasks like uploading artifacts:
build.onEnd(async (result) => {
if (!result.success) return;
for (const output of result.outputs) {
await uploadToS3(output);
}
});
onBeforeParse (native plugins only)
A native plugin hook that runs on any thread before a file is parsed. Only available to NAPI modules. See native plugins below.
Namespaces
Every module has a namespace. Common namespaces:
"file" — files on disk (default)
"bun" — Bun built-in modules (bun:test, bun:sqlite)
"node" — Node.js built-in modules (node:fs, node:path)
Use namespaces to create virtual modules:
build.onResolve({ filter: /^virtual:/, namespace: "file" }, (args) => {
return {
path: args.path.slice("virtual:".length),
namespace: "virtual",
};
});
build.onLoad({ filter: /.*/, namespace: "virtual" }, (args) => {
return {
contents: `export const name = ${JSON.stringify(args.path)}`,
loader: "js",
};
});
.defer() in onLoad
The defer argument in onLoad returns a Promise that resolves when all other modules have been loaded. This lets you produce module contents that depend on the full import graph.
const transpiler = new Bun.Transpiler();
const importCounts: Record<string, number> = {};
build.onLoad({ filter: /\.ts/ }, async ({ path }) => {
const imports = transpiler.scanImports(await Bun.file(path).arrayBuffer());
for (const i of imports) {
importCounts[i.path] = (importCounts[i.path] || 0) + 1;
}
return undefined; // let Bun load the file normally
});
build.onLoad({ filter: /stats\.json/ }, async ({ defer }) => {
await defer(); // wait for all other files to be loaded first
return {
contents: `export default ${JSON.stringify(importCounts)}`,
loader: "json",
};
});
.defer() can only be called once per onLoad callback.
Native plugins
JavaScript plugins are single-threaded. Native plugins are NAPI modules that run on multiple threads alongside Bun’s parser, offering significantly better performance.
Native plugins implement the onBeforeParse lifecycle hook, which is called before a file is parsed.
Creating a native plugin in Rust
bun add -g @napi-rs/cli
napi new
cargo add bun-native-plugin
use bun_native_plugin::{define_bun_plugin, OnBeforeParse, bun, Result, BunLoader};
use napi_derive::napi;
define_bun_plugin!("replace-foo-with-bar");
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
let source = handle.input_source_code()?;
let output = source.replace("foo", "bar");
handle.set_output_source_code(output, BunLoader::BUN_LOADER_JSX);
Ok(())
}
import myNativeAddon from "./my-native-addon";
Bun.build({
entrypoints: ["./app.tsx"],
plugins: [
{
name: "replace-foo-with-bar",
setup(build) {
build.onBeforeParse(
{ namespace: "file", filter: "**/*.tsx" },
{ napiModule: myNativeAddon, symbol: "replace_foo_with_bar" }
);
},
},
],
});
Runtime plugins
Plugins can also be used at runtime (outside the bundler) to intercept import and require calls in Bun’s module loader:
import { plugin } from "bun";
plugin({
name: "yaml-loader",
setup(build) {
build.onLoad({ filter: /\.yaml$/ }, async (args) => {
const text = await Bun.file(args.path).text();
// parse and return as JS module
return {
contents: `export default ${JSON.stringify(parseYAML(text))}`,
loader: "js",
};
});
},
});
Register it as a preload in bunfig.toml:
preload = ["./preload.ts"]
Or pass it via CLI:
bun --preload ./preload.ts run index.ts
Runtime plugins registered with plugin() are active for the current Bun process only. Bundler plugins registered via Bun.build() run only during bundling.
Plugin type reference
type BunPlugin = {
name: string;
setup(build: PluginBuilder): void | Promise<void>;
};
type PluginBuilder = {
config: BuildConfig;
onStart(callback: () => void | Promise<void>): void;
onEnd(callback: (result: BuildOutput) => void | Promise<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 },
callback: (args: {
path: string;
importer: string;
namespace: string;
kind: ImportKind;
defer: () => Promise<void>;
}) => {
loader?: Loader;
contents?: string;
exports?: Record<string, any>;
} | undefined | void
): void;
};