How to publish your NPM Package
0. TLDR
- A. Init a project
Open terminal, create a folder, give it a meaningful name, and then run:
npm init
Or if it should be scoped:
npm init --scope=talkohavy
- B. Connect project to GitHub
This step is mandatory. Every npm package needs to be connected to a remote git repo.
git remote add origin git@github.com:talkohavy/<name>.git
git push -u origin master
- C. Add some content to
Here are some good options:
- An
src
folder with anindex.js
- A _test_ folder
- A README.md file
- D. Create an .npmrc file
Create an .npmrc
file in the root project.
Its contents should be:
registry=https://registry.example.com/
//registry.npmjs.org/:_authToken=npm_some-long-hash
You'll of course need to create an access token on npm's website, under your profile. The token always starts with "npm_", so it's easy to recognize.
Make sure to NOT commit the .npmrc
file! The token is highly sensitive!
You must add it to your .gitignore
!
- E. create an .npmignore
At the root of your project create am .npmignore
file.
Inside it put:
.changeset/
When we'll create a dist
folder, we will cd into it, and run the publish command from there. When we do, we will use changeset
's publish command, which checks the config.json
file under .changeset/
folder, so we need to copy it into our dist. However, we don't want to include it in our published files, so we exclude it here.
- F. Create a build.config.js file
This fil will serve as your build
script.
At the root of your project, create a build.config.js
file:
import { execSync } from 'child_process';
import fs, { cpSync } from 'fs';
/**
* @typedef {{
* main: string,
* types: string,
* private?: string | boolean,
* scripts?: Record<string, string>,
* publishConfig: {
* access: string
* },
* }} PackageJson
*/
const outDirName = 'dist';
buildPackageConfig();
async function buildPackageConfig() {
cleanDistDirectory();
buildWithTsc();
copyReadmeFile();
copyChangesetDirectory();
copyNpmIgnore();
copyAndManipulatePackageJsonFile();
console.log('DONE !!!');
}
function cleanDistDirectory() {
console.log('- Step 1: clear the dist directory');
execSync('rm -rf dist');
}
function buildWithTsc() {
console.log('- Step 2: build with vite');
execSync('tsc -p ./tsconfig.build.json');
}
function copyReadmeFile() {
console.log('- Step 3: copy the README.md file');
const readStreamReadmeMd = fs.createReadStream('./README.md');
const writeStreamReadmeMd = fs.createWriteStream(`./${outDirName}/README.md`);
readStreamReadmeMd.pipe(writeStreamReadmeMd);
}
function copyAndManipulatePackageJsonFile() {
console.log('- Step 4: copy & manipulate the package.json file');
// Step 1: get the original package.json file
/** @type {PackageJson} */
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString());
// Step 2: Remove all scripts
delete packageJson.scripts;
console.log('-- deleted `scripts` key');
// Step 3: Change from private to public
delete packageJson.private;
packageJson.publishConfig.access = 'public';
console.log('-- changed from private to public');
console.log('-- changed publishConfig access to public');
// Step 4: remove 'outDirName/' from "main" & "types"
packageJson.main = packageJson.main.replace(`${outDirName}/`, '');
packageJson.types = packageJson.types.replace(`${outDirName}/`, '');
// Step 5: create new package.json file in the output folder
fs.writeFileSync(`./${outDirName}/package.json`, JSON.stringify(packageJson));
console.log('-- package.json file written successfully!');
}
function copyChangesetDirectory() {
console.log('- Step 5: copy the .changeset directory');
cpSync('.changeset', `${outDirName}/.changeset`, { recursive: true });
}
function copyNpmIgnore() {
console.log('- Step 6: copy the .npmignore file');
cpSync('.npmignore', `${outDirName}/.npmignore`);
}
- G. install Changesets/cli
The flow of versioning is made easy with a tool like changesets/cli
.
Install it:
pnpm add -D @changesets/cli
Init it:
pnpm changeset init
Change the content of .changeset/config.json
file:
{
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": true,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "master",
"updateInternalDependencies": "patch",
"ignore": []
}
Add the scripts:
{
"scripts": {
"clean": "rm -rf node_modules",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format-check": "prettier . --check --config .prettierrc.mjs --ignore-path .prettierignore",
"format": "prettier . --write --log-level silent --config .prettierrc.json --ignore-path .prettierignore",
"test": "node --test",
"build": "node build.config.js",
"cs-add": "pnpm changeset add",
"cs-bump": "npm run test && pnpm changeset version && npm run cs-bump-reset-format-commit",
"cs-bump-reset-format-commit": "git reset head~1 && npm run format && git add . && git commit -m 'RELEASING: Releasing 1 package(s)'",
"cs-status": "pnpm changeset status --verbose",
"cs-publish": "pnpm run build && cd dist && pnpm changeset publish",
"prepublishOnly": "npm run build"
},
}
- H. Edit your package.json
Change these keys in your package.json
file:
{
"name": "@talkohavy/dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "dist/main.js",
"types": "dist/main.d.ts",
"scripts": {},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "restricted"
},
"devDependencies": {},
}
- I. Your new workflow
• Step 1: Open a side-branch
, make changes & commit them.
• Step 2: Run the command npm run cs-add
, choose a semver, and add a short description.
• Step 3: Run the command npm run cs-add
, choose a semver, and add a short description of what has been done.
• Step 4: Make a pull request to the master
branch.
• Step 5: It's up to the master branch (the CICD pipeline) to bump the version, with npm run cs-bump
. This creates a new commit.
• Step 6: Also, it's up to the master branch (the CICD pipeline) to publish the version, with npm run cs-publish
. This does NOT create a new commit.
The publish command of changesets looks at the config.json
file under .changesets
, and makes a publish based on the instruction given to it there.
1. Init a project package.json
Create a new folder, and init a git project (Give it a meaningful name).
npm init
You can prefix your packages, just like @redux-toolkit or @babel did, with @some-name at the beginning.
If you wish to prefix your package, you can do so manually post initialization, or you can do so during the init process, using the scope
flag:
npm init --scope=talkohavy
This will have your package scoped.
For example, the above package would get a prefix of "@talkohavy/" added to its name.
2. Connect project to GitHub
This step is mandatory. Every npm package needs to be connected to a remote git repo.
git init
git add .
git commit -m 'first commit'
git remote add origin git@github.com:talkohavy/<name>.git
git push -u origin master
3. Add some content to the Package
Create the following structure:
- An
src
directory with anindex.js
- A _test_ folder
- A README.md file
And later on:
- build.config.js
- jsconfig.json
- .npmrc
- Changesets-cli
- Eslint
- Prettier
- Husky
4. Develop a build process
While you can serve the root of the project as the package itself, it is not recommended. There are a lot of things you'll need to blacklist from ending up in the final output being packaged up and be published to npm.
Even if you'll go with the whitelist approach, instead of the blacklist approach, by using the "files" within the package.json, it is still recommended to avoid serving the root directory (whether its the src or some dist folder). This is because whitelisting is something you need to maintain, and every time you'll add a new feature you'll need to go inside the package.json and update it there.
Instead, we will go with the divide & conquer
approach.
The divide & conquer
approach dictates that the repo used to develop the package will be separated from the package that will be shipped to npm.
Any package that is designed to be published will have the following steps:
- Clear the dist folder (
rm -rf dist
). - Build the project (minify/compile/transpile, i.e.
tsc -p tsconfig.json
). - Copy & manipulate the
package.json
.
The benefits of the divide & conquer
approach:
- You don't have to whitelist anything.
- You don't have to blacklist anything.
- package.json include ONLY what it needs to thanks to the act of manipulation.
- Complete control of the package's structure - the output's end result.
- Complete separation between structures (the root and the package).
The name divide & conquer
isn't an industry term, rather than a name I gave to this approach.
5. npm login, npm logout, .npmrc & .npmignore
- A. npm login & .npmrc
Publishing a package to npm requires you to be logged into npm.
You can log into npm by running:
npm login
After running this command you'll be asked to hit enter, and a browser will open up. There, you'll have to enter a 6-digit otp, which appears on an authenticator up you have connected to your npm account beforehand.
But what about CI/CD?
A CI/CD process can't run npm login
.
This is where .npmrc
comes into the picture.
A configuration file for npm.
The .npmrc
file can be:
- per project (found at the project's root directory)
- per user (found at the ~/.npmrc)
- global config file (found at $PREFIX/etc/npmrc)
The .npmrc
file will contain a token which will be used to publish our package without having to log in using the npm login
command.
registry=https://registry.example.com/
//registry.npmjs.org/:_authToken=npm_some-long-hash
You'll of course need to create an access token on npm's website, under your profile. The token always starts with "npm_", so it's easy to recognize.
Make sure to NOT commit the .npmrc
file! The token is highly sensitive!
You must add it to your .gitignore
!
- B. npm logout
Just as there is a command of npm login
to get you logged in, there's also a command to logout:
npm logout
It is SUPER important to know this command and understand what it does under the hood, because it's not so straightforward.
Not only that npm logout
logs you out, it also DELETES the .npmrc file. So if you find yourself asking "where did my npmrc file go?", it could very much be that you ran npm logout
, which deleted your file.
Another important piece of information is: if you had a token that should last a year, and you just committed an npm logout
, you have invalidated your token permanently! And it can no longer be used!
To sum it up, npm logout does 2 things:
- It deleted the line which includes the token from your .npmrc file.
- If your
.npmrc
file contains only that 1 line with the token, the .npmrc file is deleted for good.
- If your
- It invalidates the token.
- C. .npmignore
You can use the .npmignore
to blacklist files and folders from getting to the final package output that'll be published on npm.
However, since we'll be using the divide & conquer
approach, the use of .npmignore
will be minimal, if any.
- D. npm login Deep Dive
When you run npm login
, an .npmrc
file is created (or is being updated if it already exists) with a fresh new token. This means that if a previous existing (either by npm login
or by copy-pasting a generated Access Token from npm), it is now gone. Erased.
6. Versioning
A common error you'll bump into is when you'll try to re-publish a package using the same version already registered on npm.
Before running the publish
command, you'll need to bump the version.
In fact, that's the only change that's required by npm in order to be able to publish a new release.
To bump a version there are 3 commands ou can use:
When doing a bugfix:
npm version patch -m 'Upgrade to %s: some-message'
When adding a new feature:
npm version minor -m 'Upgrade to %s: some-message'
When doing a breaking-change:
npm version major -m 'Upgrade to %s: some-message'
In order to run either one of these, your git tree must be clean. If it's not, you'll get an error telling you to stash your changes and then try again.
7. The role of package.json
The package.json
file plays a huge role when it comes to publishing a package on npm.
It contains keys/fields, that could make or break the publish's output.
- A. REQUIRED keys within your package.json
- name
- version
- main
Your package.json
MUST include a name
, a version
, and a main
.
While the lack of name
, a version
would fail the publish process, the lack of main would not. However, you would not be able to import anything from the end result package! You would even get a runtime error if you try.
- B. IMPORTANT keys inside your package.json
- exports
- files
The exports
field in package.json is a relatively new addition to the npm ecosystem. It allows you as the package's author to explicitly define which modules are available for consumption when the package is imported. This helps in providing a more controlled and secure interface, limiting access to internal files that shouldn't be exposed to end-users.
The exports
field OVERRIDES whatever that is written under the main
key, and can replace it entirely.
{
// "main": "lib/index.js", // <--- not needed now that exports exists. Has no affect.
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
}
},
}
The files
key is mean for whitelisting. Only files specified under files will end up in the end-result output of the publish.
- C. Nice to have keys inside your package.json
- repository
- publishConfig
- registry
- access
- license (MIT, ISC, etc.)
8. Publishing a package to an npm registry
Now that everything is set, it's time we upload/publish our package to our npm registry.
You can publish the package by running the command:
npm publish
To help you visualize what exactly is going to be publish, you can run a dry-run publish command:
npm publish --dry-run
This will spit out all sorts of useful information.
By default, scoped packages are published with private visibility. To publish a scoped package with public visibility, use npm publish --access public.
Note: You cannot change the visibility of an unscoped package. Only scoped packages with a paid subscription may be private.
If your package is prefixed (i.e. scoped with @talkohavy), you'll have to add the access public flag:
npm publish --access=public
Without the access public flag, you'll get an error saying you must sign up for private packages, and the the publish command will fail.
This is because when trying to publish, by default, npm thinks you're trying to publish a private package. You can fix this by adding the access
flag to the publish command, and setting it to public, telling npm that this package is in fact public.
9. A publish's output - Deep Dive Strategy
Earlier in this guide we talked about the divide & conquer
approach, where separate the package's output to be published from the repo itself.
Let's take a deep-dive into what it actually means.
- A. A second package.json
In the build process, we create a dist
folder. If we were to use the the main
key within our package.json that resides in our root directory, to point to an index.js file within our dist
folder, it would result in that dist
folder appearing in our end result output. But what if we don't to see a dist
folder as part of our end result? What if we wanted to see just a single file in there?
To that goal, and in order to achieve maximum control over our final structure, we would have ourselves a second package.json
. This is what we called earlier as "Copy & manipulate the package.json".
In this step, we take the package.json found in our root directory, and copy it into our dist folder. Not before we manipulate it a bit though. The manipulation can have multiple benefits:
- We can remove scripts - stuff like "test" or "lint". They are no longer needed.
- We can change access - We can have the initial package.json start off as "restricted", thus ensuring one can't accidentally publish the root as a package, and only during manipulation change it to "public".
- We can change the private boolean - We can have the initial package.json start off as
"private": true
, which is another way to ensure that no one can publish the root as a package, and only during manipulation, we remove this key entirely.
- B. A README.md file
Copy the readme.md file as-is to the dist
folder. This readme file will appear on npm main page of the package, serving as a Getting-Started information on how-to-use the package to other developers.
- C. main, types & exports pointing
Since I know that there's going to be another package.json generated within the dist folder, the pointing on the keys "main", "types", and "exports" should be in relation to that package.json.
Let's say you have this project structure:
|-- src/
|-- dist/
| |
| |-- lib/
| | |
| | |-- index.js
| |-- index.d.js
| |-- package.json (copied & manipulated)
| |-- README.md (copied)
|-- package.json
|-- README.md
In the example case above, the pointing of "main" should NOT be "dist/lib/index.js", but rather "lib/index.js". Same for "types", it should be "index.d.js". Same goes for exports:
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
}
},
- D. A complete build process
What I like to do is create a build.config.js
at the root of the project, and have it look like so:
import { execSync } from 'child_process';
import fs from 'fs';
const outDirName = 'dist';
buildPackageConfig();
async function buildPackageConfig() {
cleanDistDirectory();
buildWithTsc();
copyReadmeFile();
copyAndManipulatePackageJsonFile();
console.log('DONE !!!');
}
function cleanDistDirectory() {
console.log('- Step 1: clear the dist directory');
execSync('rm -rf dist');
}
function buildWithTsc() {
console.log('- Step 2: build with tsc');
execSync('tsc -p jsconfig.json');
}
function copyReadmeFile() {
console.log('- Step 3: copy the README.md file');
const readStreamReadmeMd = fs.createReadStream('./README.md');
const writeStreamReadmeMd = fs.createWriteStream(`./${outDirName}/README.md`);
readStreamReadmeMd.pipe(writeStreamReadmeMd);
}
function copyAndManipulatePackageJsonFile() {
console.log('- Step 4: copy & manipulate the package.json file');
// Step 1: get the original package.json file
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString());
// Step 2: Remove all scripts
delete packageJson.scripts;
console.log('-- deleted `scripts` key');
// Step 3: Change from private to public
delete packageJson.private;
packageJson.publishConfig.access = 'public';
console.log('-- changed from private to public');
console.log('-- changed publishConfig access to public');
// Step 4: create new package.json file in the output folder
fs.writeFileSync(`./${outDirName}/package.json`, JSON.stringify(packageJson));
console.log('-- package.json file written successfully!');
}
And now i'm just adding the following script:
{
"scripts": {
"build": "node build.config.js",
}
}
- E. The publish command as a script
And finally, i'll create a script that looks like:
{
"scripts": {
// ...
"pub": "npm run build && cd dist && npm publish",
}
}
10. Typescript Types
When you involve typescript, everything changes. New behaviors are introduced, and subtle differences are brought to the table.
- A. Emitting Pure Javascript
If your package emits a plain javascript, without so much as a declaration file (.d.ts file) then nothing changes for you. Everything will work as expected.
- B. Emitting .js & .d.ts files
This is the most common use-case.
When writing a package in typescript, you'll end up needing to compile it down to javascript. Doesn't matter which tool you use, under the hood they all use tsc, so I'll be using it directly.
The end result of the compilation process will create a dist
folder, along with some js files, and a main declaration file - index.d.ts
. There can be other declaration files emitted during the process, which will be sibling to that index.d.ts
.
In this setting, where there are .d.ts files in the final output, there are some subtle differences.
The difference between "exports" and "main"
- C. Using ONLY .ts files
Let's compare 3 cases:
- Having an only js package with
"main": "lib/index.js"
, - Having an only js package with
"exports": {".": { "import": "./lib/index.js" }}
- Having Case 1 with
"types": "index.d.ts"
- Having Case 2 with
"types": "index.d.ts"
First of all, it is important to know - not including a "types": "index.d.ts"
, in your package.json
, doesn't mean that it defaults to nothing. In fact, the defaults is "types": "index.d.ts"
!
The "types" field only exists for when you have a root .d.ts file with a name that's outside the convention on index.d.ts
, or that it is located deeper in some nested folder, and not on the root.
By default, using the exports
key sets every possible key that can be added under it to null.
"."
11. Auto-Completion & Auto-Suggestion
12. Versioning Helper - Changesets/cli
While you can use npm version patch/minor/major
, it's impractical.
The flow of versioning is made easy with the help of a tool called changesets/cli
.
- A. Getting Started
npm install -D @changesets/cli
Or...
pnpm add -D @changesets/cli
If this is your first time using changesets in this project, run this:
pnpm changeset init
This will create a .changeset
folder with 2 files inside:
- config.json
- README.md
The config file is filled with all sorts of helpful configurations and tooltips explaining what each one does.
Two things you'll most definitely want/need to change are:
- "baseBranch": "main", which determines the branch that Changesets uses when finding what packages have changed. If you set a branch name that doesn't exists, the process will fail.
- "commit": false, set it to
true
instead.
- B. Add new scripts to package.json
Add these new scripts to your package.json:
{
"scripts:" {
"cs-add": "pnpm changeset add",
"cs-bump": "pnpm changeset version",
"cs-status": "pnpm changeset status --verbose",
"cs-publish": "cd dist && pnpm changeset publish"
}
}
- C. Add copy changeset to your copy flow
/* eslint-disable */
import { execSync } from 'child_process';
import fs, { cpSync } from 'fs';
const outDirName = 'dist';
buildPackageConfig();
async function buildPackageConfig() {
cleanDistDirectory();
// ...
copyChangesetDirectory();
copyNpmIgnore();
// ...
console.log('DONE !!!');
}
function copyChangesetDirectory() {
console.log('- Step 5: copy the .changeset directory');
cpSync('.changeset', `${outDirName}/.changeset`, { recursive: true });
}
function copyNpmIgnore() {
console.log('- Step 6: copy the .npmignore file');
cpSync('.npmignore', `${outDirName}/.npmignore`);
}
- D. Versioning Flow - How to use
Once you've completed the initial setup, the flow is very simple:
Step 1: Make changes & commit them
Adding a new feature, fixing a bug, or having breaking changes are the only time that this flow is valid. Needless to say that usually when doing either one of these you are standing on a side-branch
, away from master
.
You DO NOT publish a new version of your package simply because you added eslint, or prettier! Theses aren't bugs, or feature that affect the end result package!
Step 2: Run the changeset ADD command
When all commits are done, it's then time to log what has been done.
Run the newly created script:
pnpm run cs-add
Choose the semver, and give a short description of what has been done.
You can, and sometimes should, run the "cs-add" command several times, once for each change that has been made for this current release.
Each time you run the "cs-add" command, an md file with some weird name will be created under the .changesets
directory.
Step 3: Run the changeset STATUS command
To view everything that's going to be added to this release using the ADD command, you can use the STATUS command:
pnpm run cs-status
Step 4: Run the changeset VERSION command
When you're ready to bump the version, with all the logs you've made, run the BUMP script we've added earlier:
pnpm run cs-bump
This action will take all those md files with weird names, calculate the version number, create or update the CHANGELOG.md file with everything you wrote as notes, and then delete all those weird named files, as they've already served their purpose. These files are meant to be temporary. Their only role is to serve as information guide to the VERSION command.
Step 5: Run the changeset PUBLISH command
The PUBLISH command of changesets looks at the config.json
file under .changesets
, and makes a publish based on the instruction given to it there.
13. Finalize Process - test & build
In order to have the flow easy, let's make sure we are running as few script as possible, in a way that makes sense.
Look at the following snippet from my package.json:
{
"scripts": {
"clean": "rm -rf dist",
"test": "node --test",
"build-full": "node build.config.js",
"cs-add": "pnpm changeset add",
"cs-bump": "npm test && pnpm changeset version",
"cs-status": "pnpm changeset status --verbose",
"cs-publish": "pnpm run build-full && cd dist && pnpm changeset publish"
},
}
Notice that right before running the "bump" command, that's when I'm running my tests, so there can never be a version upgrade when tests are failing.
Notice that right before running the "publish" command, that's when I'm running the full-build process that creates the dist folder, along with everything needed for its publish. Since the publish itself is tightly coupled with the output of the build process, it made sense to chain them together.
999. Check locally using pnpm link --global
Before publishing a package to npm, you can test it locally by importing it to a side-project, and check that it works.
For that we use the command pnpm link --global
(original command is npm link
).
• Step 1: Run pnpm link
on the to-be-published package
Inside the to-be-published package folder, run the following command:
pnpm link --global
Or:
npm link
depending on what you're going to use on the tester project.
Running npm link
symlinks a package folder. This is handy for installing your own stuff, so that you can work on it and test iteratively without having to continually rebuild.
• Step 2: Run pnpm link pkg-name
on the tester project
Create a dummy project somewhere on your machine, and do:
pnpm init -y
pnpm link <pkg-name>
Or...
npm init -y
npm link <pkg-name>
In the dummy project, create a quick script which imports the to-be-published package, and test it.
HOW DOES NPM LINK
WORK?
Package linking is a two-step process:
- Run
npm link
inside the package you wish to publish - Run
npm link <pkg-name>
inside the test package which imports the package.
The first step will create a symlink in the global folder < prefix>/lib/node_modules/< name-of-package> that links to the package where the npm link
command was executed.
The second step will create a symbolic link from globally-installed package-name to node_modules/ of the current folder.
Note that package-name is taken from package.json, not from the directory name.