Avatar

Recap on publishing an NPM package

← Back to list
Posted on 27.01.2025
Last updated on 05.02.2025
Image by AI on Dall-E
Refill!

Table of contents

Recently I had to develop and publish an NPM package, which I haven't done for a while. I was surprised I couldn't remember a half of the things, so I am doing a short recap now. Preparing a package is somewhat different from bundling a web application, so it inflicts a few changes.

# Structuring the code

TypeScript is the de-facto standard now, so I assume we all use it to make our NPM package. Create a package folder then, go there and generate tsconfig.json:

$
npm install -g typescript # if not done before
mkdir mypackage
cd mypackage
npx tsc --init
The code is licensed under the MIT license

This will give you the initial tsconfig.json. It depends on specific case, but I usually then change:

  • target to "esnext" or at least to "es6"
  • module to
    • "esnext" when the package is intended to be used in Node environments only
    • "commonjs" otherwise, for Node and browsers

I'll also add:

  • allowJs: true
  • moduleResolution: "node"
  • resolveJsonModule: true
  • declaration: true
  • sourceMap: true

If I need React, I will also add:

  • jsx: "react"
  • lib: ["dom", "dom.iterable", "esnext"]

I assume the source files will be placed to the src/ folder (this is the default). There is a tricky moment with the destination folder, where all the files go after the compilation. I see many modules having "./" as dist, this means that after installing your package imports like this:

import foo from "mymodule/bar/foo";
The code is licensed under the MIT license

will resolve foo to "./node_modules/mymodule/bar/foo", which means the "bar" folder is located in the root folder. I usually do it like this.

I also make sure the noEmit option is set to false, otherwise the tsc won't produce any files.

There is a way to have multiple config files, one extending another, and use them in different situations:

👉 📃  tsconfig.local.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"exclude": ["node_modules", "dist"]
}
The code is licensed under the MIT license

And then you can use it like this:

npx tsc --project tsconfig.local.json
The code is licensed under the MIT license

# Writing package.json

The next step is to init the package.json file. Here is how to do it non-interactively:

$
npm init -y
The code is licensed under the MIT license

Then I do a few adjustments:

  • prefix the name with the organization name, e.g: "@myorg/mypackage"
  • set version to "0.0.0"
  • fill up author, keywords and description with relevant stuff
  • set the license to "MIT"
  • set the repository to type:"git" and url to the repo url, same for the homepage
  • if the package is not private, I set publishConfig.access to "public"

When building a CLI application, the bin parameter must be specified. It will allow the package manager to symlink the values listed there to the bin/ folder, which is added to the $PATH env variable.

Important: the entry must point to the compiled version of the package, not dev version. E.g:

"bin": {
"command_name": "./dist/index.js"
},
The code is licensed under the MIT license

The ./dist/index.js file is basically the entry point to your package upon calling the CLI command.

# Dependencies

Now, for the dependencies. There are three types:

  • dev dependencies: packages only needed while developing your package: e.g. typescript
  • dependencies: packages will be installed along when a user installs your package
  • peer dependencies: packages that will not be installed, allowing the application owner to provide them for you

Peer dependencies are useful when working with massive packages such as React, and also with packages that produce side effects. Putting them to the peer deps helps avoiding situations when you end up with several versions of the library bundled up.

# Scripts

There are three scripts I normally add:

  • to build the package: tsc
  • to build and run the package in the development mode: ts-node ./src/index.ts
  • to install peer packages for development: npx install-self-peers using @team-griffin/install-self-peers
  • linting: eslint 'src/**/*.ts'
  • format: prettier --write 'src/**/*.ts'

🚨 Never ever use ts-node to run production code, as ts-node is meant for development only! Also, feels like Node is going to support TypeScript natively soon.

# Default dependencies

There are several packages I typically include by default:

  • lodash to get access to some array/object helpers
  • debug to output the debugging info when needed
  • some packages containing type definitions

For CLI applications I recommend:

