Rollup Library Starter

RollupJavaScriptNPMpackage.json
(updated )
18 min read
Rollup Library Starter

NOTE: Make sure to also check out the new post on working with Client and Server Components with Rollup here.

Intro

In this post we'll take a look at how to package your JavaScript library code into a production-ready bundle using Rollup module bundler. By the end of this tutorial we will have a dual-module format bundle that is ready to be published to NPM, can be consumed in either server or browser environments, and is available in both ESM and CommonJS formats.

Want to learn more about the different module formats? Check out this post!

The base recipe assumes use of regular JavaScript and is meant for use with React projects, however the basic principles and patterns we'll cover here are not restricted to any one framework, and can be extended to a variety of different projects.

NOTE: If your project uses TypeScript, I would suggest using tsdx instead.

Let's get started!

Project Setup

First, init your project using npm init. You should have a package.json now that looks something like this:

package.json
{
  "name": "rollup-library-starter",
  "version": "1.0.0",
  "description": "Starter template for building JS libraries using Rollup",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Mykhaylo Ryechkin",
  "license": "MIT"
}

You'll also want to add a .gitignore file:

.DS_Store
dist
node_modules

Folder Structure

Our project files will live inside the src folder, and the example folder structure will look something like this:

src/
├── components/
│   ├── Button/
│   │   ├── Button.js
│   │   └── index.js
│   ├── Text/
│   │   ├── Text.js
│   │   └── index.js
│   └── index.js
├── utils/
│   └── index.js
└── index.js

This is just an example convention. Ultimately, you can structure it however you'd like. The main thing to remember is every folder will need an index.js file, including the top-level src:

src/index.js
export * from './components';
export * from './utils';

Here we're simultaneously importing and re-exporting all modules from the components and utils folders (each having their own index.js file). This is where we control what's available from our library for external consumption - anything that's exported will be available to the library consumer.

Modules

Our library will expose each module as a named module, ie:

import { Button, Text } from 'rollup-library-starter';

To make things easier to maintain, we're putting each module and the related files in its own folder, using the same name as the module (ie. src/components/Button and src/components/Text).

We can then further group these modules by category and put all related modules in the same folder (ie. src/components). Each folder also needs to have its own index.js file as well:

src/components/index.js
export { default as Button } from './Button';
export { default as Text } from './Text';

Here we're importing the default exports of the Button and Text modules, and re-exporting them as Button and Text.

Inside each of our Button and Text folders, we have the main index.js files, as well as separate component files (Button.js and Text.js):

src/components/Button/Button.js
const Button = (props) => {
  const { children, onClick } = props;

  function handleOnClick() {
    console.log('Button onClick');
    return onClick;
  }

  return (
    <button type="button" onClick={handleOnClick} {...props}>
      {children}
    </button>
  );
};

export const VARIANT = {
  PRIMARY: 'primary',
  SECONDARY: 'secondary',
};

export default Button;

src/components/Button/index.js
import Button, { VARIANT } from './Button';

export default Object.assign(Button, {
  VARIANT,
});

Notice the little trick we're doing inside the component index.js file above:

export default Object.assign(Button, {
  VARIANT,
});

Here we're assigning VARIANT as a property on the main Button component using Object.assign(), which will let us use VARIANT without having to import it explicitly:

import { Button } from 'rollup-library-starter';
// ...
<Button variant={Button.VARIANT.PRIMARY}>

This pattern allows us to group related code that normally would be imported together anyway, reducing some verbosity in the import statements.

The Text component is slightly simpler, with just one main default export, which is then re-exported inside index.js:

src/components/Text/Text.js
export default function Text({ children }) {
  return <div style={{ fontSize: '2.5rem', fontFamily: 'monospace' }}>{children}</div>;
}

src/components/Text/index.js
import Text from './Text';

export default Text;

And finally, in our utils folder, we have a single index.js file, with a sample function Greet exported as a named export:

src/utils/index.js
export const Greet = (text) => console.log(`Hello ${text}.`);

To export all these modules at the root, make sure they're included in the index.js file at the top of the src folder, as we saw earlier:

src/index.js
export * from './components';
export * from './utils';

The idea here is that our main index.js file will contain all the exported modules within our library, as named exports.

With that done, time to install our dependencies.

Tooling & Dependencies

Now that the base library setup, let's install all the necessary tooling. As mentioned earlier, we'll be using Rollup as our module bundler (v2 at the time of writing this post):

Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger and more complex, such as a library or application. It uses the new standardized format for code modules included in the ES6 revision of JavaScript, instead of previous idiosyncratic solutions such as CommonJS and AMD. ES modules let you freely and seamlessly combine the most useful individual functions from your favorite libraries. This will eventually be possible natively everywhere, but Rollup lets you do it today.

