Skip to main content

Best TypeScript-First Build Tools 2026

·PkgPulse Team
0

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

PackageWeekly DownloadsTrendUnderlying Engine
esbuild~25MGrowingN/A (it is the engine)
tsup~3MGrowingesbuild
unbuild~500KGrowingRollup + esbuild
pkgroll~80KGrowingRollup
vite (library mode)~20MGrowingRollup

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.

npm install --save-dev tsup 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',
  // },
});
// 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.

# 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.

npm install --save-dev unbuild
// 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)

# 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)
// 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.

npm install --save-dev pkgroll
// 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.

// 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.

// 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

tsupunbuildpkgrollVite libesbuild
Config neededMinimalMinimalZero (reads package.json)ModerateJavaScript API
Declaration (.d.ts)Yes via dtsYesYesYes via pluginNo (manual tsc)
Stub modeNoYesNoNoNo
Underlying engineesbuildRollup+esbuildRollupRollupesbuild
SpeedFastMediumFastMediumFastest
Vite dev serverNoNoNoYesNo
Export map awarePartialYesYes (source of truth)PartialNo
EcosystemLargeUnJSSmallLargeHuge (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:

{
  "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.


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.