esbuild in Practice: CLI and JS API, Watch Mode, Plugins and Dual ESM/CJS Library Builds

Key takeaways

esbuild is a JavaScript and TypeScript bundler written in Go, which makes it much faster than JavaScript-based bundlers such as webpack or Rollup on most projects. This guide covers the CLI, Node.js API, plugins, and production patterns.

Why esbuild?

Large webpack or Rollup builds can take long enough to slow down every edit-and-reload cycle, and much of that time goes into running a JavaScript-based bundler over the whole dependency graph. esbuild, created by Evan Wallace, is a bundler written in Go that aims to remove most of that wait. This post covers its CLI and Node.js API, watch mode and the dev server, plugins, dual ESM/CJS library builds, the transform API, and where it fits next to Vite and webpack.

esbuild is fast mainly because it:

  1. Uses native code (Go compiles to machine code)
  2. Runs parsing, linking, and code generation in parallel across CPU cores
  3. Keeps the number of passes over the AST small and avoids repeated data conversions

The esbuild website publishes a benchmark that bundles ten copies of three.js from scratch with minification and source maps enabled. In that benchmark esbuild finishes in a fraction of a second, while Rollup with terser and webpack 5 take tens of seconds. Treat it as the best case rather than a promise: real projects spend time in plugins, CSS processing, and type checking that esbuild does not do, so the gap on your codebase will usually be smaller. Check the esbuild site for the current figures and time your own build before and after switching.

Where you already use it

Its author is also a co-founder of Figma. Even if you never install it directly, it is probably in your toolchain already: Vite uses it to pre-bundle dependencies and to strip TypeScript during development, and some frameworks (Phoenix’s default asset pipeline, for example) ship it as their bundler.

The design choices that make it fast are the same ones that limit it. There is no type checker, the plugin API only exposes resolve and load hooks (no arbitrary AST transforms), and tree shaking is simpler than Rollup’s. If your build depends on Babel plugins that rewrite code, esbuild can only run them by calling Babel from inside an onLoad plugin, which is slow JavaScript again and removes most of the speed benefit. When I evaluate a migration, the first thing I check is how many custom Babel or webpack loaders the project relies on, because that list decides whether esbuild is a drop-in or a rewrite.

Limitations (by design)

  • No type checking: it strips types for speed, so run tsc --noEmit separately
  • Simpler tree shaking: less aggressive than Rollup’s analysis
  • Smaller plugin ecosystem than webpack

When to Choose esbuild

It is a good fit for:

  • Library bundling, including dual ESM/CJS output
  • CLI tools and Node.js scripts
  • Fast TypeScript transpilation when type checking runs separately

Consider something else when:

  • Migrating a complex webpack setup that relies on many plugins (Vite or Rollup may be easier)
  • You need the deepest possible tree shaking (Rollup)

Installation

# npm
npm install --save-dev esbuild

# Check version
npx esbuild --version

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

Without --bundle, esbuild only transforms the entry file and leaves its import statements untouched; forgetting the flag is a common reason for “the output still imports from ./utils”. --target controls syntax lowering only: with --target=chrome90, optional chaining stays as is, while with an older target it would be rewritten. esbuild never adds polyfills for missing APIs such as Array.prototype.at or structuredClone. If a syntax feature cannot be lowered to the target (top-level await for es2017, for example), the build fails with an error like Top-level await is not available in the configured target environment instead of silently producing broken code.

--platform=node changes more than it looks: Node built-ins such as fs and path become external automatically, and the default output format switches to CommonJS. The default platform, browser, instead errors on import fs from 'fs' with Could not resolve "fs", which is often the first sign that a server-only module leaked into a browser bundle.


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]',
})

With splitting: true, code shared by several entry points, or loaded through dynamic import(), is moved into separate chunk files instead of being duplicated in each output. esbuild only supports this for format: 'esm'; asking for splitting with cjs or iife is an error. The output is ES modules that import each other, so the HTML must load the entry with <script type="module">. Splitting is still marked as a work in progress in esbuild’s documentation, and one known limitation is that the order in which shared code runs across chunks can differ from the unsplit bundle, which matters only if your modules have side effects that depend on evaluation order.

Production build for Node.js CLI

import * as esbuild from 'esbuild'

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,
})

Bundling a Node CLI into one file makes installs fast and startup quick, because Node does not have to resolve hundreds of files in node_modules. Two kinds of packages resist it. Native addons fail with No loader is configured for ".node" files, and must be marked external and installed as real dependencies. Packages that locate files relative to __dirname at runtime (templates, WASM binaries, worker scripts) bundle without errors and then fail when run, because the files they look for were not copied. For servers, many teams skip bundling dependencies altogether with packages: 'external', which keeps every node_modules import as a runtime require.

