Tooling

Publishing NPM Packages: Complete Workflow

A complete guide to publishing npm packages covering package.json setup, semantic versioning, CI/CD automation, scoped packages, and registry management.

Publishing NPM Packages: Complete Workflow

Publishing a package to npm is one of those things that sounds simple until you actually do it. There are a dozen decisions to make before you ever run npm publish: what goes in package.json, how to handle versioning, whether to support both CJS and ESM, how to automate releases, and how to avoid shipping your test fixtures to 500,000 downstream consumers. This guide covers the complete workflow from initializing a package to automating releases with CI/CD, based on patterns I have used across dozens of published packages.

Prerequisites

  • Node.js v16+ installed
  • An npm account (create one at npmjs.com)
  • Basic familiarity with Git and GitHub
  • A terminal and a text editor

Setting Up package.json

Every npm package starts with package.json. Most developers run npm init and accept the defaults, but published packages need more thought. Here is a well-configured package.json for a utility library:

{
  "name": "string-toolkit",
  "version": "1.0.0",
  "description": "Lightweight string manipulation utilities for Node.js",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "files": [
    "lib/",
    "LICENSE",
    "README.md"
  ],
  "scripts": {
    "test": "mocha test/**/*.test.js",
    "lint": "eslint lib/ test/",
    "prepublishOnly": "npm test && npm run lint"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/string-toolkit.git"
  },
  "keywords": [
    "string",
    "utilities",
    "manipulation",
    "slug",
    "truncate"
  ],
  "author": "Your Name <[email protected]>",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/yourname/string-toolkit/issues"
  },
  "homepage": "https://github.com/yourname/string-toolkit#readme",
  "engines": {
    "node": ">=16.0.0"
  }
}

Let me walk through the fields that matter most for published packages:

name - This is what consumers type in npm install. It must be unique on the registry. Check availability with npm search string-toolkit or just visit npmjs.com/package/string-toolkit. If the name is taken, consider a scoped package (covered below).

version - Follows semantic versioning. Start at 1.0.0 if your API is stable, or 0.1.0 if it is still experimental. Never publish 0.0.0.

main - The entry point when someone calls require('string-toolkit'). Point this at your built/compiled output, not your source.

files - This is critical. The files array is a whitelist of what gets included in the published tarball. If you only specify ["lib/"], npm will include lib/, package.json, README.md, and LICENSE (those last three are always included). Everything else -- your tests, your .github/ folder, your 200MB fixture files -- stays out.

keywords - These drive discoverability on npmjs.com. Use 5-10 relevant terms.

engines - Declares which Node.js versions your package supports. npm will warn users if their version does not match.

prepublishOnly - This script runs before npm publish and is your last line of defense. If tests fail, the publish aborts. Use it.

The files Field vs .npmignore

You have two options for controlling what ships in your package: the files whitelist in package.json, or a .npmignore blacklist file. I strongly recommend using files. Here is why:

With .npmignore, you have to remember to exclude every new directory or file you add. Forget to add your benchmarks/ folder? It ships. Add a secrets.env by accident? It ships. The blacklist approach is fragile.

With files, you explicitly declare what gets included. New directories are excluded by default. It is safer and easier to audit.

If you want to see exactly what will be published, run:

npm pack --dry-run

This lists every file that would be included in the tarball without actually creating it:

npm notice 📦  [email protected]
npm notice === Tarball Contents ===
npm notice 1.2kB lib/index.js
npm notice 342B  lib/slugify.js
npm notice 289B  lib/truncate.js
npm notice 198B  lib/index.d.ts
npm notice 1.1kB LICENSE
npm notice 3.4kB README.md
npm notice 782B  package.json
npm notice === Tarball Details ===
npm notice name:          string-toolkit
npm notice version:       1.0.0
npm notice package size:  2.8 kB
npm notice unpacked size: 7.3 kB
npm notice total files:   7

You can also run npm pack (without --dry-run) to generate the actual .tgz file, then extract it to inspect the contents. This is the single best way to catch issues before publishing.

Testing Before You Publish

Beyond npm pack, use npm link to test your package locally in a real consumer project:

# In your package directory
cd /path/to/string-toolkit
npm link

# In a project that will consume it
cd /path/to/my-app
npm link string-toolkit

Now require('string-toolkit') in my-app will resolve to your local copy. Test your exports, make sure the entry point works, verify that TypeScript declarations resolve. When you are done:

# Clean up
cd /path/to/my-app
npm unlink string-toolkit

