DEV Community

Cover image for Building an npm package in 2023
Caleb O.
Caleb O.

Posted on • Edited on

Building an npm package in 2023

Building an npm package requires a lot of decisions that need to be considered. From choosing a default bundler to selecting an appropriate transpiler to understanding the use case of the tool or package, or I dare say, a library — you're creating.

A whole lot of thinking goes into this process than the actual process of writing the code. Yes, sometimes, this whole "deep thinking" charade may not even be necessary. But, it is important that you have it at the back of your mind.

Webpack, the oldie

Years back, one approach towards building these packages was completely dependent on the use of webpack as the go-to bundler, which also comes with a lot of plugins that can be used to bundle your JavaScript code.

But, Webpack has a lot of complexities attached to it. When I tried using it a few times, I'd always get frustrated at the number of things that flew around in the config file, I even wrote an article about my travails.

Note: My aim here isn't to rant about webpack, but to provide a more subtle approach towards building these packages, and publishing them.

A lot of folks may come for my opinions here, and bring up that famous quote "Webpack is for building apps, the others are for building libraries".

But, I think what a lot of folks are missing out here, is the good DX — Developer Experience — that the "other" bundling tools have when compared to webpack.

Yes, webpack gives you a lot of functionalities. Okay, good. But at the detriment of my sanity?

As I mentioned before, the purpose of this article somehow revolves around the mistakes I made and how I bypassed them.

For some days now, I've been working on a React Tab component that preserves the state of each Tab (or nav items) when it is clicked upon and when you navigate away from and back to where this component is mounted.

Working on it affirmed a question that I've always pondered on, for a very long time. "Can we keep component state in the browser URL?"

You should try using the package and let me know what you think.

I wrote a short piece on why you might not need a state-management library, as it establishes the proof-of-concept for this component. Take a look in your spare time.

Now, onto the matter at hand, npm packages.

From the things I've done in the past, I think I can conclude that you should use the typescript compiler — tsc — as your bundler when you decide to build an npm package that isn't in any way similar to a component library or package that depends on some sort of styling with CSS.

An example is this helper function — that extracts the heading texts from any markdown file — I worked on a while back. You can take a look at the configuration of the compiler here

Let me walk you through the essence of this config. It is the same as the one from the repo.

{
 "include": ["src"],
 "exclude": ["node_modules", "dist"],
 "compilerOptions": {
 "lib": ["ESNext"],
 "module": "ESNext",
 "sourceMap": true,
 "importHelpers": true,
 "declaration": true,
 "rootDir": "./src",
 "outDir": "./dist/esm",
 "strict": true,
 "noImplicitReturns": true,
 "noUnusedLocals": true,
 "noUnusedParameters": true,
 "moduleResolution": "node",
 "jsx": "react",
 "esModuleInterop": true,
 "skipLibCheck": true,
 "forceConsistentCasingInFileNames": true
 }
}
Enter fullscreen mode Exit fullscreen mode

include specifies the files or directories that should be included when compiling the TypeScript code. In this case, for that helper function, it includes the src directory. Yours could be different.

exclude specifies the files or directories that should be excluded from the compilation process. Here, it excludes the node_modules and dist directories.

In the compilerOptions section, there's the lib property that ensures the specific libraries that should be included in the compilation process.

Here, it includes the "ESNext" library, which provides the latest ECMAScript features.

The module property determines the module system to use. In this case, it's set to "ESNext", which enables the use of modern JavaScript modules.

sourceMap enables the generation of source maps, which are useful for debugging TypeScript code in the browser or in development tools.

The importHelpers enables the import of TypeScript helper functions to assist with certain features like decorators and async/await.

declaration generates corresponding .d.ts declaration files alongside the compiled JavaScript files, allowing for type checking and code completion in other TypeScript projects that consume this code.

rootDir specifies the root directory for TypeScript source files. Here, it's set to the src directory. Again, yours can be completely different.

In the recent package, I worked on. I set mine to packages because the library will have many use cases for different components, supposedly.

outDir determines the output directory for compiled JavaScript files. In this case, it's set to the dist/esm directory.

strict enables strict type checking and stricter compiler options.

noImplicitReturns reports an error when a function has a missing return statement.

