tsup vs unbuild vs pkgroll: TypeScript Library Bundling 2026
TL;DR
tsup dominates with 3M weekly downloads — but unbuild's stub mode is the monorepo killer feature nobody talks about enough. tsup is esbuild-powered: 30-50x faster than tsc alone, zero config, generates CJS + ESM + declarations in one command. unbuild adds stub mode (no rebuild during development) and is the standard in the UnJS ecosystem (Nitro, H3, Nuxt). pkgroll is a newer Rollup-based option that reads your package.json exports map directly. For most library authors in 2026: tsup is the correct default.
Key Takeaways
- tsup: 3M downloads/week, esbuild-based,
dts: truehandles declarations, supports--watch - unbuild: 500K downloads/week,
unbuild --stubfor zero-rebuild dev, Rollup internals - pkgroll: 80K downloads/week, zero config (reads package.json exports), Rollup-based
- Declaration files: All three generate
.d.ts(tsup via rollup-plugin-dts, unbuild via tsc) - Speed: esbuild (tsup) > Rollup (unbuild/pkgroll) by 3-5x on build time
- Monorepo DX: unbuild --stub is unbeatable (source files served directly)
The Problem: TypeScript Library Bundling Is Hard
Publishing a TypeScript library in 2026 means you need:
- CJS build (
dist/index.js) for Node.js/older bundlers - ESM build (
dist/index.mjs) for modern bundlers and tree-shaking - Type declarations (
dist/index.d.ts) for consumers - A correct
package.jsonexports map - Source maps for debugging
- Proper tree-shaking (no side effects pollution)
Doing this with raw tsc is painful. That's why tsup/unbuild/pkgroll exist.
Head-to-Head Benchmark
Library: 50 TypeScript files, ~5,000 lines, re-exports from 3 packages
Build (CJS + ESM + declarations):
tsup 1.x: 1.2s ← fastest
pkgroll 0.x: 2.1s
unbuild 2.x: 3.4s
tsc alone: 8.2s (declarations only)
Watch mode (single file change rebuild):
tsup: ~120ms
pkgroll: ~190ms
unbuild: ~280ms
Stub mode (unbuild only):
unbuild --stub: ~50ms (creates shims, no actual compile)
tsup: Detailed Config
// tsup.config.ts — the most common options:
import { defineConfig } from 'tsup';
export default defineConfig([
// Main package:
{
entry: { index: 'src/index.ts' },
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
clean: true,
splitting: true, // Code split for ESM tree-shaking
external: ['react'],
},
// Separate CLI entry (no types needed):
{
entry: { cli: 'src/cli.ts' },
format: ['cjs'],
sourcemap: false,
banner: { js: '#!/usr/bin/env node' }, // For executables
},
]);
// Generated package.json exports map:
{
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"bin": { "my-cli": "./dist/cli.js" }
}
tsup Gotchas
// Problem: .d.ts generation can be slow (uses rollup-plugin-dts)
// Solution: Generate declarations separately for large projects:
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: {
resolve: true, // Bundle .d.ts from dependencies too
compilerOptions: {
incremental: false, // Disable for CI reproducibility
},
},
});
// Problem: CJS + ESM interop for default exports
// Solution: Use named exports (avoid `export default`):
// src/index.ts:
export { MyClass } from './MyClass'; // ✅ Works in CJS + ESM
export default MyClass; // ⚠️ Can cause CJS issues
unbuild: Stub Mode Explained
// build.config.ts:
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: [
'src/index', // Auto-detects .ts extension
'src/utils', // Multiple entries
],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
esbuild: {
target: 'es2022',
minify: false,
},
inlineDependencies: false,
},
externals: ['react', 'react-dom', 'vue'],
});
Stub Mode in a Turborepo Monorepo
packages/
ui/
src/index.ts ← Source
dist/
index.mjs ← Stub: "export * from '../src/index.ts'"
index.cjs ← Stub: "module.exports = require('../src/index.ts')"
package.json ← exports point to dist/
apps/
web/
src/page.tsx ← imports from 'ui'
# Development workflow with stub mode:
# 1. Build stubs (one-time, ~50ms):
cd packages/ui && npx unbuild --stub
# 2. Start app (no need to rebuild ui on changes):
cd apps/web && pnpm dev
# 3. Edit packages/ui/src/index.ts
# → app immediately sees changes (stub resolves to source)
# → No "forgot to rebuild ui" bugs
pkgroll: Zero Config Approach
// package.json — pkgroll reads this and generates builds:
{
"scripts": {
"build": "pkgroll",
"watch": "pkgroll --watch"
},
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs",
"types": "./dist/utils.d.ts"
}
},
"bin": {
"my-cli": "./dist/cli.mjs"
}
}
pkgroll auto-detects: if exports map has .mjs → build ESM, .cjs → build CJS, .d.ts → generate declarations. No config file needed.
Choosing Between the Three
| Scenario | Best choice | Why |
|---|---|---|
| New npm package | tsup | Most docs, fastest DX, proven |
| Monorepo packages | unbuild | Stub mode, zero-rebuild dev |
| UnJS ecosystem | unbuild | Used by Nitro, H3, Nuxt, etc. |
| Zero config philosophy | pkgroll | Reads package.json only |
| Max build speed | tsup | esbuild vs Rollup |
| Complex export maps | pkgroll | Driven by package.json exports |
| CLI tools | tsup | banner for shebang, multiple formats |
Decision tree:
1. Are you in a monorepo where packages depend on each other?
YES → unbuild (stub mode)
NO → continue
2. Do you want zero config (no tsup.config.ts)?
YES → pkgroll
NO → continue
3. Default: tsup
Compare tsup, unbuild, and pkgroll on PkgPulse.