Skip to main content

tsup vs unbuild vs pkgroll (2026)

·PkgPulse Team
0

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: true handles declarations, supports --watch
  • unbuild: 500K downloads/week, unbuild --stub for 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:

  1. CJS build (dist/index.js) for Node.js/older bundlers
  2. ESM build (dist/index.mjs) for modern bundlers and tree-shaking
  3. Type declarations (dist/index.d.ts) for consumers
  4. A correct package.json exports map
  5. Source maps for debugging
  6. 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)

These benchmarks are for a mid-size library. For very small libraries (under 10 files), the differences are negligible — all three tools complete in under a second. The gap widens with library size: at 200+ files, tsup's esbuild foundation maintains sub-3-second builds while unbuild and pkgroll can take 8-15 seconds.

The watch mode numbers matter more than the cold build numbers for development velocity. Library authors typically have a consumer test application running alongside the library during development. The 120ms tsup rebuild vs 280ms unbuild rebuild translates to noticeably snappier feedback when iterating on component APIs, utility function signatures, or type definitions. Over the course of a development session with dozens of incremental changes, this difference adds up.

Declaration generation dominates build time for TypeScript-heavy libraries. The .d.ts output requires running TypeScript's type checker (even with esbuild for the JS output), which is inherently slower than transpilation alone. tsup's dts: true option runs rollup-plugin-dts as a separate Rollup pass, which can be parallelized with the esbuild JS build. For large libraries, the isolatedDeclarations TypeScript 5.5 feature — which generates declarations without full type checking — can significantly speed up the declaration step across all three tools.


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

Stub mode works because Vite and most modern bundlers can handle TypeScript source files directly. The stub file points to the TypeScript source, and Vite's development server transpiles it on demand via esbuild. This means stub mode requires your consumer to use a dev server that handles TypeScript — which is essentially always true in 2026. The production build step (unbuild without --stub) then generates the real compiled output.

Turborepo users get an additional benefit: with stub mode configured, the ui package doesn't need a build task as a dependency of web#dev. The development workflow becomes simpler and faster, and Turborepo's task graph can be optimized around the actual production build rather than an unnecessary dev rebuild step.

For more on monorepo tooling that pairs with unbuild, see best monorepo tools 2026.

For the broader TypeScript build toolchain including tsup in the context of full projects, pnpm vs Bun vs npm 2026 covers how package manager choice affects monorepo performance.

For testing TypeScript libraries during development, bun:test vs node:test vs Vitest covers the test runner options that work best with library development workflows.


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

ScenarioBest choiceWhy
New npm packagetsupMost docs, fastest DX, proven
Monorepo packagesunbuildStub mode, zero-rebuild dev
UnJS ecosystemunbuildUsed by Nitro, H3, Nuxt, etc.
Zero config philosophypkgrollReads package.json only
Max build speedtsupesbuild vs Rollup
Complex export mapspkgrollDriven by package.json exports
CLI toolstsupbanner 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

TypeScript Library Bundling in 2026

Publishing a well-structured TypeScript library requires more than running tsc. Modern npm packages need to support multiple module systems, provide accurate type declarations, and be optimally sized for consumers who may be using bundlers, Node.js, or edge runtimes. The three tools covered here — tsup, unbuild, and pkgroll — address this problem with different architectural philosophies.

Why Raw tsc Isn't Enough

TypeScript's compiler handles type checking and transpilation, but it doesn't optimize bundle size, handle CJS/ESM dual builds cleanly, or generate the package.json exports map that modern bundlers use to resolve the correct format. A library published with raw tsc output works but often causes consumers to include more code than necessary, can have CJS/ESM interop issues, and lacks the dual-format builds that let tree-shaking work correctly.

The exports field in package.json is the key feature that enables consumers to get the right build. When a consumer imports 'my-library' in a CJS context, Node.js resolves the require condition from the exports map. In an ESM context, it resolves the import condition. When bundlers generate TypeScript, they resolve the types condition. Getting all three conditions built and correctly mapped requires a build tool.

tsup: The Default Choice for Most Libraries

tsup's dominance in download numbers (3M weekly) reflects its practical advantages for solo library authors and small teams. The configuration API is minimal: entry, format, dts, external cover 90% of use cases. The dts: true option handles declaration generation, which is the most complex part of TypeScript library publishing.

The esbuild foundation means tsup builds are fast — typically 10-50x faster than running tsc alone. For watch mode during development, this speed difference is significant: rebuilds after file changes take 100-150ms rather than 2-5 seconds with a slow tsc setup. For library authors who want to iterate quickly on a package while testing it in a consumer application, this matters.

