Skip to content

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

FixDevs ·

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).

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.).

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.

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