cd /path/to/string-toolkit
npm unlink

npm Login and Authentication

Before publishing, authenticate with the registry:

npm login

This prompts for your username, password, and email. It stores a token in your ~/.npmrc file. Verify you are logged in:

npm whoami

If your organization requires two-factor authentication (and it should), you will be prompted for an OTP code during publish. You can also enforce 2FA at the package level:

npm access 2fa-required string-toolkit

This means every publish, unpublish, and ownership change requires an OTP. There is no way to accidentally push a compromised version from a stolen token alone.

Publishing Your Package

With everything configured and tested, publishing is straightforward:

npm publish

That is it. Your package is now live on the npm registry. For a first publish, make sure your version in package.json is set correctly. npm will reject a publish if that version already exists.

If you want a dry run first:

npm publish --dry-run

Scoped Packages

If the package name you want is taken, or you want to publish under an organization, use a scoped package:

{
  "name": "@yourorg/string-toolkit",
  "version": "1.0.0"
}

Scoped packages are private by default. To publish a scoped package as public:

npm publish --access public

You only need --access public on the first publish. Subsequent publishes remember the access level.

Publishing to GitHub Packages

Some teams prefer GitHub Packages as their registry. Configure your package.json:

{
  "name": "@yourorg/string-toolkit",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com"
  }
}

Authenticate with a GitHub personal access token that has write:packages scope:

npm login --registry=https://npm.pkg.github.com

Then publish normally. GitHub Packages requires the package name to match the GitHub org/user scope.

Semantic Versioning Strategy

Semantic versioning (semver) is not optional for npm packages. Your consumers depend on it for safe upgrades. The rules:

  • MAJOR (2.0.0) - Breaking changes. Removed functions, changed return types, renamed exports.
  • MINOR (1.1.0) - New features that are backward compatible. Added a new function, new optional parameter.
  • PATCH (1.0.1) - Bug fixes, documentation updates, internal refactors with no API change.

Use the npm version command to bump versions consistently:

# Bump patch: 1.0.0 -> 1.0.1
npm version patch

# Bump minor: 1.0.1 -> 1.1.0
npm version minor

# Bump major: 1.1.0 -> 2.0.0
npm version major

# Set a prerelease version
npm version prerelease --preid=beta
# 2.0.0 -> 2.0.1-beta.0

npm version does three things: updates package.json, creates a git commit, and creates a git tag. This is extremely useful for CI/CD automation, which I will cover shortly.

Dist Tags

By default, npm publish sets the latest tag. When someone runs npm install string-toolkit, they get the version tagged latest. But you can publish pre-release versions under different tags:

# Publish a beta
npm version prerelease --preid=beta
npm publish --tag beta

# Publish a release candidate
npm version prerelease --preid=rc
npm publish --tag next

Users can install specific tags:

npm install string-toolkit@beta
npm install string-toolkit@next

This is critical for testing pre-release versions without affecting your stable users. Never publish a beta to the latest tag.

To check current dist-tags:

npm dist-tag ls string-toolkit

To move a tag to a different version:

npm dist-tag add [email protected] beta

Pre-publish Scripts

The prepublishOnly script is your automated gatekeeper. Here is a robust setup:

{
  "scripts": {
    "clean": "rm -rf lib/",
    "build": "node scripts/build.js",
    "test": "mocha test/**/*.test.js",
    "lint": "eslint src/ test/",
    "prepublishOnly": "npm run clean && npm run build && npm test && npm run lint"
  }
}

This ensures that every publish starts from a clean build, runs the full test suite, and passes linting. If any step fails, the publish is aborted. I have seen packages ship with stale build artifacts because the author forgot to rebuild. prepublishOnly prevents that.

TypeScript Type Declarations

Even if your package is written in plain JavaScript, shipping TypeScript declarations is a courtesy that dramatically improves the consumer experience. Create a .d.ts file:

// lib/index.d.ts
declare module 'string-toolkit' {
  export function slugify(input: string): string;
  export function truncate(input: string, maxLength: number, suffix?: string): string;
  export function capitalize(input: string): string;
  export function camelCase(input: string): string;
}

Reference it in package.json:

{
  "types": "lib/index.d.ts"
}

TypeScript users get autocomplete and type checking. JavaScript users are unaffected. There is no downside.

Dual CJS/ESM Package Setup

The Node.js ecosystem is in the middle of a long migration from CommonJS to ES Modules. If you want to support both, you need a dual package setup. Here is the approach I recommend:

