| | |

esbuild 26 ⚡ Build Scripts — Automating esbuild in package.json

⏱️ Estimated read: 13 min | |

The package.json scripts section is the entry point for every esbuild build, and the choice between the CLI and the JavaScript API is the first decision. The CLI is a one-liner that maps the flags to the build options. The JavaScript API is a module that runs the build programmatically and returns a result object. The two produce the same output for the same options, but they differ in what they can express and how they handle the incremental lifecycle. The scripts section is where the choice is made, and the choice determines whether the build is a command or a script.

The CLI is sufficient for a simple build. The "build": "esbuild src/index.ts --bundle --outdir=dist" script is the canonical example, and it works because the esbuild binary is in the node_modules/.bin directory, which npm adds to the PATH when it runs a script . The API is necessary when the options are too many for a readable command line, when the build needs a plugin, or when the build needs the incremental context that the CLI’s --watch flag hides. The API script is a .mjs file that imports esbuild and calls build() or context(), and the package.json script is "build": "node build.mjs" .

Key point: The CLI is for a one-shot build with a few flags. The JavaScript API is for a build with many options, a plugin, or an incremental lifecycle. The context() function is the entry point for watch, serve, and rebuild, and the onEnd plugin callback is the post-build hook. The package.json script is either the esbuild command or the node command that runs the .mjs file.


Why the scripts section is the automation surface

The PATH problem. A package.json script runs with node_modules/.bin on the PATH. The esbuild command in the script resolves to the installed binary without a relative path. The documentation states this explicitly: “This directly uses the esbuild command without a relative path. This is because everything in the scripts section is run using the esbuild command which is already in the path (as long as you have installed the package)” . The script is portable across machines and CI environments because the binary is resolved from the local install.

The flag-to-option mapping. The CLI flags map to the BuildOptions properties. The --bundle flag is bundle: true, the --outdir=dist flag is outdir: 'dist', and the --minify flag is minify: true. The mapping is documented in the CLI reference, and the API reference has the same options with the same names . The two surfaces are consistent, which means a developer can move from the CLI to the API without relearning the options.

The incremental lifecycle. The CLI’s --watch and --serve flags create an internal context and run it. The API exposes the context, which means the script can combine watch and serve on the same context, call rebuild() manually from a custom file watcher, or call dispose() on exit . The onRebuild callback was removed in esbuild 0.17.0, and the onEnd plugin callback is the replacement. The onEnd callback fires after every build, including the first, which the onRebuild callback did not . The API script that needs post-build logic uses onEnd, not a watch callback.

The trade-off. The CLI is shorter and requires no .mjs file. The API is longer and requires a file, but it expresses the options that the CLI cannot and exposes the lifecycle that the CLI hides. A project that only needs a one-shot build uses the CLI. A project that needs a plugin, a custom watcher, or a post-build hook uses the API.


a. The CLI scripts and the dev-server flags

The CLI scripts are the shortest form. The build script is the one-shot build, and the dev script combines watch and serve.

// package.json — the CLI scripts
{
  "scripts": {
    "build": "esbuild src/index.ts --bundle --outdir=dist --minify",
    "dev": "esbuild src/index.ts --bundle --outdir=dist --watch --serve=8000"
  }
}

The --watch flag rebuilds on file changes. The --serve=8000 flag starts a local HTTP server on port 8000. The --servedir=dist flag tells the server where the static files are. The combination of --watch and --serve is the dev server pattern . The live reload is not automatic; the client must add the EventSource listener to reload the page when the build changes.

// The client-side live-reload script (development only)
new EventSource('/esbuild').addEventListener('change', () => location.reload());

The EventSource connects to the /esbuild endpoint that the serve mode exposes. The server sends a change event when the build output changes, and the script reloads the page. The script should be guarded by an environment check so it does not run in production .

The CLI script can also include the --servedir flag and the --loader flag for static files. The --loader:.html=copy flag copies the HTML without processing it, and the --inject flag adds the live-reload script to the bundle .


b. The API scripts and the incremental context

