Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Bundling and Build Tooling for Libraries

A library bundler turns your TypeScript source into the exact .mjs, .cjs, and .d.ts artifacts your package.json exports map promises consumers — get the bundler wrong and every downstream install inherits the mistake. This guide covers configuring tsup, Rollup, and esbuild for dual-format library output, and it sits within the broader Core JavaScript Package Workflows area.

Concept overview and where it fits

A bundler for a library has a different job than one for an application. An app bundle inlines everything into a single deployable; a library bundle must preserve module boundaries, externalize peer dependencies, and emit both module formats so that a consumer's own bundler can tree-shake what it imports. The three tools most teams reach for — tsup (a batteries-included wrapper over esbuild and Rollup), Rollup (the most configurable), and esbuild (the fastest) — all produce the same shape of output but trade configurability for speed differently.

Library bundler comparison tsup, Rollup and esbuild across config, speed and declarations. Tool Config Types + speed tsup minimal, opinionated dts built in, fast Rollup maximal, plugin-based via plugin, medium esbuild low-level, fast no dts, fastest
Each tool trades configurability against speed and built-in declaration support.

Whatever tool you pick, the contract it must satisfy is fixed by the manifest: the import condition needs an ESM file, the require condition needs a CommonJS file, and each needs a matching declaration file, as detailed in Generating Dual CJS/ESM Type Definitions.

Initialization and configuration

Start with a minimal, explicit configuration. The bundler needs three things: an entry point, the output formats, and the list of packages to treat as external. Everything a consumer is expected to provide — framework peers, Node built-ins — must be externalized so it is never inlined into your artifact.

Source to artifacts One entry fanned out to ESM, CJS and declarations. src/index.ts single entry bundler format + external dist/*.mjs + *.cjs .d.ts alongside
One source entry produces every artifact the exports map references.
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  sourcemap: true,
  clean: true,
  treeshake: true,
  external: ['react', 'react-dom'],
});

The dts: true flag makes tsup emit declarations alongside the JavaScript, and external keeps your peer dependencies out of the tarball. Pair the output with an exports map so each condition resolves to the right file.

Architecture: the module graph

Under the hood, these tools model your code as a module graph: entry points are roots, import/require statements are edges, and anything reachable is a candidate for the output unless it is marked external. Externalizing a dependency prunes that subtree from the graph and replaces the import with a bare reference the consumer resolves at install time. This is why a missing external entry is so damaging — it silently pulls an entire dependency (and its transitive graph) into your bundle, duplicating it in every consumer that also installs it.

Externalizing peers A bundled peer versus an externalized reference. Peer bundled • react inlined into dist • two copies at runtime • broken context + bloat Peer externalized • bare import kept • consumer resolves one copy • small artifact
Externalizing prunes the peer's subtree so the consumer supplies one shared copy.

Execution strategy: tsup, Rollup, esbuild

For most libraries, tsup's defaults are the fastest path to correct dual output. Reach for Rollup when you need fine-grained control over output chunks, custom plugins, or preserved module structure (preserveModules: true) so consumers can deep-import. Reach for raw esbuild when build speed dominates and you can accept that esbuild does not emit .d.ts files itself — you pair it with tsc --emitDeclarationOnly.

Execution strategy: tsup, Rollup, esbuild For most libraries, tsup's defaults are the fastest path to correct dual output. Execution strategy: tsup, Rollup, esbuild For most libraries, tsup's defaults are the fastest path to correct dual output.
Execution strategy: tsup, Rollup, esbuild — the core idea of this section at a glance.
// package.json — wiring the built output to consumers
{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "sideEffects": false
}

Security and isolation

Treat the build as a supply-chain surface. Run the bundler with --ignore-scripts during install so a compromised transitive dependency cannot execute a postinstall payload while you build, and pin the bundler itself in devDependencies with a frozen lockfile install. Never bundle secrets or environment values into a library artifact — define/env replacements belong in application builds, not published packages, because whatever you inline ships to every consumer verbatim.

Security and isolation Treat the build as a supply-chain surface. Security and isolation Treat the build as a supply-chain surface.
Security and isolation — the core idea of this section at a glance.

CI/CD integration

Validate the built artifact in CI, not just that the build ran. The most valuable check is publint and @arethetypeswrong/cli, which resolve your exports map exactly as a consumer's tooling would and fail on a mismatch before publish.

Artifact verification Build then resolve exports exactly as a consumer would. build emit dist publint exports valid attw --pack types resolve
publint and attw catch a broken exports map before publish, not after.
jobs:
  build-and-verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - run: pnpm install --frozen-lockfile --ignore-scripts
      - run: pnpm build
      - run: pnpm exec publint
      - run: pnpm exec attw --pack .

Common Pitfalls and Remediation

Mistake Impact Remediation
Not externalizing peers Framework duplicated in every consumer bundle Add every peer to external and peerDependencies.
esbuild-only, no .d.ts Consumers get 'no type declarations' Emit types with tsc --emitDeclarationOnly or use tsup dts: true.
Missing sideEffects: false Tree-shaking disabled for consumers Declare sideEffects: false (or an array) in package.json.
Bundling node: built-ins for the browser Runtime Cannot resolve errors Set the correct platform/target and externalize built-ins.
Common Pitfalls and Remediation Common Pitfalls and Remediation in production JavaScript package workflows. Common Pitfalls and Remediation Common Pitfalls and Remediation in production JavaScript package workflows.
Common Pitfalls and Remediation — the core idea of this section at a glance.

Frequently Asked Questions

Do I need a bundler at all for a small library?

Not always — tsc alone can emit ESM and declarations. But once you need dual CJS/ESM output, minification, or externalized peers, a bundler like tsup removes a lot of manual exports and dual-emit plumbing.

Why externalize peer dependencies instead of bundling them?

Bundling a peer inlines a second copy into your artifact, so a consumer ends up with two React instances and broken context. Externalizing keeps a single shared copy resolved from the consumer's own tree.

tsup, Rollup, or esbuild — which should I default to?

Default to tsup for libraries: it wraps esbuild for speed and Rollup for declaration bundling with almost no config. Drop to Rollup for chunk control and to raw esbuild only when build speed dominates.

How do I stop my published tarball from ballooning?

Set a files allowlist to ["dist"], declare sideEffects: false, externalize peers, and verify the packed result with npm pack --dry-run before publishing.

Related

Core JavaScript Package Workflows