Choosing format: 'esm' for Node output has its own well-known trap. If a bundled CommonJS dependency calls require('fs'), esbuild cannot turn that into an import and emits a shim that throws Dynamic require of "fs" is not supported at runtime. Either stay on cjs for Node bundles, keep such dependencies external, or add a banner that defines require via createRequire(import.meta.url).


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',
})

await ctx.watch()  // serve alone does not watch; combine it with watch mode

// Built-in serve — rebuilds are served from memory
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}`)

context() is the long-lived form of build(): it keeps parsed files in memory, so rebuilds after a change only redo the work that changed, and it is the entry point for both watch() and serve(). The built-in server serves the build output (from memory) plus any static files in servedir, and it can combine with watch mode for live reload. Live reload is not automatic, though: esbuild exposes a /esbuild event stream that emits change after each rebuild, and your page has to subscribe to it:

// in your app's dev entry
new EventSource('/esbuild').addEventListener('change', () => location.reload())

This is a full page reload, not hot module replacement; component state is lost on every change. That limitation is the main reason web apps usually use Vite’s dev server, which has real HMR, and reserve raw esbuild for libraries and tools. Remember to call ctx.dispose() when a script is done with a context, otherwise the esbuild child process keeps Node alive and the script never exits.


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],
  // ...
})

transformCSSModules stands in for whatever CSS Modules implementation you use; it is not an esbuild function. Since esbuild 0.18 you may not need this plugin at all: files ending in .module.css use the built-in local-css loader, which scopes class names and exports the mapping, so import styles from './Button.module.css' works out of the box.

The two hooks work as a pair. onResolve decides what an import path means; here it claims the bare specifier env and moves it into a private env-ns namespace so no other plugin or the file system tries to resolve it. onLoad then supplies the contents for paths in that namespace. The filter is a Go regular expression evaluated before any JavaScript runs, and keeping it narrow matters for speed: a plugin with filter: /.*/ in the default namespace forces esbuild to call into JavaScript for every single file, which is the easiest way to make an esbuild build slow. For this particular job, the built-in define option (section 8) is simpler than a plugin.

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'] }
    }),
  ],
})

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

esbuild reads only the tsconfig settings that affect how code is emitted, such as paths, baseUrl, jsx, experimentalDecorators, useDefineForClassFields and verbatimModuleSyntax. target and module in tsconfig are ignored in favor of esbuild’s own options. Because esbuild compiles each file in isolation, a few TypeScript features that need cross-file type information do not work: const enum values imported from another file, and re-exporting a type without export type. Setting isolatedModules: true in tsconfig makes tsc flag those cases, so the type checker catches what esbuild would get wrong.


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": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Conditions are matched in order, and the first match wins, so types goes first. The .mjs and .cjs extensions make each file’s module format explicit regardless of the package’s "type" field. Marking peer dependencies external is essential: bundling React into a component library gives the consuming app two copies of React, and hooks then fail with Invalid hook call.

Shipping two formats has a subtle cost called the dual-package hazard. If one part of an application loads your library through import and another through require, Node loads both copies, and module-level state (a cache, a singleton, an instanceof check against your classes) exists twice. For stateless utility libraries this does not matter; for libraries with internal state, shipping ESM only, or making the CJS build a thin wrapper around shared code, avoids it. Tools such as publint and “Are the Types Wrong?” check an exports map before you publish.

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)
// minified JS with the types stripped, roughly: 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',
})

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',
  },

  // Replace free references to globals with imports from this file
  inject: ['./src/polyfills.js'],
})

define replaces identifiers and property chains in the parsed code (not raw text, so strings and comments are untouched), and values must be JavaScript expressions, which is why strings are wrapped in JSON.stringify. Writing 'process.env.NODE_ENV': 'production' makes esbuild substitute the identifier production, which is then an undefined variable at runtime. Once process.env.NODE_ENV becomes the literal "production", minification removes if (process.env.NODE_ENV !== 'production') branches entirely, which is how React’s development warnings disappear from production bundles.

inject is narrower than “add to every file”. If polyfills.js exports a name such as Buffer or process, every file that refers to that global gets an automatic import of it instead; files that do not use the name are unaffected. It is the esbuild equivalent of webpack’s ProvidePlugin.


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

Frequently Asked Questions (FAQ)

Q. Why doesn’t esbuild emit .d.ts files for my library build?

A. esbuild only strips TypeScript types; it never type-checks and never generates declaration files. For the dual ESM/CJS library setup in this article, run tsc --emitDeclarationOnly --declaration as a separate step and point the types condition in package.json exports at the output. TypeScript matches export conditions in order, so it is safest to list types before import and require.