Last updated on December 31, 2023

Publish React NPM Package with Rollup and Storybook

This tutorial will take you through a step-by-step process of how to create a clean, lightweight, and performant react npm package. You will go from the very basics until the point where you will create your development environment with storybook and bundle your package with rollup.

I recently built my first react npm package and I was impressed by the lack of content where you can go through each step of the process with a detailed explanation of what is being done in code. Here, I will make sure you understand every single step and by the end of this tutorial, you will be able to build your own high-quality package with confidence.

You can find the end result of this tutorial here if you are only interested in checking out the code.

Setting up the Project

Let's start by creating our main project folder which we will call react-npm-package (from now on we will refer to this folder as the root folder). Then we will initialize our npm package inside this folder by running the command npm init -y. The -y flag is used to bypass the process of answering questions in the terminal but I encourage you to try the command with and without the flag to understand the difference.

As you may have seen, this initialize command created inside your root folder a new file called package.json. This file is the most important file of our npm package, it contains the footprint of what the package is and what it needs to work.

Until this point, our folder structure should look like this:

react-npm-package/
  package.json

And our package.json file should look like this:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

To understand better the content of the package.json file, we will go through a brief explanation of each field.

  • name: This is the package name. It should be unique between all the registered packages in npm.
  • version: This is the package version. You will always start with 1.0.0 but remember to update this field before publishing a new update of your package.
  • description: This is a small description of the package. The default value is "" but you can describe in a few words what your package does.
  • main: This is the definition of the file that will execute your package. The default value is index.js but later in this tutorial, we will change this for a new path.
  • keywords: This is a list of keywords that will help users find your package through the npm search.
  • author: This is the package creator. The default value is "" but you should add your name in this field.
  • license: This is the license definition of your package. This field is important so other users understand how they can use your package. By default, it comes with ISC but you can check other types of licenses here.

Now that we have grasped the basics, we are going to jump into three important fields commonly used in the package.json file, these are dependencies, devDependencies, and peerDependencies.

These fields are simple objects that map the package dependencies used in our package and they follow the structure "package-name": package-version. Now let's go into detail through every dependency field.

dependencies

These are the packages our project needs to work correctly. When someone installs our package in their project, all the dependencies we defined will automatically be downloaded to assure everything works.

To add a new package, we need to run the command npm add package-name. This command will download all the needed files in our npm package (it is stored in a folder called node_modules) and will automatically add the package to the dependencies field of the package.json file. Here is an example of how the package.json would look with some dependencies:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  …,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

devDependencies

These are the packages our project needs in the development phase. You can find here packages for formatting code like prettier, bundling your projects like rollup, and packages related to testing like cypress or jest.

When you build your package, these devDependencies will not be added to the published result. To add a new package, you need to run the command npm add -D package-name. Here is an example of how the package.json would look with some dependencies:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  …,
  "devDependencies": {
    "eslint": "^8.56.0",
    "prettier": "^3.1.1",
    "rollup": "^4.9.2",
    "jest": "^29.7.0"
  }
}

peerDependencies

These are the packages that you define as required for your project to work correctly but are not installed in your projects like the dependencies and devDependencies.

A great example for the use of peerDependencies is our package. We will create a React function which means that our peerDependencies are react and react-dom. Since our package will use the state hook useState, we need a version of react equal or greater to 16.8.0 (version where React Hooks was launched). The best way to define this is by writing the peerDependencies in the package.json file in the following way:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  …,
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  }
}

If someone installs your package in their project and do not have react and react-dom installed or they have an older version of react than 16.8.0, they will get a message like this:

npm WARN react-npm-package@1.0.0 requires a peer of react@>=16.8.0 but none is installed. You must install peer dependencies yourself.
npm WARN react-npm-package@1.0.0 requires a peer of react-dom@>=16.8.0 but none is installed. You must install peer dependencies yourself.

There is a topic I would recommend you read through that is called version ranges. I found a very small and simple tutorial about it here.