So,

$
npm install lodash debug
npm install --save-dev typescript tsc ts-node @team-griffin/install-self-peers @types/debug @types/lodash @types/node@typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettier
The code is licensed under the MIT license

# Unit tests

Unit testing is an essential part of software development cycle. You can write unit tests using libraries such as Jest, which became sort of an industry standard. Of course, there are other projects available. Basically you want to have unit tests that make sure the contracts you package offers are not broken, and do so on every CI pass.

To enable jest, add jest.config.js:

👉 📃  jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node', // or 'jsdom' for browser-like tests
transform: {
'^.+\\.tsx?$': 'ts-jest', // Use ts-jest for ts and tsx files
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'node'],
};
The code is licensed under the MIT license

And then add "include": ["src/**/*", "tests/**/*.ts"] to tsconfig.json, and

"scripts: {
"test": "jest",
"test:watch": "jest --watch"
},
The code is licensed under the MIT license

to package.json.

# Examples of package.json and tsconfig.json

Given all mentioned above, here goes a typical package.json:

👉 📃  package.json
{
"name": "@myorg/mypackage",
"version": "0.0.0",
"description": "My super cool package",
"bin": {
"mytool": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "ts-node src/index.ts",
"lint": "eslint 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts'",
"install_peers": "npx install-self-peers",
"test": "jest",
"test:watch": "jest --watch"
},
"publishConfig": {
"access": "public"
},
"keywords": ["amazing", "package"],
"author": "Sergei",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"lodash": "^4.17.21"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/lodash": "^4.17.14",
"@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"lodash": "^4.17.21",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"@team-griffin/install-self-peers": "^1.1.1",
"jest": "^29.7.0"
},
"peerDependencies": {
}
}
The code is licensed under the MIT license

And tsconfig.json:

👉 📃  tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./"
},
"include": ["src/**/*", "tests/**/*.ts"],
"exclude": ["node_modules"]
}
The code is licensed under the MIT license

# Other important files

There is a bunch of files that must be also created.

# .gitignore

Yes, we all know what .gitignore is. Typically, it looks like this:

👉 📃  .gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directories
node_modules/
jspm_packages/
# Environment variables
.env
.env.*.local
# Build outputs
dist/
build/
out/
.next/
.nuxt/
.svelte-kit/
.esm-cache/
# Test coverage
coverage/
.nyc_output/
# Debug files
*.log
*.tsbuildinfo
# IDE-specific files
.vscode/
.idea/
*.swp
*.swo
*.sublime-workspace
# OS-specific files
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
# Lint and formatting cache
.eslintcache
.prettiercache
# TypeScript
*.d.ts
*.d.ts.map
# Optional npm cache directory
.npm
# Yarn
.yarn/
.pnp.*
.yarnrc.yml
# Parcel
.cache/
# Binaries
*.exe
*.dll
*.so
*.dylib
# Generated files
*.gz
*.tgz
*.zip
# Git-specific files
.git/
.gitignore.lock
The code is licensed under the MIT license

# .npmignore

This file is important and is easily overlooked. The .npmignore serves the same purpose as .gitignore, but for NPM. Specify there all files you don't want to be included into the package upon publishing to NPM.

👉 📃  .npmignore
node_modules
.git/
.env
.prettierrc
.eslintrc.json
github
The code is licensed under the MIT license

# .npmrc

This file isn't necessary to be committed as a static file. It can be created upon publishing the package to NPM in case you're publishing to a private NPM registry, such as Github registry. Here is the example:

👉 📃  .npmrc
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
@myorg:registry=https://npm.pkg.github.com
The code is licensed under the MIT license

The NPM_TOKEN env variable in this case contains the github access token and typically comes from the repository secrets on CICD. The token should have rights to read and publish NPM packages. If you company uses SSO, don't forget to enable the SSO for the token!

Again, you will only need this file in the repo if you intent to publish from your local machine, which I'm strongly against of.