tsup's multi-entry support is clean: each entry in the entry object generates its own set of outputs. A library with both a main export and a secondary CLI tool can build both with different format configurations in a single tsup.config.ts file. The banner option for adding shebangs (#!/usr/bin/env node) to CLI builds is a small but practical feature that removes a common friction point.

unbuild: The Monorepo Specialist

unbuild's stub mode is its defining feature, and it's genuinely transformative for monorepo development workflows. The problem it solves: in a monorepo, Package A depends on Package B. During development, you want to edit Package B's source and immediately see the effect in Package A's development server. Without stub mode, you need to run Package B's build after every change — either manually or with a parallel watch process. Forgetting to rebuild is a constant source of "why isn't my change showing up?" confusion.

Stub mode replaces the actual build output with proxy files that re-export from the TypeScript source directly. Running unbuild --stub takes ~50ms and creates dist/index.mjs files that contain export * from '../src/index.ts'. Any consumer of the package — including bundlers like Vite — then resolves directly to the TypeScript source, bypassing the build entirely. TypeScript source changes are reflected instantly without any rebuild step.

This pattern is used throughout the UnJS ecosystem (Nitro, H3, Nuxt) and has been adopted by other monorepo-based open-source projects as the standard development setup. The stub approach works because modern bundlers and Node.js with tsx/ts-node can resolve TypeScript directly; the "build" is effectively performed by the consuming project's bundler rather than as a separate step.

For production builds, unbuild uses Rollup under the hood, which produces excellent output for library use cases. Rollup's tree-shaking is more thorough than esbuild's for library bundles (though esbuild is faster), and its handling of re-exports is particularly good.

pkgroll: Convention Over Configuration

pkgroll represents the most minimal approach: it reads your package.json exports map and infers everything it needs to build. If your exports map references .mjs files, pkgroll builds ESM. If it references .cjs files, it builds CJS. If it references .d.ts files, it generates declarations. No configuration file is needed.

This convention-over-configuration approach is excellent for library authors who want to keep their project structure as the source of truth for build configuration. The exports map is something you'd define regardless — it's required for consumers to resolve the correct module format. pkgroll just reads it and builds accordingly.

The trade-off is flexibility. Complex build requirements — multiple independent entry points with different format configurations, banner insertion for CLI files, advanced TypeScript compiler options — require workarounds with pkgroll. For libraries that fit its conventions cleanly, pkgroll is the simplest option. For libraries with complex requirements, tsup's explicit configuration is worth the extra file.

Declaration Generation: The Shared Challenge

All three tools generate TypeScript declaration files, but the mechanics differ. tsup uses rollup-plugin-dts to bundle declarations — this produces a single .d.ts file that includes all re-exported types, which is ideal for consumers using TypeScript but requires rollup-plugin-dts to be installed. unbuild uses tsc directly for declaration generation, which is slower but more faithful to TypeScript's native output. pkgroll also uses a Rollup-based approach similar to tsup.

The declaration bundling question (single .d.ts vs. multiple files mirroring source structure) has practical implications. Single-file declarations are simpler for consumers but require the bundling step to work correctly. Multi-file declarations mirror the source structure, which can be useful for libraries with complex re-export trees. tsup defaults to single-file declarations; unbuild's tsc-based approach produces multi-file output.

For libraries with peer dependencies (particularly React component libraries), the external configuration is critical. Forgetting to mark react as external causes tsup to bundle React into your library output — a common mistake that leads to duplicate React instances in consumers. All three tools support marking packages as external; tsup's external array is the most straightforward API.

Ecosystem and Community

The three tools occupy different niches in the TypeScript ecosystem. tsup is the most broadly adopted and has the most community resources: tutorials, blog posts, example configurations, and GitHub discussions covering edge cases. When you encounter an unusual configuration requirement, you're more likely to find an existing Stack Overflow answer or GitHub issue for tsup than for unbuild or pkgroll.

unbuild's community is centered on the UnJS ecosystem, which is large and active but more specialized. If you're building with Nuxt, Nitro, or H3, unbuild is the native tool and documentation is excellent. Outside the UnJS ecosystem, community resources are thinner but the library is well-documented in its own right.

pkgroll is the newest of the three and has the smallest community, but its convention-based approach means most users don't need community support — it either works with your exports map or you switch to tsup. For its intended use case, pkgroll is self-documenting.

For related TypeScript build tooling, the tsup vs Rollup vs esbuild comparison covers the lower-level bundler layer, and pnpm vs Bun vs npm covers package manager choices that affect monorepo workflows.

Compare tsup, unbuild, and pkgroll on PkgPulse.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.