esbuild’s blazing speed is often lauded, but its true superpower in a monorepo lies in its ability to rebuild only what’s necessary, making shared package changes feel almost instantaneous.

Let’s see esbuild in action, building a small monorepo where app depends on ui and utils.

Monorepo Structure:

/monorepo
  /apps
    /app
      src/index.ts
      package.json
      esbuild.config.js
  /packages
    /ui
      src/button.ts
      package.json
      esbuild.config.js
    /utils
      src/format.ts
      package.json
      esbuild.config.js

packages/utils/src/format.ts:

export function formatCurrency(amount: number): string {
  return `$${amount.toFixed(2)}`;
}

packages/utils/package.json:

{
  "name": "@monorepo/utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
  "scripts": {
    "build": "esbuild src/format.ts --bundle --outfile=dist/index.js --platform=node --format=esm"
  },
  "devDependencies": {
    "esbuild": "^0.20.1"
  }
}

packages/ui/src/button.ts:

import { formatCurrency } from '@monorepo/utils';

export function renderButton(label: string, price: number): string {
  return `<button>${label} - ${formatCurrency(price)}</button>`;
}

packages/ui/package.json:

{
  "name": "@monorepo/ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
  "scripts": {
    "build": "esbuild src/button.ts --bundle --outfile=dist/index.js --platform=node --format=esm --external:@monorepo/utils"
  },
  "dependencies": {
    "@monorepo/utils": "workspace:*"
  },
  "devDependencies": {
    "esbuild": "^0.20.1"
  }
}

apps/app/src/index.ts:

import { renderButton } from '@monorepo/ui';

console.log(renderButton('Add to Cart', 19.99));

apps/app/package.json:

{
  "name": "app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "esbuild src/index.ts --bundle --outfile=dist/index.js --platform=node --format=esm --external:@monorepo/ui --external:@monorepo/utils"
  },
  "dependencies": {
    "@monorepo/ui": "workspace:*"
  },
  "devDependencies": {
    "esbuild": "^0.20.1"
  }
}

The Build Process:

  1. Build utils:

    cd packages/utils
    npm run build
    

    This creates dist/index.js containing the formatCurrency function.

  2. Build ui:

    cd ../ui
    npm run build
    

    esbuild sees that @monorepo/utils is external. It doesn’t bundle formatCurrency into ui’s output. Instead, dist/index.js in ui will import formatCurrency from @monorepo/utils.

  3. Build app:

    cd ../../apps/app
    npm run build
    

    Similarly, @monorepo/ui is marked external. The app’s dist/index.js will import renderButton from @monorepo/ui.

The Mental Model:

At its core, esbuild in a monorepo is about dependency resolution and code splitting, but with an emphasis on declarative externalization.

  • --bundle: This flag tells esbuild to include all dependencies in the output file. However, this is often counterproductive in monorepos.
  • --external:<package-name>: This is the key. When you mark a package as external (e.g., @monorepo/utils), esbuild stops trying to bundle its code. Instead, it generates an import statement that assumes the external package will be available at runtime. This is crucial for shared packages because you want one version of that shared code to be resolved by Node.js or your bundler, not duplicated across every consumer.
  • workspace:* in package.json: This is a Yarn/npm/pnpm feature that tells the package manager to link local packages. esbuild doesn’t directly understand this, but it relies on the fact that your package manager will make these linked packages resolvable at runtime.

When you run a build, esbuild analyzes your import and require statements. If a module is marked --external, it generates an import statement for it. If it’s not external and not a built-in Node.js module, it will be bundled.

The power comes from this chain: app depends on ui, and ui depends on utils. By marking utils and ui as external in app, and utils as external in ui, you create a dependency graph where each package only bundles its own code, and relies on the runtime to resolve the shared dependencies.

This means if you change packages/utils/src/format.ts, you only need to rebuild packages/utils and then packages/ui (which will pick up the change) and finally apps/app. esbuild’s speed means even this small rebuild chain is incredibly fast. If app didn’t depend on ui or utils, changing utils would require no rebuilds in app or ui.

The most surprising thing about esbuild in monorepos is how effectively external mirrors the runtime’s module resolution. You’re not telling esbuild how to find the shared package, but rather that it shouldn’t bundle it, trusting that the environment (Node.js with linked workspaces, or a top-level bundler) will handle it. This trust is what unlocks incremental builds.

When you use esbuild.build directly in a config file (e.g., esbuild.config.js), you pass these options as an object:

// esbuild.config.js for @monorepo/ui
import esbuild from 'esbuild';

esbuild.build({
  entryPoints: ['src/button.ts'],
  bundle: true,
  outfile: 'dist/index.js',
  platform: 'node',
  format: 'esm',
  external: ['@monorepo/utils'],
}).catch(() => process.exit(1));

The next logical step is to orchestrate these individual builds efficiently, often using tools like pnpm or nx to trigger builds only when necessary based on file changes.

Want structured learning?

Take the full Esbuild course →