Dor Shinar

Creating a tree-shakable library with tsup

September 18, 2023
cover image

I recently had a chance to work on a component library distributed through NPM and consumed by several applications. The library is bundled with tsup and it seemed to work great, until I took a look at our bundle sizes and saw that we have a problem.

While the library was properly bundled, and build times were very quick thanks to ESBuild, I noticed that our application bundles were too big to seem reasonable.

When importing the <Button /> component, Import Cost reported 97Kb added to our bundle. Don't get me wrong, we have nice buttons, but not 97Kb worth of buttons. Importing *.svg icons resulted in a similar bundle size added to our application's bundle.

I tested it in multiple environments. Both webpack (in a Next.js application) and vite behaved similarly, so I figured out our library was not as tree-shakable as I thought it was.

As it turns out, creating a tree-shakable library can be tricky. In this post I'll walk you through how to use tsup to create a tree-shakable library.

What is tree shaking?

First, an introduction to tree-shaking. Taken from the webpack documentation:

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.

To illustrate how it works, let's say we are working on a component library. We currently have 2 components - a Button and a Dialog component.

Our Button component is very simple:

export function Button(props: HTMLAttributes<HTMLButtonProps>) {
  return <button {...props} />;
}

But for the Dialog component we'll use the excellent @radix-ui/react-dialog. I pulled the example from their docs.

import * as RadixDialog from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
 
export function Dialog() {
  return (
    <RadixDialog.Root>
      <RadixDialog.Trigger asChild>
        <button>Edit profile</button>
      </RadixDialog.Trigger>
      <RadixDialog.Portal>
        <RadixDialog.Overlay />
        <RadixDialog.Content>
          <RadixDialog.Title>Edit profile</RadixDialog.Title>
          <RadixDialog.Description>
            Make changes to your profile here. Click save when you're done.
          </RadixDialog.Description>
          <RadixDialog.Close asChild>
            <button aria-label="Close">
              <Cross2Icon />
            </button>
          </RadixDialog.Close>
        </RadixDialog.Content>
      </RadixDialog.Portal>
    </RadixDialog.Root>
  );
}

Without any other external dependencies, our button component weighs about 99 bytes, and our dialog weighs about 50kB (minified, not gzipped).

Different applications may import different components from the library. Some only need the button, others the dialog and some need both. The real library naturally has many more components. We want each application to produce a bundle that only contains the code that it imports.

Without tree-shaking, each application that uses the <Button> component will have 51.9kB it doesn't need added to its bundle size. As our library grows larger, this will become an issue. It is not unheard of to have component libraries that contain 100kB+, where each application only needs a sliver of.

We definitely don't need 100kB extra in our landing page.

To address this issue, bundlers such as webpack and rollup perform an operation called tree-shaking. In the tree-shaking process the bundlers will scan our code to figure out what external pieces of code we imported, and only include that code in the final build.

Requirements for tree-shaking

ESM

To get tree-shaking working reliably, distribute your code in ESM format. ESM uses statically analyzable import/export statements which bundlers can use when bundling your code. Webpack and rollup support it as well as modern browsers, so it's a safe compilation target.

When bundling with tsup, the default (and somewhat outdated) format is cjs. Change that to esm either via the command line:

tsup src/index.ts --format esm

Or via the config file:

import { defineConfig } from "tsup";
 
export default defineConfig({
  format: ["esm"],
});

If you are bundling your library with typescript, set the compilerOptions.module to anything above ES2015, preferably ESNext.

No side effects

The second thing you should do is make sure that your code has no side effects. From the webpack docs:

A "side effect" is defined as code that performs a special behavior when imported, other than exposing one or more exports. An example of this are polyfills, which affect the global scope and usually do not provide an export.

Basically, any code that performs an action, alters the global state or does anything else when imported is considered to have side effects.

This is critical because while side effects are rare, if you do have code that is not pure, like polyfills or otherwise, you might not want it excluded from your bundle when the imported module is not referenced. This can include CSS files that were imported but not referenced.

import "normalize.css";

In webpack the config is called sideEffects and in rollup it's called moduleSideEffects. Both can receive the value false to indicate that there are no side effects in the code, or a list of files that do contain side effects.

Separate files as entry points

Tree-shaking works best when bundlers can exclude entires files from the bundle. While modern versions of webpack and rollup can remove unused parts withing the same file, it's best to stick to the lowest common denominator to produce the most predictable results.

