Skip to main content
Technology & EngineeringPackage Management395 lines

Bundling Libraries

Bundling JavaScript/TypeScript libraries for distribution using tsup, unbuild, and Rollup

Quick Summary28 lines
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 lines
Paste into your CLAUDE.md or agent config

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

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:

  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 *).

Declaration Files

TypeScript consumers need .d.ts files. Options:

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

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 types and default conditions
  • Missing types condition in exports
  • CJS default export issues (named vs. default)
  • Files referenced in exports not included in files

Best Practices

  • Ship both ESM and CJS for maximum compatibility. Use format: ['cjs', 'esm'] in tsup.
  • Always externalize dependencies and peerDependencies. Never bundle them into your library output.
  • Include bundled .d.ts declarations for every entry point.
  • Set "sideEffects": false in package.json if your library has no side effects.
  • Use publint and arethetypeswrong in CI to validate package configuration before publishing.
  • Keep tsconfig.build.json separate from tsconfig.json to 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-run before every publish to verify the tarball contents.
  • Put types before default in exports conditions — TypeScript resolves the first matching condition.

Common Pitfalls

  • Bundling dependencies into the library: If react gets bundled into your library, consumers end up with two copies. Always externalize.
  • Missing types condition: Without the types condition in exports, TypeScript consumers get no type resolution. Modern TypeScript (5.0+) uses exports conditions.
  • Wrong condition order in exports: types must come before default. Resolvers use first-match, and putting default first means TypeScript never sees types.
  • CJS/ESM interop issues: A CJS file that does module.exports = { default: fn } instead of module.exports = fn confuses consumers. Test both import styles.
  • Forgetting .mjs/.cjs extensions: When using "type": "module", CJS files must use .cjs extension. Match your exports paths 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 pack and test the tarball.
  • Source maps pointing to missing sources: Shipping source maps without the original source files. Either include src/ in files or disable source maps for published packages.
  • Barrel file bloat: A single index.ts that re-exports everything defeats tree-shaking for bundlers that cannot handle deep re-exports. Use direct entry points via exports for 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

Get CLI access →