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.
The mental shift that makes library bundling click is that you are not building a deployable — you are building an input to someone else's build. An application bundle inlines everything and minifies for the browser; a library bundle must instead preserve the seams a consumer's bundler needs: externalized peers so they are not duplicated, both module formats so the consumer can pick one and tree-shake it, and matching declarations so the type checker resolves the right file. Every default that is correct for an app — inline everything, target one environment — is usually wrong for a library.
That difference is why the three common tools trade off the way they do. tsup wraps esbuild for fast transpilation and Rollup for declaration bundling, giving correct dual output with almost no configuration; Rollup exposes the full plugin and output-chunk machinery for cases that need precise control; esbuild is the raw speed option that leaves declaration emit to a separate tsc step. For the large majority of libraries, tsup's defaults are the shortest path to a correct artifact, and you drop to Rollup or esbuild only when a specific requirement demands it.
It is worth being explicit about what a library build should not do, because the app-oriented defaults are tempting. It should not minify aggressively — that hurts the consumer's own debugging and is redundant with their production build; it should not inline environment values or feature flags, which belong to the application layer; and it should not target a specific browser, because the consumer's toolchain decides the deployment target. A library build's job is to produce clean, standard, externalized module output that a consumer can transform, not a finished bundle.
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.
The three fields a library build must get right are the entry, the output formats, and the external list, and the external list is the one most often wrong. Everything a consumer is expected to provide — framework peers, Node built-ins — must be externalized so it is never inlined; deriving that list from your peerDependencies keeps it from drifting as dependencies change. Match subpath imports with a prefix rule rather than an exact string, so a peer's subpath (its JSX runtime, its client entry) stays external alongside the package root instead of being partially bundled.
A reliable starting point is to be explicit about the three things a library build must get right — entry, formats, and externals — and to leave everything else at the tool's default. Over-configuring a library build is a common early mistake: enabling aggressive minification, targeting a specific browser, or inlining values are all application concerns that hurt a library consumer. The minimal correct configuration emits both module formats with declarations, externalizes peers and Node built-ins, and does nothing else, leaving the consumer's toolchain to make the deployment-specific decisions that are theirs to make.
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 is a graph operation, and seeing it that way clarifies why a missing external entry is so damaging. The bundler starts from your entry, follows every reachable import, and includes what it finds unless a rule marks it external; externalizing a package prunes its entire subtree from the graph and replaces the import with a bare specifier the consumer resolves at install time. Forget to externalize a peer and you do not just add its code — you add its whole transitive graph, duplicated into every consumer that also installs it.
Tree-shaking operates on what remains after resolution, which is why it cannot rescue a resolution mistake. Dead-code elimination proves, from static ESM import/export, that a binding is unused and drops it — but only for code that resolved successfully and only when the format is statically analyzable. A CommonJS build defeats it because require is dynamic; a barrel file with import-time side effects defeats it because the bundler cannot prove the siblings are unused. Correct output format and honest sideEffects metadata are prerequisites for the consumer to shrink your library at all.
Seeing the build as a graph also explains why entry-point design matters so much. Each entry you declare is a separate root the bundler traverses, so splitting a library into a browser entry and a node entry — or into per-feature subpath entries — gives you precise control over what each consumer's import can reach. A single monolithic entry forces every consumer to resolve the whole graph; deliberate entry points let a consumer pull exactly the slice they use, which is the structural foundation for both environment separation and fine-grained tree-shaking.
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
}
The choice between preserving modules and bundling into one file is a real architectural decision, not a detail. Bundling everything into a single index.mjs is simplest and smallest for a focused library, but it prevents consumers from deep-importing a slice; preserving module structure (preserveModules) keeps the file layout so consumers can import exactly one feature, at the cost of more files and a larger tarball. For libraries with many independent entry points, subpath exports plus preserved modules give consumers the finest-grained tree-shaking.
Whichever tool you use, the output must satisfy the manifest contract precisely: the import condition needs an ESM file, the require condition a CommonJS file, and each needs a matching declaration wired through nested types conditions. Getting the build right but the exports map wrong produces a package that works in your tests — which resolve the source directly — and fails in a consumer's install, which resolves through the published conditions. That is why the build and the manifest must be validated together, against the tarball, before publish.
Non-JavaScript assets are a frequent source of library-build confusion. A library that imports CSS, images, or other assets cannot assume the consumer's bundler handles them the same way, so the safest approach is to leave asset imports external and document that consumers must configure their bundler to handle them, or to ship the assets alongside the JavaScript and let consumers import them by path. Inlining assets into the JavaScript bundle bloats the artifact and takes control away from the consumer, which usually wants to optimize images and CSS with its own pipeline.
Deriving the external list from the manifest keeps it from drifting as dependencies change, and matching subpaths with a prefix rule keeps a peer's subpath external alongside its root:
// rollup.config.mjs
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
const deps = [
...Object.keys(pkg.peerDependencies ?? {}),
...Object.keys(pkg.dependencies ?? {}),
];
export default {
input: 'src/index.ts',
external: (id) =>
/^node:/.test(id) || deps.some((d) => id === d || id.startsWith(d + '/')),
output: [
{ file: 'dist/index.mjs', format: 'es' },
{ file: 'dist/index.cjs', format: 'cjs' },
],
};
Generating external from peerDependencies and dependencies means the two lists cannot disagree, the prefix rule keeps subpath imports external, and the node: regex externalizes built-ins — so nothing a consumer is expected to provide gets inlined into the artifact.
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.
The build is a supply-chain surface, and treating it as one closes a common gap. Running installs with ignored scripts during the build prevents a compromised transitive dependency from executing a lifecycle payload while you build, and pinning the bundler and its plugins with a frozen lockfile keeps the toolchain reproducible. Just as important, never inline secrets or environment values into a library artifact — define-style replacements belong in application builds, because whatever you inline ships verbatim to every consumer of the package.
A library build should never embed environment-specific values, because whatever you inline ships verbatim to every consumer. Define-style replacements that bake a value into the output at build time — an API endpoint, a feature flag, a build timestamp — belong to application builds, where the output is deployed as-is, not to library builds, where the output is an input to someone else's build. A library that inlines an endpoint forces that endpoint on every consumer regardless of their environment, which is both a correctness bug and, if the value is sensitive, a disclosure. Keep such values as runtime configuration the consumer supplies, not build-time constants the artifact carries.
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 .
The most valuable thing CI can do for a library build is resolve the published package the way a consumer will, which your own source-importing tests never do. A job that builds, then runs publint and @arethetypeswrong/cli against the packed tarball, catches a broken exports map or a mis-wired declaration before publish rather than after. Add a require-and-import smoke test under node16 resolution and the pipeline exercises the real conditional resolution end to end, turning packaging bugs into red builds on the PR that caused them.
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. |
The recurring library-build mistakes all share a shape: a default that is correct for an application but wrong for a library. Bundling peers instead of externalizing them, emitting one declaration for both formats, omitting sideEffects, minifying output the consumer will re-process, inlining assets the consumer wants to optimize — each is the app-oriented choice applied where the library-oriented one belongs. The remedy is the same discipline throughout: remember that a library is an input to someone else's build, and validate the published artifact the way that consumer will resolve it.
Validating the built artifact, not just the build
A green build is not evidence of a correct package, because the most damaging bundling mistakes only manifest when a consumer resolves your published exports — a path your own tests, which import the source directly, never exercise. The fix is to validate the artifact the way a consumer will. publint inspects the exports map for structural mistakes, and @arethetypeswrong/cli packs the tarball and resolves the types across every module and resolution combination, failing on a mismatch. Running both in CI turns a broken dual-format publish from a bug reported weeks later into a red build on the pull request.
Complement the static checks with a resolution smoke test: from a .mts file import the built package and from a .cts file require it, then type-check both under node16 resolution. This exercises the real conditional resolution end to end — the JavaScript loads under each format and the declarations resolve to the format-appropriate file — catching the reordered condition, the missing .d.cts, and the dual-package hazard that structural linting alone can miss. The principle throughout is that the artifact, not the source, is what ships, so the artifact is what you verify.
Keeping the published surface lean
Two levers control how much of your library a consumer actually pays for, and they act on different consumers. The files allowlist controls the tarball every install downloads, so trimming it to just the built output helps everyone, including users who never bundle; sideEffects: false controls what a consumer's bundler may eliminate, unlocking tree-shaking for those who do. A library can have a small tarball and still be un-shakeable if sideEffects is missing, so both matter and neither substitutes for the other.
Measure the two dimensions rather than assume them. npm pack --dry-run shows the tarball contents and size — a quick way to catch a leaked source directory or a stray test folder — while a bundle probe that imports a single symbol and measures the minified output shows whether a consumer can tree-shake effectively. If importing one function still pulls in the whole library, the cause is usually a CommonJS-only build or an impure barrel file, not the tarball. Making both a tracked budget in CI catches a size regression on the PR that introduced it, when it is cheap to fix, rather than in a user's bundle analyzer months later.
Source maps and the debugging experience you ship
Source maps are part of the artifact a library ships, and getting them right materially improves the consumer's debugging experience while getting them wrong quietly degrades it. Emitting source maps that point at your original TypeScript lets a consumer step into your library's real source in their debugger instead of the transpiled output, which is a genuine kindness for anyone diagnosing an issue that crosses into your code. The maps add a little tarball size, but for a library the debugging benefit usually justifies it.
The pitfall is shipping maps that reference source files not included in the tarball. If the files allowlist excludes the original sources but the maps point at them, a consumer's debugger follows a dangling reference and the experience is worse than no map at all. Either include the referenced sources (or inline them into the map with a sources-content option) so the maps resolve, or omit maps entirely — a broken map is a false promise. Deciding this deliberately, and verifying with npm pack --dry-run that whatever the maps reference actually ships, keeps the debugging experience you advertise the one consumers actually get.
Handling the CommonJS and ESM output together
Producing two formats from one source is the core of a dual library build, and the details of how the two outputs differ decide whether consumers hit interop errors. The ESM output uses import/export and typically a .mjs extension or a type: module context; the CommonJS output uses module.exports and a .cjs extension. Each must be paired with a matching declaration — .d.ts for the ESM entry, .d.cts for the CommonJS entry — and wired through nested types conditions in the exports map, or a consumer under modern resolution gets the wrong-shaped types for their import style.
The subtle hazard in dual output is shared mutable state. If a stateful module — a registry, a cache, a class whose identity matters — is duplicated across the two builds and a consumer reaches your package through both import and require, they get two independent instances that silently desynchronize. The defense is to keep any such singleton in a single module both builds reference, or to ship a single format where you do not need dual support. A dual build is straightforward for stateless utilities and requires this extra care precisely where state is involved, which is the part a naive tool configuration will not warn you about.
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.
Why does my library work in tests but break for consumers?
Your tests import the source directly, so they never exercise the published exports conditions. A consumer resolves through them. Validate the tarball with publint and attw plus a require/import smoke test under node16, so the resolution consumers hit is checked in your CI.
Should a library bundle into one file or preserve modules?
Bundle into one file for a small, focused library — simplest and smallest. Preserve modules (with subpath exports) when consumers benefit from deep-importing one feature, which gives them the finest-grained tree-shaking at the cost of more files.
What makes my library un-tree-shakeable even with sideEffects: false?
Usually a CommonJS-only build, whose dynamic require defeats static analysis, or a barrel file whose re-exported modules run code at import time. Ship an ESM build and keep barrels pure re-exports so the consumer's bundler can prove siblings are unused.
Should a library minify its output?
Generally no. Minification hurts the consumer's debugging and is redundant with their production build, which minifies anyway. A library should ship clean, standard, externalized module output that the consumer's toolchain can transform.
Why does my source map make debugging worse?
Almost always because it references source files the files allowlist excludes from the tarball, so the consumer's debugger follows a dangling path. Either include the referenced sources (or inline them into the map) or omit maps entirely — a broken map is worse than none.
Which bundler should I use for a library?
Default to tsup — it wraps esbuild for speed and handles dual-format output and declarations with almost no configuration. Drop to Rollup for fine-grained chunk control and to raw esbuild only when build speed dominates and you handle declarations separately.
How do I know my library's build is correct?
Validate the packed tarball the way a consumer resolves it: publint for the exports structure, @arethetypeswrong/cli for type resolution across modes, and a smoke test that imports and requires the build. Your own source-importing tests never exercise the published conditions, so the artifact is what you verify.
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.