<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/best-typescript-first-build-tools-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/best-typescript-first-build-tools-2026/raw.md -->
<!-- Source path: content/guides/best-typescript-first-build-tools-2026.mdx -->

---
og_image: "/images/guides/best-typescript-first-build-tools-2026.webp"
title: "Best TypeScript-First Build Tools for Libraries 2026"
description: "tsup, unbuild, pkgroll, and esbuild compared for building TypeScript libraries. Bundle formats, declaration generation, watch mode, and which to pick for."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["tsup", "unbuild", "esbuild", "typescript", "library-bundling", "npm", "2026"]
tier: 2
---

## TL;DR

**tsup is the default for TypeScript library bundling in 2026 — zero config, esbuild-powered, 3M weekly downloads.** unbuild (from the Nuxt/UnJS team) is the best choice for monorepos and cross-platform packages. pkgroll is the newest, fastest option with Rollup under the hood. If you're publishing an npm package today: tsup gets you to done fastest. If you need advanced export map control or cross-platform compatibility: unbuild.

## Key Takeaways

- **tsup**: ~3M downloads/week, zero config, esbuild-powered, CommonJS + ESM output
- **unbuild**: ~500K downloads/week, UnJS ecosystem, advanced export maps, stub mode
- **pkgroll**: ~80K downloads/week, Rollup-based, fastest builds, newer
- **Vite library mode**: first-class component library support, Rollup for production
- **esbuild (direct)**: 25M downloads/week — the underlying engine, use it via tsup usually
- **For most libraries**: tsup (simplest DX, proven, extensive docs)
- **For monorepo packages**: unbuild (stub mode = instant dev reloads)

---

## Why TypeScript Library Bundling Still Requires Dedicated Tools

Publishing a TypeScript library to npm involves more than running `tsc`. You need to produce CommonJS output for Node.js compatibility, ESM output for tree-shaking, TypeScript declaration files for editor support, and a correctly structured `package.json` exports map so consumers can import from subpaths. Doing all of this by hand with raw `tsc` and esbuild invocations works but is tedious. The tools below exist specifically to collapse this complexity into one command.

The biggest shift in 2024–2025 was the industry moving toward dual CJS+ESM output as a baseline rather than a premium option. Most bundlers now assume you want both, and the tools below all handle the dual-format problem well. The question is which one best fits your specific workflow and complexity level.

## Package Health Table

| Package | Weekly Downloads | Trend | Underlying Engine |
|---------|-----------------|-------|-------------------|
| `esbuild` | ~25M | Growing | N/A (it is the engine) |
| `tsup` | ~3M | Growing | esbuild |
| `unbuild` | ~500K | Growing | Rollup + esbuild |
| `pkgroll` | ~80K | Growing | Rollup |
| `vite` (library mode) | ~20M | Growing | Rollup |

---

## tsup: The Standard for Most Libraries

tsup is the right default choice for the overwhelming majority of npm package authors in 2026. It wraps esbuild with sensible defaults: run `tsup src/index.ts --format cjs,esm --dts` and you get three outputs — a CommonJS bundle, an ESM bundle, and a TypeScript declaration file. That single command does what used to take three separate tools.

The philosophy is zero-config-first with escape hatches. Start with command-line flags, graduate to `tsup.config.ts` when you need finer control. The esbuild foundation means builds are fast even on large codebases — typically sub-second for anything under 100,000 lines.

```bash
npm install --save-dev tsup typescript
```

```typescript
// tsup.config.ts — zero config or full config:
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],  // Or multiple entries
  format: ['cjs', 'esm'],   // Output both formats
  dts: true,                 // Generate .d.ts files
  splitting: false,           // No code splitting for libraries
  sourcemap: true,
  clean: true,               // Clean dist before build

  // Tree-shaking friendly:
  treeshake: true,

  // For bundling vs externalizing:
  external: ['react', 'react-dom'],  // Don't bundle peer deps
  noExternal: ['my-util'],           // Force bundle this dep

  // Multiple entry points with custom output names:
  // entry: {
  //   index: 'src/index.ts',
  //   cli: 'src/cli.ts',
  // },
});
```

```json
// package.json for the published library:
{
  "name": "my-library",
  "version": "1.0.0",
  "main": "./dist/index.js",           
  "module": "./dist/index.mjs",         
  "types": "./dist/index.d.ts",         
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch"
  }
}
```

### tsup Watch Mode

During library development, you typically want your package to rebuild whenever you save. tsup ships with a `--watch` flag that uses esbuild's incremental rebuild API — changes propagate in milliseconds rather than seconds.

```bash
# Rebuild on file changes (good for library development):
npx tsup --watch

# Or with type checking:
npx tsup --watch & npx tsc --watch --noEmit
```

The `--watch` and `tsc --watch --noEmit` combination is a common pattern: tsup handles the fast build loop while the TypeScript compiler runs in parallel to surface type errors. Because esbuild transpiles TypeScript by stripping types without type-checking, you want that second process running to catch mistakes.

