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.
Selecting the right modern build tools for TypeScript requires balancing compilation speed, output fidelity, and ecosystem compatibility. Whether you are packaging a lightweight utility or architecting a monolithic design system, the underlying toolchain dictates developer experience, CI latency, and cross-environment reliability. This guide establishes the architectural foundation for your TypeScript Configuration & Build Tooling strategy, detailing how to configure, optimize, and deploy tsup, esbuild, and Rollup in production environments.
Tool Selection & Architecture Overview
Choosing between tsup, Rollup, and esbuild hinges on three primary vectors: raw compilation throughput, feature parity (tree-shaking, code splitting, legacy transpilation), and CI/CD resource constraints. While esbuild prioritizes native Go-based execution speed, Rollup excels at granular module resolution and advanced chunk splitting, and tsup abstracts both into a zero-config, developer-friendly CLI wrapper.
For cross-environment compatibility, dual ESM/CJS outputs are non-negotiable. Modern Node.js runtimes and frontend bundlers expect explicit conditional exports. Initialize your baseline distribution strategy by aligning your package.json with the following structure:
{
"name": "@scope/your-library",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
},
"files": ["dist"]
}
️ Hazard Prevention: Ensure type: "module" is explicitly set to prevent ambiguous resolution in Node.js. Always map types first in the conditional exports object to satisfy TypeScript’s module resolution algorithm before runtime evaluation.
tsup: Streamlined Dual Bundling for Modern Libraries
tsup operates as a high-level orchestrator built on top of esbuild, providing automatic declaration generation, format targeting, and minification without boilerplate configuration. Before initiating the bundling process, validate your baseline compiler options by reviewing Optimizing tsconfig.json for Library Distribution.
tsup’s CLI conventions allow rapid iteration. To generate parallel distributions, use the --format flag with comma-separated values:
tsup src/index.ts --format cjs,esm --dts --minify --clean
For complex projects, a tsup.config.ts file provides granular control over splitting strategies and external dependency declarations:
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true, // Generates .d.ts files independently
splitting: true, // Enables code splitting for ESM
clean: true,
external: ['react', 'react-dom'], // Prevents bundling peer dependencies
outDir: 'dist',
esbuildOptions(options) {
options.banner = { js: '"use strict";' }
},
})
When detailing the exact CLI syntax and config arrays required for parallel format generation, refer to Using tsup to Bundle Dual ESM and CJS Outputs for advanced splitting heuristics and minification tradeoffs.
️ Hazard Prevention: Dual ESM/CJS outputs sharing identical filenames cause package.json exports collisions. Always configure distinct output directories (e.g., dist/esm/ and dist/cjs/) or use .mjs/.cjs extensions explicitly to prevent ambiguous module resolution in consumer environments.
esbuild: High-Speed Compilation & CI Integration
esbuild’s native Go engine bypasses the traditional JavaScript AST traversal overhead, delivering sub-second compilation times even for massive dependency graphs. This makes it ideal for CI/CD pipelines where feedback loops must remain tight. For a comprehensive breakdown of compilation speeds and type-check-only migration strategies, see Migrating from tsc to esbuild for Faster Builds.
To leverage esbuild programmatically, create a build.ts script that handles platform targeting and output configuration:
import { build } from 'esbuild'
const config = {
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node', // Use 'browser' for web targets
outdir: 'dist',
outExtension: { '.js': '.mjs' },
minify: true,
sourcemap: true,
treeShaking: true,
external: ['@prisma/client', 'sharp'],
}
build(config).catch(() => process.exit(1))
️ Hazard Prevention: esbuild aggressively strips TypeScript types and performs zero type-checking. Running it in isolation will produce syntactically valid but potentially unsound JavaScript. Always decouple workflows: run tsc --noEmit in parallel with the primary bundler to catch type regressions before they reach production.
Rollup: Advanced Tree-Shaking & Plugin Orchestration
Rollup remains the industry standard for library authors requiring deterministic tree-shaking, precise module resolution, and legacy compatibility. Its plugin architecture allows deep customization of the compilation pipeline, making it indispensable for complex packages with mixed module formats.
A production-ready rollup.config.mjs handles CommonJS interop, resolves Node modules, and preserves directory structures for optimal consumer tree-shaking:
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' }),
],
output: [
{
file: 'dist/index.cjs',
format: 'cjs',
exports: 'named',
sourcemap: true,
},
{
file: 'dist/index.mjs',
format: 'esm',
sourcemap: true,
},
],
external: ['lodash-es', 'zod'],
preserveModules: true, // Maintains original file structure for optimal tree-shaking
preserveModulesRoot: 'src',
})
️ Hazard Prevention: When preserveModules: true is enabled, ensure your package.json exports map to the correct directory root. Failing to align the output structure with the exports field will break static analysis tools and prevent consumers from importing submodules correctly.
Module Resolution Mechanics & Path Alias Translation
TypeScript’s paths compiler option operates exclusively at compile time. Bundlers do not natively interpret tsconfig.json aliases, which frequently results in MODULE_NOT_FOUND errors in published packages. To prevent this, you must explicitly translate compile-time paths to runtime-compatible relative imports.
For a deep dive into how bundlers translate compile-time aliases to runtime-safe relative paths, consult Path Mapping and Module Resolution Strategies.
Align your tsconfig.json with your bundler’s alias configuration:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@src/*": ["src/*"],
"@utils/*": ["src/utils/*"]
}
}
}
In tsup or esbuild, map these explicitly:
// tsup.config.ts or esbuild.build.ts
export default {
alias: {
'@src': './src',
'@utils': './src/utils'
}
}
️ Hazard Prevention: Never rely on tsconfig.json paths for runtime resolution in distributed packages. Use bundler-specific alias plugins (e.g., rollup-plugin-alias or tsup’s built-in aliasing) to explicitly map paths to relative file structures during compilation. This guarantees consumer environments resolve imports correctly without requiring path-matching polyfills.
CI/CD Workflows & Incremental Build Strategies
Scalable distribution pipelines require deterministic caching, parallel execution, and artifact management. Modern CI environments (GitHub Actions, GitLab CI) benefit from filesystem-level caching of node_modules and dist directories, drastically reducing cold-start overhead.
Implement incremental builds using esbuild’s API for watch mode or hot-reloading during development:
import { context } from 'esbuild'
const ctx = await context({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
incremental: true,
})
await ctx.watch()
For GitHub Actions, orchestrate parallel type-checking and bundling with explicit cache keys:
name: Build & Distribute
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 Artifacts
uses: actions/cache@v4
with:
path: dist
key: ${{ runner.os }}-dist-${{ hashFiles('src/**/*') }}
- name: Type Check & Bundle
run: |
npx tsc --noEmit & \
npx tsup
env:
NODE_ENV: production
️ Hazard Prevention: Sequential type-checking and bundling steps create unnecessary pipeline latency. Decouple workflows by running tsc --noEmit in parallel with the primary bundler (using & in bash or CI matrix jobs), and implement strict filesystem caching for node_modules and dist artifacts across pipeline runs to prevent redundant compilation.