noUnusedLocals This reports an error when a local variable is declared but not used.

noUnusedParameters This reports an error when a function parameter is declared but not used.

moduleResolution specifies how TypeScript resolves module imports. Here, it's set to "node", which uses Node.js module resolution.

This part tends to be very frustrating at times.

jsx determines the syntax used for JSX. In this case, it's set to "react" to support React JSX syntax.

esModuleInterop enables interoperability between CommonJS and ES modules, allowing for easier importing of CommonJS modules in TypeScript.

skipLibCheck skips type checking of declaration files (*.d.ts) from dependencies, which can improve compilation speed.

forceConsistentCasingInFileNames enforces consistent casing of filenames, which can help prevent issues when working on different operating systems.

You do not have to use this config strictly if you don't want to. You are free to set up whichever one works for you.

Enter, Rollup.js

Rollup is a bundler for JavaScript applications and it is by far the most recommended tool to use if you want to build a component library or any npm package, because of its intuitive and straightforward process of onboarding devs when compared to webpack.

The good thing here is that you can use Typescript and Rollup together when you want to build an npm package. especially one that depends on styling.

All you'd need to do is find the right plugins. When I set out to build the react-tab package, I started with Typescript's compiler, tsc as my bundler.

But, it brought a lot of limitations, because, it couldn't extract and handle CSS outputs correctly. Since Rollup has an ecosystem of plugins that I could use, doing research and finding the appropriate ones were not too strenuous.

Remember how I also talked about the "thinking" that goes into creating an ideal package?

If the npm package you set out to build is in one way or another other dependent on using CSS, you may want to consider choosing an approach that you'll take.

Some people like going with CSS modules, some with scss or Sass, and some with plain 'Ol CSS. I used styled-components for react-tab

"Why would you even do that? There are so many limitations with styled-components"

I'll get to that later. My point here revolves around those little edge cases. For example now, if you decided to use CSS modules for a package whose major end user(s) are likely to use it in a Next.js project, you'll have some issues to deal with.

Going with styled-components was a wise decision for me, since the Tab components are meant to be intuitive and completely customizable for developers. I needed to expose some certain style props.

And it is no new thing that you can extend the props of a component when using styled-components.

// component.styled.ts
import styled from "styled-components"

type themeProps = {
 theme: string
}

export const Container = styled.div<themeProps>`
 background: ${({theme}) => theme ? theme : "brown"}
`
Enter fullscreen mode Exit fullscreen mode

Container then becomes something similar to the snippet below. This is just the tip of the iceberg of what can be achieved with this CSS component-library

<Container theme="purple">
 // children
</Container>
Enter fullscreen mode Exit fullscreen mode

But, the limitations still persist. Because, and as I mentioned previously, typescript's compiler cannot properly handle the outputs of styles that'll be bundled together onto the component, Rollup swoops in to save the day.

The source of truth — rollup.config.js

It is a common practice for most bundlers to have a config file that sorta controls how the application is chunked, crunched, compiled, and any other action that goes on behind the scenes.

An example of a config file can be seen below. This one exports the entry point — input — of the application and the output directory.

export default {
 input: "packages/index.ts",
 output: {
   file: "dist/index.js",
   format: "cjs"
 }
}
Enter fullscreen mode Exit fullscreen mode

Since, react-tab uses an external dependency, next/router to be precise, an "unresolved dependencies" warning will be logged when I try to build the package with the rollup -c command.

Rollup treats dependencies differently based on their import paths. When it encounters an import that starts with . or /, it assumes it's a local file and resolves it accordingly.

However, when the import starts with a module name, such as next/router, it treats it as an external dependency that should be resolved by whoever uses the package.

To bypass this warning, I had to specify that next/router is an external dependency in my rollup config.

This same approach applies to you, once you've identified the external packages that your application/library depends on.

export default {
 input: "packages/index.ts",
 output: {
   file: "dist/index.js",
   format: "cjs",
 },
 external: ["react", "react-dom", "next/router"],
}
Enter fullscreen mode Exit fullscreen mode

Recall when I mentioned something related to the limitations of styled-components? Here's the issue with it.

When using styled-components in a shared component or library, you should keep three things in mind to ensure that the styles are applied correctly when the package is consumed by other projects.

