How to build a tree-shakable library with Vite and Rollup

Today we will talk about the Moby Dick of ES Module bundlers. There were so many ambitions, mysteries and failed promises in this industry. Tree shaking is a smart JavaScript optimization that prunes unused code from your final bundle. It's not just about tidiness; it's a key performance factor, shrinking file sizes and making our applications load significantly faster. Build as a single file While modern bundlers aim to provide tree shaking for libraries using ES Modules (identified via module or exports in package.json), the reality is often more nuanced. Achieving optimal results depends heavily on specific configurations. To illustrate, consider the recommended Rollup configuration recommended by Vite devs when operating in library mode: import {resolve} from 'path'; import {defineConfig} from 'vite'; export default defineConfig({     //... other config     build: {         sourcemap: true,         lib: {             entry: resolve(__dirname, 'src/index.ts'),             name: 'MyLibraryName',             fileName: 'index'         },         rollupOptions: {             // Ensure dependencies aren't bundled into the library             external: external(),             output: {                 // Define global variable names                 globals: {                     vue: 'Vue', // Example for Vue                     react: 'React' // Example for React                 }             }         }     } }); Package file configuration. { "type": "module", "files": [ "dist" ], "main": "./dist/index.umd.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.umd.cjs" } } } This example provides us with perfectly capable ES module and UMD builds. Our library is built in a following way, src/index.ts works as a barrel file containing re-exports of our library modules. export {ComponentA} from './ComponentA'; export {ComponentB} from './ComponentB'; // ... A significant drawback of Rollup's standard ES Module bundling strategy is its negative impact on tree shaking in bundlers like Webpack and Next.js. By compiling all module code into a single ./dist/index.js file (which is also designated in the module property of the package.json), even a small import can result in the entire library and its dependencies being included in your application. While this setup is compatible with Vite and Rollup, Next.js experimental optimizePackageImports feature, which I tested unsuccessfully on version 15.1.6, doesn't seem to resolve this issue. Build as multiple files Fortunately, Rollup offers a solution to this tree shaking challenge. By enabling the preserveModules setting, we can maintain the original module structure, significantly improving tree shaking capabilities. This configuration results in the library being built with one file per module instead of a single chunk. Since this approach is incompatible with the default UMD (Universal Module Definition) build, we've updated the formats setting to ['es', 'cjs'], replacing UMD build with CommonJS. To manage output file names, we've implemented a fileName function. This ensures the main entry point is output as dist/index.js for ES modules and dist/index.cjs for CommonJS, while other entries retain their original path with the appropriate extension. import {defineConfig} from 'vite'; export default defineConfig(() => ({ // ... build: { sourcemap: true, lib: { // ... fileName: (format, entryName) => { if (entryName === 'src/lib/index') { return `index.${format === 'es' ? 'js' : 'cjs'}`; } return `${entryName}.${format === 'es' ? 'js' : 'cjs'}`; }, formats: ['es', 'cjs'], }, rollupOptions: { // ... output: { // ... preserveModules: true, }, }, }, })); Bundle size impact I made a comparison using two different versions of Koval UI library, the first had a single file bundle and the second was split. The total size of the library is approximately 75 kb gzipped. Two small modules were used in the Next.js project. Here are the results of next build command. Build First load Server code Client code Single file 184 kb 62.4 kb 37 kb Split by module 124 kb 12.6 kb 10 kb Use library template You can use this set up for your projects by cloning the React library template repository. Many other useful features are included.

Apr 8, 2025 - 13:06
 0
How to build a tree-shakable library with Vite and Rollup

Today we will talk about the Moby Dick of ES Module bundlers. There were so many ambitions, mysteries and failed promises in this industry. Tree shaking is a smart JavaScript optimization that prunes unused code from your final bundle. It's not just about tidiness; it's a key performance factor, shrinking file sizes and making our applications load significantly faster.

Build as a single file

While modern bundlers aim to provide tree shaking for libraries using ES Modules (identified via module or exports in package.json), the reality is often more nuanced. Achieving optimal results depends heavily on specific configurations. To illustrate, consider the recommended Rollup configuration recommended by Vite devs when operating in library mode:

import {resolve} from 'path';
import {defineConfig} from 'vite';

export default defineConfig({
    //... other config
    build: {
        sourcemap: true,
        lib: {
            entry: resolve(__dirname, 'src/index.ts'),
            name: 'MyLibraryName',
            fileName: 'index'
        },
        rollupOptions: {
            // Ensure dependencies aren't bundled into the library
            external: external(),
            output: {
                // Define global variable names
                globals: {
                    vue: 'Vue', // Example for Vue
                    react: 'React' // Example for React
                }
            }
        }
    }
});

Package file configuration.

{
  "type": "module",
  "files": [
    "dist"
  ],
  "main": "./dist/index.umd.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "sideEffects": false,
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.umd.cjs"
    }
  }
}

This example provides us with perfectly capable ES module and UMD builds.

Our library is built in a following way, src/index.ts works as a barrel file containing re-exports of our library modules.

export {ComponentA} from './ComponentA';
export {ComponentB} from './ComponentB';
// ...

A significant drawback of Rollup's standard ES Module bundling strategy is its negative impact on tree shaking in bundlers like Webpack and Next.js. By compiling all module code into a single ./dist/index.js file (which is also designated in the module property of the package.json), even a small import can result in the entire library and its dependencies being included in your application.

While this setup is compatible with Vite and Rollup, Next.js experimental optimizePackageImports feature, which I tested unsuccessfully on version 15.1.6, doesn't seem to resolve this issue.

Build as multiple files

Fortunately, Rollup offers a solution to this tree shaking challenge. By enabling the preserveModules setting, we can maintain the original module structure, significantly improving tree shaking capabilities. This configuration results in the library being built with one file per module instead of a single chunk.

Since this approach is incompatible with the default UMD (Universal Module Definition) build, we've updated the formats setting to ['es', 'cjs'], replacing UMD build with CommonJS.

To manage output file names, we've implemented a fileName function. This ensures the main entry point is output as dist/index.js for ES modules and dist/index.cjs for CommonJS, while other entries retain their original path with the appropriate extension.

import {defineConfig} from 'vite';

export default defineConfig(() => ({
    // ...
    build: {
        sourcemap: true,
        lib: {
            // ...
            fileName: (format, entryName) => {
                if (entryName === 'src/lib/index') {
                    return `index.${format === 'es' ? 'js' : 'cjs'}`;
                }
                return `${entryName}.${format === 'es' ? 'js' : 'cjs'}`;
            },
            formats: ['es', 'cjs'],
        },
        rollupOptions: {
            // ...
            output: {
                // ...
                preserveModules: true,
            },
        },
    },
}));

Bundle size impact

I made a comparison using two different versions of Koval UI library, the first had a single file bundle and the second was split. The total size of the library is approximately 75 kb gzipped. Two small modules were used in the Next.js project.

Here are the results of next build command.

Build First load Server code Client code
Single file 184 kb 62.4 kb 37 kb
Split by module 124 kb 12.6 kb 10 kb

Use library template

You can use this set up for your projects by cloning the React library template repository. Many other useful features are included.