{
  "name": "string-toolkit",
  "version": "1.0.0",
  "main": "lib/cjs/index.js",
  "module": "lib/esm/index.mjs",
  "exports": {
    ".": {
      "require": "./lib/cjs/index.js",
      "import": "./lib/esm/index.mjs"
    }
  },
  "files": [
    "lib/"
  ]
}

The exports field uses conditional exports to serve different files depending on how the package is loaded. When someone uses require('string-toolkit'), they get the CJS version. When someone uses import ... from 'string-toolkit', they get the ESM version.

Your CJS entry:

// lib/cjs/index.js
var slugify = require('./slugify');
var truncate = require('./truncate');

module.exports = {
  slugify: slugify,
  truncate: truncate
};

Your ESM entry:

// lib/esm/index.mjs
export { slugify } from './slugify.mjs';
export { truncate } from './truncate.mjs';

This is more work, but it avoids the "ERR_REQUIRE_ESM" error that plagues consumers who have not migrated to ESM yet.

Deprecating and Managing Versions

Published a version with a critical bug? Deprecate it:

npm deprecate [email protected] "Critical bug in slugify(), upgrade to 1.2.4"

Users will see a warning when they install the deprecated version. To deprecate an entire package:

npm deprecate string-toolkit "This package is no longer maintained. Use string-toolkit-v2 instead."

To transfer ownership of a package:

npm owner add newmaintainer string-toolkit
npm owner rm oldmaintainer string-toolkit

Package Provenance

npm now supports package provenance, which cryptographically links a published package to its source repository and build. This is a supply chain security feature. When you publish from GitHub Actions with provenance enabled, npm will show a "Provenance" badge on the package page.

Enable it by adding --provenance to your publish command:

npm publish --provenance

This requires the publish to happen inside a GitHub Actions workflow with id-token: write permissions. More on this in the CI/CD section.

Complete Working Example

Let me walk through building and publishing a complete utility package called string-toolkit. This is the full workflow from npm init to automated releases.

Project Structure

string-toolkit/
  lib/
    index.js
    slugify.js
    truncate.js
    capitalize.js
    index.d.ts
  test/
    slugify.test.js
    truncate.test.js
    capitalize.test.js
  .github/
    workflows/
      publish.yml
  .npmignore
  .gitignore
  package.json
  README.md
  LICENSE
  CHANGELOG.md

Source Code

