Without a correctly configured build pipeline, Node.js consumers encounter ERR_REQUIRE_ESM when importing a pure-ESM package, bundler consumers receive duplicate singleton instances from the dual-package hazard, and TypeScript users get Module '"your-package"' has no exported member errors because declaration files were omitted or mis-routed. These failures materialise at runtime or type-check time — not during your own build — making them expensive to diagnose in downstream projects. This page covers how to configure tsup, esbuild, and Rollup to produce dual ESM/CJS outputs with accurate .d.ts files, from Node.js 18 onward.

Prerequisites


Tool Comparison at a Glance

Feature tsup esbuild Rollup
Speed Very fast (esbuild core) Fastest (native Go) Moderate (JS)
.d.ts generation Built-in (dts: true) None (use tsc separately) Via @rollup/plugin-typescript
Tree-shaking Good (esbuild) Good Best-in-class
Code splitting ESM only ESM + CJS ESM only
preserveModules Yes No Yes
Config complexity Low Medium High
Best for Library authors, fast iteration CI pipelines, scripts Complex libraries, OSS packages

Canonical Configuration Block

The package.json exports map is the contract that governs which file consumers receive. Configure this before writing any bundler config — the bundler outputs must match these paths exactly:

{
  "name": "@scope/your-library",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "files": ["dist"]
}

The types condition must appear first in the object — TypeScript’s module resolution algorithm evaluates conditions top-to-bottom and stops at the first match. default must appear last.


Build Tool Architecture

The diagram below shows how each tool fits into the TypeScript → distributable pipeline. All three share the same inputs (TypeScript source + tsconfig.json) but differ in where type-stripping, declaration emit, and tree-shaking occur:

Build Tool Pipeline: tsup vs esbuild vs Rollup Three parallel pipelines showing how TypeScript source flows through tsup, esbuild, and Rollup to produce ESM, CJS, and .d.ts outputs. src/index.ts tsup (esbuild + tsc --emitDeclarationOnly) esbuild (Go native, no types) Rollup (@rollup/plugin-typescript) index.mjs/.cjs index.d.ts index.mjs/.cjs tsc --noEmit index.mjs/.cjs index.d.ts bundle types bundle type check bundle types publint · attw · tsc --noEmit validation (all three tools)

Step-by-Step Implementation

Step 1 — Configure tsup for dual ESM/CJS output

tsup is an opinionated wrapper around esbuild that adds automatic declaration generation and format targeting. For most library authors it is the lowest-friction path to a correct dual-format distribution.

Install:

npm install --save-dev tsup

Create tsup.config.ts:

import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],       // emit both index.cjs and index.mjs
  dts: true,                     // run tsc --emitDeclarationOnly in parallel
  splitting: true,               // ESM code-splitting (ignored for CJS)
  clean: true,                   // remove dist/ before each build
  external: ['react', 'react-dom'], // never bundle peer dependencies
  outDir: 'dist',
  sourcemap: true,
  treeshake: true,
  esbuildOptions(options) {
    // Ensure strict mode header on CJS output
    options.banner = { js: '"use strict";' }
  },
})

Run the build:

npx tsup

Expected output:

CLI Building entry: src/index.ts
CLI Using tsconfig: tsconfig.json
ESM dist/index.mjs    4.2 KB
CJS dist/index.cjs    4.6 KB
DTS dist/index.d.ts   1.1 KB

For advanced splitting heuristics and minification trade-offs specific to tsup, see Using tsup to Bundle Dual ESM and CJS Outputs.

HAZARD PREVENTION: When tsup generates both .mjs and .cjs outputs from the same outDir, files named index.js can collide if you also have a type: "module" package and a consumer requires the CJS form. Always use explicit .mjs/.cjs extensions (tsup does this by default for format: ['esm', 'cjs']) and never set outExtension to .js for both formats.


Step 2 — Wire esbuild for high-speed CI compilation

esbuild’s native Go engine processes TypeScript by stripping types without running the compiler — build times for large packages drop from tens of seconds to under one second. The trade-off is that esbuild performs zero type-checking, so you must run tsc --noEmit as a parallel step.

Install:

npm install --save-dev esbuild

Create scripts/build.ts:

import { build, context } from 'esbuild'

const sharedConfig = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node' as const,  // use 'browser' for browser-only packages
  external: ['@prisma/client', 'sharp'],
  sourcemap: true,
  treeShaking: true,
}

if (process.argv.includes('--watch')) {
  // Development: incremental rebuilds via context API
  const ctx = await context({
    ...sharedConfig,
    outdir: 'dist',
    outExtension: { '.js': '.mjs' },
  })
  await ctx.watch()
  console.log('Watching for changes…')
} else {
  // Production: parallel ESM + CJS builds
  await Promise.all([
    build({
      ...sharedConfig,
      format: 'esm',
      outdir: 'dist',
      outExtension: { '.js': '.mjs' },
    }),
    build({
      ...sharedConfig,
      format: 'cjs',
      outdir: 'dist',
      outExtension: { '.js': '.cjs' },
    }),
  ])
  console.log('Build complete.')
}

Add to package.json:

{
  "scripts": {
    "build": "node --import tsx/esm scripts/build.ts",
    "typecheck": "tsc --noEmit"
  }
}

For step-by-step migration paths from a pure tsc workflow, including declaration file generation strategies, see Migrating from tsc to esbuild for Faster Builds.

HAZARD PREVENTION: esbuild strips TypeScript syntax but never validates it. A type error that would halt tsc silently produces malformed JavaScript output from esbuild. Always run tsc --noEmit in a parallel CI job — or as a concurrent npm-run-all task — and gate the release on both jobs passing before publishing to npm.