Rollup has an extensive plugin ecosystem, which provides a lot of flexibility in adding specific functionality for a variety of use cases. Check out the awesome curated list here.

Now, it can be daunting to figure out which exact plugins to use - and the goal of this article is to provide guidance on what's needed for a majority of bundles that you'll be building. First, we will install all the required dependencies, and then go through what each of them does as we start to configure the steps.

We'll be using Babel to transpile our code into JavaScript that all current and older browsers and environments will understand. For this, install @babel/runtime as a dependency. This will be our only production dependency, and will let Rollup bundle some of the helper functions required for backwards compatibility:

npm i @babel/runtime

Next, we need to install a few Babel plugins, as well as Rollup itself and a number of Rollup plugins:

These should all be devDependencies, so make sure to add the -D flag:

npm i -D rollup rollup-plugin-analyzer @rollup/plugin-alias @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-terser @babel/plugin-transform-runtime @babel/preset-env @babel/preset-react

And lastly, since we're using React in our library, we'll also need to specify react and react-dom as peer dependencies in package.json:

package.json
{
  "peerDependencies": {
    "react": "^16.14.0 || ^17.0.0 || ^18.2.0",
    "react-dom": "^16.14.0 || ^17.0.0 || ^18.2.0"
  }
}

This will ensure that our library doesn't try to include its own copy of React when it's installed, as that can cause all sorts of issues.

Rollup Configuration

To get started with Rollup, we'll need to create a configuration file at the root of our project, where we specify all the options for bundling, plugins, presets, etc. This file will be the bulk of our work.

Our library is written using the modern ESM module syntax, however we want our code to be executable in all JavaScript environments. In order to support this, we will ship two distinctly separate bundles as part of our library package: ESM and CommonJS (or CJS).

Rollup will handle all the transpilation of our code from ESM to CJS, and generate two separate bundles for each format. This will make our library package consumable in both server (Node) and browser environments. We will also need to modify our package.json to support this.

It's worthwhile to keep in mind the dual package hazard, which is explained in the official Node docs. With that said, this pattern has been working great for my team across multiple JS libraries for several years now, and has proven itself in many enterprise-grade production applications.

Base Config

First, let's create a file called rollup.config.js at the root of our project. We will import all the packages and plugins, and then break down what each of them does.

rollup.config.js
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

import alias from '@rollup/plugin-alias';
import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import analyze from 'rollup-plugin-analyzer';

const require = createRequire(import.meta.url);
const pkg = require('./package.json');

const config = {
  // Rollup options go here
};

export default config;

The config is where we'll be setting all the different options, as well as plugins and Babel presets.

Output Options

We will be generating two bundles, and there are some common output options shared between the two. Let's put these in a variable:

rollup.config.js
// ...
const outputOptions = {
  exports: 'named',
  preserveModules: true,
  banner: `/*
 * Rollup Library Starter
 * {@link https://github.com/mryechkin/rollup-library-starter}
 * @copyright Mykhaylo Ryechkin (@mryechkin)
 * @license MIT
 */`,
};

Here, we're setting the exports option to "named", because we're exporting everything as named exports. This is intentional, as there can be issues with mixing both default and named patterns of exports at the root of your package, when exporting to CommonJS format. Rollup will also throw a warning when you do that.

The preserveModules option will tell Rollup to create separate chunks for all modules, using the original module names as file names.

This is needed in order for tools like Webpack 5 to be able to successfully tree-shake unused imports when consuming our library. We ran into this issue with our component library when we originally moved to Rollup. Enabling this option eliminated issues with tree-shaking for our users, and this pattern has worked well for us since.

Lastly, and completely optional, we can add a banner to each of the generated files. This is a good way to provide some info about our library, and add author attribution.

For all other available options, check out the big list of options here.

With these options defined, let's now provide the entry point for our package. This will be the root-level index.js file, which if you recall containts all of our available modules exported as "named". We will also set our output options as well:

rollup.config.js
// ...
const config = {
  input: 'src/index.js',
  output: [
    {
      dir: 'dist/esm',
      format: 'esm',
      ...outputOptions,
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      ...outputOptions,
    },
  ],
};

Our output option is an array of objects, one for each of the bundles we'd like to build. Here we specify both esm and cjs formats, which output to dist/esm and dist/cjs folders respectively. All other shared outputOptions are provided for both.

External Modules

Next, we need to tell Rollup which of the modules used in our code are external to our library. Together with @rollup/plugin-node-resolve, this ensures that Rollup doesn't bundle those dependencies into our final bundle. The function makeExternalPredicate() generates the list of package names specified in dependencies and peerDependencies in package.json. All credit for this and a big thank you goes out to Mateusz Burzyński for providing it in this issue:

rollup.config.js
// ...
const makeExternalPredicate = (externalArr) => {
  if (externalArr.length === 0) {
    return () => false;
  }
  const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`);
  return (id) => pattern.test(id);
};

const config = [
  {
    // ...
    external: makeExternalPredicate([
      ...Object.keys(pkg.dependencies || {}),
      ...Object.keys(pkg.peerDependencies || {}),
    ]),
  },
];

Plugins

Next up, we have the plugins array, and it specifies which Rollup plugins to run. The order in which plugins are specified is very important, as the input for each plugin is the previous one's output, so make sure to follow the order they're listed in here.

Plugin: Alias

The first plugin we're using is @rollup/plugin-alias, which enables us to use absolute import paths for src (or any other path you want to configure):

rollup.config.js
import alias from '@rollup/plugin-alias';

// ...
const config = [
  {
    // ...
    plugins: [
      alias({
        entries: {
          src: fileURLToPath(new URL('src', import.meta.url)),
        },
      }),
    ],
  },
];

Plugin: Resolve Node Modules

Next, we have @rollup/plugin-node-resolve, which allows Rollup to resolve external modules from node_modules:

rollup.config.js
import nodeResolve from '@rollup/plugin-node-resolve';

// ...
const config = [
  {
    // ...
    plugins: [
      // ...
      nodeResolve(),
    ],
  },
];

Plugin: CommonsJS

The @rollup/plugin-commonjs plugin converts 3rd-party CommonJS modules into ES6 code, so that they can be included in our Rollup bundle:

rollup.config.js
import commonjs from '@rollup/plugin-commonjs';

// ...
const config = [
  {
    // ...
    plugins: [
      // ...
      commonjs({ include: ['node_modules/**'] }),
    ],
  },
];

Plugin: Babel

Next, we need to enable Babel for code transpilation. We do that by passing @rollup/plugin-babel as a plugin, and then specifying the @babel/plugin-transform-runtime Babel plugin:

rollup.config.js
import babel from '@rollup/plugin-babel';
// ...
const babelRuntimeVersion = pkg.dependencies['@babel/runtime'].replace(/^[^0-9]*/, '');

const config = [
  // ...
  plugins: [
    // ...
    babel({
      plugins: [['@babel/plugin-transform-runtime', { version: babelRuntimeVersion }]]
    })
  ],
];

The @babel/plugin-transform-runtime plugin enables re-use of Babel's injected helper code, to help reduce the final bundle size.

From the plugin's docs:

Babel uses very small helpers for common functions such as _extend. By default this will be added to every file that requires it. This duplication is sometimes unnecessary, especially when your application is spread out over multiple files.

The version of Babel runtime is pulled from package.json by reading the dependencies:

rollup.config.js
const babelRuntimeVersion = pkg.dependencies['@babel/runtime'].replace(/^[^0-9]*/, '');

We're also telling the Babel plugin how to handle Babel helper code via babelHelpers (it is recommended to use the "runtime" option for bundling libraries with Rollup), as well as not to touch anything imported from node_modules by setting the exclude option:

rollup.config.js
import babel from '@rollup/plugin-babel';
// ...
const config = [
  plugins: [
    // ...
    babel({
      babelHelpers: 'runtime',
      exclude: /node_modules/,
      plugins: [
        // ...
      ],
    })
  ],
];

We also need to specify a few presets so that we can use latest JavaScript features, as well as enable React support. These are @babel/preset-env and @babel/preset-react, respectively:

rollup.config.js
import babel from '@rollup/plugin-babel';
// ...
const config = [
  plugins: [
    // ...
    babel({
      babelHelpers: 'runtime',
      exclude: /node_modules/,
      presets: [
        ['@babel/preset-env', { targets: 'defaults' }],
        ['@babel/preset-react', { runtime: 'automatic' }],
      ],
    })
  ],
];

Plugin: Terser

With Babel configuration out of the way, we only have a few plugins left.

This next one will help us reduce final bundle size by minifying the generated code. It's called @rollup/plugin-terser and uses terser under the hood to minify the code.

We'll be sticking with the defaults it provides, so no need to specify any options:

rollup.config.js
import terser from '@rollup/plugin-terser';
// ...
const config = [
  plugins: [
    // ...
    terser(),
  ],
];

NOTE: You may want to comment out this plugin if you're trying to debug your generated code, but you'll definitely want to have it enabled for your production bundle.

Plugin: Bundle Analyzer

Lastly, we have rollup-plugin-analyzer. This plugin will print out some useful info about our generated bundle upon successful builds:

rollup.config.js
import analyze from 'rollup-plugin-analyzer';
// ...
const config = [
  plugins: [
    // ...
    analyze({
      hideDeps: true,
      limit: 0,
      summaryOnly: true,
    }),
  ],
];

export default config;

Config Summary

And with that, we're done configuring Rollup! Here's what your rollup.config.js should look like in the end:

rollup.config.js
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

import alias from '@rollup/plugin-alias';
import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import analyze from 'rollup-plugin-analyzer';

const require = createRequire(import.meta.url);
const pkg = require('./package.json');

const makeExternalPredicate = (externalArr) => {
  if (externalArr.length === 0) {
    return () => false;
  }
  const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`);
  return (id) => pattern.test(id);
};