What this means for you in practice is that you should either configure your library to have multiple entry points, or have a single entry point that imports and exports your code from separate files. In my experience, both methods yield similar results, so it's up to you to decide which one you prefer.

The dist/ folder of a library with multiple entry points would look like this:

dist/
├── button.mjs
└── dialog.mjs

And when importing code we would import from the relevant file:

import { Button } from "component-library/button";

While this works, it's not very ergonomic when you import multiple things from your component library in the same file:

import { Button } from "component-library/button";
import { Dialog } from "component-library/dialog";

To solve that you can bundle your library so that you still have a single entry point, but it points to separate files that can be excluded individually. It should look similar to this, although it may be messier due to the way bundlers split files when they bundle.

dist/
├── index.mjs
├── button.mjs
└── tooltip.mjs

And your index.mjs would look like this:

export * from "./button";
export * from "./dialog";

And now your imports look like this:

import { Button, Dialog } from "component-library";

Bundling a library with tsup

Now that we know what we need to do to get tree-shaking to work, let's see how we can do that with tsup. tsup is a zero-config bundler that uses esbuild under the hood. To get it working properly we would need to configure it a bit.

First, install tsup:

npm i tsup -D
# Or yarn
yarn add tsup --dev
# Or pnpm
pnpm add tsup -D

Next, create a tsup.config.ts file at the root of your project, and add the following code:

import { defineConfig } from "tsup";
 
export default defineConfig({});

The tsup docs will tell you to specify an entry point such as src/index.js, and tsup will crawl all files imported from that entry point and bundle them into a single index.mjs file. While this can work, as explained earlier, we prefer to bundle each file separately.

To get tsup to bundle each file separately, we need to change the entry option. This option accepts a glob pattern, so we can specify all .ts files in the src/ folder:

import { defineConfig } from "tsup";
 
export default defineConfig({
  entry: ["src/**/*.ts"],
  format: ["esm"],
});

Don't forget to include format: ["esm"]. This will tell tsup to output ESM files, which is the only format that supports tree-shaking. You can choose to output cjs as well if you want to support older bundlers, but it's not necessary.

If you are working with other file types such as .tsx, .js or .jsx you can add them to the glob pattern, like so:

import { defineConfig } from "tsup";
 
export default defineConfig({
  entry: ["src/**/*@(ts|tsx)"],
  format: ["esm"],
});

You can also have tsup generate .d.ts files as well.

The last part we need is to let the bundlers that will consume our library know where to import files from. We also need to let them know we don't plan on performing any side effects that may make them bail out of tree-shaking.

Update your package.json and add the following lines:

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      // This part is only required if your output is CJS
      "require": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "sideEffects": false
}

The exports field specifies where to import files from. The exports["."] identifier means that imports from the root of the package (import * from "package-name") will be imported from the file in the default field.

If we wanted to allow importing from a sub-module, we would specify it as well. For example, to allow importing a CSS file named style.css, we would add the following line to the exports object:

{
  "exports": {
    "style.css": "./dist/style.css"
  }
}

The exports["."].import field is for ESM bundlers, and the exports["."].require field is for CJS bundlers. The exports["."].import.default field is for the default import, and the exports["."].import.types field is for telling typescript where to find the types for our files.

Other than that we also specify our main file, which is used by older bundlers that are not aware of the exports syntax, as well as types which is used by typescript, and last we specify that our code has no side effects.

This should be everything you need to bundle a tree-shakable library with tsup.

Comparing the numbers

Let's make sure our library is tree-shakable by looking at some numbers. I've created a repository with an example package, and a couple of brand new applications - Next.js and Vite with a react template.

The library contains 2 components - a Button and a Dialog component. The Button component is a simple component that renders a button, and the Dialog component is a wrapper around @radix-ui/react-dialog.

Both applications contain the same code in the index route. Note that in the Next.js app I've poisoned the root page with "use client" to make sure the page is not a React Server Component.

import { useState } from "react";
import { Button, Dialog } from "example-package";
 
export default function Home() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      Count is: {count}
      <Button onClick={() => setCount((c) => c + 1)}>Increment</Button>
      {count > 5 && <Dialog></Dialog>}
    </div>
  );
}

Let's first look at the application bundle sizes when importing both components.

> next-project@0.1.0 build treeshaking-demo/next-project
> next build
 
- info Creating an optimized production build
- info Compiled successfully
- info Linting and checking validity of types
- info Collecting page data
- info Generating static pages (4/4)
- info Finalizing page optimization
 
