
Recap on publishing an NPM package
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.
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 beforemkdir mypackagecd mypackagenpx tsc --init
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";
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:
{"extends": "./tsconfig.json","compilerOptions": {"outDir": "./dist"},"exclude": ["node_modules", "dist"]}
And then you can use it like this:
npx tsc --project tsconfig.local.json
The next step is to init the package.json file. Here is how to do it non-interactively:
npm init -y
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 ./dist/index.js file is basically the entry point to your package upon calling the CLI command.
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.
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.
There are several packages I typically include by default:
For CLI applications I recommend:
So,
npm install lodash debugnpm 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
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:
module.exports = {preset: 'ts-jest',testEnvironment: 'node', // or 'jsdom' for browser-like teststransform: {'^.+\\.tsx?$': 'ts-jest', // Use ts-jest for ts and tsx files},moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'node'],};
And then add "include": ["src/**/*", "tests/**/*.ts"] to tsconfig.json, and
"scripts: {"test": "jest","test:watch": "jest --watch"},
to package.json.
Given all mentioned above, here goes a typical 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": {}}
And 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"]}
There is a bunch of files that must be also created.
Yes, we all know what .gitignore is. Typically, it looks like this:
# Logslogs*.lognpm-debug.log*yarn-debug.log*yarn-error.log*# Runtime datapids*.pid*.seed*.pid.lock# Dependency directoriesnode_modules/jspm_packages/# Environment variables.env.env.*.local# Build outputsdist/build/out/.next/.nuxt/.svelte-kit/.esm-cache/# Test coveragecoverage/.nyc_output/# Debug files*.log*.tsbuildinfo# IDE-specific files.vscode/.idea/*.swp*.swo*.sublime-workspace# OS-specific files.DS_StoreThumbs.db# Temporary filestmp/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
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.
node_modules.git/.env.prettierrc.eslintrc.jsongithub
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:
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}@myorg:registry=https://npm.pkg.github.com
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.
These two files are needed to configure linter:
{"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}}
... and prettier:
{"semi": true,"singleQuote": true,"tabWidth": 4,"trailingComma": "es5"}
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.
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.
name: Build & Pushon:release:types: [published]jobs:publish:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Setup Node.jsuses: actions/setup-node@v3with:node-version: 23cache: 'npm'- name: Install dependenciesrun: |npm installnpx install-self-peers- name: Buildrun: npm run build- name: Run unit testsrun: npm test- name: Sync version with release tagrun: npm version $(echo ${GITHUB_REF##*/} | sed 's/^v//') --no-git-tag-version- name: Authenticate with npmenv:NPM_TOKEN: ${{ secrets.NPM_TOKEN }}run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc- name: Publish to npmrun: npm publish- name: Success notificationrun: echo "Package successfully published!"
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.
name: Build & Pushon:workflow_dispatch:repository_dispatch:types: [ trigger-event ]jobs:publish:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Setup Node.jsuses: actions/setup-node@v3with:node-version: 23cache: 'npm'- name: Install dependenciesrun: |npm installnpx install-self-peers- name: Git configrun: |git config --global user.email "cicd-bot@gmail.com"git config --global user.name "CICD Bot"- name: Buildrun: npm run build- name: Version uprun: |npm version minor --no-git-tag-version- name: Publish to npmenv:NPM_TOKEN: ${{ secrets.GH_TOKEN }}run: npm publish- name: Commit the version change backrun: |git add package.json package-lock.jsongit commit -m "Update version to $(node -p "require('./package.json').version")"git push origin ${{ github.ref_name }}- name: Success notificationrun: echo "Package successfully published!"
This is how to trigger a workflow within another workflow:
trigger-build:runs-on: ubuntu-lateststeps:- name: Trigger buildrun: |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" }}'
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.
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.

Sergei Gannochenko
Golang/Node, React, TypeScript, Docker/K8s, AWS/GCP, NextJS.
20+ years in dev.