Bundling Libraries
Bundling JavaScript/TypeScript libraries for distribution using tsup, unbuild, and Rollup
You are an expert in bundling JavaScript and TypeScript libraries for npm distribution using tsup, unbuild, and Rollup, including dual CJS/ESM output, tree-shaking, and declaration file generation. ## Key Points - **ESM (ES Modules)**: `import/export` syntax. Tree-shakeable. Used by modern bundlers and Node.js with `"type": "module"`. - **CJS (CommonJS)**: `require/module.exports` syntax. Used by Node.js (default without `"type": "module"`) and legacy tooling. - **UMD (Universal Module Definition)**: Works in `<script>` tags, AMD, and CommonJS. Rarely needed today. - **IIFE (Immediately Invoked Function Expression)**: For direct `<script>` tag usage. 1. Output must use ESM (`import`/`export`). 2. The package must declare `"sideEffects": false` (or list side-effectful files). 3. Avoid patterns that defeat static analysis (dynamic `require`, barrel file re-exports without `export *`). - **Bundled declarations**: A single `.d.ts` matching each output entry point. - **TypeScript `declaration` compiler option**: Emits individual `.d.ts` files mirroring source structure. - **`dts` plugins**: Tools like `tsup` and `unbuild` generate bundled declarations automatically. - Mismatched `types` and `default` conditions - Missing `types` condition in `exports` ## Quick Example ```bash npm install -D tsup ``` ```bash npm install -D unbuild ```
skilldb get package-management-skills/Bundling LibrariesFull skill: 395 linesBundling Libraries — Package Management
You are an expert in bundling JavaScript and TypeScript libraries for npm distribution using tsup, unbuild, and Rollup, including dual CJS/ESM output, tree-shaking, and declaration file generation.
Core Philosophy
Overview
Bundling a library means compiling and packaging source code into distributable formats (CommonJS, ESM, or both) that consumers can import. Unlike application bundling (where you optimize for a single runtime), library bundling must produce outputs that work across Node.js, bundlers, and sometimes browsers, while preserving tree-shakeability and providing TypeScript declarations.
Core Concepts
Output Formats
- ESM (ES Modules):
import/exportsyntax. Tree-shakeable. Used by modern bundlers and Node.js with"type": "module". - CJS (CommonJS):
require/module.exportssyntax. Used by Node.js (default without"type": "module") and legacy tooling. - UMD (Universal Module Definition): Works in
<script>tags, AMD, and CommonJS. Rarely needed today. - IIFE (Immediately Invoked Function Expression): For direct
<script>tag usage.
Dual Package Output
Most libraries ship both ESM and CJS to maximize compatibility:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
Tree-Shaking
For a library to be tree-shakeable:
- Output must use ESM (
import/export). - The package must declare
"sideEffects": false(or list side-effectful files). - Avoid patterns that defeat static analysis (dynamic
require, barrel file re-exports withoutexport *).
Declaration Files
TypeScript consumers need .d.ts files. Options:
- Bundled declarations: A single
.d.tsmatching each output entry point. - TypeScript
declarationcompiler option: Emits individual.d.tsfiles mirroring source structure. dtsplugins: Tools liketsupandunbuildgenerate bundled declarations automatically.
Implementation Patterns
tsup
tsup is a zero-config bundler for TypeScript libraries, powered by esbuild.
Installation:
npm install -D tsup
Basic configuration (tsup.config.ts):
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: true,
clean: true,
sourcemap: true,
outDir: 'dist',
});
Multiple entry points:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
helpers: 'src/helpers.ts',
'cli/index': 'src/cli/index.ts',
},
format: ['cjs', 'esm'],
dts: true,
clean: true,
});
With external dependencies:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
// Don't bundle dependencies — consumers will install them
external: ['react', 'react-dom'],
// Or auto-detect from package.json
// tsup automatically externalizes dependencies and peerDependencies
});
package.json scripts:
{
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
}
}
package.json entry points:
{
"name": "@myorg/utils",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"prepublishOnly": "npm run build"
}
}
unbuild
unbuild is a unified build system by the UnJS team, supporting passive (bundleless) and active (bundled) modes.
Installation:
npm install -D unbuild
Configuration (build.config.ts):
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
inlineDependencies: false,
},
externals: ['react'],
});
Stub mode for development:
# Creates a stub that points to source files (fast, no rebuild needed)
unbuild --stub
Stub mode uses jiti to transpile on-the-fly, so changes to source files are reflected immediately without rebuilding.
Multiple build entries:
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: [
'src/index',
'src/helpers',
{ input: 'src/cli/', outDir: 'dist/cli' },
],
declaration: true,
rollup: {
emitCJS: true,
},
});
package.json:
{
"scripts": {
"build": "unbuild",
"dev": "unbuild --stub",
"prepublishOnly": "npm run build"
}
}
Rollup (Direct Configuration)
For maximum control, use Rollup directly.
Installation:
npm install -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-dts
Configuration (rollup.config.mjs):
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';
const external = ['react', 'react-dom'];
export default [
// Main build
{
input: 'src/index.ts',
output: [
{
file: 'dist/index.cjs',
format: 'cjs',
sourcemap: true,
},
{
file: 'dist/index.mjs',
format: 'es',
sourcemap: true,
},
],
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.build.json' }),
],
external,
},
// Declaration bundle
{
input: 'src/index.ts',
output: {
file: 'dist/index.d.ts',
format: 'es',
},
plugins: [dts()],
external,
},
];
tsconfig.build.json:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}
Conditional Exports for Multiple Entry Points
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./helpers": {
"import": {
"types": "./dist/helpers.d.mts",
"default": "./dist/helpers.mjs"
},
"require": {
"types": "./dist/helpers.d.cts",
"default": "./dist/helpers.cjs"
}
},
"./package.json": "./package.json"
}
}
Verifying the Build
# Check what gets published
npm pack --dry-run
# Verify exports resolve correctly
node -e "import('@myorg/utils').then(m => console.log(Object.keys(m)))"
node -e "console.log(Object.keys(require('@myorg/utils')))"
# Check bundle size
npx bundlephobia @myorg/utils
# Or locally:
npx esbuild dist/index.mjs --bundle --analyze
# Validate package.json exports
npx publint
npx attw --pack . # arethetypeswrong
publint and arethetypeswrong
These tools catch common packaging mistakes:
# Check for common publishing issues
npx publint
# Check TypeScript types resolution for all entry points
npx @arethetypeswrong/cli --pack .
Common issues they catch:
- Mismatched
typesanddefaultconditions - Missing
typescondition inexports - CJS default export issues (named vs. default)
- Files referenced in
exportsnot included infiles
Best Practices
- Ship both ESM and CJS for maximum compatibility. Use
format: ['cjs', 'esm']in tsup. - Always externalize
dependenciesandpeerDependencies. Never bundle them into your library output. - Include bundled
.d.tsdeclarations for every entry point. - Set
"sideEffects": falseinpackage.jsonif your library has no side effects. - Use
publintandarethetypeswrongin CI to validate package configuration before publishing. - Keep
tsconfig.build.jsonseparate fromtsconfig.jsonto exclude tests and dev files from the build. - Use tsup for most TypeScript libraries — it handles the common case with minimal configuration.
- Use unbuild's stub mode during development for instant feedback without rebuilds.
- Use Rollup directly only when you need fine-grained control over the output (plugins, chunk splitting, custom transforms).
- Run
npm pack --dry-runbefore every publish to verify the tarball contents. - Put
typesbeforedefaultinexportsconditions — TypeScript resolves the first matching condition.
Common Pitfalls
- Bundling dependencies into the library: If
reactgets bundled into your library, consumers end up with two copies. Always externalize. - Missing
typescondition: Without thetypescondition inexports, TypeScript consumers get no type resolution. Modern TypeScript (5.0+) usesexportsconditions. - Wrong condition order in
exports:typesmust come beforedefault. Resolvers use first-match, and puttingdefaultfirst means TypeScript never seestypes. - CJS/ESM interop issues: A CJS file that does
module.exports = { default: fn }instead ofmodule.exports = fnconfuses consumers. Test both import styles. - Forgetting
.mjs/.cjsextensions: When using"type": "module", CJS files must use.cjsextension. Match yourexportspaths to actual output filenames. - Not testing the published package: The library works in the monorepo but fails when installed from npm because paths, missing files, or configuration differ. Use
npm packand test the tarball. - Source maps pointing to missing sources: Shipping source maps without the original source files. Either include
src/infilesor disable source maps for published packages. - Barrel file bloat: A single
index.tsthat re-exports everything defeats tree-shaking for bundlers that cannot handle deep re-exports. Use direct entry points viaexportsfor large libraries.
Anti-Patterns
Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.
Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.
Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.
Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.
Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.
Install this skill directly: skilldb add package-management-skills
Related Skills
Dependency Audit
Security auditing npm dependencies for vulnerabilities, license compliance, and supply chain risks
Lockfile Management
Lock file strategies for deterministic installs across npm, pnpm, and Yarn
Npm Publishing
Publishing packages to the npm registry with proper configuration, access control, and release automation
Pnpm
pnpm workspace management for monorepos with content-addressable storage and strict dependency isolation
Private Registries
Setting up and using private npm registries with Verdaccio, GitHub Packages, and GitLab Package Registry
Semantic Versioning
Semantic versioning (SemVer) conventions, version ranges, and strategies for managing breaking changes