Testing npm packages locally before publishing can be tricky, as you will want to verify everything works correctly in a real project before pushing to npm. Let me walk through some common approaches and what has worked best for me.

  1. npm link: create a symlink between the local package and a project that depends on it.
  2. npm pack: bundle the package into a .tgz archive to mimic the process of publishing to npm.
  3. using relative paths in package.json: directly reference a local directory for the dependency.
  4. npm install with a local directory: install a local package directly without bundling.
  5. pnpm workspaces: manage multiple packages in a single repository with symlinks.

After trying various approaches, I have settled on a combination of options two and three. While it requires a bit more manual work than other options, I have found it to be more reliable and easier to debug than solutions that rely on linking.

Process#

npm pack creates a .tgz tarball file containing files specified in your package’s files property. This exactly replicates what users receive when installing from npm — one of the main reasons I prefer this approach. It respects .gitignore and .npmignore files, ensuring development files stay out of the package.

For a project with the following package.json file:

{
  "name": "my-package",
  "version": "1.0.0",
  "main": "index.js",
  "files": [
    "build/"
  ]
}

Running npm pack in this directory creates a file named my-package-1.0.0.tgz following the naming convention <package-name>-<version>.tgz. To preview the included files before creating the actual tarball, use npm pack --dry-run:

npm notice 📦 my-package@1.0.0
npm notice === Tarball Contents ===
npm notice 478B  package.json
npm notice 12KB  build/index.js
npm notice 2.1KB build/index.d.ts
npm notice === Tarball Details === 
npm notice name:          my-package
npm notice version:       1.0.0
npm notice filename:      my-package-1.0.0.tgz
npm notice package size:  4.8 kB
npm notice unpacked size: 14.5 kB
npm notice shasum:        a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9
npm notice integrity:     sha512-[base64hash]
npm notice total files:   3

This output is helpful for catching any missing or unwanted files before creating the actual package. This can even be made part of the build process with a script command like package: vite && npm pack.

The tarball can then installed in the project using a relative path in package.json:

{
  "dependencies": {
    "my-package": "file:../my-package/my-package-1.0.0.tgz"
  }
}

Then run npm install.

When updating and reinstalling the package, you will likely encounter caching issues — something that caused me considerable confusion initially. npm’s caching behaviour means that if the version in your consuming project’s package.json matches a previously installed version, npm skips fetching the new tarball, even if you’ve updated the file.

I have found two reliable solutions to this:

  1. The thorough approach: Bump the version in package.json before each pack operation (e.g., "version": "1.0.0-rc1"). Remember to update the relative file path as well. After installation, check package-lock.json to verify the correct version.

  2. The quick approach: Reinstall with cache-busting flags: npm install ../my-package/my-package-1.0.0.tgz --force --no-cache

While solutions like npm link might seem simpler initially, my experience has shown they often lead to mysterious issues that prove difficult to debug. This tarball-based workflow has become my go-to approach for local package development. The extra manual steps are worth it for the clarity and reliability they provide.