Publishing npm libraries can feel like a labyrinth of build tools and output formats, but esbuild simplifies it dramatically. The most surprising thing about esbuild is how quickly it transforms your source code into multiple, production-ready formats without you needing to deeply understand the intricacies of CommonJS versus ECMAScript Modules.

Let’s see it in action. Imagine you have a simple library in src/index.ts:

// src/index.ts
export function greet(name: string): string {
  return `Hello, ${name}!`;
}

export function farewell(name: string): string {
  return `Goodbye, ${name}!`;
}

You want to publish this to npm so it can be used by both Node.js (typically CJS) and modern front-end frameworks (typically ESM), and you need TypeScript type definitions.

Here’s a basic esbuild configuration file, build.js:

// build.js
const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  format: 'esm', // First, build as ESM
  splitting: true, // Enable code splitting for ESM
  sourcemap: true,
  target: 'esnext', // Target modern JavaScript features
  outExtension: { '.js': '.mjs' }, // Use .mjs for ESM
}).catch(() => process.exit(1));

esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/index.cjs', // Specify outfile for CJS
  format: 'cjs', // Then, build as CJS
  sourcemap: true,
  target: 'node14', // Target Node.js LTS or specific version
}).catch(() => process.exit(1));

// For TypeScript types
esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: false, // Don't bundle for type generation
  outdir: 'dist',
  format: 'esm', // Format doesn't strictly matter for dts, but good practice
  platform: 'neutral', // Neutral platform for type generation
  banner: { js: '/**\n * @fileoverview Generated by esbuild\n **/ ' },
  // esbuild doesn't directly generate .d.ts. It relies on tsc for this.
  // However, we can configure esbuild to run tsc after the JS build.
  // For simplicity here, we'll assume you run `tsc --emitDeclarationOnly` separately
  // or integrate it into a more complex build script.
  // For a direct esbuild approach, you'd typically use a plugin.
}).catch(() => process.exit(1));

To run this, you’d install esbuild and typescript (if you’re using TS):

npm install --save-dev esbuild typescript

And then execute the build script:

node build.js

This script runs esbuild twice, once for ESM and once for CJS, each with slightly different configurations.

The dist directory will contain:

dist/
├── index.mjs
├── index.mjs.map
├── index.cjs
├── index.cjs.map
└── index.d.ts  (if you ran `tsc --emitDeclarationOnly` separately)

For esbuild to generate TypeScript definitions (.d.ts files), it doesn’t directly compile them like tsc. Instead, it can facilitate running tsc with specific options or use plugins. A common pattern is to run tsc --emitDeclarationOnly in your package.json scripts after the esbuild JavaScript output is generated.

Your package.json might look like this:

{
  "name": "my-awesome-library",
  "version": "1.0.0",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "node build.js && tsc --emitDeclarationOnly",
    "prepublishOnly": "npm run build"
  },
  "devDependencies": {
    "esbuild": "^0.19.0",
    "typescript": "^5.0.0"
  }
}

The main field points to the CommonJS entry point, module to the ESM entry point, and types to the TypeScript declaration file. files ensures only the dist directory is included in the npm package. prepublishOnly automatically runs your build script before publishing.

The mental model here is that you’re providing esbuild with entry points and desired output formats. esbuild then traverses your code, bundles it (if bundle: true), and outputs JavaScript files. For ESM, splitting: true is crucial; it tells esbuild to create separate files for dynamically imported chunks, which is standard for modern ESM. The target option ensures compatibility; esnext is for modern browsers/Node.js, while node14 ensures compatibility with Node.js versions 14 and above.

When esbuild generates ESM with splitting: true, it doesn’t just output one file. It creates an index.mjs that acts as the main entry point and potentially other .mjs files for code that was split. The package.json’s module field correctly points to the main ESM file, and bundlers know how to resolve these. For CJS, outfile is used to specify a single output file, dist/index.cjs.

esbuild optimizes by default. It performs tree-shaking to remove unused code and minifies the output. The sourcemap: true option is vital for debugging published libraries, allowing users to see their original source code when errors occur.

A common pitfall is forgetting that TypeScript types are a separate concern from JavaScript output. While esbuild is incredibly fast at JavaScript compilation, it doesn’t inherently understand or generate .d.ts files without help. You must run tsc --emitDeclarationOnly or use a more advanced plugin ecosystem if you want type definitions. esbuild’s platform: 'neutral' for type generation is a hint that the output should be usable across different JavaScript environments without specific Node.js or browser APIs.

The next step after mastering this is often integrating a more sophisticated build pipeline, perhaps with Rollup or Vite, which can leverage esbuild for its speed while offering more advanced plugin capabilities for tasks like generating CSS, handling assets, or more complex type checking workflows.

Want structured learning?

Take the full Esbuild course →