One practical limitation: tsup's `dts: true` mode uses the TypeScript compiler API to generate declarations and is much slower than the main build. For large packages, some teams disable `dts` during watch mode and only generate declarations in the final `build` step.

---

## unbuild: Monorepo Powerhouse

unbuild is the build tool created by the UnJS ecosystem — the same team behind Nuxt, Nitro, H3, and ofetch. It is Rollup-based, which gives it better tree-shaking characteristics than esbuild for certain patterns, and it includes features specifically designed for monorepo workflows.

The killer feature is **stub mode**. In a monorepo, package A might depend on package B. Without stub mode, every time you change B you need to rebuild B before A can pick up the change. With `unbuild --stub`, the dist output is replaced by a thin shim that imports directly from your TypeScript source, so changes are visible instantly in dependent packages without any rebuild step.

```bash
npm install --save-dev unbuild
```

```typescript
// build.config.ts — multiple entry points:
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: [
    'src/index',
    'src/cli',
    { input: 'src/utils/', outDir: 'dist/utils' },
  ],
  declaration: true,

  rollup: {
    emitCJS: true,
    esbuild: {
      minify: false,
      target: 'es2020',
    },
  },

  // External packages (don't bundle):
  externals: ['react', 'vue', 'rollup'],

  // Auto-detect workspace packages and externalize them:
  // failOnWarn: true,  // Fail CI on warnings
});
```

### Stub Mode (unbuild's Secret Weapon)

```bash
# Instead of building, creates a stub that points to source:
npx unbuild --stub

# This creates dist/index.mjs that just re-exports from src/:
# export * from '../src/index.ts'
# (TypeScript is executed directly via tsx)
```

```json
// Monorepo package.json workflow:
{
  "scripts": {
    "dev": "unbuild --stub",    // Instant — no rebuild on changes
    "build": "unbuild",          // Real build for publishing
    "prepack": "unbuild"         // Build before npm publish
  }
}
```

In a Turborepo setup, running `pnpm dev` across all packages with unbuild stub mode means every package is immediately available with its latest TypeScript source. No build order dependencies, no race conditions, no stale cache issues during development.

unbuild also handles export maps from `package.json` intelligently. If you define subpath exports like `"./utils"`, unbuild reads that and generates the corresponding output files. This reduces the chance of export map mismatches — a common source of "module not found" errors when consumers try to use subpath imports.

---

## pkgroll: Rollup-Powered and Package.json-Driven

pkgroll takes a different philosophy from tsup and unbuild: instead of having its own configuration file, it reads your `package.json` exports field and builds exactly what you've declared there. If you have both `import` and `require` conditions in your exports, pkgroll produces both. No config file needed.

This makes pkgroll the most opinionated tool of the group in a good way — it enforces alignment between your declared public API and your build outputs. You can't accidentally ship a build artifact that isn't referenced in your exports map.

```bash
npm install --save-dev pkgroll
```

```json
// package.json — pkgroll reads exports map and builds accordingly:
{
  "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"
    }
  }
}
```

pkgroll reads your `package.json` exports map and builds whatever outputs you declare. Zero separate config file needed. Run `pkgroll --minify` for production and `pkgroll --sourcemap` for debugging. For pure-ESM packages with clean export maps, it is the least friction option available.

The trade-off is limited flexibility for unusual build requirements. If you need custom rollup plugins, transforms, or complex entry point logic, you will hit pkgroll's limitations and need to graduate to a more configurable tool.

---

## Vite Library Mode: For Component Libraries

Vite's library mode (`vite build --lib`) is aimed at a slightly different use case than the other tools here: component libraries that also need a development preview environment. If you are building a React or Vue component library and want to see your components in a story-like browser environment during development, Vite lets you do both — run `vite` for the dev server and `vite build` for the production library bundle.

Under the hood, Vite uses Rollup for library builds, giving you the same tree-shaking quality as unbuild and pkgroll. The configuration lives in `vite.config.ts`.

```typescript
// vite.config.ts — library mode:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';

export default defineConfig({
  plugins: [
    react(),
    dts({ rollupTypes: true }),  // Generate declaration files
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyComponentLibrary',
      formats: ['es', 'cjs'],
      fileName: (format) => `my-lib.${format === 'es' ? 'mjs' : 'js'}`,
    },
    rollupOptions: {
      // Externalize peer deps — don't include them in the bundle:
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
      },
    },
  },
});
```

The `vite-plugin-dts` plugin handles TypeScript declaration generation. With `rollupTypes: true`, it bundles all your type declarations into a single `.d.ts` file rather than replicating your source directory structure, which is almost always what you want for a published library.

One advantage over tsup and pkgroll is the unified dev+build story. Your component stories run in the Vite dev server (with HMR), and the same configuration produces the library bundle. Teams that use Storybook sometimes find that Vite library mode is lighter weight than their previous separate-tool setup.

---

## esbuild Direct: For Custom Build Scripts