# .eslintrc.json & .prettierrc

These two files are needed to configure linter:

👉 📃  .eslintrc.json
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "prettier"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"rules": {
"prettier/prettier": "warn"
},
"env": {
"node": true,
"es2021": true
}
}
The code is licensed under the MIT license

... and prettier:

👉 📃  .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5"
}
The code is licensed under the MIT license

# CICD and versioning

A CICD pipeline should be used to publish new versions of the package. I go strongly against publishing from the local machine, as it can quickly turn into a mess.

I use Github Actions to build and publish packages, as for physical customers it's free unless abused.

Remember we've set "0.0.0" as the version number in package.json? Right, there are two ways to version packages that I use:

  • Make a release on Github and take the version number from there.
  • Auto-generate the next version at build time, and commit it back to the repository. This works better when a package is automatically built.

# Taking the version from the release

Here is the example of a workflow that takes the version. Note that the version is not committed back, so in the repository itself the package.json file holds the "0.0.0" value (and that's okay):

Also note, that .npmrc is generated only to publish the package, but is never committed.

👉 📃  .github/workflows/buildpush.yml
name: Build & Push
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 23
cache: 'npm'
- name: Install dependencies
run: |
npm install
npx install-self-peers
- name: Build
run: npm run build
- name: Run unit tests
run: npm test
- name: Sync version with release tag
run: npm version $(echo ${GITHUB_REF##*/} | sed 's/^v//') --no-git-tag-version
- name: Authenticate with npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
- name: Publish to npm
run: npm publish
- name: Success notification
run: echo "Package successfully published!"
The code is licensed under the MIT license

# Choosing the version automatically

And here is another option of the workflow, where the version is determined based on the current version specified in the package.json, and then incremented. That's why the version change must be committed back to the repository.

This approach can be useful for automatic builds, as the workflow can be triggered manually via a webhook or through Github UI.

👉 📃  .github/workflows/buildpush.yml
name: Build & Push
on:
workflow_dispatch:
repository_dispatch:
types: [ trigger-event ]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 23
cache: 'npm'
- name: Install dependencies
run: |
npm install
npx install-self-peers
- name: Git config
run: |
git config --global user.email "cicd-bot@gmail.com"
git config --global user.name "CICD Bot"
- name: Build
run: npm run build
- name: Version up
run: |
npm version minor --no-git-tag-version
- name: Publish to npm
env:
NPM_TOKEN: ${{ secrets.GH_TOKEN }}
run: npm publish
- name: Commit the version change back
run: |
git add package.json package-lock.json
git commit -m "Update version to $(node -p "require('./package.json').version")"
git push origin ${{ github.ref_name }}
- name: Success notification
run: echo "Package successfully published!"
The code is licensed under the MIT license

This is how to trigger a workflow within another workflow:

trigger-build:
runs-on: ubuntu-latest
steps:
- name: Trigger build
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/owner/repo/dispatches \
-d '{"event_type":"trigger-event", "client_payload": { "some": "parameter" }}'
The code is licensed under the MIT license

# Mono repositories

A few words on automating monorepos. Some projects can have functionality distributed between several packages, that depend on each other. For example, there could be packages @someproject/core, @someproject/cli, @someproject/web and so on.

Whenever there is a new update being pushed, you can either increment versions of all packages at once to keep them in sync, or you can have different versions per each package.

To manage all this, Lerna can help.

# README.md & LICENSE

The README.md file is crucial to on-boarding users of the package. The file can be a substitution for a website dedicated to the package, and should have at least brief explanation, some use-cases, a reference and some contacts.

The LICENSE file is sometimes required to be included, as a certain type of license may demand. License texts can be found here.

Well that's pretty much it! Cheers.


Avatar

Sergei Gannochenko

Business-focused product engineer, in ❤️ with amazing products.
Golang/Node, React, TypeScript, Docker/K8s, AWS/GCP, NextJS.
20+ years in dev.