Bundling Libraries with Rollup: Output Formats, Tree Shaking, Plugins and Code Splitting
Key takeaways
Rollup is a module bundler for JavaScript that compiles ES modules into optimized bundles. It's the bundler of choice for libraries and powers Vite's production builds.
Introduction
Rollup is a module bundler for JavaScript that treats ES modules as first-class citizens. It’s designed for building libraries and produces highly optimized output.
Why Rollup?
Traditional bundlers include lots of runtime code:
// Webpack output (simplified)
(function(modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
// ... runtime code
}
return __webpack_require__(0);
})([/* modules */]);
Rollup output is clean and minimal:
// Just your code, optimized
function add(a, b) {
return a + b;
}
export { add };
I’ve shown this side-by-side comparison to teams evaluating a bundler switch and it’s usually the thing that actually convinces people, more than any feature list — webpack’s runtime code (module registry, __webpack_require__, hot-module-replacement scaffolding) exists to support things an application genuinely needs, like dynamically loading code chunks at runtime and swapping modules during development. A library, by contrast, is typically consumed as a single, already-resolved unit by someone else’s build — it doesn’t need its own runtime module system bundled in, since the consuming application’s own bundler handles that. Shipping webpack’s runtime inside a library adds real, unnecessary weight to every consumer’s bundle; Rollup’s design assumption — that the whole module graph is known and resolved at build time — is exactly what lets it skip that runtime entirely and just emit optimized, direct function calls.
Installation
npm install --save-dev rollup
Basic config:
// rollup.config.js
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
}
};
rollup -c
Even this minimal config surfaces the design choice worth internalizing before anything else in this guide: Rollup wants a single entry point (input) and figures out the whole dependency graph from there via real ES import/export statements — this static, single-entry model is exactly what makes deep tree-shaking possible, since the bundler can trace precisely which exports are actually reachable from that one starting point. It’s also the reason Rollup historically handled CommonJS (require/module.exports) as a second-class citizen needing a dedicated plugin (covered in Section 4) — CommonJS’s require() calls can be conditional or computed at runtime in ways ES modules’ static import statements can’t, which makes CommonJS fundamentally harder to statically analyze and tree-shake with full confidence.
Output Formats
Rollup supports multiple module formats:
export default {
input: 'src/index.js',
output: [
// ES Module (modern)
{
file: 'dist/bundle.esm.js',
format: 'es'
},
// CommonJS (Node.js)
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
// UMD (browser)
{
file: 'dist/bundle.umd.js',
format: 'umd',
name: 'MyLibrary'
},
// IIFE (browser script tag)
{
file: 'dist/bundle.iife.js',
format: 'iife',
name: 'MyLibrary'
}
]
};
Shipping all four formats from one config isn’t overkill for a real published library — it’s the practical answer to “I don’t control how my consumers build their apps.” es is for modern bundlers that can tree-shake your library in turn; cjs is for plain Node.js require() callers and older tooling; umd is the universal fallback that works via <script> tag, CommonJS, or AMD depending on the environment it’s loaded into; iife is specifically for a raw <script> tag with no module system at all, attaching the library to a global variable. Getting this wrong in practice usually looks like a bug report from a consumer using a build setup you never tested against — publishing the wrong single format (or the right format under the wrong package.json field) is a genuinely common source of “works for me, broken for them” library issues.
Tree Shaking
Rollup pioneered tree shaking - removing unused code:
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
// main.js
import { add } from './math.js';
console.log(add(1, 2));
Output (only used code):
function add(a, b) {
return a + b;
}
console.log(add(1, 2));
// subtract and multiply are removed!
Tree shaking’s name is a helpful mental image (shake the tree, dead leaves fall off) but the actual mechanism is closer to reachability analysis than shaking: starting from what’s actually imported in main.js, Rollup traces which exports are genuinely used and discards everything unreachable from that starting point — subtract and multiply aren’t removed because they’re “unused” in some generic sense, they’re removed because nothing in the traced import graph ever references them. This is precisely why side effects are the thing that breaks tree-shaking in practice: a module like import './setup-analytics.js' that runs code for its side effects rather than exporting anything meaningful can’t be safely eliminated just because nothing imports a named export from it — Rollup (and other bundlers) generally has to assume a module might have side effects unless told otherwise, which is exactly what a package.json’s sideEffects: false field or per-file /*#__PURE__*/ annotations exist to communicate explicitly when it’s actually safe to elide.
Essential Plugins
@rollup/plugin-node-resolve
Resolves npm packages:
npm install --save-dev @rollup/plugin-node-resolve
import resolve from '@rollup/plugin-node-resolve';
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [resolve()]
};
Worth understanding why this plugin exists at all rather than being built into Rollup’s core: Rollup’s core only understands bare ES module import/export syntax pointing at actual file paths — it has no built-in concept of npm’s node_modules resolution algorithm (walking up parent directories looking for a package, reading package.json’s main/module fields to pick an entry file). @rollup/plugin-node-resolve is what teaches Rollup to understand import { debounce } from 'lodash-es' as “go look in node_modules/lodash-es and figure out which file that actually means” — without it, any bare-specifier import of an installed package fails to resolve at all.
@rollup/plugin-commonjs
Converts CommonJS to ES modules:
npm install --save-dev @rollup/plugin-commonjs
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
export default {
plugins: [
resolve(),
commonjs() // After resolve
]
};
The // After resolve comment matters more than a passing style note — plugin order genuinely changes behavior in Rollup, since each plugin’s hooks run in the sequence they’re listed, and commonjs() needs resolve() to have already turned a bare package specifier into an actual file path before it can inspect that file’s contents and decide whether it’s CommonJS needing conversion. Reversing the order is a real, commonly-hit misconfiguration — commonjs() running before a module has even been resolved to a real path has nothing to convert yet, and the build can fail or silently skip the conversion depending on the specific plugin versions involved.
@rollup/plugin-babel
Transpiles modern JavaScript:
npm install --save-dev @rollup/plugin-babel @babel/core @babel/preset-env
import babel from '@rollup/plugin-babel';
export default {
plugins: [
babel({
babelHelpers: 'bundled',
presets: ['@babel/preset-env']
})
]
};
babelHelpers: 'bundled' is worth understanding before it produces a confusing duplication problem: Babel’s output for certain modern syntax (async/await down-compilation, class inheritance) relies on small shared helper functions, and 'bundled' inlines a fresh copy of those helpers into every single output file that needs them — fine for a single-bundle library, but if you’re outputting multiple chunks (Section 6) or multiple formats from one build, each one gets its own duplicate copy of the same helper code. The alternative, 'runtime', instead imports the helpers from @babel/runtime as a real shared dependency, deduplicating them across outputs at the cost of adding that package as a dependency consumers need installed — the right choice depends on whether a library is single-bundle (favor 'bundled' for simplicity) or genuinely multi-chunk (favor 'runtime' to avoid repeated helper code bloating every chunk).
@rollup/plugin-terser
Minifies output:
npm install --save-dev @rollup/plugin-terser
import terser from '@rollup/plugin-terser';
export default {
plugins: [
terser()
]
};
Terser deserves a distinction from the transpilation plugins around it: Babel and TypeScript’s plugins change syntax (down-leveling modern features, stripping types), while Terser changes size — minifying names, removing whitespace and comments, and applying safe dead-code elimination at the statement level, on top of whatever Rollup’s own module-level tree-shaking already did. It’s the very last plugin that should run in a build pipeline for exactly that reason: running it before other transforms means minifying code that’s about to be transformed again, which is both wasted work and can interfere with source map accuracy for the final, actually-shipped output.
Building a Library
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import terser from '@rollup/plugin-terser';
export default {
input: 'src/index.js',
output: [
{
file: 'dist/my-library.esm.js',
format: 'es',
sourcemap: true
},
{
file: 'dist/my-library.cjs.js',
format: 'cjs',
sourcemap: true
},
{
file: 'dist/my-library.min.js',
format: 'umd',
name: 'MyLibrary',
sourcemap: true,
plugins: [terser()]
}
],
plugins: [
resolve(),
commonjs(),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**'
})
],
external: ['react', 'react-dom'] // Don't bundle React
};
external is a decision I’ve seen teams get wrong in both directions, and either mistake is real trouble to untangle after the fact. Forgetting to externalize a peer dependency like React means your library bundles its own copy of React, and if the consuming app also has React installed (which it will, since your component library needs it to render anything), the app ends up with two separate React instances — the classic symptom is components silently failing to share Context correctly, or React throwing “Invalid hook call” errors that have nothing obviously to do with the actual bug. The opposite mistake — externalizing something that isn’t genuinely a peer dependency the consumer is expected to already have — produces a library that throws Cannot find module at runtime for anyone who doesn’t happen to have that exact package installed separately. rollup-plugin-peer-deps-external (used later in the React Library section) exists specifically to read a library’s own package.json peerDependencies and externalize exactly those, rather than hand-maintaining this list and risking it drifting out of sync.
package.json:
{
"name": "my-library",
"main": "dist/my-library.cjs.js",
"module": "dist/my-library.esm.js",
"browser": "dist/my-library.min.js",
"files": ["dist"],
"scripts": {
"build": "rollup -c"
}
}
main/module/browser here is the older, field-by-field way of telling different tools which build to load — main for plain Node require(), module for bundlers smart enough to prefer ESM (and therefore able to tree-shake the library further), browser as a hint for browser-targeted bundling. Modern package.jsons increasingly use the newer exports field instead (covered in more depth in the Vite guide elsewhere on this site) for more precise, conditional resolution — worth knowing both exist, since a lot of real-world libraries (and a lot of tooling that consumes them) still rely on this older three-field convention rather than the newer one.
Code Splitting
export default {
input: {
main: 'src/main.js',
admin: 'src/admin.js'
},
output: {
dir: 'dist',
format: 'es'
}
};
Dynamic imports:
// main.js
async function loadModule() {
const module = await import('./heavy-module.js');
module.init();
}
Switching output.file for output.dir is the detail that actually enables this — a single file output can’t represent multiple emitted chunks, so declaring multiple input entries (or using dynamic import(), which Rollup automatically splits into its own chunk) requires dir instead, letting Rollup name and emit as many output files as the dependency graph actually needs, including any shared code the main/admin entries both depend on, automatically factored out into its own common chunk.
TypeScript Support
npm install --save-dev @rollup/plugin-typescript typescript
import typescript from '@rollup/plugin-typescript';
export default {
input: 'src/index.ts',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [
typescript()
]
};
@rollup/plugin-typescript type-checks and compiles in one step by shelling out to the real TypeScript compiler, which is worth knowing as a tradeoff against faster alternatives like esbuild’s TypeScript support: esbuild strips types without actually checking them (it assumes the code already type-checks, which is fast but means a type error slips silently into the build unless something else catches it), while this plugin does full, real type-checking as part of the build — slower, but it’s what makes declaration: true meaningful, since accurate .d.ts output requires the compiler to have actually resolved and verified the real types, not just erased annotations.
React Library
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
export default {
input: 'src/index.jsx',
output: [
{
file: 'dist/index.js',
format: 'cjs',
sourcemap: true
},
{
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true
}
],
plugins: [
peerDepsExternal(), // Externalize peer dependencies
resolve(),
babel({
babelHelpers: 'bundled',
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }]
],
exclude: 'node_modules/**'
}),
commonjs()
],
external: ['react', 'react-dom']
};
resolve(), babel(), and commonjs() here run in an order worth noticing precisely because it differs slightly from the plain-JS example earlier — peerDepsExternal() runs first (it needs to mark peer deps as external before anything else tries to resolve or bundle them), resolve() next, then babel() transpiling JSX before commonjs() gets a chance to touch anything. This is the same “order changes behavior” principle from the resolve/commonjs pairing earlier, just with an extra plugin added to the chain — worth internalizing as a general rule for any Rollup config: plugins that need to run before a module is resolved or transformed generally have to be listed before the plugins that do that resolving/transforming.
CSS and Assets
PostCSS
npm install --save-dev rollup-plugin-postcss
import postcss from 'rollup-plugin-postcss';
export default {
plugins: [
postcss({
extract: true,
minimize: true
})
]
};
extract: true here is the setting most worth pausing on: without it, rollup-plugin-postcss inlines the compiled CSS as a string and injects it via JavaScript at runtime (a <style> tag appended by your own bundle’s code) — convenient for a quick component library, but it means the CSS can’t be cached independently by the browser and loads only after the JS itself has executed, which is worse for both caching and initial paint. extract: true instead emits a genuine separate .css file consumers link normally, at the cost of requiring them to remember to actually import that stylesheet themselves — a real documentation burden for a component library, but the more correct default for anything beyond a small demo.
Images
npm install --save-dev @rollup/plugin-image
import image from '@rollup/plugin-image';
export default {
plugins: [
image()
]
};
Worth knowing this only handles bundling images that are actually imported as modules (turning them into base64 data URIs inline in the JS, similar to the ?inline asset pattern covered in the Vite guide) — it doesn’t optimize, resize, or convert formats the way a build-time image pipeline in a full application framework would. For a library shipping a handful of small icons this is fine; for anything with substantial image assets, keeping them as plain files the consuming app’s own asset pipeline handles is usually the more appropriate choice than bundling them into the library’s JS output at all.
Development Workflow
npm install --save-dev rollup-plugin-serve rollup-plugin-livereload
import serve from 'rollup-plugin-serve';
import livereload from 'rollup-plugin-livereload';
const dev = process.env.NODE_ENV !== 'production';
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'iife',
sourcemap: dev
},
plugins: [
// ... other plugins
dev && serve({
open: true,
contentBase: ['dist', 'public'],
port: 3000
}),
dev && livereload('dist')
].filter(Boolean)
};
{
"scripts": {
"dev": "rollup -c -w",
"build": "NODE_ENV=production rollup -c"
}
}
The dev && serve(...) / .filter(Boolean) pattern is worth recognizing as an idiom, since it shows up repeatedly through the rest of this guide (the Conditional Plugins section reuses it): JavaScript’s && short-circuits to false when dev is falsy, and a plugins array can’t contain a bare false value, so .filter(Boolean) strips those out afterward, leaving only the genuinely-included plugins — a compact way to conditionally include or exclude plugins in a single array literal without an if statement fragmenting the config into multiple branches. It’s Rollup-community idiom specifically, not a Rollup API feature — worth recognizing it in other people’s configs even though there’s nothing magic about it once you see the shape.
Advanced Configuration
Multiple Configs
// rollup.config.js
import dev from './rollup.config.dev.js';
import prod from './rollup.config.prod.js';
export default process.env.NODE_ENV === 'production' ? prod : dev;
This is worth reaching for over sprinkling production && ... conditionals throughout one shared config once that config genuinely diverges between environments — a dev config with sourcemap: true, unminified output, and a dev server, versus a prod config with multiple output formats and minification, are different enough shapes that maintaining them as one heavily-conditional file becomes harder to read than two separate, focused files re-exported through a thin switch like this one.
Conditional Plugins
const production = !process.env.ROLLUP_WATCH;
export default {
plugins: [
resolve(),
commonjs(),
production && terser()
].filter(Boolean)
};
process.env.ROLLUP_WATCH isn’t something you set yourself — Rollup sets it automatically whenever the build runs in watch mode (rollup -c -w), which is what makes !process.env.ROLLUP_WATCH a genuinely convenient way to detect “is this a one-shot production build or an ongoing watch session” without needing a separate NODE_ENV flag threaded through your npm scripts specifically for this purpose.
Optimizations
External Dependencies
export default {
external: [
'react',
'react-dom',
/^lodash/ // All lodash packages
]
};
The regex form here is worth noticing as a distinct capability from the plain string entries — external accepts either an exact module specifier or a pattern matching a whole family of related packages, which matters for scoped or modular packages like lodash’s per-function submodules (lodash/debounce, lodash/throttle) where listing every individual import path by hand would be both tedious and fragile against future additions.
Mangled Props (Advanced)
import terser from '@rollup/plugin-terser';
export default {
plugins: [
terser({
mangle: {
properties: {
regex: /^_/ // Mangle properties starting with _
}
}
})
]
};
This one deserves a genuine warning label, not just a description — property mangling renames every property matching the pattern across the entire codebase, including ones you didn’t intend, and I’ve seen it silently break a library at runtime in a way that passed every existing test because the tests never happened to exercise the broken path. The risk in practice: a _privateField accessed consistently via dot notation gets renamed safely everywhere, but the moment it’s also accessed dynamically (obj['_privateField']), serialized to JSON, or read by external code outside the mangled bundle (a plugin system, a debugging tool), the string-based access still looks for the original name that no longer exists post-mangling. It’s a real bundle-size win when applied narrowly and deliberately to fields you’re certain are only ever accessed via static dot notation inside your own bundled code — but it’s advanced enough that it’s worth treating as opt-in for a specific, well-understood set of properties rather than a blanket regex applied hopefully.
Watch Mode
rollup -c -w
// rollup.config.js
export default {
watch: {
include: 'src/**',
exclude: 'node_modules/**',
clearScreen: false
}
};
The default watch behavior clears the terminal on every rebuild, which is convenient for a clean view of just the latest build output but genuinely inconvenient the moment you’re trying to correlate a build error against something printed by another process in the same terminal (a dev server’s logs, a test runner) — clearScreen: false keeps history scrolling instead, worth flipping on whenever the watch process is sharing a terminal with other ongoing output rather than running standalone.
Real-World Example
Complete library build:
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';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';
const production = !process.env.ROLLUP_WATCH;
// The old `rollup-plugin-terser` package (a named export, `{ terser }`) is
// deprecated in favor of the official `@rollup/plugin-terser` (a default
// export) used consistently elsewhere in this guide — worth using the
// current package and import style rather than the older one, which still
// works but is no longer maintained.
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs',
sourcemap: true,
exports: 'named'
},
{
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true,
exports: 'named'
},
{
file: 'dist/index.umd.js',
format: 'umd',
name: 'MyLib',
sourcemap: true,
exports: 'named',
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
],
// exports: 'named' silences a Rollup warning (and, for cjs specifically,
// changes the actual output shape) that fires whenever a module has both
// a default export and named exports — without it, Rollup assumes
// you only meant a single default export and may drop the named ones
// from certain output formats. globals is required specifically for the
// umd format: since react/react-dom are external (never bundled), the
// UMD build needs to know what global variable name to reference them
// under when loaded via a plain <script> tag with no module system —
// cjs and es formats don't need this, since they resolve externals
// through require()/import instead of a global lookup.
plugins: [
peerDepsExternal(),
resolve({
extensions: ['.js', '.jsx', '.ts', '.tsx']
}),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist'
}),
postcss({
extract: 'styles.css',
minimize: production
}),
production && terser()
].filter(Boolean),
external: ['react', 'react-dom']
};
Performance Tips
Use Cache
Rollup caches by default in watch mode — each rebuild only reprocesses modules that actually changed and reuses the previous analysis for everything else, which is why watch-mode rebuilds are typically dramatically faster than a fresh cold build, even on a fairly large project.
Parallelize Builds
npm install --save-dev npm-run-all
{
"scripts": {
"build:esm": "rollup -c rollup.config.esm.js",
"build:cjs": "rollup -c rollup.config.cjs.js",
"build": "npm-run-all --parallel build:*"
}
}
Splitting into per-format configs and running them in parallel processes trades memory for wall-clock time — worth doing once a library’s build genuinely takes long enough to matter (multiple output formats, TypeScript declaration generation, CSS extraction all adding up), since each rollup -c invocation is a separate Node process able to use a separate CPU core, rather than one process working through every format serially.
Minimize Plugin Work
plugins: [
resolve({
mainFields: ['module', 'main'],
extensions: ['.js'] // Only what you need
})
]
extensions narrowing matters more than it looks for build speed on a real project: without restricting it, resolve() has to probe the filesystem for each of its default extensions (.mjs, .js, .json, .node, and more) for every bare import it resolves, and on a large dependency tree that’s a meaningful number of redundant filesystem checks — restricting the list to only what the project actually uses cuts that probing down proportionally, a small but real, cheap-to-apply win.