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

Splitting the Build: Types and JavaScript Have Different Bottlenecks

The fastest library builds separate two jobs that are usually conflated. Emitting JavaScript is a transformation problem — strip the types, rewrite the syntax, produce two formats — and esbuild or SWC does it in milliseconds. Emitting declarations is a type-checking problem, because the compiler must resolve every type in the program to know what to write, and only tsc can do it. Running them as one sequential tsc build means the fast half waits for the slow half.

Parallel build topology One TypeScript source tree feeds two independent jobs: tsc emits declaration files while esbuild or tsup emits the ESM and CommonJS JavaScript. Both outputs are then validated together before publishing. Two jobs, two tools, one validation gate src/*.ts one source of truth tsc --emitDeclarationOnly slow: full type resolution esbuild / tsup fast: syntax transform only publint + attw checks both together The two halves can disagree — only the validation step notices
{
  "scripts": {
    "build": "npm-run-all --parallel build:js build:types",
    "build:js": "tsup src/index.ts --format esm,cjs --clean",
    "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly",
    "check": "publint && attw --pack ."
  }
}

The arrangement has one hazard, and the diagram names it: because the two halves run independently, they can disagree. A syntax-only transformer does not type-check, so a build that emits JavaScript successfully proves nothing about type correctness — and if the declaration job is allowed to fail without failing the whole build, the package ships JavaScript with stale or missing types. Use a runner that propagates failure from either job (npm-run-all does, a bare & in a shell script does not), and treat the validation step as non-optional rather than as a nice-to-have.

Choosing Between the Tools on Something Other Than Speed

Build-tool comparisons default to benchmark numbers, but for a library the deciding factors are usually elsewhere: what the tool can express, what it hands you for free, and how much configuration you own afterwards.

Configuration surface. tsup is a wrapper with defaults tuned for exactly this job — dual output, declarations, clean, sourcemaps — and a five-line config. Rollup exposes every stage as a plugin, which is what you want when the build genuinely is unusual (multiple entry graphs, custom asset pipelines, a code-generation step) and pure overhead when it is not. esbuild sits between them: fast and configurable in code, with no built-in declaration emit.

Output control. Rollup produces the cleanest ESM output, with real scope hoisting and no wrapper overhead, which matters for a library whose consumers will re-bundle it. esbuild’s output is slightly more verbose but perfectly tree-shakeable. Where output shape matters to your consumers’ bundle size, the difference is measurable with the comparison harness in Comparing Bundler Tree-Shaking Output.

Declaration handling. Neither esbuild nor its wrappers can produce declarations without invoking tsc somewhere — every tool that appears to do so is orchestrating the compiler underneath. The relevant question is therefore not “can it emit types” but “does it run the compiler in a way I can configure”, because a hidden tsc invocation with defaults you cannot override is the thing you will fight when a declaration problem appears.

Ecosystem gravity. If the package lives in a workspace where every other package already uses one tool, matching it is worth several benchmark points. Shared configuration, one set of plugins to keep current, and one set of failure modes to learn beats a marginally faster build that only one package uses.

A concrete decision rule that holds for most libraries: start with tsup, because it does exactly this job with almost no configuration. Move to Rollup when you hit something it cannot express — and expect that moment to come from an output-shape requirement rather than from performance. Reach for raw esbuild only when you are building tooling around it, since at that point you are writing the orchestration that tsup would otherwise provide.

HAZARD PREVENTION

Symptom: The published package works, but consumers using moduleResolution: nodenext see Cannot find module '@scope/my-library' or its corresponding type declarations for the CommonJS entry only.

Root cause: The build emitted one index.d.ts and pointed both the import and require conditions at it. Under nodenext, a declaration file’s format follows the same .d.ts/.d.cts extension rules as runtime files, so a CommonJS entry needs index.d.cts.

Fix: Emit both extensions (tsup’s dts option does this when both formats are configured; with raw tsc it means a second pass or a rename step) and give each condition its own types entry pointing at the matching file.


A Short Selection Path

The decision usually resolves in three questions, asked in this order.

Choosing a build tool in three questions First, does the workspace already standardise on a tool. Second, does the build need something tsup cannot express. Third, are you building tooling rather than a library. Answering no to all three leaves tsup as the default. Answer in order; the first yes decides it 1. Does the workspace already use one? shared config beats benchmarks yes → match it and stop here 2. Is the build genuinely unusual? multiple graphs, asset pipeline, codegen yes → Rollup, for its plugin model 3. Are you building tooling, not a library? you want the API, not a CLI yes → esbuild directly; else tsup

Three answers of “no” leave tsup, which is the right default for the large majority of libraries precisely because it makes this decision uninteresting.


Pages in This Section



Back to TypeScript Configuration & Build Tooling