The API script is a .mjs file that imports esbuild and calls context(). The context is the entry point for watch, serve, and rebuild, and the dispose() call is the cleanup.

// build.mjs — the API build script
import * as esbuild from 'esbuild';

const isWatch = process.argv.includes('--watch');

const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  minify: !isWatch,
  sourcemap: isWatch,
});

if (isWatch) {
  // The initial build is performed by rebuild().
  // The watch() call starts the watcher.
  await ctx.rebuild();
  await ctx.watch();
  console.log('Watching...');
} else {
  await ctx.rebuild();
  await ctx.dispose();
}

The ctx.rebuild() call performs the build. The ctx.watch() call starts the watcher. The ctx.dispose() call frees the resources. The context() function does not perform an initial build on creation, which is why the script calls rebuild() explicitly . The --watch flag in the script’s arguments is the mode switch, and the process.argv.includes('--watch') check is the pattern.

// package.json — the API script
{
  "scripts": {
    "build": "node build.mjs",
    "dev": "node build.mjs --watch"
  }
}

The node build.mjs command runs the script. The --watch argument is passed to the script and read from process.argv. The .mjs extension is required for the top-level await and the import syntax .

The API script can also use the onEnd plugin callback for post-build logic. The onEnd callback fires after every build, including the first, and it receives the build result. The callback is the replacement for the removed onRebuild callback .

// build.mjs — the onEnd callback
const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  plugins: [
    {
      name: 'build-logger',
      setup(build) {
        build.onEnd((result) => {
          if (result.errors.length > 0) {
            console.error('Build failed:', result.errors);
          } else {
            console.log('Build succeeded at', new Date().toISOString());
          }
        });
      },
    },
  ],
});

await ctx.watch();

The onEnd callback is the hook for the build result. It fires for the initial build and for every rebuild. The result.errors array is empty on success, and the result.warnings array is the warning list.


Complete Example Session

// ============================================
// PART 1: THE CLI BUILD SCRIPT
// ============================================

// package.json
{
  "scripts": {
    "build": "esbuild src/index.ts --bundle --outdir=dist --minify",
    "dev": "esbuild src/index.ts --bundle --outdir=dist --watch --serve=8000"
  }
}

// The build script is a one-shot build.
// The dev script combines watch and serve.

// ============================================
// PART 2: THE CLI LIVE RELOAD
// ============================================

// The client-side script (development only):
new EventSource('/esbuild').addEventListener('change', () => location.reload());

// The script connects to the serve mode's endpoint.
// The page reloads when the build changes.

// ============================================
// PART 3: THE API BUILD SCRIPT
// ============================================

// build.mjs
import * as esbuild from 'esbuild';

const isWatch = process.argv.includes('--watch');

const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  minify: !isWatch,
  sourcemap: isWatch,
});

if (isWatch) {
  await ctx.rebuild();
  await ctx.watch();
} else {
  await ctx.rebuild();
  await ctx.dispose();
}

// ============================================
// PART 4: THE API PACKAGE SCRIPT
// ============================================

// package.json
{
  "scripts": {
    "build": "node build.mjs",
    "dev": "node build.mjs --watch"
  }
}

// The node command runs the script.
// The --watch argument is read from process.argv.

// ============================================
// PART 5: THE onEnd CALLBACK
// ============================================

// build.mjs — the onEnd callback
const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  plugins: [
    {
      name: 'logger',
      setup(build) {
        build.onEnd((result) => {
          console.log(result.errors.length === 0 ? 'OK' : 'FAIL');
        });
      },
    },
  ],
});

await ctx.watch();

// The callback fires after every build.
// The result.errors array is the error list.

// ============================================
// PART 6: THE SERVE MODE IN THE API
// ============================================

// build.mjs — the serve mode
const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'www/js',
});

const { hosts, port } = await ctx.serve({
  servedir: 'www',
  port: 8000,
});

console.log(`http://${hosts[0]}:${port}`);

// The serve mode serves the build output from memory.
// The servedir is the static files directory.

