Skip to content

Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting

FixDevs · (Updated: )

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 output

Or 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=browser removes Node APIs from the bundle. --target=bun includes Bun-specific globals. --target=node targets Node’s stdlib.
  • Format defaults to esm. For CJS or IIFE outputs you need --format=cjs or iife. Some Node tools expect CJS; some browsers (older inline scripts) want IIFE.
  • Externals follow Bun’s resolver. --external works with package names but interacts with peer dependencies and the project’s package.json exports. 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.env is replaced with literal values at build time (or undefined for missing keys).
  • Node imports (fs, path, net) error at build unless you provide a polyfill.
  • Buffer is 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 \
  --minify

Pro 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=iife

For 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.cjs

In 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 dist

After 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.js

This 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/db

bun build, bun run, and bun test all honor this. No separate tsconfig-paths setup needed.

If paths don’t resolve:

  • Check the tsconfig.json is at the project root (or pass --tsconfig=path/to/tsconfig.json).
  • Verify baseUrl is 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 demand

For multiple entry points:

bun build src/index.ts src/admin.ts \
  --target=browser \
  --outdir dist \
  --splitting

Shared 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 dist

index.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 respects package.json exports but some packages have buggy exports. Try --external X to skip bundling that module, or report to the package’s repo.
  • Source maps don’t work in DevTools. Use --sourcemap=external (separate .map file) 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.log during the macro.
  • Splitting produces too many chunks. Tune --splitting granularity isn’t directly exposed; restructure your code to share more or less between dynamic imports.
  • process.env.NODE_ENV not replaced. Pass --define explicitly: --define process.env.NODE_ENV='"production"'.
  • Sourcemap absolute paths leak. Bun’s source maps may include workspace paths. Use --sourcemap=external --root=/abs/path to control.
  • Tree-shaking misses unused exports. Mark side-effect-free files: package.json "sideEffects": false. Bun respects this.
  • bun build output works in dev but breaks in production CDN. Production minification dropped a Function.name your runtime depends on (common with class-based DI containers). Pass --minify-identifiers=false or use the Bun.build API with minify: { 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 the dist/chunks/ directory is uploaded and that the static host serves arbitrary .js files 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 --external for heavy dependencies you can load separately, or run both bundlers and compare dist sizes; 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles