Creating a Tree-Shakeable Package

Last updated February 27, 2023

Configuring a project to tree shake properly is surprisingly difficult. This page attempts to give a conceptual overview of what’s actually happening when tree shaking and how to set it up to realize some bundle size savings.

Disclaimer: This page attempts to simplify the overall process to make it easily understandable. There will be exceptions to assertions made in this document. In many cases, with additional configuration you can work around many of the limitations and requirements I talk about here. The idea of this document is to give a general idea of what a proper tree-shaking setup should look like, to get people started. It is not intended to be exhaustive or a definitive guide.

First off, let’s talk about what happens when tree shaking at a conceptual level. One team builds a package and publishes it to npm, then another team bundles it in with their page to deliver on the site. Or they import it into another package, but eventually it’s included on some page of the site. For simplicity, we’ll talk about a single package being consumed directly by a page on the site.

The general flow would look something like this:

Let’s zoom in on step two, the package build. This is, technically, optional. You can put whatever you want in an npm package. However, if you want to use the latest development tools, and you want it to be easily consumable, you will probably want to have a build step to make the outputs of your package look somewhat standard. This means:

In Step 5, assuming it is configured to do so, the bundler will attempt to tree shake out any dead code. Several things must be managed properly for it to do so successfully:

Configuration specifics

package.json

Below is a trimmed-down version of the package.json, with comments calling out the importance of certain lines.

{
  "name": "@vp/react-bookends",
  "main": "dist/index.cjs.js", // Points to the cjs bundle
  "module": "build/index.esm.js", // Points to the esm entrypoint (ideally not a bundle)
  "typings": "dist/index.d.ts", // Points to the types declaration
  "scripts": { ... },
  "sideEffects": [ // Either set to false or an array of files that do have side effects
    "*.css" //If you are using and including scss, you may instead need "**/*.scss.js" here
  ],
  "dependencies": {
    // Move anything that might be used by another
    // package to peerDependencies instead!
  },
  "peerDependencies": {
    // Any dependencies that might be used by another
    // package should go here, so the page bundler can dedupe them
  },
  "devDependencies": { ... }
}

tsconfig.json

In tsconfig, set the target to either es5, es2015, or es6 so tsc will output files with in their original structure instead of bundled into a cjs bundle.

webpack.config.js or rollup.config.js

Set “preserveModules”: true in the config to output files in their original structure instead of bundled as one.

Output both a cjs file and an esm file

Make sure your build process outputs both file types, so consumers who know how to utilize it can use esm for tree shaking, but you still have cjs for backwards compatibility.