So, you are thinking of publishing a package on the npm registry?

Here are some best practices that may not be so obvious at a glance but I’ve learned with experience.

Include only the files you need

You can add the following inside your package.json

{
    "files": [
        "dist"
    ]
}

This is assuming you are using TypeScript and have a dist/ folder, this can be your src/ folder if you are using plain JavaScript.

This is helpful for making sure nothing unecessary ends up in your final package. There is also the .npmignore file which helps exclude files just like a .gitignore but for your package, but it’s better to use an inclusive approach rather than an exclusive one, since you might add new config files or other unrelated files and forget to exclude them, but with an inclusive approach you are making it clear, this is where the source code lives and only that should be included.

npm will automatically bundle some important files like your README.md, LICENSE, package.json and such so you do not need to be explicit about them.

Run your build scripts on prepare

Inside the scripts section of your package.json we can use a script called prepare that runs before npm starts packing your project into the final bundle to be published.

This is a great place to run your build scripts such as tsc to ensure you have the final bundle ready before publishing.

{
    "scripts": {
        "prepare": "tsc"
    }
}

There have been times, where I changed some source code, ran npm publish and forgot that I didn’t even run tsc to build the final code!

This script will ensure that never happens again, as anytime npm publish is invoked, this script is also invoked before the publishing happens.

As a bonus this also allows installing from git directly, for those who might want the latest development version, etc. As this will make npm install the dependencies and devDependencies and run the prepare script so they can get a usable package from git directly without needing to commit the dist/ folder to git.

You can also use prepack which is similar to prepare but excludes the above mentioned git behavior, as it only runs for packing the package and publishing.

Use npm version and push the tags

If you aren’t using npm version already for bumping the version, then you should.

Instead of manually changing the version in your package.json use npm version <major|minor|patch>, this way you also think more about whether this is a patch release or a minor or major release.

Additionally npm version will create a git tag so your GitHub repository is nicely tagged with each release, but make sure you push them!

After git pushing your normal stuff, in addition run:

git push --tags

This will then push the tags to your repo.

You could probably even combine this with GitHub Actions/CI so that when a tag is pushed, it could automatically run tests and npm publish it.

Use exports instead of main

exports is the modern alternative to main in package.json and takes priority when it’s defined.

{
    "exports": {
        ".": {
            "types": "./dist/index.d.ts",
            "import": "./dist/index.mjs",
            "require": "./dist/index.cjs"
        },

        "./utils": {
            "types": "./dist/utils/index.d.ts",
            "import": "./dist/utils/index.mjs",
            "require": "./dist/utils/index.cjs"
        }
    }
}

In this example, two entry points are exported: . and ./utils. . refers to the package root, so it can be imported as package-name (or whatever you name your package), while ./utils adds a subpath that can be imported as package-name/utils.

Previously, with the old main approach, people could still import any internal file they wanted, such as require('package-name/dist/stuff'), even if you did not intend to make those files part of the package’s public API. With exports, you can explicitly define the entry points that are publicly accessible, and importing arbitrary files that aren’t exported is disallowed.

Within each entry point’s definition are conditional exports, which tell the module resolver which file to use depending on the conditions being matched. This allows you to, for example, ship both CJS and ESM builds and tell the resolver which one to use.

Conditional exports are evaluated in object order, so the order can matter. For example, if a condition matches before a later condition is reached, the later condition won’t be considered. The types condition should generally be placed first so that TypeScript can find the package’s declaration files before the runtime conditions such as import and require.

For a typical ESM-only package, a minimal example would be:

{
    "type": "module",

    "exports": {
        ".": {
            "types": "./dist/index.d.ts",
            "import": "./dist/index.js"
        }
    }
}