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
}
}
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"}
`
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>
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"
}
}
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"],
}
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",
},
}),
})
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"],
}),
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(),
}
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",
},
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(),
],
};
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"
},
}
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
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
Top comments (12)
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...
Oh, wow! This feels promising!
What about people who tend to bend towards the commonjs side of things?
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...
Oh! Nice! I get your point now.
Everything supports ESM now, excepts sticks in the mud.
Ah! That's great to hear Tbh!
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.
Oh wow! Thank you so much for suggesting this. I'll definitely give it a try when next I'm working on something
Or Yalc
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?
Shameless plug, this is how to do it the right way with all the modern tools.
dev.to/apestein/how-to-publish-to-...
Ayy!! thank you for putting it here. I'll update the article too. :D