Building the React Package

Now that we went through the basics, we will jump into creating our react functional component. To start, we will install our react dependencies by running the command npm add -D react react-dom. As you can see, we added these packages as devDependencies, the reason for this is that we want to test our project in our development environment but we do not want to add these packages in our published result. To make sure our package works as expected, we need to define react and react-dom as peerDependencies. With this, our package.json file should look like this:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "devDependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

Now that our dependencies are all set, we will jump into the definition of our react functional component. We need to create a new folder inside our root folder called src and inside this new folder, we will create an index.jsx file which will contain our react code. Our folder structure should now be:

react-npm-package/
  src/
    index.jsx
  package-lock.json
  package.json

For the src/index.jsx file, we will create a very simple react function that increments and decrements a counter. For this, please copy the following code:

src/index.jsx
import React, { useState } from 'react';
 
const CounterComponent = () => {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>Currently, the count is {count}</p>
      <button onClick={() => setCount(count - 1)}>Subtract</button>
      <button onClick={() => setCount(count + 1)}>Add</button>
    </div>
  );
};
 
export { CounterComponent };

Now that we completed our react function, we are ready for the next section, creating a development environment. Don't forget, for your package to work in other projects, you will need to import and use the CounterComponent in the following way:

import { CounterComponent } from 'react-npm-package';
 
const App = () => (
  <CounterComponent />
);

Configure Storybook Development Environment

To make sure our package works as expected, we need to be able to test our project locally before making it available to the public. In other words, we need to create a development environment.

After doing some research, I found package linking (npm link) to be the most common way of testing npm packages but this is not an optimal solution, it requires a two-step process and sometimes it doesn't work as expected. With this, I found myself with storybook.

Storybook is a JavaScript tool that creates a development environment for your components. It supports multiple frameworks like React, Vue, Angular, and React Native.

Now that we have selected our tool, let's start by setting up our development environment.

First, we need to install the storybook dependencies with the command npm add -D storybook @storybook/react-vite. Then we will add a new script into our package.json file which is "storybook": "storybook dev" and the script's main purpose is to automatically set up our development environment. To start the script, we need to run the command npm run storybook. Our package.json file should now look like this:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "storybook": "storybook dev"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "devDependencies": {
    "@storybook/react-vite": "^7.6.6",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "storybook": "^7.6.6"
  }
}

Now let's create our storybook configuration file. For this, we need to create a new folder inside our root folder called .storybook and inside this folder, we need to create a main.js file (our storybook configuration file).

For the main.js file, please copy the following code:

.storybook/main.js
export default {
  stories: ['./*.stories.jsx'],
  framework: { name: '@storybook/react-vite' },
};

Inside the main.js file, we are loading all the story files inside the new .storybook folder. Also, we are defining the storybook framework we want to use (for our react npm package, we will use @storybook/react-vite).

Now that the storybook setup is completed, we will create our first story. For this, we need to create inside the .storybook folder a new file which we will call Component.stories.jsx. The name of this file is all up to you but remember, the story files name always needs to end with stories.jsx. With all the latest setups, our new folder structure should look like this:

react-npm-package/
  .storybook/
    main.js
    Component.stories.jsx
  src/
    index.jsx
package-lock.json
package.json

For the Component.stories.jsx, please copy the following code:

.storybook/Component.stories.jsx
import React from 'react';
import { CounterComponent } from '../src/index.jsx';
 
export default {
  component: CounterComponent,
  title: 'Counter'
};
 
export const Placeholder = () => <CounterComponent />;

To understand what we did in this story file:

  1. We imported the React library and our React function.
  2. We exported our default object. Here we provided the title of the story and defined the component we wanted to display.
  3. We exported a state of our React function. With Storybook any export that is not default is considered a variation of the component and will be nested under the title we provided in the default export. If you want to learn more about how to create stories, you can read the Storybook writing stories introduction here.

We now have a complete development environment setup. We can start focusing on creating new features for our npm package and be able to test every new functionality before making it available to the public.

