Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting
Quick Answer
How to fix bun build errors — target (browser/bun/node) mismatch, format esm/cjs/iife, externals not respected, Bun macros at compile time, splitting and chunks, plugin API, and Bun.build vs CLI.
The Error
You run bun build and the output uses Node APIs:
$ bun build src/index.ts --outdir dist
# In dist/index.js:
const fs = require("fs");
const path = require("path");
# But you want a browser bundle!Or external packages get bundled anyway:
$ bun build src/index.ts --outdir dist --external react
# dist/index.js still includes react in 200 KB outputOr your TypeScript paths don’t resolve:
import { db } from "@/lib/db";error: Could not resolve "@/lib/db"Or Bun macros don’t run at compile time:
import { greeting } from "./greeting.ts" with { type: "macro" };
const msg = greeting(); // Should be inlined at build, but runs at runtime.Why This Happens
bun build is a fast bundler — it can replace esbuild/Rollup for many projects. It diverges from those bundlers in several ways:
- Target affects API availability.
--target=browserremoves Node APIs from the bundle.--target=bunincludes Bun-specific globals.--target=nodetargets Node’s stdlib. - Format defaults to esm. For CJS or IIFE outputs you need
--format=cjsoriife. Some Node tools expect CJS; some browsers (older inline scripts) want IIFE. - Externals follow Bun’s resolver.
--externalworks with package names but interacts withpeer dependenciesand the project’spackage.jsonexports. Misalignment causes weird inclusion. - Macros run at compile time with
with { type: "macro" }. Without that import attribute, the import is just a regular ESM import (runs at runtime).
Fix 1: Choose the Right Target
# Browser bundle (no Node globals):
bun build src/index.ts --target=browser --outdir dist
# Node-compatible (CJS or ESM, can use Node APIs):
bun build src/index.ts --target=node --outdir dist
# Bun runtime (uses Bun.* globals):
bun build src/index.ts --target=bun --outdir dist--target=browser:
process.envis replaced with literal values at build time (orundefinedfor missing keys).- Node imports (
fs,path,net) error at build unless you provide a polyfill. Bufferis not available unless polyfilled.
--target=node:
- Node APIs preserved.
- Output uses Node’s module system (CJS by default unless
--format=esm).
--target=bun:
- Bun-specific APIs (
Bun.file,Bun.serve,Bun.write) are preserved. - Bun-specific imports (
bun:test,bun:sqlite) work. - Default for projects that ship Bun-only code.
For a single-page app:
bun build src/main.tsx \
--target=browser \
--outdir dist \
--format=esm \
--splitting \
--minifyPro Tip: For dual-target libraries (both Bun and Node), build twice with different targets and ship both via package.json exports:
{
"exports": {
".": {
"bun": "./dist/bun.js",
"node": "./dist/node.js",
"default": "./dist/node.js"
}
}
}Fix 2: Format — ESM, CJS, IIFE
# ESM (default — modern):
bun build ... --format=esm
# CommonJS (for Node packages or legacy):
bun build ... --format=cjs
# IIFE (single self-contained script, like for <script> tags):
bun build ... --format=iifeFor libraries, ship both ESM and CJS:
bun build src/index.ts --target=node --format=esm --outfile dist/index.mjs
bun build src/index.ts --target=node --format=cjs --outfile dist/index.cjsIn package.json:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}For iife outputs that pollute the global namespace (e.g. a UMD-style bundle):
bun build src/index.ts --format=iife --target=browser --outfile dist/lib.js --global-name=MyLib--global-name=MyLib defines window.MyLib in the IIFE.
Common Mistake: Using --format=cjs with --target=browser. CJS doesn’t work in browsers; you’ll get require is not defined at runtime. Browser bundles should be ESM (modern) or IIFE (legacy).
Fix 3: Externals
--external keeps the listed packages out of the bundle, expecting the runtime to provide them:
bun build src/index.ts \
--target=node \
--external react \
--external react-dom \
--outdir distAfter build, your bundle does require("react") (CJS) or import "react" (ESM) instead of inlining React.
For all dependencies (typical for Node libraries):
bun build src/index.ts --target=node --packages=external --outfile dist/index.js--packages=external treats all package.json dependencies as external — only your own code gets bundled.
For Bun runtime that auto-resolves at runtime:
bun build src/server.ts --target=bun --packages=external --outfile dist/server.jsThis is the right pattern for shipping a Bun-runnable single file.
Common Mistake: Listing --external for a package not actually imported. The flag is a no-op then but doesn’t error — silent gotcha if you mistype the name.
To verify what got bundled:
bun build src/index.ts --outdir dist --minify=false --sourcemap
# Inspect dist/index.js — search for module names.Fix 4: TypeScript Paths
Bun reads tsconfig.json paths natively:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}import { db } from "@/lib/db"; // Resolves to src/lib/dbbun build, bun run, and bun test all honor this. No separate tsconfig-paths setup needed.
If paths don’t resolve:
- Check the
tsconfig.jsonis at the project root (or pass--tsconfig=path/to/tsconfig.json). - Verify
baseUrlis set — without it, paths are relative to the tsconfig’s directory by default. - Run
bun run --print "import.meta.dir"to confirm Bun is reading the right config.
For monorepos:
bun build apps/web/src/index.ts \
--tsconfig=apps/web/tsconfig.json \
--target=browser \
--outdir apps/web/dist--tsconfig lets you override Bun’s auto-discovery for non-standard layouts.
Fix 5: Code Splitting
bun build src/main.ts \
--target=browser \
--outdir dist \
--splitting \
--format=esm--splitting enables code splitting. Bun creates chunks for shared code between dynamic imports:
// src/main.ts
const { heavyFn } = await import("./heavy");dist/main.js # Small — just the entry
dist/heavy.js # Loaded on demandFor multiple entry points:
bun build src/index.ts src/admin.ts \
--target=browser \
--outdir dist \
--splittingShared modules go into chunks; each entry stays small.
Common Mistake: Splitting works only for ESM output. With --format=iife or --format=cjs, splitting is disabled — everything bundles into one file.
For controlling chunk names:
bun build src/index.ts \
--outdir dist \
--splitting \
--entry-naming "[dir]/[name].[ext]" \
--chunk-naming "chunks/[name]-[hash].[ext]"Hashed chunk names enable long-term browser caching.
Fix 6: Bun Macros
Bun macros execute at build time, inlining their return values into the output:
// greeting.ts (run at build time)
export function greeting() {
return `Built at ${new Date().toISOString()}`;
}// main.ts
import { greeting } from "./greeting.ts" with { type: "macro" };
const msg = greeting(); // Inlined at build: const msg = "Built at 2026-05-20T...";
console.log(msg);The with { type: "macro" } import attribute is critical. Without it, the import is regular ESM and greeting() runs at runtime.
Bun macros can do anything pure (read files, compute config, query DBs at build time). Be careful with side effects — they happen once per build.
// dataset.ts (a macro that reads a CSV at build time)
import { readFile } from "fs/promises";
export async function loadData() {
const csv = await readFile("./data.csv", "utf-8");
return csv.split("\n").map(row => row.split(","));
}import { loadData } from "./dataset.ts" with { type: "macro" };
const data = await loadData(); // Inlined as the parsed CSV at build time.The CSV is read once at build, not at runtime. Smaller production bundle, no runtime file I/O.
Pro Tip: Use macros for config baked into your bundle (build-time secrets, feature flag defaults, etc.). Don’t use them for anything that should change between requests.
Fix 7: Bun.build API (Programmatic)
For complex build orchestration, use Bun.build:
import { build } from "bun";
const result = await build({
entrypoints: ["src/index.ts"],
outdir: "dist",
target: "browser",
format: "esm",
splitting: true,
sourcemap: "external",
minify: {
whitespace: true,
identifiers: true,
syntax: true,
},
define: {
"process.env.NODE_ENV": '"production"',
__VERSION__: '"1.2.3"',
},
external: ["react"],
plugins: [
{
name: "stripped-types",
setup(build) {
build.onResolve({ filter: /\.types\.ts$/ }, () => {
return { external: true };
});
},
},
],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
console.log("Built", result.outputs.length, "files");define lets you replace identifiers with literal values at build time (similar to esbuild’s define).
plugins accept esbuild-compatible plugins, though some hooks are Bun-specific.
For watch mode:
const builder = await build({ ..., watch: true });
// Re-runs on file changes.Common Mistake: Mixing bun build CLI flags with Bun.build() options. They have similar shapes but the JS API uses different naming (minify: { whitespace: true } vs --minify).
Fix 8: HTML Imports
Bun supports HTML as an entry point:
bun build index.html --outdir distindex.html:
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>Bun walks the <script> tags, bundles their entries, and rewrites the HTML to point at the bundled output. Similar to esbuild’s HTML entry support.
For CSS:
<link rel="stylesheet" href="./src/styles.css" />Bun processes CSS imports and includes them in the output.
Pro Tip: This is Bun’s simplest “static site” workflow. For React apps with hot-reload, use bun --hot or a more complete framework integration (Bun + Hono, Bun + Elysia, etc.).
Still Not Working?
A few less-obvious failures:
- Output uses outdated Bun API. Update Bun:
bun upgrade. The bundler is rapidly evolving. Cannot find module 'X'. Bun’s resolver respectspackage.jsonexports but some packages have buggy exports. Try--external Xto skip bundling that module, or report to the package’s repo.- Source maps don’t work in DevTools. Use
--sourcemap=external(separate.mapfile) and ensure the dev server serves both. Inline maps work too but bloat the bundle. - Macro produces unexpected output. Macros run in Bun, not Node. APIs that work in Node but not Bun (rare but possible) fail silently. Check with
console.logduring the macro. - Splitting produces too many chunks. Tune
--splittinggranularity isn’t directly exposed; restructure your code to share more or less between dynamic imports. process.env.NODE_ENVnot replaced. Pass--defineexplicitly:--define process.env.NODE_ENV='"production"'.- Sourcemap absolute paths leak. Bun’s source maps may include workspace paths. Use
--sourcemap=external --root=/abs/pathto control. - Tree-shaking misses unused exports. Mark side-effect-free files:
package.json "sideEffects": false. Bun respects this.
For related Bun and bundling issues, see Bun not working, Bun test not working, Bun shell not working, and Webpack bundle size too large.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts
How to fix Bun Shell errors — $ template auto-escape vs raw strings, piping with pipe() vs |, throws on non-zero exit, cwd/env scoping, glob expansion differences, and Windows path handling.
Fix: Nx Not Working — project.json Targets, Affected Commands, Caching, and Generators
How to fix Nx errors — nx.json plugin config, project.json target inputs/outputs, nx affected base branch, cache misses, generator schema, custom executors, and nx migrate failures.
Fix: Bun Test Not Working — Module Mocking, DOM Setup, Coverage, and Watch Mode
How to fix Bun test runner issues — mock.module not isolating, happy-dom setup for DOM tests, --coverage missing files, timer mocks, snapshot updates, TypeScript path aliases, and preload files.
Fix: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration
How to fix ESLint flat config errors — eslint.config.js not found, .eslintrc.json ignored after upgrade, ignores replacing .eslintignore, plugin object form, typescript-eslint integration, monorepo configs, and ESLINT_USE_FLAT_CONFIG.