Modern Build Tools: tsup, Rollup, and esbuild
Compare tsup, Rollup, and esbuild for dual ESM/CJS TypeScript package builds. Configure each tool for optimal tree-shaking, declaration emit, and CI/CD performance.
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:
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
.mjsand.cjsoutputs from the sameoutDir, files namedindex.jscan collide if you also have atype: "module"package and a consumer requires the CJS form. Always use explicit.mjs/.cjsextensions (tsup does this by default forformat: ['esm', 'cjs']) and never setoutExtensionto.jsfor 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
tscsilently produces malformed JavaScript output from esbuild. Always runtsc --noEmitin a parallel CI job — or as a concurrentnpm-run-alltask — 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: trueoutputs 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 usingpreserveModules, update both theexportsmap inpackage.jsonto use"./dist/*"glob patterns and setdeclarationDirto 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 viatsconfig.jsonpaths, consumers have no such configuration and will receive a broken import. Runnode -e "require('./dist/index.cjs')"after building — if it throwsMODULE_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
- Using tsup to Bundle Dual ESM and CJS Outputs — Advanced splitting heuristics, minification trade-offs, and subpath entry points for tsup.
- Migrating from tsc to esbuild for Faster Builds — Step-by-step migration from a
tsc-only workflow, including declaration file strategies and type-check-only CI setup.
Related
- Optimizing tsconfig.json for Library Distribution — Compiler option baselines (
moduleResolution,target,lib) that every librarytsconfig.jsonshould set before wiring a bundler. - Declaration File Generation and Type Stripping — When and how to generate accurate
.d.tsfiles, including theisolatedDeclarationsoption in TypeScript 5.5. - Path Mapping and Module Resolution Strategies — How
moduleResolution: "bundler"differs fromnode16and which bundlers respect each setting. - Mastering the package.json exports Field — The conditional exports format that your bundler output must match to avoid broken imports in downstream projects.
- Tree-Shaking and Bundle Optimization — How to configure the
sideEffectsflag and eliminate barrel-file anti-patterns so Rollup and esbuild can eliminate dead code.