esbuild Complete Guide | Fastest JavaScript Bundler
이 글의 핵심
esbuild bundles JavaScript and TypeScript 10–100x faster than webpack or Rollup by writing the bundler in Go instead of JavaScript. This guide covers the CLI, Node.js API, plugins, and production patterns.
Why esbuild?
esbuild is a JavaScript/TypeScript bundler written in Go. It’s fast because it:
- Uses native code (Go compiles to machine code)
- Runs transformations in parallel
- Uses memory efficiently (no intermediate AST copies)
Benchmark (three.js library, from scratch):
| Tool | Time |
|---|---|
| esbuild | 0.10s |
| Rollup + terser | 22.9s |
| webpack | 41.6s |
Installation
# npm
npm install --save-dev esbuild
# Check version
npx esbuild --version
1. CLI — Quick Start
# Bundle a single file
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js
# With source maps and minification
npx esbuild src/index.ts \
--bundle \
--minify \
--sourcemap \
--target=chrome90 \
--outfile=dist/bundle.js
# Watch mode
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js --watch
# Multiple entry points
npx esbuild src/main.ts src/worker.ts --bundle --outdir=dist/
# Platform targets
npx esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
# Format options: iife, cjs, esm
npx esbuild src/lib.ts --bundle --format=esm --outfile=dist/lib.mjs
2. Node.js API
The API is more flexible than the CLI for complex builds.
Basic build
// build.mjs
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
minify: true,
sourcemap: true,
target: ['chrome90', 'firefox90', 'safari15'],
outfile: 'dist/bundle.js',
})
console.log('Build complete')
Multiple entry points with code splitting
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: {
main: 'src/main.ts',
worker: 'src/worker.ts',
'vendor/react': 'react', // extract to separate chunk
},
bundle: true,
splitting: true, // enable code splitting (ESM only)
format: 'esm',
outdir: 'dist',
chunkNames: 'chunks/[name]-[hash]',
})
Production build for Node.js CLI
import * as esbuild from 'esbuild'
import { execSync } from 'child_process'
await esbuild.build({
entryPoints: ['src/cli.ts'],
bundle: true,
platform: 'node',
target: 'node20',
format: 'cjs',
outfile: 'dist/cli.js',
external: [
// Don't bundle these — they'll be installed as dependencies
'fsevents',
],
banner: {
js: '#!/usr/bin/env node', // shebang for CLI
},
minify: true,
sourcemap: true,
})
3. Watch Mode + Dev Server
import * as esbuild from 'esbuild'
import { createServer } from 'http'
import { readFile } from 'fs/promises'
// Watch mode — rebuild on change
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
sourcemap: true,
outdir: 'dist',
})
await ctx.watch()
console.log('Watching for changes...')
// Simple dev server
const server = createServer(async (req, res) => {
const filePath = req.url === '/' ? '/index.html' : req.url
try {
const content = await readFile(`dist${filePath}`)
res.writeHead(200)
res.end(content)
} catch {
res.writeHead(404)
res.end('Not found')
}
})
server.listen(3000, () => console.log('Dev server: http://localhost:3000'))
// Cleanup on exit
process.on('SIGINT', async () => {
await ctx.dispose()
server.close()
process.exit(0)
})
esbuild’s built-in serve (simpler)
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
})
// Built-in serve — handles file watching + live reload
const { host, port } = await ctx.serve({
servedir: 'dist',
port: 3000,
onRequest: ({ remoteAddress, method, path, status }) => {
console.log(`${method} ${path} → ${status}`)
},
})
console.log(`Serving at http://${host}:${port}`)
4. Plugins
Plugins let you intercept the resolve and load phases:
// Plugin to handle CSS Modules
const cssModulesPlugin = {
name: 'css-modules',
setup(build) {
build.onLoad({ filter: /\.module\.css$/ }, async (args) => {
const css = await readFile(args.path, 'utf8')
// Transform CSS modules → JS object
const { transformed, classNames } = transformCSSModules(css)
return {
contents: `
const style = document.createElement('style')
style.textContent = ${JSON.stringify(transformed)}
document.head.appendChild(style)
export default ${JSON.stringify(classNames)}
`,
loader: 'js',
}
})
},
}
// Plugin to replace environment variables
const envPlugin = {
name: 'env',
setup(build) {
build.onResolve({ filter: /^env$/ }, (args) => ({
path: args.path,
namespace: 'env-ns',
}))
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify({
NODE_ENV: process.env.NODE_ENV,
API_URL: process.env.API_URL,
}),
loader: 'json',
}))
},
}
// Usage in build
await esbuild.build({
plugins: [cssModulesPlugin, envPlugin],
// ...
})
Popular community plugins
import { sassPlugin } from 'esbuild-sass-plugin'
import svgrPlugin from 'esbuild-plugin-svgr'
import { copy } from 'esbuild-plugin-copy'
await esbuild.build({
plugins: [
sassPlugin(), // SCSS → CSS
svgrPlugin(), // SVG → React component
copy({
assets: { from: ['public/**/*'], to: ['dist'] }
}),
],
})
5. TypeScript — Transpile Without Bundle
// Transpile TS → JS without bundling (like tsc but fast)
await esbuild.build({
entryPoints: ['src/**/*.ts'], // all TS files
outdir: 'dist',
format: 'esm',
// NO bundle: true → preserves import statements
})
// Fast type checking separate from bundling
// package.json
{
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"build:full": "npm run typecheck && npm run build"
}
}
tsconfig integration
// esbuild reads tsconfig.json automatically
// But you can specify it explicitly
await esbuild.build({
entryPoints: ['src/index.ts'],
tsconfig: './tsconfig.build.json', // separate tsconfig for builds
})
6. Library Build (Dual ESM/CJS)
// build.mjs — build for npm library
import * as esbuild from 'esbuild'
const shared = {
entryPoints: ['src/index.ts'],
bundle: true,
external: ['react', 'react-dom'], // peer deps — don't bundle
sourcemap: true,
}
// ESM build
await esbuild.build({
...shared,
format: 'esm',
outfile: 'dist/index.mjs',
})
// CommonJS build
await esbuild.build({
...shared,
format: 'cjs',
outfile: 'dist/index.cjs',
})
// Type declarations (still need tsc for this)
// tsc --emitDeclarationOnly --declaration --outDir dist/types
// package.json
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/types/index.d.ts"
}
}
}
7. Transform API
For single-file transforms without bundling:
import * as esbuild from 'esbuild'
// Transform TypeScript string to JavaScript
const result = await esbuild.transform(`
const greet = (name: string): string => \`Hello, \${name}!\`
export default greet
`, {
loader: 'ts',
target: 'es2020',
minify: true,
})
console.log(result.code)
// const greet=n=>`Hello, ${n}!`;export default greet;
// Transform JSX
const jsx = await esbuild.transform('<div className="hello">Hello</div>', {
loader: 'jsx',
jsxFactory: 'h',
jsxFragment: 'Fragment',
})
8. Define and Inject
await esbuild.build({
// Replace global constants at build time
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'__VERSION__': JSON.stringify('1.2.3'),
'DEBUG': 'false',
},
// Inject variables into every file
inject: ['./src/polyfills.js'],
})
esbuild vs Vite vs webpack
| esbuild | Vite | webpack | |
|---|---|---|---|
| Speed | Fastest | Fast (dev), Slower (prod) | Slow |
| Use case | Libraries, CLI tools | Web apps | Web apps (legacy/complex) |
| HMR | Manual | Built-in | Built-in |
| Plugins | Growing ecosystem | Large (Vite plugins + Rollup) | Huge ecosystem |
| Config | Simple | Simple | Complex |
| CSS | Basic | Full (PostCSS, Sass) | Full |
| Code splitting | ESM only | Full | Full |
Decision guide:
- Building a npm library → esbuild (fast, simple dual ESM/CJS)
- Building a CLI tool → esbuild (zero-config Node.js bundle)
- Building a web app → Vite (uses esbuild internally + better DX)
- Legacy app with many webpack plugins → webpack
Key Takeaways
- esbuild is 10–100x faster than webpack/Rollup by using Go and parallelism
- Transpiles TypeScript but does NOT type-check — run
tsc --noEmitseparately - API modes:
build()for files,transform()for strings,context()for watch mode - Plugins hook into resolve/load phases — handles SCSS, SVG, environment variables
- Code splitting requires
format: 'esm'+splitting: true - Best for libraries and Node.js tools; use Vite for full-featured web app DX