There is a lot of mystery around making your own npm
package. Every package
likely does it a bit differently, and it can be tricky to get a setup you like.
Should you use a "starter kit" or a boilerplate example? Or just roll your own?
Should you use a bundler? How do you use typescript? Well, why don't we try
starting from scratch and seeing where we can get?
TLDR: here is a github repo with a template package https://github.com/cmdcolin/npm-package-tutorial/
An npm
package can be very bare bones. In some sense, npmjs.com is just an
arbitrary file host, and you can upload pretty much anything you want to it.
The magic is in the package.json file, which tells npm:
const lib = require('mypackage')
, this is governed by the "main"
field, "module" field which is bundler specific, and "export maps" which are
newer but tricky)Let's try an experiment...
Open up a terminal, and run
This init command outputs something like this, and we accept the defaults
Then, you can create a file named index.js
(in your package.json it says
"main": "index.js"
to refer to this file, the entrypoint)
In your index.js
file, generally, you would do things like export a function
or functions. I will use commonjs exports here for maximum compatibility:
This npm package, mypackage
can now be published to npm
with a simple
command.
This will prompt you for your npmjs.com username, password, email, and if needed, 2FA token (highly recommended)
Once it is published, you can use it in your create-react-app app or other npm package.
Then you can use
in any of your other codebases
This all seems pretty boring thus far but it tells us a couple things
Let's try adding typescript
To do this, we will use the typescript compiler to compile a directory of files in our "src" directory and output the compiled files to a directory named "dist"
To start, let's add typescript
Our package.json now will have typescript
in it's devDependencies
(this
means that when someone installs your package, it they don't get typescript as a
dependency, it is just a dependency for while you are developing the library
locally).
Then we need to create a tsconfig.json for typescript to use
This will generate a tsconfig.json
file (needed by typescript
) with a bunch
of options, but I have stripped it down in my projects to look like this
Now, let's wrote a little typescript. We can now use "ESM" style code, we will compile it to commonjs format.
util.ts
index.ts
And then we will add a "build"
script to package.json
to compile the
library, and refer to the "dist"
directory for the "files"
and "main"
fields in package.json
We can now run
And this will run the "build"
script we created, which in turn, just runs
tsc
with no arguments.
You can also add a "prebuild"
script that clears out the old contents. In
fact, npm scripts generalizes the naming system -- you can make scripts with
whatever name you want, e.g.
Then running
To make this useful, we will use rimraf
(a node package) to make a
cross-platform removal of the dist
directory
and then update your package.json
We could make it say "rm -rf dist" instead of "rimraf dist" (e.g. run arbitrary shell commands), but rimraf allows it to be cross-platform
Without extra instructions, your yarn publish
command would not create a fresh
build and you could publish an older version that was lingering in the dist
folder.
We can use a preversion
script that will automatically get invoked when you
run yarn publish
to make sure you get a fresh build in the dist
folder
before you publish
When you run yarn publish
, npm will automatically create a commit with the
version name and a git tag, it will not automatically push tag to your
repository.
Add a postversion
script that pushes the tag to your repo after your publish
We can use this to do incremental/watch builds
You can use ts-jest to test your code. This involves installing jest, typescript, ts-jest, @types/jest, and then initializing a jest.config.json
We can then create a test
test/util.spec.ts
Then we can then create a script in the package.json that says "test": "jest"
,
and then we can say
You can also create an alternative system where you use babel-eslint
and
various babel strategies to test your code, but if you are using typescript
,
ts-jest
and typescript
works great.
Create a .gitignore with just a line that references this dist
folder and
node_modules
folder
dist
node_modules
There is a shift happening where modules are changing to be pure ESM rather than keeping commonjs equivalents
https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
There are many challenges here, but one shortcut that I have used is to actually go "halfway to ESM" and just publish a "dual" package: one "main" field in the package.json referring to a commonjs file, and one "module" field with an ESM style build for bundlers. I do it like this:
The "module" field is understood by bundlers like webpack and you can do
slightly less polyfilling/babeling on it (hence the different --target
attributes)
This is not "pure ESM" with the "type":"module" in package.json, but it does help to have less "babelification" (which in our case is done by tsc) of your source code.
This tutorial shows you how you can create a basic package that you can publish
to npm
. This little boilerplate includes these features:
yarn build
or yarn publish
ts-jest
for testingYou also have full control, and understand the decisions we took to get to this
point. This package does not use any bundling (rollup or webpack or otherwise).
It just uses tsc
is used to compile the files to the dist
folder, and the
dist folder is published to npm
!
If you need your package to be usable by consumers that don't themselves use
bundlers, consider looking into <script type="module">
for importing ESM
modules in the browser, or you can bundle your library using rollup or webpack
and output e.g. a UMD bundle
See https://github.com/cmdcolin/npm-package-tutorial/
This is a setup that works for me, but there are many ways to publish a package so take it with a grain of salt!
Also see my follow up rant: you may not need a bundler https://cmdcolin.github.io/posts/2022-05-27-youmaynotneedabundler
There are many high powered "monorepo" setups like lerna, nx, turborepo, etc.
I think for many purposes, these can be a bit overkill. I would start with yarn workspaces. Basically, the way this works is you can have e.g. in your root package.json in your repo something likely
And then in your lib
directory you can have your library as we created above
and app
for example can be an instance of a vite
app that uses your library.
You can reference your lib by name in the app
folders package.json, and it
will automatically get the latest version of it that you have built from the
lib
directory e.g. your app
will look at the libs "dist" folder: it's
compiled outputs. That means you can run yarn tsc --watch
in the lib folder to
continuously build it, and then e.g. when you are running e.g. yarn dev
in the
app
directory, it will see updates to the lib
dist directory and auto-update
via hot module refresh
High powered solutions like nx, turborepo, etc may have solutions for
'automatically building all the stuff' without you explicitly having to run the
build in the lib
directory, but for simple monorepo setups, this works ok