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 |
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.
{
"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: nodenextseeCannot find module '@scope/my-library' or its corresponding type declarationsfor the CommonJS entry only.Root cause: The build emitted one
index.d.tsand pointed both theimportandrequireconditions at it. Undernodenext, a declaration file’s format follows the same.d.ts/.d.ctsextension rules as runtime files, so a CommonJS entry needsindex.d.cts.Fix: Emit both extensions (tsup’s
dtsoption does this when both formats are configured; with rawtscit means a second pass or a rename step) and give each condition its owntypesentry pointing at the matching file.
A Short Selection Path
The decision usually resolves in three questions, asked in this order.
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
-
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. -
Preserving ‘use client’ Directives in Builds — keeping per-module directives intact by preserving module boundaries in the output.
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.