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.
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.
// 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.
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.
// 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.
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.
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. |
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
- Understanding package.json Fields — the exports map and fields the bundler output must satisfy.
- Generating Dual CJS/ESM Type Definitions — emitting the declarations that pair with each JS format.
- ESM and CJS Interoperability — the module-format rules your dual output must honor.
- Lockfile Management Strategies — pinning the bundler and its plugins for reproducible builds.