Bun provides a universal plugin API that works in both the runtime and the bundler. Plugins intercept imports and perform custom loading logic: reading files, transpiling code, transforming content, and more.
Use plugins to:
- Add support for file types Bun doesn’t handle natively (
.scss, .yaml, .svg, etc.)
- Implement custom module resolution rules
- Inject code or environment data as virtual modules
- Apply framework-level transforms like CSS extraction or macros
Creating a plugin
A plugin is a plain object with a name string and a setup function. The setup function receives a build object with lifecycle hook registration methods.
import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "my-plugin",
setup(build) {
// register lifecycle hooks here
},
};
Using a plugin at runtime
To apply a plugin when running code with bun run, use Bun.plugin() in a preload file and register that file in bunfig.toml.
Define the plugin
import { plugin } from "bun";
plugin({
name: "text-loader",
setup(build) {
build.onLoad({ filter: /\.txt$/ }, async (args) => {
const text = await Bun.file(args.path).text();
return {
contents: `export default ${JSON.stringify(text)}`,
loader: "js",
};
});
},
});
Register as a preload in bunfig.toml
preload = ["./my-plugin.ts"]
Import the custom file type
import message from "./greeting.txt";
console.log(message); // contents of greeting.txt as a string
Using a plugin in the bundler
Pass the plugin to Bun.build via the plugins array.
import { myPlugin } from "./my-plugin";
await Bun.build({
entrypoints: ["./app.ts"],
outdir: "./dist",
plugins: [myPlugin],
});
Lifecycle hooks
onLoad
onLoad intercepts a module after it has been resolved and lets you replace its contents before Bun parses it.
build.onLoad(
args: { filter: RegExp; namespace?: string },
callback: (args: { path: string; importer: string; namespace: string }) => {
loader?: Loader;
contents?: string;
exports?: Record<string, any>;
}
): void;
The filter regex is matched against the file path. Return contents (a string) and optionally a loader to tell Bun how to interpret the content.
Example: virtual environment module
import { plugin } from "bun";
plugin({
name: "env-plugin",
setup(build) {
build.onLoad({ filter: /^env$/, namespace: "file" }, () => {
return {
contents: `export default ${JSON.stringify(process.env)}`,
loader: "js",
};
});
},
});
// Usage: import env from "env"
// env.NODE_ENV === "production"
Example: load .txt files as strings
import { plugin } from "bun";
plugin({
name: "txt-loader",
setup(build) {
build.onLoad({ filter: /\.txt$/ }, async (args) => {
const text = await Bun.file(args.path).text();
return {
contents: `export default ${JSON.stringify(text)}`,
loader: "js",
};
});
},
});
.defer()
The second argument to onLoad is a defer function. Calling await defer() pauses execution of this callback until all other modules have been loaded. This is useful when a module’s content depends on what other modules export.
plugin({
name: "import-stats",
setup(build) {
const transpiler = new Bun.Transpiler();
const trackedImports: Record<string, number> = {};
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 for every other onLoad to finish
await defer();
return {
contents: `export default ${JSON.stringify(trackedImports)}`,
loader: "json",
};
});
},
});
onResolve
onResolve intercepts module resolution and lets you redirect imports to different paths.
build.onResolve(
args: { filter: RegExp; namespace?: string },
callback: (args: { path: string; importer: string }) => {
path: string;
namespace?: string;
} | void
): void;
Example: redirect image imports
import { plugin } from "bun";
plugin({
name: "image-resolver",
setup(build) {
build.onResolve({ filter: /.*/, namespace: "file" }, (args) => {
if (args.path.startsWith("images/")) {
return {
path: args.path.replace("images/", "./public/images/"),
};
}
});
},
});
onStart
onStart registers a callback that runs once when the bundler starts. It can be async; the bundler waits for all onStart callbacks to resolve before continuing.
import { plugin } from "bun";
plugin({
name: "start-logger",
setup(build) {
build.onStart(async () => {
const now = Date.now();
await Bun.$`echo ${now} > build-time.txt`;
});
},
});
onStart callbacks cannot modify build.config. To mutate the build config, do so directly inside setup() before registering any hooks.
Available loaders
The loader field in an onLoad return value tells Bun how to parse the returned contents.
| Loader | Description |
|---|
js | JavaScript |
jsx | JavaScript with JSX |
ts | TypeScript |
tsx | TypeScript with JSX |
json | JSON — exported as a default object |
jsonc | JSON with comments |
toml | TOML — exported as a default object |
yaml | YAML — exported as a default object |
text | Plain text — exported as a default string |
file | Binary file — exported as a file path |
css | CSS stylesheet |
html | HTML document |
wasm | WebAssembly module |
napi | Native Node-API module |
Namespaces
Every module has a namespace. The default namespace is "file". Namespaces are used to prefix module paths in transpiled code.
import foo from "bun:test" → namespace: "bun", path: "test"
import bar from "node:fs" → namespace: "node", path: "fs"
import baz from "./file.ts" → namespace: "file", path: "./file.ts"
Specifying a custom namespace in onLoad or onResolve scopes the hook to only matching modules:
build.onLoad({ filter: /.*/, namespace: "virtual" }, (args) => {
// only runs for imports like: import x from "virtual:..."
});
Native plugins
JavaScript plugins run on a single thread. For maximum performance, you can write a native plugin as a Node-API (NAPI) module. Native plugins run on multiple threads and avoid UTF-8 to UTF-16 conversion overhead.
onBeforeParse
The onBeforeParse hook is the only lifecycle hook available to native plugins. It runs immediately before a file is parsed, on any thread, and can replace the file’s source code.
import myNativeAddon from "./my-native-addon";
await Bun.build({
entrypoints: ["./app.tsx"],
plugins: [
{
name: "native-transform",
setup(build) {
build.onBeforeParse(
{ namespace: "file", filter: /\.tsx$/ },
{
napiModule: myNativeAddon,
symbol: "replace_foo_with_bar",
},
);
},
},
],
});
The native module exports a C ABI function matching the onBeforeParse signature. Here is an example in Rust using the bun-native-plugin crate:
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 input = handle.input_source_code()?;
let output = input.replace("foo", "bar");
handle.set_output_source_code(output, BunLoader::BUN_LOADER_JSX);
Ok(())
}
Plugin type reference
type BunPlugin = {
name: string;
setup(build: PluginBuilder): void | Promise<void>;
};
type PluginBuilder = {
onStart(callback: () => 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; defer: () => Promise<void> }) =>
{ loader?: Loader; contents?: string; exports?: Record<string, any> } | undefined,
): void;
config: BuildConfig;
};
type Loader =
| "js" | "jsx" | "ts" | "tsx"
| "json" | "jsonc" | "toml" | "yaml"
| "text" | "file" | "css" | "html"
| "wasm" | "napi";