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:
@babel/plugin-transform-runtime
@babel/preset-env
@babel/preset-react
@rollup/plugin-alias
@rollup/plugin-babel
@rollup/plugin-commonjs
@rollup/plugin-node-resolve
@rollup/plugin-terser
rollup-plugin-analyzer
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 { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
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 { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
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 separtely 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:
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!
Top comments (0)