Build a Tree‑Shakable Rollup Library with Preserved ES Module Structure
Configure Rollup with format es and preserveModules true to emit a directory of ES files that mirrors your source, enabling downstream tree‑shaking without bundling the whole library.
09 Feb 2026, 04:08 UTC

Problem: consumers bundle your whole library instead of just used exports
You ship a library as ES modules but after Rollup bundles it into one file, downstream bundlers cannot drop unused code. The useful takeaway is to emit a directory of ES files that mirrors your source, with one output file per source module and original import/export statements kept intact. That preserves static analysis for tree‑shaking.
Desired outcome
A distributable package where each source module maps to a separate output file. Imports remain as ES imports, not inlined. Consumers can import a single named export and a bundler can eliminate the rest without executing the library.
Prerequisites
Rollup installed in the project, typically as a dev dependency. Source code written as ES modules with explicit named exports. A build script entry point that represents the public API. A clear boundary between public API files and internal helpers.
Package metadata that points consumers to the preserved output. You will need write permission in the project root to create config files and the output directory.
Focused procedure
1. Define the entry and output shape
Create rollup.config.js in the project root. Set input to the library entry and output to a directory with format es and preserveModules true.
// rollup.config.js
export default {
input: 'src/index.js',
output: {
dir: 'dist/esm',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
sourcemap: true
},
plugins: [
// resolve bare specifiers, do not transform to CJS
]
}
preserveModules keeps the module graph. preserveModulesRoot prevents the src prefix from appearing in output paths. Format es ensures import/export syntax is kept.
2. Keep the module graph intact with plugins
Plugins that generate synthetic modules, inject helpers, or merge chunks can break one‑to‑one mapping. Prefer plugins that transform code in place. If you need CommonJS interop, limit its use to dependencies and avoid options that create a single bundled helper.
Do not enable code splitting for a library build with preserveModules. Code splitting is for applications.
3. Wire package.json to the preserved output
Point the package entry to the preserved directory and optionally expose subpaths.
// package.json
{
"type": "module",
"exports": {
".": "./dist/esm/index.js",
"./*": "./dist/esm/*.js"
},
"sideEffects": false
}
sideEffects false is a hint for bundlers. It is only safe if your modules are pure.
4. Run the build
Run the build from the project root with user permissions.
npm run build
With a script like "build": "rollup -c". Expected risk: preserveModules increases file count. Consumers that load via native ES modules in the browser will incur more HTTP requests unless they bundle further.
Expected checks
List the output directory and confirm multiple .js files exist with names matching source modules.
ls dist/esm
Inspect a sample output file. It should contain ES import/export statements and should not contain the entire library concatenated.
head -n 20 dist/esm/utils.js
Verify package resolution. In a test consumer, import a single export and confirm the bundler can report it as used while other exports remain removable. This is a static check, not a runtime test.
Recovery options
If output is flattened to one file, disable plugins that merge code and re‑run the build. Check that preserveModules is true and that output.dir is used, not output.file.
If paths are wrong, adjust preserveModulesRoot to match the source folder relative to input. If a plugin creates a synthetic helper, move that transformation to a separate build step or avoid it.
Alternative when preserveModules is unsuitable: build a single ES bundle with explicit sideEffects annotations and a granular exports map. This reduces file count at the cost of less granular tree‑shaking.
Limitations
preserveModules behavior is version sensitive and interacts with plugins. Older Rollup releases handle file naming differently. File count grows with source modules and can affect load performance for unbundled consumers. Plugins that inject runtime helpers may break the one‑to‑one mapping.
Check the result by inspecting the output directory and a sample file for preserved imports. That confirms the module structure is maintained for downstream tree‑shaking.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.