listing it as a peer dependency : I had to make sure that styled-components is listed as a peer dependency in my package.json file.

This informs consumers that they need to have styled-components installed in their own project to properly utilize your component.

If you do not have it that way in your package.json file, you can do so by modifying the peerDependencies property with the generatePackageJSON plugin like so:

generatePackageJSON({
 outputFolder: "dist",
 baseContents: (pkg) => ({
   name: pkg.name,
   main: "/dist/index.js",
   peerDependencies: {
     react: "^18.2.0",
     "styled-components": "^6.0.0-rc.3",
   },
  }),
})
Enter fullscreen mode Exit fullscreen mode

CSS extraction: By default, styled-components applies styles by injecting them into the DOM at runtime.

However, when building a shared component library or package, it's best to extract the styles during the build process, so they can be bundled and used by the consuming project.

To achieve this, I had to use a tool like babel-plugin-styled-components along with the Babel plugin in my rollup config

babel({
 extensions: [".ts", ".tsx"],
 exclude: "node_modules/**",
 presets: ["@babel/preset-react", "@babel/preset-typescript"],
 plugins: ["styled-components"],
}),
Enter fullscreen mode Exit fullscreen mode

CSS output: there's a rollup plugin — rollup-plugin-styles — that should be included in your config file, so long as you're writing anything that is transpiled down to CSS.

The plugin ensures that the styles you'll be writing are properly extracted.

export default {
 input: "packages/index.ts",
 output: {
   file: "dist/index.js",
   format: "cjs",
 },
 external: ["react", "react-dom", "next/router"],
 styles(),
}
Enter fullscreen mode Exit fullscreen mode

In the peerDependencies property, the exact version of the dependencies was hard coded. But, there is a way to ensure that the peer dependencies are automatically updated to the latest compatible versions without hardcoding them manually.

You can use the latest tag in the version range of the peer dependencies.

peerDependencies: {
 react: "latest",
 "styled-components": "latest",
},
Enter fullscreen mode Exit fullscreen mode

By using the latest tag, it will automatically fetch the latest version of the peer dependency that satisfies the specified version range.

This way, you don't need to manually update the versions every time there's a newer compatible version.

When users install your package or when they run npm install or yarn install, the package manager will fetch the latest compatible versions of the peer dependencies based on the version range specified in your package.json.

But — there's always a but — keep in mind that using the latest tag can also introduce potential breaking changes if a new major version of the peer dependency is released and it includes breaking changes.

To mitigate this, it's a good practice to thoroughly test your package with the latest versions of the peer dependencies before releasing a new version.

Here's what the complete rollup config looks like:

import babel from "rollup-plugin-babel";
import commonjs from "rollup-plugin-commonjs";
import generatePackageJSON from "rollup-plugin-generate-package-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import styles from "rollup-plugin-styles";
import { terser } from "rollup-plugin-terser";

const dev = process.env.NODE_ENV !== "production";

export default {
  input: "packages/index.ts",
  output: {
    file: "dist/index.js",
    format: "cjs",
  },
  external: ["react", "react-dom"],
  plugins: [
    nodeResolve({
      extensions: [".ts", ".tsx"],
    }),
    babel({
      extensions: [".ts", ".tsx"],
      exclude: "node_modules/**",
      presets: ["@babel/preset-react", "@babel/preset-typescript"],
    }),
    generatePackageJSON({
      outputFolder: "dist",
      baseContents: (pkg) => ({
        name: pkg.name,
        main: "/dist/index.js",
        peerDependencies: {
          react: "^18.2.0",
          "styled-components": "^6.0.0-rc.3",
        },
      }),
    }),
    terser({
      ecma: 2015,
      mangle: { toplevel: true },
      compress: {
        toplevel: true,
        drop_console: !dev,
        drop_debugger: !dev,
      },
      output: { quote_style: 1 },
    }),
    commonjs(),
    styles(),
  ],
};
Enter fullscreen mode Exit fullscreen mode

And here's what the important part of the package.json file entails. It specifies the build configuration and script for the react-tab package.

You can adopt it to suit your use case.