Step 3 — Configure Rollup for advanced tree-shaking

Rollup produces the leanest possible bundles because its tree-shaking operates on ES module static graph analysis, not heuristic dead-code detection. It is the right tool when consumers need to import individual exports without pulling in unrelated code, or when you need tree-shaking to work correctly across complex re-export chains.

Install:

npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript

Create rollup.config.mjs:

import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'
import { defineConfig } from 'rollup'

export default defineConfig({
  input: 'src/index.ts',
  plugins: [
    resolve({ preferBuiltins: true }),
    commonjs(),
    typescript({
      tsconfig: './tsconfig.build.json',  // use a build-only tsconfig
      declaration: true,
      declarationDir: 'dist/types',
    }),
  ],
  output: [
    {
      file: 'dist/index.cjs',
      format: 'cjs',
      exports: 'named',
      sourcemap: true,
    },
    {
      file: 'dist/index.mjs',
      format: 'esm',
      sourcemap: true,
    },
  ],
  external: ['lodash-es', 'zod'],  // never bundle runtime dependencies
  preserveModules: false,           // set to true for subpath exports (see below)
  treeshake: {
    moduleSideEffects: false,        // assume all modules are side-effect-free
  },
})

HAZARD PREVENTION: preserveModules: true outputs a directory tree instead of a single bundle, which is ideal for packages that expose subpath exports (e.g. import { Button } from 'ui/button'). When using preserveModules, update both the exports map in package.json to use "./dist/*" glob patterns and set declarationDir to match the output root — otherwise TypeScript resolution for deep imports breaks silently.


Step 4 — Translate TypeScript path aliases to bundler aliases

TypeScript’s paths compiler option is a compile-time-only hint — bundlers do not read tsconfig.json at runtime. If your source uses @utils/format and you publish without translating that alias, consumers receive a package that throws MODULE_NOT_FOUND: Cannot find module '@utils/format'.

tsconfig.json (compile-time):

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

Translate into tsup/esbuild alias at bundle time:

// tsup.config.ts
import { defineConfig } from 'tsup'
import { resolve } from 'path'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],
  dts: true,
  esbuildOptions(options) {
    options.alias = {
      '@src': resolve('./src'),
      '@utils': resolve('./src/utils'),
    }
  },
})

For Rollup, use @rollup/plugin-alias:

import alias from '@rollup/plugin-alias'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'

const __dirname = dirname(fileURLToPath(import.meta.url))

export default {
  plugins: [
    alias({
      entries: [
        { find: '@src', replacement: resolve(__dirname, 'src') },
        { find: '@utils', replacement: resolve(__dirname, 'src/utils') },
      ],
    }),
    // ...other plugins
  ],
}

For a complete breakdown of path mapping and module resolution strategies — including how moduleResolution: "bundler" interacts with these aliases — see the dedicated guide.

HAZARD PREVENTION: Never ship a package whose bundled output still contains bare @ aliases. Even if your own project resolves them via tsconfig.json paths, consumers have no such configuration and will receive a broken import. Run node -e "require('./dist/index.cjs')" after building — if it throws MODULE_NOT_FOUND, an alias was missed.


Step 5 — Set up a parallel CI workflow

Sequential type-checking and bundling in CI creates unnecessary wait time. The pattern below decouples them so the bundler starts immediately while TypeScript validates in parallel, then both must pass before the release step:

# .github/workflows/build.yml
name: Build & Type-Check
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: Cache dist
        uses: actions/cache@v4
        with:
          path: dist
          key: dist-${{ hashFiles('src/**/*', 'tsup.config.ts') }}
          restore-keys: dist-

      - name: Bundle (tsup)
        run: npx tsup
        env:
          NODE_ENV: production

      - name: Type-check (tsc)
        run: npx tsc --noEmit

      - name: Validate package
        run: |
          npx publint
          npx attw --pack

Tooling Validation

After building, run these commands to verify the package is sound before publishing:

# 1. Check package.json exports, main, types fields
npx publint

# 2. Verify TypeScript types resolve correctly for every consumer scenario
npx attw --pack

# 3. Confirm no type errors in source
npx tsc --noEmit

# 4. Smoke-test the CJS output in Node.js
node -e "const lib = require('./dist/index.cjs'); console.log(Object.keys(lib))"

# 5. Smoke-test the ESM output
node --input-type=module --eval "import * as lib from './dist/index.mjs'; console.log(Object.keys(lib))"

Sample publint pass output:

✓ exports["."]["types"] is "dist/index.d.ts" — valid
✓ exports["."]["import"] is "dist/index.mjs" — valid
✓ exports["."]["require"] is "dist/index.cjs" — valid
✓ No issues found

Sample attw pass output:

  ✓ node10           dist/index.d.ts  OK
  ✓ node16 (CJS)     dist/index.d.ts  OK
  ✓ node16 (ESM)     dist/index.d.ts  OK
  ✓ bundler          dist/index.d.ts  OK

Compatibility Matrix

Tool Node.js min TypeScript min ESM output CJS output .d.ts built-in Code splitting
tsup 8.x 18 4.7 Yes (.mjs) Yes (.cjs) Yes ESM only
esbuild 0.21+ 18 any Yes Yes No (use tsc) ESM + CJS
Rollup 4.x 18 4.7 (plugin) Yes Yes Via plugin ESM only

Pages in This Section



Back to TypeScript Configuration & Build Tooling