When none of the above tools fit your requirements, esbuild's JavaScript API gives you full programmatic control. This is appropriate for application builds, custom multi-step pipelines, or cases where you need to do work before and after the bundle step.

```typescript
// build.ts — direct esbuild API:
import esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  format: 'esm',
  outfile: 'dist/index.mjs',
  external: ['react', 'react-dom'],
  target: 'es2020',
  sourcemap: true,
  treeShaking: true,
  minify: process.env.NODE_ENV === 'production',
  define: {
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'development'),
  },
});

// Run for CJS:
await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  format: 'cjs',
  outfile: 'dist/index.js',
  external: ['react', 'react-dom'],
});

// Generate declarations separately (esbuild doesn't emit .d.ts):
import { execSync } from 'child_process';
execSync('tsc --emitDeclarationOnly --outDir dist');
```

The important limitation to know: esbuild does not generate TypeScript declaration files. You need a separate `tsc --emitDeclarationOnly` step, which is exactly what tsup abstracts away with its `dts: true` option. Using esbuild directly gives you speed and flexibility but requires you to wire up this step yourself.

---

## Comparison Table

| | tsup | unbuild | pkgroll | Vite lib | esbuild |
|--|------|---------|---------|----------|---------|
| **Config needed** | Minimal | Minimal | Zero (reads package.json) | Moderate | JavaScript API |
| **Declaration (.d.ts)** | Yes via dts | Yes | Yes | Yes via plugin | No (manual tsc) |
| **Stub mode** | No | Yes | No | No | No |
| **Underlying engine** | esbuild | Rollup+esbuild | Rollup | Rollup | esbuild |
| **Speed** | Fast | Medium | Fast | Medium | Fastest |
| **Vite dev server** | No | No | No | Yes | No |
| **Export map aware** | Partial | Yes | Yes (source of truth) | Partial | No |
| **Ecosystem** | Large | UnJS | Small | Large | Huge (lower-level) |

---

## When to Choose

**tsup** is the right default for the vast majority of npm packages. The documentation is extensive, the configuration is intuitive, and the esbuild foundation keeps build times fast. Start here if you are publishing a utility library, a React hook library, or any general-purpose npm package.

**unbuild** shines in monorepos where packages depend on each other. Stub mode eliminates the rebuild-before-use friction that slows down development in multi-package repos. It is also the natural choice if you are working within the UnJS ecosystem or need sophisticated export map handling across multiple subpath exports.

**pkgroll** is ideal when you want your package.json exports map to be the single source of truth for your build. It enforces discipline: if the export is not in your exports map, it does not get built. Good for modern, pure-ESM packages with simple requirements.

**Vite library mode** is the right pick when you are building a component library that also needs a dev-server preview environment. The unified story — one config for both dev server and library build — reduces tooling overhead compared to maintaining tsup for builds and something else for development.

**esbuild direct** is for custom build pipelines and application builds where you need programmatic control that no higher-level tool can provide.

---

## Setting Up Your Library for Publishing

Regardless of which build tool you choose, a properly configured `package.json` is what makes your library work correctly for consumers. Here is the complete pattern:

```json
{
  "name": "@myorg/my-library",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./utils": {
      "import": {
        "types": "./dist/utils.d.ts",
        "default": "./dist/utils.js"
      }
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "prepublishOnly": "pnpm run build"
  }
}
```

The `types` condition inside the exports map (rather than the top-level `types` field) is the modern TypeScript 5.x approach and is required for bundlers that support `moduleResolution: bundler`. The `files` field ensures only the `dist` folder is published to npm, not your source TypeScript.

---

## Related Reading

Teams migrating from webpack and babel have a concrete question: what do these TS-first tools provide out of the box that they previously had to configure manually? tsup and esbuild handle TypeScript transpilation and JSX transforms natively, eliminating the `@babel/preset-typescript` and `@babel/preset-react` chain entirely. You get faster builds without maintaining a Babel config. What you lose is Babel's plugin ecosystem — if you depend on `babel-plugin-styled-components`, a decorator transform that differs from TypeScript's native implementation, or any Babel transform that isn't a straight TypeScript feature, you'll need to verify coverage before migrating. Similarly, webpack's `ts-loader` or `babel-loader` provided source maps and module resolution that tsup replicates via esbuild, but webpack's mature ecosystem of loader plugins — for CSS Modules, SVG imports, worker files, and similar — has no direct equivalent in tsup. For library bundling specifically, these gaps are rarely relevant; most library code is pure TypeScript. For application bundling, Vite (which wraps esbuild and Rollup) covers more webpack use cases than tsup alone.

- Compare tsup and esbuild bundle quality: [/blog/esbuild-vs-swc-2026](/guides/esbuild-vs-swc-2026)
- See tsup package health metrics: [/packages/tsup](/packages/tsup)
- Rollup vs Vite for library builds: [/blog/rollup-vs-vite-2026](/guides/rollup-vs-vite-2026)
