mrkeyoor.com_
Wed 05 Aug 05:04 UTC
npmCLI & Toolingupdated 05 Aug 2026

esbuild

esbuild is a JavaScript and CSS bundler and minifier written in Go, built around one idea: build tools should be fast without a cache. It handles JavaScript, TypeScript, JSX, and CSS out of the box, bundles both ESM and CommonJS, and ships tree shaking, minification, source maps, a watch mode, and a small dev server. In practice it sits in two places: as the direct build tool for libraries and Node services, and as the engine inside other tools (Vite uses it for dependency pre-bundling and transforms). The project's own benchmarks claim mainstream bundlers are 10 to 100x slower, and day-to-day builds that took tens of seconds elsewhere routinely finish in well under a second here.

Verdict

The default choice for bundling Node code and libraries, and the fastest way to get a build step that you never think about again. For browser apps use Vite on top of it, and always pair it with tsc for type checking.

API stability3/5Still 0.x after six years by explicit choice; minor releases are allowed to break the API and sometimes do (the 0.17 watch/serve rewrite). The core build/transform API has been steady in practice, but you must pin versions.
Docs5/5esbuild.github.io documents every option with runnable examples in CLI, JS, and Go form, plus an honest FAQ about what the tool will not do. One of the best-documented tools in the ecosystem.
Maintenance4/5Actively maintained with 40k stars and last push June 2026, but it is essentially one maintainer (evanw) and release cadence has slowed compared to the 2020-2022 era; 637 open issues and PRs.
Ecosystem4/5Massive indirect reach (258M weekly downloads, powers Vite, tsup, and many others) and a healthy community plugin list, but the plugin API is narrow so the direct ecosystem is smaller than webpack's or rollup's.

Use it if

  • You are bundling a Node service, CLI, or library and want sub-second builds with zero config beyond a one-line command
  • You need a fast TypeScript/JSX transpile step and are happy to run tsc separately for type checking
  • You are building a tool on top of a bundler and want a simple, well-documented JS or Go API instead of a plugin labyrinth
  • Your CI build time with webpack or rollup is the bottleneck and you can live with esbuild's smaller feature set
Skip it if

Setup reality

Install is one package, but under the hood npm pulls a platform-specific native binary via optionalDependencies. That is where the pain lives: copying node_modules between macOS and Linux docker images breaks with a wrong-platform binary error, pnpm and Yarn PnP setups occasionally skip the optional dep, and offline/proxied registries need every @esbuild/* platform package mirrored. Pin the exact version (no caret) because 0.x minor bumps can break the API. The config surface is small: most projects need one build script of ten lines, not a config file.

Patterns

Bundle a file from the command linebundle-cli

npx esbuild app.ts --bundle --outfile=dist/app.js --platform=node --target=node20

Without --bundle esbuild only transforms the single input file and leaves imports unresolved.

Bundle with the JS build APIbuild-api

import * as esbuild from 'esbuild'

await esbuild.build({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outfile: 'dist/app.js',
  platform: 'node',
  format: 'esm',
})

build() rejects with all errors attached; set logLevel: 'info' to also get pretty-printed output.

Rebuild on file changeswatch-mode

import * as esbuild from 'esbuild'

const ctx = await esbuild.context({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outdir: 'dist',
})
await ctx.watch()
// later: await ctx.dispose()

watch moved to the context API in 0.17; the old build({ watch: true }) form is gone.

Serve builds locallydev-server

const ctx = await esbuild.context({
  entryPoints: ['src/app.tsx'],
  bundle: true,
  outdir: 'www/js',
})
const { hosts, port } = await ctx.serve({ servedir: 'www' })

It rebuilds per request but there is no HMR; the browser needs a manual or live-reload refresh.

Production minify with source mapsminify-sourcemap

await esbuild.build({
  entryPoints: ['src/app.ts'],
  bundle: true,
  minify: true,
  sourcemap: true,
  outfile: 'dist/app.min.js',
})

minify enables whitespace, identifier, and syntax minification together; they can also be toggled individually.

Inline environment variablesdefine-env

await esbuild.build({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outfile: 'dist/app.js',
  define: {
    'process.env.NODE_ENV': '"production"',
  },
})

Values are substituted as expressions, so strings need the inner quotes or you get an identifier.

Keep node_modules out of the bundleexternal-deps

await esbuild.build({
  entryPoints: ['src/server.ts'],
  bundle: true,
  platform: 'node',
  packages: 'external',
  outfile: 'dist/server.js',
})

packages: 'external' externalizes every bare import; use external: ['pg'] to pick specific ones.

Choose ESM or CommonJS outputoutput-format

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  format: 'esm',
  target: 'es2022',
  outfile: 'dist/index.mjs',
})

format defaults differ by platform; CommonJS output cannot preserve top-level await.

Transpile a string without bundlingtransform-string

import * as esbuild from 'esbuild'

const result = await esbuild.transform('let x: number = 1', {
  loader: 'ts',
})
console.log(result.code) // "let x = 1;\n"

transform never resolves imports; it is the right tool for on-the-fly TS/JSX in dev servers.

Write a resolve/load pluginwrite-plugin

const envPlugin = {
  name: 'env',
  setup(build) {
    build.onResolve({ filter: /^env$/ }, () => ({
      path: 'env', namespace: 'env-ns',
    }))
    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env), loader: 'json',
    }))
  },
}
await esbuild.build({ entryPoints: ['app.js'], bundle: true, outfile: 'out.js', plugins: [envPlugin] })

Plugins only get onResolve/onLoad/onStart/onEnd hooks; there is no AST access by design.

Split shared code into chunkscode-splitting

await esbuild.build({
  entryPoints: ['src/home.ts', 'src/admin.ts'],
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist',
})

splitting only works with format: 'esm' and an outdir; dynamic import() also creates chunks.

Pair a build with real type checkingtypecheck-alongside

// package.json
{
  "scripts": {
    "build": "tsc --noEmit && esbuild src/app.ts --bundle --outfile=dist/app.js"
  }
}

esbuild never reads types, so without the tsc pass type errors ship to production.

Alternatives

PackageRegistryPick it when
vitenpmYou are building a browser app and want HMR and framework plugins; it uses esbuild internally anyway
rollupnpmYou are publishing a library and want the most mature tree shaking and output control, and can accept slower builds
@swc/corenpmYou need a fast transpiler (not a bundler) to slot into an existing toolchain like Jest or Next.js
parcelnpmYou want zero-config bundling for a small site including HTML entry points