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:
-
Build
utils:cd packages/utils npm run buildThis creates
dist/index.jscontaining theformatCurrencyfunction. -
Build
ui:cd ../ui npm run buildesbuild sees that
@monorepo/utilsisexternal. It doesn’t bundleformatCurrencyintoui’s output. Instead,dist/index.jsinuiwill importformatCurrencyfrom@monorepo/utils. -
Build
app:cd ../../apps/app npm run buildSimilarly,
@monorepo/uiis markedexternal. Theapp’sdist/index.jswill importrenderButtonfrom@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 animportstatement 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:*inpackage.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.