Publishing an npm package transforms your code from a local utility into a shared resource that thousands — or even millions — of developers can use. The npm registry hosts over two million packages, and behind each one lies a series of deliberate decisions about structure, versioning, testing, and distribution. Whether you are extracting a utility function from a larger project or building a full-featured library, understanding the end-to-end publishing workflow is essential for delivering reliable, maintainable software.
This guide walks you through every stage of the npm publishing lifecycle: initializing a package, configuring metadata, writing tests, automating releases with CI/CD, and maintaining your package over time. By the end, you will have a repeatable process for shipping packages that developers trust and depend on.
Why Publish an npm Package?
Before diving into the mechanics, it is worth considering why you should publish a package at all. The primary motivations fall into a few categories:
- Code reuse across projects. If you find yourself copying the same utility functions between repositories, packaging them eliminates duplication and centralizes bug fixes.
- Open-source contribution. Sharing solutions to common problems builds your reputation and gives back to the community that produces the tools you rely on.
- Internal distribution. Organizations use private registries or scoped packages to share code across teams without exposing it publicly.
- Enforcing standards. Publishing shared ESLint configurations, Prettier presets, or TypeScript type definitions ensures consistency across an entire engineering organization.
Regardless of your motivation, the technical process follows the same pattern. Let us start from the beginning.
Setting Up Your Package
Initializing the Project
Every npm package starts with a package.json file. You can generate one interactively with npm init or skip the prompts with npm init -y. However, for a package you intend to publish, you should fill in every field deliberately rather than accepting defaults.
The critical fields in package.json include:
- name — must be unique on the npm registry. Use a scoped name (
@yourorg/package-name) to avoid conflicts and signal ownership. - version — follows semantic versioning (semver), starting at
1.0.0for your first stable release or0.1.0if you consider the API experimental. - description — a concise summary that appears in search results. Make it specific and keyword-rich.
- main — the entry point for CommonJS consumers (typically
dist/index.js). - module — the entry point for ES module consumers.
- types — path to TypeScript declaration files.
- files — an allowlist of files and directories to include in the published package. This is the inverse of
.npmignoreand generally preferred because it is explicit. - license — use an SPDX identifier like
MIT,Apache-2.0, orISC. - repository — link to the source code, which npm displays on the package page.
- keywords — an array of terms that help people discover your package through search.
Project Structure
A well-organized package directory makes collaboration easier and signals professionalism to potential users. Here is a structure that works for most libraries:
my-package/
├── src/
│ ├── index.ts # Main entry point
│ ├── utils.ts # Helper functions
│ └── types.ts # TypeScript type definitions
├── tests/
│ ├── index.test.ts
│ └── utils.test.ts
├── dist/ # Compiled output (gitignored, npm-published)
├── .github/
│ └── workflows/
│ └── publish.yml # CI/CD pipeline
├── package.json
├── tsconfig.json
├── LICENSE
├── README.md
└── CHANGELOG.md
The src/ directory holds your source code, dist/ contains compiled output, and tests/ contains your test suite. If you are working within a monorepo using Turborepo or Nx, each package follows this same internal structure while sharing build configuration at the root level.
Configuring TypeScript
Most modern npm packages are written in TypeScript and compiled to JavaScript for distribution. Your tsconfig.json should target a reasonable baseline and produce both CommonJS and ES module outputs if you want to support the widest range of consumers.
Key compiler options for library authors include setting declaration to true so that TypeScript generates .d.ts files, enabling strict mode for maximum type safety, and configuring outDir to point to your dist/ directory. You should also set declarationMap to true, which lets consumers jump to your original TypeScript source when using “Go to Definition” in their editor.
Dual Package Output: CommonJS and ESM
The JavaScript ecosystem is in the middle of a long transition from CommonJS (require) to ES modules (import). As a package author, you should support both formats to maximize compatibility. The package.json exports field lets you specify different entry points for different module systems.
Here is a practical example of a complete package.json for a published package:
{
"name": "@myorg/utils",
"version": "1.2.0",
"description": "Shared utility functions for data validation and formatting",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/myorg/utils"
},
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
},
"./validation": {
"import": "./dist/esm/validation.js",
"require": "./dist/cjs/validation.js",
"types": "./dist/types/validation.d.ts"
}
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"files": ["dist", "LICENSE", "README.md"],
"scripts": {
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check src/ tests/",
"prepublishOnly": "npm run lint && npm run test && npm run build",
"size": "size-limit"
},
"keywords": ["utils", "validation", "formatting", "typescript"],
"devDependencies": {
"typescript": "^5.3.0",
"vitest": "^1.0.0",
"@biomejs/biome": "^1.5.0",
"size-limit": "^11.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Notice the exports field with conditional exports for import and require, the files allowlist that keeps the published package lean, and the prepublishOnly script that runs linting, testing, and building before every publish. This prevents you from accidentally publishing broken code.
Writing and Running Tests
No package should be published without tests. Your test suite serves multiple purposes: it verifies correctness, documents expected behavior, and prevents regressions when you make changes later.
For npm packages, focus on unit tests that cover your public API surface. Each exported function should have tests for normal inputs, edge cases, and error conditions. If your package performs I/O or has side effects, use mocking to isolate the logic under test.
Modern test runners like Vitest offer fast execution, native TypeScript support, and a familiar API. Run tests as part of your prepublishOnly script so that every release is verified automatically.
Pre-Publish Checklist
Before your first publish, walk through this checklist:
- Run
npm packand inspect the tarball. This shows you exactly what files will be included in the published package. Look for accidentally included files like.env, test fixtures, or build artifacts that consumers do not need. - Check the package size. Tools like
size-limitorbundlephobiahelp you understand the impact your package has on downstream bundle sizes. Smaller is almost always better. - Verify the README. Your README is the first thing potential users see. It should include a clear description, installation instructions, usage examples, and API documentation. Good technical writing directly impacts adoption.
- Test installation locally. Use
npm linkornpm packfollowed bynpm install ./package.tgzin a test project to verify that the package installs and imports correctly. - Ensure the license file is present. Without a license, your code is technically “all rights reserved,” which discourages adoption.
- Set up two-factor authentication. Enable 2FA on your npm account before publishing. This protects your packages from unauthorized publishes if your credentials are compromised.
Your First Publish
With everything in order, publishing is straightforward:
# Log in to npm (one-time setup)
npm login
# Publish a public scoped package
npm publish --access public
# Or for a private/unscoped package
npm publish
After publishing, verify the package page on npmjs.com. Check that the README renders correctly, the file list matches your expectations, and the version number is correct.
Automating Releases with CI/CD
Manual publishing works for the first release, but it does not scale. Human error — forgetting to run tests, publishing from an unclean working directory, or tagging the wrong commit — becomes increasingly likely as you iterate. Automating the release process with GitHub Actions or similar CI/CD platforms eliminates these risks.
A Release Workflow
The most reliable approach uses a dedicated CI pipeline that triggers when you push a version tag. Here is a GitHub Actions workflow that handles the entire process:
name: Publish to npm
on:
push:
tags:
- 'v*'
permissions:
contents: write
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Build package
run: npm run build
- name: Verify package contents
run: npm pack --dry-run
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
This workflow runs on every version tag push, installs dependencies with npm ci for reproducibility, runs the full test and lint suite, builds the package, and publishes with provenance. The --provenance flag links the published package to its source code and build process, giving consumers cryptographic proof of where the package came from.
The Release Process
With this workflow in place, your release process becomes:
- Update the version in
package.json(following semver rules). - Update the
CHANGELOG.mdwith a description of changes. - Commit the version bump.
- Create a git tag:
git tag v1.2.0. - Push the tag:
git push origin v1.2.0.
The CI pipeline handles everything else. This approach is deterministic, auditable, and eliminates the possibility of publishing untested code.
Automating Version Bumps
For teams that want even more automation, tools like changesets, semantic-release, or release-it can automatically determine the next version number based on commit messages, generate changelogs, and trigger publishes. These tools pair well with conventional commit conventions and are especially useful in monorepo setups where multiple packages may need coordinated releases.
Effective project management is crucial when coordinating releases across teams — tools like Taskee help keep release cycles organized by tracking tasks, deadlines, and cross-team dependencies in a single view.
Versioning Strategy
Semantic versioning is a contract with your users. When you publish version 2.3.1, consumers understand that:
- Patch (2.3.x) — bug fixes only, no API changes.
- Minor (2.x.0) — new features added, fully backward compatible.
- Major (x.0.0) — breaking changes that require consumers to update their code.
The most common mistake package authors make is underestimating what constitutes a breaking change. Removing an exported function is obviously breaking, but so are narrowing a type definition, changing a default value, dropping support for a Node.js version, or making a previously synchronous function asynchronous. When in doubt, treat it as a major version bump. Your users will thank you for the caution.
For a deeper exploration of versioning practices and changelog generation, see our guide to semantic versioning and changelogs.
Package Security
As a package author, you have a responsibility to your users. Compromised npm packages have been used to steal credentials, mine cryptocurrency, and exfiltrate data. Take these precautions seriously:
- Enable 2FA on npm. This is the single most important security measure. Use an authenticator app, not SMS.
- Use npm provenance. The
--provenanceflag in npm 9.5+ creates a verifiable link between your published package and the CI build that produced it. - Minimize dependencies. Every dependency is a potential attack surface. Audit your dependency tree regularly with
npm auditand consider whether you truly need each one. - Use lockfiles. Commit your
package-lock.jsonand usenpm ciin CI to ensure reproducible installs. - Scope your npm tokens. CI tokens should be automation tokens with the minimum required permissions, and they should be rotated regularly.
- Review before publishing. Run
npm pack --dry-runbefore every release to verify that no secrets or sensitive files are included.
When building complex applications that depend on many packages, having a structured project management workflow with Toimi helps coordinate security audits, dependency updates, and release planning across teams.
Maintaining Your Package
Publishing is not the end — it is the beginning. A published package requires ongoing maintenance to remain useful and trustworthy.
Handling Issues and Pull Requests
Set clear expectations with a CONTRIBUTING.md file that explains how to report bugs, request features, and submit pull requests. Use issue templates to gather structured information from reporters. Respond to issues promptly, even if the response is just an acknowledgment — silence erodes trust faster than a slow fix.
Dependency Updates
Keep your dependencies current. Outdated dependencies accumulate security vulnerabilities and compatibility issues. Tools like Dependabot or Renovate automate dependency update pull requests, which your CI pipeline can test automatically. Review and merge these regularly rather than letting them pile up.
Deprecation and End of Life
If you decide to stop maintaining a package, do it gracefully. Use npm deprecate to warn users when they install the package. Point them to alternatives in the deprecation message. If the package has active users, consider transferring ownership to someone willing to maintain it.
Monitoring Usage
The npm registry provides download statistics for every package. Monitor these to understand adoption trends and identify when a major version migration is complete. Third-party tools can alert you when your download counts change significantly, which sometimes indicates that a popular project has added or removed your package as a dependency.
Advanced Topics
Peer Dependencies
If your package is a plugin or extension for another library (like a React component library or an ESLint plugin), use peerDependencies rather than dependencies. This tells npm that the consumer must install the host library themselves, avoiding version conflicts and duplicate installations. Specify wide version ranges in peer dependencies to maximize compatibility.
Package Provenance
npm provenance, introduced in 2023, uses Sigstore to create a verifiable chain from your source code to the published package. When consumers see the provenance badge on npmjs.com, they know the package was built from the linked repository by a specific CI workflow. This makes supply-chain attacks significantly harder to execute undetected.
Publishing from Monorepos
Monorepo publishing adds complexity because multiple packages may have interdependencies and coordinated release cycles. Tools like changesets are specifically designed for this scenario. They let contributors propose version bumps alongside their pull requests, and a release workflow aggregates these into coordinated publishes. See our monorepo tooling guide for detailed configuration examples.
Code Quality and Linting
Consistent code style matters even more in open-source packages where multiple contributors collaborate. Modern tools like Biome combine linting and formatting in a single fast tool, replacing the need for separate ESLint and Prettier configurations. Run these checks in CI to ensure that every contribution meets your quality standards before merging.
Choosing a Package Manager
While this guide focuses on npm, the npm registry is compatible with alternative package managers like pnpm and Yarn. Each has strengths: pnpm offers strict dependency isolation and disk efficiency, Yarn provides plug-and-play and workspace features, and npm benefits from universal availability. Our pnpm vs npm vs Yarn comparison covers the tradeoffs in detail. Regardless of which manager you use for development, npm publish is the standard command for publishing to the registry.
Common Mistakes to Avoid
After reviewing hundreds of npm packages, certain anti-patterns appear repeatedly:
- Not using the
filesfield. Without it, npm publishes everything not in.npmignore, which often includes test fixtures, documentation source files, and CI configurations that bloat the package. - Forgetting
prepublishOnly. Without this script, you can easily publish stale build artifacts or skip tests before a release. - Overly broad version ranges. Specifying
"*"or very wide ranges for dependencies invites breaking changes into your users’ projects. - No TypeScript types. Even if you write in JavaScript, providing type definitions (via JSDoc or separate
.d.tsfiles) dramatically improves the developer experience for consumers. - Ignoring bundle size. A utility package that adds 500KB to a frontend bundle will be replaced by a lighter alternative, no matter how good its API is.
- Breaking changes in minor versions. This violates semver and breaks downstream builds. Always use major versions for breaking changes.
- Abandoning without deprecation. If you stop maintaining a package, formally deprecate it so users know to find alternatives.
Conclusion
Publishing an npm package is a well-defined process, but doing it well requires attention to detail at every stage. Start with a clean project structure and thorough package.json configuration. Write tests before your first publish. Automate releases through CI/CD to eliminate human error. Follow semantic versioning faithfully. And commit to ongoing maintenance — because a published package is a promise to the developers who depend on it.
The npm ecosystem thrives because individual developers take the time to package their solutions properly. By following the practices outlined in this guide, you ensure that your contributions meet the standard your users expect and deserve.
Frequently Asked Questions
How do I publish a scoped npm package as public?
By default, scoped packages (those with an @org/ prefix) are private on npm. To publish them as public, add the --access public flag to your publish command: npm publish --access public. You can also set this permanently in your package.json by adding "publishConfig": { "access": "public" }, which removes the need to specify the flag on every publish.
What is the difference between dependencies and devDependencies in a published package?
When someone installs your package, npm only installs items listed in dependencies — these are the packages your code needs at runtime. Items in devDependencies (test frameworks, build tools, linters) are only installed during development and are ignored when your package is consumed by others. Putting build tools in dependencies is a common mistake that bloats downstream installations unnecessarily.
How do I unpublish or fix a broken npm release?
You can unpublish a package within 72 hours of publishing using npm unpublish package-name@version. After 72 hours, unpublishing is restricted to prevent breaking projects that depend on your package. The better approach for fixing a broken release is to publish a new patch version with the fix and use npm deprecate package-name@broken-version "Use version x.y.z instead" to warn users away from the broken version.
Should I bundle my npm package or publish unbundled source?
For most libraries, you should publish compiled but unbundled JavaScript. Bundling (combining all code into a single file) can prevent tree-shaking by downstream bundlers, inflating final application sizes. Publish individual ES module files so that tools like webpack, Rollup, or esbuild can eliminate unused exports. The exception is packages targeting direct browser usage via CDN, where a bundled UMD or IIFE build is appropriate to include alongside your module builds.
How do I test my npm package locally before publishing?
There are three common approaches. First, npm link creates a symlink from your global node_modules to your package, then npm link package-name in a test project links it as a dependency. Second, npm pack creates a tarball that you can install with npm install ./package-name-1.0.0.tgz — this most closely simulates a real install. Third, tools like yalc provide a local registry that avoids some symlink quirks. Always test with npm pack at least once before your first publish to catch file inclusion issues.