const babelRuntimeVersion = pkg.dependencies['@babel/runtime'].replace(/^[^0-9]*/, '');

const outputOptions = {
  exports: 'named',
  preserveModules: true,
  banner: `/*
 * Rollup Library Starter
 * {@link https://github.com/mryechkin/rollup-library-starter}
 * @copyright Mykhaylo Ryechkin (@mryechkin)
 * @license MIT
 */`,
};

const config = {
  input: 'src/index.js',
  output: [
    {
      dir: 'dist/esm',
      format: 'esm',
      ...outputOptions,
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      ...outputOptions,
    },
  ],
  external: makeExternalPredicate([
    ...Object.keys(pkg.dependencies || {}),
    ...Object.keys(pkg.peerDependencies || {}),
  ]),
  plugins: [
    alias({
      entries: {
        src: fileURLToPath(new URL('src', import.meta.url)),
      },
    }),
    nodeResolve(),
    commonjs({ include: ['node_modules/**'] }),
    babel({
      babelHelpers: 'runtime',
      exclude: /node_modules/,
      plugins: [['@babel/plugin-transform-runtime', { version: babelRuntimeVersion }]],
      presets: [
        ['@babel/preset-env', { targets: 'defaults' }],
        ['@babel/preset-react', { runtime: 'automatic' }],
      ],
    }),
    terser(),
    analyze({
      hideDeps: true,
      limit: 0,
      summaryOnly: true,
    }),
  ],
};

export default config;

The example configuration file can be found here for your reference as well.

Keep in mind, this configuration is meant to be the foundation for your library. It serves as a starting point, but there's lots more you can do here with all the plugins available in the Rollup ecosystem. Make sure to explore the awesome curated selection, and see if there's anything else that is applicable to your project.

Package Setup

With Rollup configuration done, let's now configure our package file package.json so that it can be properly packaged and published to NPM.

Scripts

First, let's add a build script, so that we can actually run Rollup:

package.json
{
  "scripts": {
    "prebuild": "rm -rf dist",
    "build": "rollup -c"
  }
}

This will run Rollup CLI using the configuration we defined in rollup.config.js. Since this file is at the root level, we don't need to specify it explicitly (the -c flag takes care of that).

ESM

As of Rollup v3, we need to ensure that our configuration file is loaded as an ES module by Node.

There are a few ways to do this. One is by using the .mjs extension, and another is by specifying our library package to be ESM, which is done by setting the type to "module" in the package file:

package.json
{
  "type": "module"
}

Entry Points

We've configured Rollup to output the bundle to the dist folder. And since we've configured our library to have a root-level index.js, we can use this file as the entry point of our package. Since we're publishing a dual-module package, we will need to specify these entry points separately for each of the formats (ESM and CommonJS).

For CommonJS, set the main field to point to the cjs bundle:

package.json
{
  "main": "./dist/cjs/index.js"
}

Then do the same for ES Modules, by pointing the module field to the esm bundle:

package.json
{
  "module": "./dist/esm/index.js"
}

Exports

In order to ensure maximum interoperability with tools like Webpack (and others), we should also specify the subpath exports separately for each module format.

To do this, add an exports field and specify the main entry point's import and require to point to the esm and cjs bundles, respectively:

package.json
{
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

This is to ensure that we can do both:

// ESM
import { Button } from 'rollup-library-starter';

// CommonJS
const { Button } = require('rollup-library-starter');

Package Files

Lastly, we need to tell NPM which files and folders to include in our package. For this we'll use the files field, which describes the entries to be included when the package is installed as a dependency. In addition to the default includes (package.json itself, README, LICENSE, etc.) we need to specify the folder with output from Rollup.

Add the dist folder to the files array:

package.json
{
  "files": ["dist"]
}

The full list of package.json configuration options is available here

Running the Build

With package.json setup, let's now run our build and check it out. In the terminal at the root of our project type in npm run build. This will run the Rollup CLI and generate its output in the dist folder. You'll notice that there are also two folders in there: esm and cjs, each containing code in the corresponding module format.

Thanks to the rollup-plugin-analyzer plugin, we also get a summary of our package build:

Rollup bundle analyzer plugin outputRollup bundle analyzer plugin output

Wrap Up

And that about does it! Hopefully this recipe gives you a good starting point for your next JavaScript library, and remember to experiment and adjust it as needed.

The full example project is available in GitHub for your reference:

If you've made it this far - thanks for reading, and until next time!

00000