Setting up Rollup

We have finally arrived at the core section of our npm package, the build system. After going through different options, I found two bundlers to be the most commonly used around the web, webpack, and rollup. These two bundlers follow the same objective, to bundle JavaScript files but based on the use of these packages and as Rich Harris wrote in one of his articles, use webpack for apps and rollup for libraries. Since we are creating a library, rollup is our best choice.

Now that we selected our bundling system, let's start setting up Rollup in our npm package.

First, we need to install the rollup dependency with the command npm add -D rollup. To let rollup know how to bundle our package, we need to create a configuration file inside our root folder called rollup.config.js. With this, our new folder structure should be:

react-npm-package/
  .storybook/
    main.js
    Component.stories.jsx
  src/
    index.jsx
package-lock.json
package.json
rollup.config.js

For the rollup.config.js file, please copy the following code:

rollup.config.js
export default {
  input:'src/index.jsx',
  output: [],
  plugins: [],
  external: []
};

To understand better each field of the rollup.config.js file, we will dive into each one of them and update its content step by step.

input

This field defines the file that will run our react code. In our case, this is src/index.jsx. In this field, it is important to point to the file that is exporting our react function, without it, users would not be able to use our package.

output

This field specifies the format(s) we want to export our package and the path(s) where our bundle will be allocated. Here we will introduce a new folder called dist and this folder will contain the bundled version(s) of our package.

There are multiple formats that we can use to bundle our package and each one of those formats has a specific usage. Here we will talk about two formats which are CJS and ESM.

  • CJS stands for CommonJS and it is the format used by NodeJS. Since we are creating an npm package, we should export our project in this format.
  • ESM stands for ES Modules and it works in all modern browsers. It also allows your package to do tree-shaking making the bundled version load faster and be published with less code.

Now, let's export our npm package in these two formats. Our rollup.config.js should look like this:

rollup.config.js
import pkg from './package.json' assert { type: 'json' };
 
export default {
  input:'src/index.jsx',
  output: [
    { file: pkg.main, format: 'cjs' },
    { file: pkg.module, format: 'esm' }
  ],
  plugins: [],
  external: []
};

In this file, we are using a new import called pkg. This import allows us to access the package.json object values from the configuration file and since we are generating our output in pkg.main and pkg.module, we need to make sure that these fields exist and they contain the final paths to the bundled versions of our package, these paths are dist/index.cjs.js and dist/index.esm.js.

We will also add a new script into our package.json file which will automatically bundle our package, this script is "build": "rollup -c" and you can run it with the command npm run build.

Our package.json file should now look like this:

package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "scripts": {
    "storybook": "storybook dev",
    "build": "rollup -c"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "devDependencies": {
    "@storybook/react-vite": "^7.6.6",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "rollup": "^4.9.2",
    "storybook": "^7.6.6"
  }
}

plugins

This field defines all the plugins our package needs for bundling. Plugins allow us to customize rollup's behavior using different elements like a code minifier, code transpiler to old browsers, and much more. For our package, we will use plugins that will help us support JSX, ES5 and minify our npm package.

First, let's start by understanding what is ES5. ES5 is an abbreviation of ECMAScript 5 which was the first revision of JavaScript and is the version that supports the use of JavaScript in all major browsers (including Internet Explorer 11). After ES5, we have received multiple versions of ECMAScript which bring great new features like arrow functions, classes, let and const, promises, and much more but these versions are not always supported by all browsers. If you want to make sure your package can be used by any user, you should make your package ES5 compatible.

Now that we understand what ES5 is, we will dive into babel. Babel is a JavaScript compiler that allows us to use all the features added to ECMAScript after ES5 without waiting for browser support, for this, it has a set of plugins which are called Presets.

