I started this year building two projects that are meant to be deployed as NPM packages — Catalyst UI and Rescribe.
I built and deployed an NPM package years ago at work. At that time, not knowing better, I didn't automate anything. Every time I made a change to the package, I manually ran the build script, tested it on an example repo by manually updating the package, logged into npm from the terminal, and ran npm publish.
This time around, I wanted to do it differently — modern tooling, automation, etc. This is the stack I ended up with, by checking what popular libraries are using and what interested me.
Monorepo with TurboRepo
When building any sort of shared libraries or npm packages, using a monorepo is the easiest way to do it. I used to be scared of monorepos. I tried to learn Lerna a few times but it seemed so daunting.
Turborepo changed that and made it easy to manage monorepos. It's a build system for JS/TS codebases that takes care of running tasks like linting, testing, and building. And it does that super fast by leveraging caching, both locally and remotely. Running a build task but nothing changed from the last build? Turborepo won't do the build again. It would just return the cached version.
Turborepo is configured using a turbo.json
file located in the project root. I rarely touch this file though. The create-turbo
command generates a starter that has a turbo.json
pre-configured and ready-to-go.
In a monorepo setup there will be websites, web app(s), shared libraries, and even shared configurations all in one repo.
For example, I put all my shared libs and configurations in a packages folder in the project root. I then create a different directory for the website(s) or app(s). Now I just have to declare my workspaces in my root package.json
file.
{
"workspaces": [
"apps/*",
"packages/*"
]
}
Now that I have the build system sorted out, it's time to manage the dependencies. Here's where pnpm workspaces come in.
Pnpm Workspaces
I recently moved from yarn to pnpm for Node package management. I made the switch because of the mess that is yarn versions (I still stayed on 1.22). But the move worked out quite well because I found pnpm workspaces to be quite easy to use.
I just have to create a pnpm-workspace.yaml
file and define my packages.
packages:
- "apps/*"
- "packages/*"
In a monorepo, there'll be multiple projects under one roof. Each of those will have its own dependencies and most probably, will depend on one another. Pnpm makes it easy to target specific workspaces from the project root.
Enter, the --filter
flag. For example, let's say I want to install a dependency to a workspace called ui
under packages. I just have to do this:
pnpm add react react-dom --filter ui
This will install react
and react-dom
to only the workspace I defined under the --filter
flag. No need to traverse directories every time.
Tsup
I recommend writing library/package code in TypeScript. Even if the users of the library don't use TypeScript, writing the library in TypeScript has some benefits.
Editors can detect types in the library and do auto-completion
It helps write a better library by giving clarity on the data it's handling
If users of the library are using TypeScript, they can use the types that are exposed by the library in their codebase.
Coming back to tsup. It is a bundler that's made for building TypeScript libraries and is built on top of esbuild. It's fast and needs no configuration to get started.
Just add two scripts to the library's package.json
:
{
"build": "tsup",
"dev": "tsup --watch"
}
If there's a tsup.config.ts
alongside package.json
, tsup will use that config.
Changesets
Changesets are one of those things that make life as a library author so much easier. Many popular libraries use changesets to automate versioning and publishing. It even generates changelogs for each version!
Every time there's a change in one of the packages, run:
pnpm changeset
Changeset will detect which package has changed, or provide the choice to select all packages. Then it will ask to select the type of change, following the semver rules — major, minor, and patch. Based on the selection, the changeset will update the version and create a pull request.
Once the pull request is merged, the changeset will publish the package to the NPM repository.
To handle all of this, some configuration is needed, including GitHub Actions for publishing to NPM. Changeset even has a pre-built action for use in GitHub Actions.
For example, here's a simple GitHub Action that I use for publishing
Catalyst:
name: Publish
on:
push:
branches:
- 'main'
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
build:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 7
- uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
publish: pnpm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GitHub Actions
Of course, automating the publishing part of the package won't be possible without some sort of CI/CD, like GitHub Actions.
With a simple YAML file like the one above, GitHub Actions will run the tasks required for building and publishing the package whenever there are code changes to the main
branch.
One of the tasks is a release
script in the root package.json
file:
"release": "turbo run build --filter @i4o/catalystui && changeset publish"
This will make the changeset do its thing — generating a new version, pushing to NPM, creating a changelog, and publishing a new release in the Github repository.
This is the stack that makes it easy to publish a library to NPM. I've used this stack to publish packages to NPM this year.
Thanks for reading!