// ============================================
// PART 7: THE COMBINED DEV SERVER
// ============================================

// build.mjs — watch + serve
await ctx.rebuild();
await ctx.watch();
const { port } = await ctx.serve({ servedir: 'www' });

// The watch rebuilds the output.
// The serve serves the output.
// The client script reloads the page.

// ============================================
// PART 8: THE ENVIRONMENT VARIABLE INJECTION
// ============================================

// build.mjs — the define option
import dotenv from 'dotenv';
dotenv.config();

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/app.js',
  define: {
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'development'),
    '__API_BASE_URL__': JSON.stringify(process.env.API_BASE_URL),
  },
});

// The define option substitutes the variables at build time.
// The JSON.stringify is required for string literals.

// ============================================
// PART 9: THE CODE SPLITTING SCRIPT
// ============================================

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

// The splitting option requires the esm format.
// The shared chunks are extracted automatically.

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The CLI script uses the esbuild binary from node_modules.
// [ ] The API script is a .mjs file with top-level await.
// [ ] The context() function is used for watch and serve.
// [ ] The rebuild() call performs the initial build.
// [ ] The watch() call starts the watcher.
// [ ] The dispose() call frees the resources.
// [ ] The onEnd callback fires after every build.
// [ ] The define option uses JSON.stringify for strings.

The ten parts covered the CLI build, the live reload, the API build, the API package script, the onEnd callback, the serve mode, the combined dev server, the environment variable injection, the code splitting, and the checklist. The through-line is that the CLI is the simple case and the API is the expressive case.


Quick Reference

The CLI scripts

ScriptCommandPurpose
buildesbuild src/index.ts --bundle --outdir=distOne-shot build
devesbuild src/index.ts --bundle --outdir=dist --watch --serve=8000Watch + serve
build:watchesbuild ... --watchWatch only

The API script

StepCodePurpose
Importimport * as esbuild from 'esbuild'Load the module
Contextawait esbuild.context(options)Create the context
Buildawait ctx.rebuild()Perform the build
Watchawait ctx.watch()Start the watcher
Serveawait ctx.serve(options)Start the server
Cleanupawait ctx.dispose()Free resources

The package.json scripts

ScriptCommandWhen
buildnode build.mjsOne-shot API build
devnode build.mjs --watchWatch API build

The define option

VariableValue
process.env.NODE_ENVJSON.stringify('development')
__API_URL__JSON.stringify(process.env.API_URL)

Best Practices

✅ Do This:

// Use the esbuild binary directly in the CLI script
"build": "esbuild src/index.ts --bundle --outdir=dist"           // ✅
// Use the context API for watch and serve
const ctx = await esbuild.context(options); await ctx.watch();   // ✅
// Use JSON.stringify for the define values
define: { '__API_URL__': JSON.stringify(process.env.API_URL) }   // ✅

❌ Don’t Do This:

// Use a relative path to the esbuild binary
"build": "./node_modules/.bin/esbuild src/index.ts"              // ❌
// Use buildSync in a long-running script
esbuild.buildSync(options); // blocks the event loop             // ❌
// Use onRebuild
watch: { onRebuild: () => {} } // removed in 0.17.0              // ❌

Common Pitfalls

PitfallWhy It HappensFix
The CLI command is not foundThe binary is not in the PATHRun npm install and use npm run
The API script fails on awaitThe file is .js without "type": "module"Use .mjs or set "type": "module"
The watch does not rebuildctx.watch() called without ctx.rebuild()Call rebuild() first
The onRebuild is not calledRemoved in 0.17.0Use onEnd plugin callback
The define value is a raw stringJSON.stringify not usedWrap the value in JSON.stringify

Real-World Examples

1. The CLI build

"build": "esbuild src/index.ts --bundle --outdir=dist --minify"

2. The CLI dev

"dev": "esbuild src/index.ts --bundle --outdir=dist --watch --serve=8000"

3. The API build

const ctx = await esbuild.context({ entryPoints: ['src/index.ts'], bundle: true });
await ctx.rebuild(); await ctx.dispose();

