Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting
Part of: JavaScript & TypeScript Errors
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).
A second source of confusion is that bun build is not the same tool as bun run or bun dev. The runtime (bun run) handles TypeScript, JSX, and .env files automatically at execution time without bundling. The bundler (bun build) produces output files for shipping. Many users assume bun run src/index.ts is somehow “building” the file — it isn’t, it’s interpreting it. Configuration that works at runtime (path aliases via tsconfig.json, automatic .env loading) sometimes needs explicit flags at build time. If “bun works locally but the built bundle breaks,” the gap between those two pipelines is the first thing to check.
A third source is the bundler’s maturity. As of mid-2026, bun build is fast and ergonomic but younger than esbuild or Rollup. Edge-case handling around CSS imports, dynamic import() rewriting, and tree-shaking of complex packages has caught up but isn’t always pixel-equivalent. Pin Bun in CI and re-run failed builds with --verbose to see resolution traces.
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.).
Bun Bundler vs esbuild vs Vite vs Rollup vs Rspack vs Turbopack: How Do They Compare?
bun build lives in a crowded space. Each bundler optimizes for a different point on the speed/feature/ecosystem triangle, and the right pick usually depends more on your framework than on raw benchmarks.
Bun bundler is a Zig-implemented bundler shipped as part of the Bun runtime. It targets browser, Node, and Bun. Performance is in the same ballpark as esbuild for cold builds. The unique features are macros (build-time code execution), HTML/CSS entry points, and tight integration with bun run. The tradeoff: it’s a young tool with a smaller plugin ecosystem and rare edge-case bugs that esbuild has long since fixed.
esbuild is the original “fast bundler” written in Go. Still the gold standard for performance, battle-tested at scale. Vite uses esbuild for dep pre-bundling; many CLIs use it under the hood. No dev server, no HMR — pick esbuild when you want a small, stable, fast bundler and you’ll wire up the rest yourself.
Vite is a dev server plus build pipeline. In dev: native ESM with esbuild pre-bundling. In prod: Rollup. Default for Vue, Svelte, Solid, and most non-Next React projects. Pick Vite for app development; esbuild or Bun for library bundling.
Rollup is the godfather of ESM-aware bundlers. Produces the cleanest output for library publishing (small, tree-shakable, dual-format) with the most mature plugin ecosystem. Slower than esbuild and Bun on raw speed, but most published JS libraries are still Rollup-bundled.
Rspack is a Rust port of webpack by ByteDance. It targets webpack’s plugin API so existing configs port over, but builds 5-10x faster. The right migration target for teams stuck on webpack 5 who want speed without a rewrite.
Turbopack is Vercel’s Rust-based webpack successor, used by Next.js. Outside Next.js, it’s not a general-purpose CLI tool the way Bun, esbuild, and Vite are.
If you ship a Bun app: bun build. If you ship an npm library: Rollup or esbuild. If you build a React/Vue/Svelte app: Vite. If you’re stuck on webpack: Rspack. If you’re on Next.js: Turbopack. The speed gap is usually small in absolute terms — ecosystem fit matters more than benchmarks.
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. bun buildoutput works in dev but breaks in production CDN. Production minification dropped aFunction.nameyour runtime depends on (common with class-based DI containers). Pass--minify-identifiers=falseor use theBun.buildAPI withminify: { whitespace: true, syntax: true, identifiers: false }.- Dynamic imports load a chunk that 404s in production. The deployment platform served the entry HTML but not the chunked JS — usually a build artifact gitignore problem or a static-host config that only allows
index.html. Verify thedist/chunks/directory is uploaded and that the static host serves arbitrary.jsfiles from subdirectories. - Built bundle is larger than esbuild’s equivalent. Bun and esbuild handle some packages differently — Bun’s tree-shaker is sometimes more conservative around re-exports. Try
--externalfor heavy dependencies you can load separately, or run both bundlers and comparedistsizes; the gap is shrinking but not always zero.
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: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.