// lib/slugify.js
function slugify(input) {
  if (typeof input !== 'string') {
    throw new TypeError('slugify expects a string, got ' + typeof input);
  }

  return input
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

module.exports = slugify;
// lib/truncate.js
function truncate(input, maxLength, suffix) {
  if (typeof input !== 'string') {
    throw new TypeError('truncate expects a string, got ' + typeof input);
  }

  suffix = suffix || '...';
  maxLength = maxLength || 100;

  if (input.length <= maxLength) {
    return input;
  }

  var truncated = input.substring(0, maxLength - suffix.length);
  var lastSpace = truncated.lastIndexOf(' ');

  if (lastSpace > maxLength * 0.8) {
    truncated = truncated.substring(0, lastSpace);
  }

  return truncated + suffix;
}

module.exports = truncate;
// lib/capitalize.js
function capitalize(input) {
  if (typeof input !== 'string') {
    throw new TypeError('capitalize expects a string, got ' + typeof input);
  }

  if (input.length === 0) {
    return input;
  }

  return input.charAt(0).toUpperCase() + input.slice(1).toLowerCase();
}

module.exports = capitalize;
// lib/index.js
var slugify = require('./slugify');
var truncate = require('./truncate');
var capitalize = require('./capitalize');

module.exports = {
  slugify: slugify,
  truncate: truncate,
  capitalize: capitalize
};

Type Declarations

// lib/index.d.ts
export function slugify(input: string): string;
export function truncate(input: string, maxLength?: number, suffix?: string): string;
export function capitalize(input: string): string;

Tests

// test/slugify.test.js
var assert = require('assert');
var slugify = require('../lib/slugify');

describe('slugify', function() {
  it('should convert a string to a URL slug', function() {
    assert.strictEqual(slugify('Hello World'), 'hello-world');
  });

  it('should remove special characters', function() {
    assert.strictEqual(slugify('Hello, World! #2024'), 'hello-world-2024');
  });

  it('should trim leading and trailing hyphens', function() {
    assert.strictEqual(slugify('  --Hello World--  '), 'hello-world');
  });

  it('should collapse multiple spaces and underscores', function() {
    assert.strictEqual(slugify('hello___world   test'), 'hello-world-test');
  });

  it('should throw on non-string input', function() {
    assert.throws(function() {
      slugify(42);
    }, TypeError);
  });
});
// test/truncate.test.js
var assert = require('assert');
var truncate = require('../lib/truncate');

describe('truncate', function() {
  it('should return the original string if under maxLength', function() {
    assert.strictEqual(truncate('hello', 10), 'hello');
  });

  it('should truncate long strings with ellipsis', function() {
    var result = truncate('This is a very long string that needs truncating', 25);
    assert.ok(result.length <= 25);
    assert.ok(result.endsWith('...'));
  });

  it('should use a custom suffix', function() {
    var result = truncate('This is a long string', 15, ' [more]');
    assert.ok(result.endsWith('[more]'));
  });

  it('should throw on non-string input', function() {
    assert.throws(function() {
      truncate(null);
    }, TypeError);
  });
});
// test/capitalize.test.js
var assert = require('assert');
var capitalize = require('../lib/capitalize');

describe('capitalize', function() {
  it('should capitalize the first letter', function() {
    assert.strictEqual(capitalize('hello'), 'Hello');
  });

  it('should lowercase the rest', function() {
    assert.strictEqual(capitalize('hELLO'), 'Hello');
  });

  it('should handle empty strings', function() {
    assert.strictEqual(capitalize(''), '');
  });

  it('should throw on non-string input', function() {
    assert.throws(function() {
      capitalize(123);
    }, TypeError);
  });
});

package.json

{
  "name": "string-toolkit",
  "version": "1.0.0",
  "description": "Lightweight string manipulation utilities for Node.js",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "files": [
    "lib/",
    "LICENSE",
    "README.md",
    "CHANGELOG.md"
  ],
  "scripts": {
    "test": "mocha test/**/*.test.js",
    "lint": "eslint lib/ test/",
    "prepublishOnly": "npm test && npm run lint"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/string-toolkit.git"
  },
  "keywords": [
    "string",
    "utilities",
    "slug",
    "truncate",
    "capitalize",
    "text"
  ],
  "author": "Your Name <[email protected]>",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/yourname/string-toolkit/issues"
  },
  "homepage": "https://github.com/yourname/string-toolkit#readme",
  "engines": {
    "node": ">=16.0.0"
  },
  "devDependencies": {
    "eslint": "^8.0.0",
    "mocha": "^10.0.0"
  }
}

GitHub Actions CI/CD for Automated Publishing

This is where everything comes together. The following workflow publishes to npm automatically when you push a version tag:

# .github/workflows/publish.yml
name: Publish to npm

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read
  id-token: write

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16, 18, 20]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

  publish:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm publish --provenance
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The release workflow becomes:

# Make your changes, commit them
git add -A
git commit -m "Add camelCase utility function"

# Bump the version (creates commit + tag)
npm version minor -m "Release v%s"

# Push the commit and tag
git push origin main --tags

When the v1.1.0 tag hits GitHub, the workflow runs tests across Node 16/18/20, then publishes to npm with provenance. Fully automated.

To set this up, create an npm access token (Automation type) and add it as a repository secret named NPM_TOKEN in your GitHub repository settings.

Publishing a Beta

For pre-release testing:

npm version prerelease --preid=beta -m "Release v%s"
git push origin main --tags

Modify the publish step in your workflow to detect beta tags:

      - name: Determine dist tag
        id: tag
        run: |
          VERSION=$(node -p "require('./package.json').version")
          if echo "$VERSION" | grep -q "beta"; then
            echo "dist_tag=beta" >> $GITHUB_OUTPUT
          elif echo "$VERSION" | grep -q "rc"; then
            echo "dist_tag=next" >> $GITHUB_OUTPUT
          else
            echo "dist_tag=latest" >> $GITHUB_OUTPUT
          fi

      - run: npm publish --provenance --tag ${{ steps.tag.outputs.dist_tag }}
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Changelog Generation

Maintain a CHANGELOG.md manually or automate it. For automated changelogs, a simple approach using conventional commits:

npm install --save-dev conventional-changelog-cli

Add a script to package.json:

{
  "scripts": {
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
  }
}

Then before each release:

npm run changelog
git add CHANGELOG.md
git commit -m "docs: update changelog"
npm version minor -m "Release v%s"
git push origin main --tags

Writing a Good README