4. The API watch

await ctx.rebuild(); await ctx.watch();

5. The API serve

const { port } = await ctx.serve({ servedir: 'www' });

6. The onEnd callback

build.onEnd((result) => console.log(result.errors.length));

7. The define option

define: { '__API_URL__': JSON.stringify(process.env.API_URL) }

8. The code splitting

splitting: true, format: 'esm'

9. The live reload script

new EventSource('/esbuild').addEventListener('change', () => location.reload());

10. The package script

"dev": "node build.mjs --watch"

Visual

The CLI vs API Script

┌──────────────────────────────────────────────────────────────┐
│  CLI SCRIPT                    API SCRIPT                    │
│                                                              │
│  "build": "esbuild ..."        "build": "node build.mjs"    │
│         │                            │                       │
│         ▼                            ▼                       │
│  ┌─────────────────────┐      ┌─────────────────────┐       │
│  │ The esbuild binary  │      │ The build.mjs file  │       │
│  │ runs the build      │      │ imports esbuild     │       │
│  │                     │      │ calls context()     │       │
│  └─────────────────────┘      └─────────────────────┘       │
│                                                              │
│  Simple, one line              Expressive, many options     │
└──────────────────────────────────────────────────────────────┘

The Context Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  context() → rebuild() → watch() / serve() → dispose()        │
│                                                              │
│  const ctx = await esbuild.context(options)                 │
│         │                                                    │
│         ├──▶ await ctx.rebuild()    (the initial build)     │
│         ├──▶ await ctx.watch()      (the file watcher)      │
│         └──▶ await ctx.serve()      (the dev server)        │
│                                                              │
│  await ctx.dispose()  (free resources)                      │
│                                                              │
│  The same context runs all three.                           │
└──────────────────────────────────────────────────────────────┘

The onEnd Callback

┌──────────────────────────────────────────────────────────────┐
│  onEnd FIRES AFTER EVERY BUILD                                │
│                                                              │
│  Initial build:                                              │
│  rebuild() ──▶ onEnd(result) ──▶ log the result              │
│                                                              │
│  File change:                                                │
│  watcher ──▶ rebuild() ──▶ onEnd(result) ──▶ log the result  │
│                                                              │
│  The callback is the post-build hook.                        │
│  The onRebuild callback was removed.                         │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
CLI scriptesbuild src/index.ts --bundle --outdir=dist
API scriptnode build.mjs
API file extension.mjs (for top-level await)
Context entryesbuild.context(options)
Initial buildctx.rebuild()
Watchctx.watch()
Servectx.serve({ servedir })
Cleanupctx.dispose()
Post-build hookonEnd plugin callback
Environment injectiondefine with JSON.stringify

Key takeaways:

  • The CLI is the one-liner; the API is the module. The CLI is sufficient for a build with a few flags. The API is necessary when the options are too many for a readable command line, when the build needs a plugin, or when the build needs the incremental context .
  • The API script is a .mjs file. The top-level await and the import syntax require the .mjs extension or a "type": "module" in the package.json. The node build.mjs command runs the script .
  • The context API is the incremental entry point. The context() function creates the context, rebuild() performs the build, watch() starts the watcher, and serve() starts the server. The dispose() call frees the resources .
  • The onEnd callback replaces onRebuild. The onEnd plugin callback fires after every build, including the first. The onRebuild callback was removed in esbuild 0.17.0, and the onEnd callback is the replacement .
  • The define option injects environment variables. The value must be wrapped in JSON.stringify for string literals. The process.env variables are not available in the browser, so they must be replaced at build time .

Remember: The package.json scripts section is the automation surface. The CLI script is the one-liner for the simple build. The API script is the .mjs file for the expressive build. The context API is the lifecycle for the incremental build. The onEnd callback is the post-build hook. The define option is the environment injection. The CLI and the API share the same options, and the choice between them is the choice between a command and a script. The command is for the case that fits in a line. The script is for the case that does not.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!