Route (app)                                Size     First Load JS
┌ ○ /                                      14.2 kB        92.6 kB
└ ○ /favicon.ico                           0 B                0 B
+ First Load JS shared by all              78.4 kB
  ├ chunks/442-744379bed9c4eb8a.js         26.1 kB
  ├ chunks/ad1c5502-63654f6e7379ed3e.js    50.5 kB
  ├ chunks/main-app-52e10136fc1e8b70.js    217 B
  └ chunks/webpack-d9b3eee2dd984a06.js     1.65 kB
 
Route (pages)                              Size     First Load JS
─ ○ /404                                   182 B          76.5 kB
+ First Load JS shared by all              76.3 kB
  ├ chunks/framework-16d02afa1e645564.js   45.1 kB
  ├ chunks/main-d0362342523b905b.js        29.4 kB
  ├ chunks/pages/_app-d289e2608d34b8bd.js  195 B
  └ chunks/webpack-d9b3eee2dd984a06.js     1.65 kB
 
○  (Static)  automatically rendered as static HTML (uses no initial props)

I've highlighted the relevant line - our initial bundle is 92.6kB. Let's see the Vite application results:

> vite-project@0.0.0 build treeshaking-demo/vite-project
> vite build
 
vite v4.4.5 building for production...
✓ 87 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/index-04490b11.css    0.95 kB │ gzip:  0.52 kB
dist/assets/index-ffdd97bc.js   182.54 kB │ gzip: 59.17 kB
✓ built in 746ms

The Vite bundle is 59.17kB.

So that's our baseline - 92.6kB for Next.js and 59.17kB for Vite.

Now let's see what happens when we don't import our large Dialog component.

> next-project@0.1.0 build treeshaking-demo/next-project
> next build
 
- info Creating an optimized production build
- info Compiled successfully
- info Linting and checking validity of types
- info Collecting page data
- info Generating static pages (4/4)
- info Finalizing page optimization
 
Route (app)                                Size     First Load JS
┌ ○ /                                      3.49 kB        81.9 kB
└ ○ /favicon.ico                           0 B                0 B
+ First Load JS shared by all              78.4 kB
  ├ chunks/442-60a07becc06e0d47.js         26.1 kB
  ├ chunks/ad1c5502-5aa4d9472840edb6.js    50.5 kB
  ├ chunks/main-app-3dcab946c3b1e0ba.js    216 B
  └ chunks/webpack-ea62f276db27253b.js     1.64 kB
 
Route (pages)                              Size     First Load JS
─ ○ /404                                   182 B          76.5 kB
+ First Load JS shared by all              76.3 kB
  ├ chunks/framework-16d02afa1e645564.js   45.1 kB
  ├ chunks/main-d0362342523b905b.js        29.4 kB
  ├ chunks/pages/_app-d289e2608d34b8bd.js  195 B
  └ chunks/webpack-ea62f276db27253b.js     1.64 kB
 
○  (Static)  automatically rendered as static HTML (uses no initial props)
> vite-project@0.0.0 build treeshaking-demo/vite-project
> vite build
 
vite v4.4.5 building for production...
✓ 87 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/index-04490b11.css    0.95 kB │ gzip:  0.52 kB
dist/assets/index-2de9fe73.js   152.15 kB │ gzip: 48.90 kB
✓ built in 667ms

We got 81.9kB and 48.9kB respectively. That's about a 10kB reduction in bundle size, which is very respectable. This is the true power of tree-shaking - only bundle what you need.

The final config

The tsup.config.ts:

import { defineConfig } from "tsup";
 
export default defineConfig({
  entry: ["src/**/*@(ts|tsx)"],
  dts: true,
  format: ["esm"],
  treeshake: true,
});

And the package.json:

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "sideEffects": false,
  "scripts": {
    "build": "tsup"
  }
}

Finding the right config has been a bit of trial and error for me, so if you have any suggestions or improvements, please let me know.

Tips and Tricks

Lastly, I've wanted to mention a few tools that helped me identify tree-shaking issues.

The first is Import Cost. It's a VSCode extension that show the size of imported modules right in your editor. Very useful for quick checks.

The second is bundlephobia. If you publish your library to npm, you can use bundlephobia to check the size of your library. You can also check other libraries if you are concerned about bundle size.

The third is publint. It's a very useful CLI that validates that your packages are configured properly to be published. It's not specific to tree-shaking, but it's an excellent tool to have in your toolbox.

Thanks you for reading!