Your README is the first thing potential users see. At minimum, include:

  1. What it does - One sentence.
  2. Install - npm install string-toolkit
  3. Quick start - 5-line code example showing the most common use case.
  4. API reference - Every exported function with parameters and return values.
  5. License - MIT, Apache 2.0, or whatever you choose.
# string-toolkit

Lightweight string manipulation utilities for Node.js.

## Install

npm install string-toolkit

## Usage

var toolkit = require('string-toolkit');

toolkit.slugify('Hello World');    // 'hello-world'
toolkit.truncate('Long text...', 10);  // 'Long te...'
toolkit.capitalize('hello');       // 'Hello'

## API

### slugify(input)
Converts a string to a URL-friendly slug.

### truncate(input, maxLength?, suffix?)
Truncates a string to maxLength with an optional suffix (default: '...').

### capitalize(input)
Capitalizes the first letter and lowercases the rest.

## License
MIT

Common Issues and Troubleshooting

1. "You do not have permission to publish"

npm ERR! 403 Forbidden - PUT https://registry.npmjs.org/string-toolkit
npm ERR! You do not have permission to publish "string-toolkit".

The package name is either taken or you are not logged in as the correct user. Run npm whoami to verify your identity. If the name is taken, use a scoped package name like @yourorg/string-toolkit.

2. "Cannot publish over previously published version"

npm ERR! 403 Forbidden - PUT https://registry.npmjs.org/string-toolkit
npm ERR! You cannot publish over the previously published versions: 1.0.0

You forgot to bump the version. Run npm version patch (or minor/major) before publishing. npm does not allow overwriting existing versions, even if you unpublished them (there is a 24-hour cooldown).

3. "Package too large" or unexpected files in tarball

Run npm pack --dry-run and check the output. If you see test files, .env, or node_modules in the tarball, add a files field to package.json to whitelist only what should ship. Common culprits: forgetting that .npmignore overrides .gitignore, or having no files field at all.

4. "Missing README" warning on npmjs.com

npm WARN publish npm auto-generated a README for this package.

Your README must be named exactly README.md (case-sensitive on some systems). Ensure it is in the project root and included in the files field (it is included by default, but a misconfigured .npmignore can exclude it).

5. TypeScript users cannot find type declarations

Could not find a declaration file for module 'string-toolkit'.

Ensure "types" in package.json points to a valid .d.ts file, and that the file is included in your files whitelist. Run npm pack --dry-run to confirm the .d.ts file is in the tarball.

6. ERR_REQUIRE_ESM when consuming the package

Error [ERR_REQUIRE_ESM]: require() of ES Module /node_modules/string-toolkit/index.mjs not supported.

Your main field points to an .mjs file, but the consumer is using require(). Use conditional exports in the exports field to serve CJS to require() callers and ESM to import callers, as shown in the dual CJS/ESM section above.

Best Practices

  1. Always use the files field - Whitelist what ships in your package. Never rely on .npmignore alone. Run npm pack --dry-run before every publish to verify contents.

  2. Enable 2FA on your npm account and package - Supply chain attacks are real. Run npm access 2fa-required <package> to require OTP for all publish operations. Use automation tokens (which bypass 2FA) only in CI/CD with proper secret management.

  3. Ship TypeScript declarations even for JS packages - It costs almost nothing and dramatically improves the experience for a large portion of your users. A simple handwritten .d.ts file takes minutes to create.

  4. Test on multiple Node.js versions in CI - Use a matrix strategy in GitHub Actions to run tests on Node 16, 18, and 20. Catching a compatibility issue before publish is infinitely better than after.

  5. Use npm version instead of manually editing package.json - It creates a git commit and tag atomically, which integrates cleanly with tag-based CI/CD publish workflows. Manual version bumps invite mistakes.

  6. Never publish from your laptop in production - Set up CI/CD to publish on tag push. This ensures every release goes through the test suite, is reproducible, and has provenance. The only command you run locally is npm version and git push --tags.

  7. Pin your dev dependencies, range your peer dependencies - Your devDependencies should use exact versions for reproducible builds. If your package has peerDependencies, use ranges like ">=16.0.0" to give consumers flexibility.

  8. Start with a clear changelog from day one - Whether manual or automated, a changelog builds trust with consumers. When someone is evaluating your package, a well-maintained changelog signals active, thoughtful maintenance.

  9. Use --provenance when publishing from CI - This links your npm package to its source commit and build log. It is a free trust signal that costs nothing to implement in GitHub Actions.

References

Powered by Contentful