To set up our npm package, we will install babel and our presets with the command npm add -D @rollup/plugin-babel @babel/core @babel/preset-env @babel/preset-react. To understand a little bit more about what these packages are:

  • @rollup/plugin-babel: This package integrates rollup and babel.
  • @babel/core: This package is required for our project to work with babel and its presets.
  • @babel/preset-env: This is a babel preset that allows our package to use the latest JavaScript.
  • @babel/preset-react: This babel preset will help our project understand react code (JSX).

Let's update our rollup.config.js file with the following code:

rollup.config.js
import babel from '@rollup/plugin-babel';
import pkg from './package.json' assert { type: 'json' };
 
export default {
  input:'src/index.jsx',
  output: [
    { file: pkg.main, format: 'cjs' },
    { file: pkg.module, format: 'esm' }
  ],
  plugins: [
    babel({
      babelHelpers: 'bundled',
      exclude: 'node_modules/**',
      presets: ['@babel/preset-env','@babel/preset-react']
    })
  ],
  external: []
};

Now that babel is configured, we will install three rollup plugins to complete our bundle system, we can do so with the command npm add -D @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-terser. To understand these packages:

  • @rollup/plugin-terser: This plugin minifies all our package code (removes whitespace, comments, and unnecessary characters).
  • @rollup/plugin-commonjs: This plugin converts CJS modules into ES5 compatible code.
  • @rollup/plugin-node-resolve: This plugin locates third party modules in node_modules.

Lets now update our rollup.config.js file:

rollup.config.js
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import pkg from './package.json' assert { type: 'json' };
 
export default {
  input:'src/index.jsx',
  output: [
    { file: pkg.main, format: 'cjs' },
    { file: pkg.module, format: 'esm' }
  ],
  plugins: [
    babel({
      babelHelpers: 'bundled',
      exclude: 'node_modules/**',
      presets: ['@babel/preset-env','@babel/preset-react']
    }),
    resolve(),
    commonjs(),
    terser()
  ],
  external: []
};

external

This field specifies the peerDependencies our package needs. In our case, we defined this in the package.json file. To bring these values to our rollup configuration file, we can use the pkg import and define the external field as Object.keys(pkg.peerDependencies).

Our package.json and rollup.config.js files should now look like this:

rollup.config.js
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import pkg from './package.json' assert { type: 'json' };
 
export default {
  input:'src/index.jsx',
  output: [
    { file: pkg.main, format: 'cjs' },
    { file: pkg.module, format: 'esm' }
  ],
  plugins: [
    babel({
      babelHelpers: 'bundled',
      exclude: 'node_modules/**',
      presets: ['@babel/preset-env','@babel/preset-react']
    }),
    resolve(),
    commonjs(),
    terser()
  ],
  external: Object.keys(pkg.peerDependencies),
};
package.json
{
  "name": "react-npm-package",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "scripts": {
    "storybook": "storybook dev",
    "build": "rollup -c"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "devDependencies": {
    "@babel/core": "^7.23.7",
    "@babel/preset-env": "^7.23.7",
    "@babel/preset-react": "^7.23.3",
    "@rollup/plugin-babel": "^6.0.4",
    "@rollup/plugin-commonjs": "^25.0.7",
    "@rollup/plugin-node-resolve": "^15.2.3",
    "@rollup/plugin-terser": "^0.4.4",
    "@storybook/react-vite": "^7.6.6",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "rollup": "^4.9.2",
    "storybook": "^7.6.6"
  }
}

If we now run the command npm run build, we will get the final bundled version of our package in the dist folder. This version is all set to be published to npm.

Publish

Now that we have completed the development of our package, we are ready for our last step, to publish the package into npm. For this, we need to first access our npm account (if you don't have one yet, you can signup here) with the command npm login. As you will see, the terminal will ask you for your username, password, and email address.

Once we are logged in, we can publish the package with the command npm publish. If we want to publish a scoped package and want it to be public, we need to instead run the command npm publish --access public.

Conclusion

Congratulations, you have developed a clean, lightweight, and performant react npm package. I hope this tutorial has helped you understand every step of the process. Happy coding!