{
 "main": "./dist/cjs/index.js",
 "module": "./dist/esm/index.js",
 "types": "./dist/esm/index.d.ts",
 "scripts": {
   "build:esm": "tsc",
   "build": "yarn build:esm && yarn build:cjs",
   "build:cjs": "rollup -c"
 },
}
Enter fullscreen mode Exit fullscreen mode

Here's a breakdown of the snippet;

"main": "./dist/cjs/index.js" specifies the entry point for CommonJS (CJS) modules. When someone imports your package using require() or import in a CommonJS environment, this is the file that will be loaded.

"module": "./dist/esm/index.js" specifies the entry point for ECMAScript Modules (ESM). When someone imports your package using import in an ESM-supported environment, this is the file that will be loaded.

"types": "./dist/esm/index.d.ts" specifies the location of the TypeScript declaration file (.d.ts).

This file provides type information for your package, allowing TypeScript users to get type-checking and autocompletion when using your package.

"build:esm": "tsc" runs the TypeScript compiler — tsc — to build the ESM version of your package. It compiles the TypeScript code into JavaScript and outputs the files in the ./dist/esm directory.

"build": "yarn build:esm && yarn build:cjs" runs the build:esm script first, and then executes the build:cjs script.

It ensures that both the ESM and CJS versions of your package are built.

"build:cjs": "rollup -c" runs the Rollup bundler — rollup — with the configuration file (-c).

Rollup reads the configuration file to bundle your code, applying any specified transformations or optimizations. The output is generated in the ./dist/cjs directory.

testing locally before publishing.

This part is somewhat tricky as it may somehow affect the versioning process of your tool.

Instead of running npm publish anytime you make a significant change, you should instead link the package with npm link

If you're on any Linux distro OS, you'll have to append sudo to npm link to grant permissions. This ensures that a symlink is created for the package.

Then you can proceed to link the package in another project where you want to use it like so.

npm link package-name
Enter fullscreen mode Exit fullscreen mode

wrapping up.

Inasmuch as the intent of this article isn't entirely to walk you through the process of building a component library by following some steps, below are some resources that may help you accomplish your aim.

How to Publish to NPM the Right Way

How to build a component library with React and TypeScript

Component library setup with React, TypeScript and Rollup

Easily build and publish a React / Typescript component library package to Npm using Storybook and Rollup.

Top comments (12)

Collapse
 
rxliuli profile image
rxliuli

We are now only compatible with esm, and all construction is done based on vite, and even wrote a vite plug-in for it to specifically support it.

ref: npmjs.com/package/@liuli-util/vite...

Collapse
 
seven profile image
Caleb O.

Oh, wow! This feels promising!

What about people who tend to bend towards the commonjs side of things?

Collapse
 
rxliuli profile image
rxliuli • Edited

I just hate complicated compatibility issues. If they want to run node programs directly, they can use something like vite-node/tsx/esno/esm and those tools will handle it automatically. If it's a web program, which now almost certainly goes through a build tool, that's not a problem anymore.
I also wrote an article introducing
dev.to/rxliuli/developing-and-buil...

Thread Thread
 
seven profile image
Caleb O.

Oh! Nice! I get your point now.

Collapse
 
seanmclem profile image
Seanmclem

Everything supports ESM now, excepts sticks in the mud.

Thread Thread
 
seven profile image
Caleb O. • Edited

Ah! That's great to hear Tbh!

Collapse
 
damian_cyrus profile image
Damian Cyrus

Good article!

For local testing the final, published package you can use verdaccio as a local npm registry. This way it is possible to test it like a real package with installation.

Collapse
 
seven profile image
Caleb O.

Oh wow! Thank you so much for suggesting this. I'll definitely give it a try when next I'm working on something

Collapse
 
odus_ex profile image
Aditya Tripathi

Or Yalc

Collapse
 
seanmclem profile image
Seanmclem

Why not have the package have a dependency for styled-components? Instead of having the user install the dependency, even if they don’t use it directly. Also, you pre-build the styles, why would the user need the styled components library at all, at that point?

Collapse
 
apestein profile image
Apestein

Shameless plug, this is how to do it the right way with all the modern tools.
dev.to/apestein/how-to-publish-to-...

Collapse
 
seven profile image
Caleb O.

Ayy!! thank you for putting it here. I'll update the article too. :D