esbuild Complete Guide | Fastest JavaScript Bundler

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:

  1. Uses native code (Go compiles to machine code)
  2. Runs transformations in parallel
  3. Uses memory efficiently (no intermediate AST copies)

Benchmark (three.js library, from scratch):

ToolTime
esbuild0.10s
Rollup + terser22.9s
webpack41.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],
  // ...
})
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

esbuildVitewebpack
SpeedFastestFast (dev), Slower (prod)Slow
Use caseLibraries, CLI toolsWeb appsWeb apps (legacy/complex)
HMRManualBuilt-inBuilt-in
PluginsGrowing ecosystemLarge (Vite plugins + Rollup)Huge ecosystem
ConfigSimpleSimpleComplex
CSSBasicFull (PostCSS, Sass)Full
Code splittingESM onlyFullFull

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 --noEmit separately
  • 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