Rollup for Library Authoring
Why Libraries Need a Different Bundler
Here's something that trips up a lot of developers: the bundler that's best for your app isn't necessarily the best for your library.
When you build an application, the output runs directly in the browser. You want one or a few optimized chunks. Webpack and Vite excel at this.
When you build a library, the output is consumed by other bundlers. Your users import your library in their webpack/Vite/Rollup project, and their bundler processes it further. This changes everything about what "good output" means.
Rollup was designed specifically for this use case. Its ESM-first philosophy produces clean, tree-shakable output that downstream bundlers can optimize further.
Think of the difference like cooking a full meal vs. selling ingredients. An application bundler (webpack) is a chef preparing a complete dish — everything combined, seasoned, plated, ready to eat. A library bundler (Rollup) is a supplier packaging individual ingredients — each one clean, well-labeled, and easy for the chef (the consumer's bundler) to use. If you pre-cook the ingredients (bundle everything into one blob), the chef can't adjust seasoning or leave out what they don't need.
ESM-First Philosophy
Rollup was the first bundler built around ES modules from the ground up. While webpack was designed in the CommonJS/AMD era and adapted to ESM later, Rollup was born after ES2015 standardized import/export.
This matters because Rollup's internal representation is ESM. It understands the static structure of imports and exports natively, which makes its tree shaking more aggressive and its output cleaner.
// rollup.config.js
export default {
input: 'src/index.js',
output: [
{ file: 'dist/my-lib.esm.js', format: 'es' },
{ file: 'dist/my-lib.cjs.js', format: 'cjs' },
{ file: 'dist/my-lib.umd.js', format: 'umd', name: 'MyLib' },
],
}
Rollup produces one config, multiple output formats. Your library ships ESM for modern bundlers, CJS for legacy Node.js, and UMD for <script> tag usage. Same source, multiple targets.
Tree Shaking by Design
Rollup pioneered tree shaking. The term was literally coined to describe what Rollup does — "shake the tree" of your module graph and let unused exports fall off.
Here's what makes Rollup's tree shaking different from webpack's:
Rollup: Includes only what's explicitly used. Starts with nothing and adds what's needed.
Webpack: Includes everything and marks what's unused for Terser to remove later.
The result: Rollup's output is typically 10-30% smaller for library code because it's more aggressive about eliminating dead code.
// src/index.js — your library
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }
export function multiply(a, b) { return a * b }
export function divide(a, b) { return a / b }
// Consumer imports only 'add'
import { add } from 'your-library'
Rollup's output when a consumer uses only add:
// Only add() appears in the consumer's bundle
function add(a, b) { return a + b }
export { add };
The other three functions are never included. Webpack would include all four and rely on Terser to remove the unused ones (which works, but only if the functions have no side effects).
Output Formats Explained
Rollup supports every major module format. Here's when to use each:
export default {
input: 'src/index.js',
output: [
{
file: 'dist/index.esm.js',
format: 'es',
},
{
file: 'dist/index.cjs.js',
format: 'cjs',
},
{
dir: 'dist/umd',
format: 'umd',
name: 'MyLib',
globals: { react: 'React' },
},
{
file: 'dist/index.iife.js',
format: 'iife',
name: 'MyLib',
},
],
}
| Format | Use Case | Tree Shakable |
|---|---|---|
| es (ESM) | Modern bundlers (Vite, webpack 5, Rollup), modern Node.js | Yes |
| cjs (CommonJS) | Legacy Node.js, older bundlers, require() consumers | No |
| umd (Universal) | Script tags + AMD + CJS — works everywhere | No |
| iife (Immediately Invoked) | Script tags only — wraps code in a function scope | No |
For modern libraries, the recommended approach is to publish ESM as the primary format with CJS as a fallback. Set up your package.json exports map:
{
"name": "my-library",
"type": "module",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
},
"./utils": {
"import": "./dist/utils.esm.js",
"require": "./dist/utils.cjs.js"
}
},
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"sideEffects": false
}
External Dependencies
This is critical for libraries and where beginners mess up the most. When bundling a library, you should not include your dependencies in the output. Your consumers already have React — you shouldn't ship another copy inside your library.
export default {
input: 'src/index.js',
external: ['react', 'react-dom', /^react-dom\//],
output: {
file: 'dist/index.esm.js',
format: 'es',
},
}
The external option tells Rollup: "don't resolve these imports — leave them as-is in the output." The consumer's bundler will resolve them from the consumer's node_modules.
// Your source
import React from 'react'
import { clsx } from 'clsx'
export function Button({ className }) {
return React.createElement('button', { className: clsx('btn', className) })
}
// Rollup output (react is external, clsx is bundled)
import React from 'react';
function clsx(...args) { /* inlined */ }
function Button({ className }) {
return React.createElement('button', { className: clsx('btn', className) });
}
export { Button };
A common pattern: mark all dependencies and peerDependencies as external, but bundle devDependencies:
import pkg from './package.json' assert { type: 'json' }
export default {
input: 'src/index.js',
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
],
output: { file: 'dist/index.esm.js', format: 'es' },
}
If you forget to mark React as external and bundle it into your library, consumers end up with two copies of React — theirs and yours. This causes the infamous "Invalid hook call" error because React hooks break when multiple React instances coexist. Always externalize peer dependencies.
preserveModules: The Directory-Matching Strategy
By default, Rollup concatenates your source files into a single output file. But for large libraries, there's a better approach: preserveModules.
export default {
input: 'src/index.js',
output: {
dir: 'dist',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
},
}
Source: Output:
src/ dist/
index.js → index.js
utils/ utils/
format.js → format.js
validate.js → validate.js
components/ components/
Button.js → Button.js
Modal.js → Modal.js
This mirrors your source directory structure in the output. Why would you want this?
- Better tree shaking for consumers — each file is an independently shakable module
- Direct file imports — consumers can import
your-lib/utils/formatdirectly - Smaller individual chunk sizes — consumers only load what they need
Libraries like date-fns, lodash-es, and @radix-ui/react-* use this approach.
Code Splitting in Rollup
Rollup supports code splitting when you use dir output instead of file:
export default {
input: {
main: 'src/index.js',
utils: 'src/utils/index.js',
},
output: {
dir: 'dist',
format: 'es',
chunkFileNames: 'chunks/[name]-[hash].js',
},
}
Rollup automatically extracts shared modules between entry points into separate chunks. If both main and utils import a helper function, that helper ends up in a shared chunk instead of being duplicated.
Dynamic imports also create split points:
export async function loadChart() {
const { Chart } = await import('./Chart')
return new Chart()
}
Rollup creates a separate chunk for Chart and its dependencies, loaded on demand when loadChart() is called.
Rollup Plugin System
Rollup's plugin API is clean, well-documented, and the foundation that Vite's plugin API is built on. Plugins hook into Rollup's build lifecycle:
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'
import terser from '@rollup/plugin-terser'
export default {
input: 'src/index.ts',
external: ['react', 'react-dom'],
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.build.json' }),
terser(),
],
output: [
{ file: 'dist/index.esm.js', format: 'es' },
{ file: 'dist/index.cjs.js', format: 'cjs' },
],
}
Essential plugins for library authoring:
- @rollup/plugin-node-resolve — resolves bare imports from
node_modules - @rollup/plugin-commonjs — converts CJS dependencies to ESM so Rollup can process them
- @rollup/plugin-typescript — compiles TypeScript (also generates
.d.tsdeclarations) - @rollup/plugin-terser — minifies the output
| What developers do | What they should do |
|---|---|
| Bundling React/framework dependencies into your library Bundling React into your library means consumers get duplicate React copies, causing 'Invalid hook call' errors and bloated bundles. | Mark frameworks as external and list them as peerDependencies |
| Only publishing CJS format CJS cannot be tree-shaken. Modern bundlers prefer ESM. Publishing only CJS forces consumers to include your entire library regardless of usage. | Publish ESM as primary with CJS fallback |
| Missing sideEffects: false in package.json Without this declaration, consuming bundlers conservatively include all modules from your library, even unused ones, because they can't prove the modules are side-effect free. | Add sideEffects: false (or list specific files with side effects) |
| Using webpack to bundle a library Webpack wraps each module in a function scope and adds its runtime. Rollup concatenates modules into flat, clean code with less overhead — better for libraries that will be processed by another bundler. | Use Rollup (or Vite in library mode) for cleaner, smaller output |
- 1Use Rollup for libraries, Vite/webpack for applications. Different use cases need different bundler philosophies.
- 2Always externalize peer dependencies (React, frameworks) — never bundle them into your library output.
- 3Publish ESM as primary format with the 'module' and 'exports' fields in package.json. Add CJS fallback via 'main'.
- 4Set sideEffects: false in package.json so consuming bundlers can tree-shake your library effectively.
- 5Use preserveModules for large libraries — it mirrors your source structure and enables fine-grained tree shaking.
- 6Rollup's tree shaking is more aggressive than webpack's — it includes only what's used rather than including everything and removing unused code.