Skip to main content

Building and Publishing an NPM Package

Creating your own NPM package allows you to share reusable code with the world, contribute to the open-source ecosystem, and establish your presence as a developer. But more importantly, understanding the package lifecycle — from initialization to publishing to maintenance — transforms you from someone who consumes packages into someone who understands the entire Node.js dependency system. When something goes wrong with a dependency (and it will), that understanding is invaluable. This comprehensive guide will walk you through every step of building a professional-grade NPM package, with a focus on the decisions that separate a throwaway experiment from a package people actually trust and use.

Why Create an NPM Package?

Benefits of Publishing Packages

  1. Code Reusability: Use your code across multiple projects without copy-pasting. When you fix a bug in the package, every project that depends on it gets the fix via a version bump.
  2. Community Contribution: Help other developers solve problems they should not have to solve from scratch. The best packages codify hard-won knowledge about edge cases and gotchas.
  3. Portfolio Building: A published package with tests, documentation, and real downloads says more about your skills than most portfolio websites. Interviewers can read your actual production code.
  4. Version Control: Maintain and update code systematically using semantic versioning, so consumers know exactly what to expect from each release.
  5. Collaboration: Allow others to contribute improvements, report bugs, and expand functionality in ways you never imagined. Some of the best features in popular packages came from community PRs.

When to Create a Package

Create a package when you:
  • Have code you use repeatedly across projects
  • Solve a problem that others might face
  • Want to open-source a tool or utility
  • Need to share internal libraries across teams
  • Want to learn about package development

Planning Your Package

Choosing a Package Name

Your package name must be:
  • Unique: Check availability on npmjs.com
  • Descriptive: Clearly indicate what the package does
  • Lowercase: NPM package names are case-insensitive
  • URL-safe: No spaces, only hyphens allowed

Naming Strategies

Scoped packages (starting with @) allow you to namespace your packages and avoid naming conflicts. They’re free for public packages!

Defining Package Requirements

Before coding, define:
  • Purpose: What problem does it solve?
  • Target Audience: Who will use it?
  • Dependencies: What external packages are needed?
  • API Design: How will users interact with it?
  • Compatibility: Which Node versions will it support?

Project Structure

Basic Package Structure

Advanced Package Structure

Initializing Your Package

Step 1: Create Project Directory

Step 2: Initialize package.json

You’ll be prompted for:
  • package name: string-manipulator
  • version: 1.0.0
  • description: A lightweight utility for advanced string manipulation
  • entry point: index.js
  • test command: jest
  • git repository: https://github.com/yourusername/string-manipulator
  • keywords: string, utility, manipulation, text
  • author: Your Name your.email@example.com
  • license: MIT

Step 3: Complete package.json

Every field here serves a purpose. The ones people skip are often the ones that matter most for discoverability and trust:
A few fields deserve extra attention:
  • "files" — This is a whitelist of what gets published. Without it, NPM publishes everything not in .npmignore. Using "files" is safer because it is explicit: you list what IS included rather than trying to list everything that is NOT. This prevents accidentally publishing test/, .env, or other sensitive files.
  • "engines" — Declares the minimum Node.js version. If a user tries to install your package on Node 12 and you require Node 14+, NPM will warn them. Without this field, they install successfully but get a confusing runtime error.
  • "prepublishOnly" — This lifecycle hook runs before npm publish and blocks publishing if tests or linting fail. Think of it as a pre-commit hook for your package registry. It has saved countless developers from publishing broken code.

Writing Package Code

Example: String Manipulator Package

src/index.js (Main Entry Point)

Supporting Multiple Export Formats

For maximum compatibility, support both CommonJS and ES Modules. This is one of the most confusing parts of the Node.js ecosystem, so let me be clear about why it matters: older projects and most server-side code use require() (CommonJS). Modern frontend tooling, newer Node.js projects, and browser bundlers expect import/export (ES Modules). If your package only supports one format, you lose half your potential users.

index.js (Dual Export)

package.json (ES Module Support)

Practical approach for 2025+: If you are starting a new package today, write in ES Module syntax (import/export) and use a build tool like tsup or unbuild to generate both CJS and ESM outputs. These tools handle the dual-format complexity for you in a single config file, so you do not have to manually manage two entry points.

Testing Your Package

Setting Up Jest

test/index.test.js

Running Tests

Coverage Report Example

Documentation

README.md Structure

Or with Yarn:

Usage

API

toTitleCase(str)

Converts a string to Title Case. Parameters:
  • str (string): The input string
Returns: string Example:

toCamelCase(str)

Converts a string to camelCase. Parameters:
  • str (string): The input string
Returns: string Example:
[… continue with all other functions …]

Browser Usage

TypeScript

Contributing

Contributions are welcome! Please read CONTRIBUTING.md for details.

License

MIT © Your Name

Changelog

See CHANGELOG.md for version history.

Configuring Package Files

.gitignore

.npmignore

If .npmignore exists, it overrides .gitignore. Make sure not to accidentally exclude important files!

LICENSE (MIT Example)

Testing Locally

Test your package locally before publishing. npm link creates a symlink from the global node_modules to your local package directory, so changes you make to the package are reflected immediately in any project that links to it. Think of it as a live development connection between your package source and your test project.
Common npm link pitfall: If your package and your test project use different versions of a shared dependency (like React), the symlink can cause “duplicate module” errors because Node.js resolves the dependency from the package’s node_modules (not the test project’s). Use npm pack and install the tarball for final pre-publish testing to avoid this issue.

Using Local Path

Test in Isolation

Create a test directory to verify your package works as expected:

Version Management

Semantic Versioning (SemVer)

Format: MAJOR.MINOR.PATCH — this is not just a convention, it is a contract with your users. When someone writes "string-manipulator": "^1.2.0" in their package.json, the ^ means “give me any version 1.x.x that is >= 1.2.0.” They are trusting you to not break their code in a minor or patch release.
  • MAJOR: Breaking changes — you renamed a function, removed a parameter, changed return types (1.0.0 to 2.0.0). Consumers must update their code.
  • MINOR: New features that are backward-compatible — you added a new function, added an optional parameter (1.0.0 to 1.1.0). Existing code keeps working.
  • PATCH: Bug fixes that are backward-compatible — you fixed a regex edge case, improved performance (1.0.0 to 1.0.1). No API changes at all.
The hardest SemVer question: Is fixing a bug that people accidentally depend on a breaking change? For example, if your toTitleCase function incorrectly handles hyphens and someone’s code relies on that incorrect behavior, fixing it could break their code. Technically this is a bug fix (patch), but in practice, it can break consumers. The pragmatic approach: fix it as a minor version bump and document the behavioral change in your CHANGELOG. Reserve major bumps for intentional API redesigns.

Updating Versions

Version Lifecycle Scripts

Publishing Your Package

Step 1: Create NPM Account

Step 2: Prepare for Publishing

Always run npm pack --dry-run before your first publish. This is your safety net against publishing .env files, node_modules, test fixtures with credentials, or your entire Git history. Review the file list carefully. If you see anything unexpected, adjust your "files" field in package.json or add entries to .npmignore.

Step 3: Publish

Step 4: Verify Publication

Publishing Scoped Packages

Public Scoped Package

Private Scoped Package

package.json for Scoped Package

Continuous Integration

GitHub Actions Workflow

CI/CD is non-negotiable for a package that other people depend on. A green CI badge on your README tells potential users “this person takes quality seriously.” Here is a production-ready workflow: Create .github/workflows/ci.yml:

Automated Publishing

Create .github/workflows/publish.yml:

Advanced Package Features

Adding TypeScript Definitions

Even if your package is written in JavaScript, provide TypeScript definitions:

index.d.ts

package.json

CLI Support

Make your package executable from command line:

bin/cli.js

package.json

Usage

Peer Dependencies

For plugins or extensions that require a host package:

Optional Dependencies

For packages that enhance functionality but aren’t required:

Package Maintenance

Updating Your Package

Deprecating Versions

Unpublishing Packages

Unpublishing is discouraged as it breaks projects depending on your package. Use deprecation instead.

Security Best Practices

1. Validate Input

2. Avoid eval() and Function()

3. Keep Dependencies Minimal

Every dependency you add is a liability. It is code you did not write, cannot fully control, and must keep updated. The infamous left-pad incident (2016) showed how a single 11-line package being unpublished broke thousands of builds globally. The event-stream incident (2018) showed how a dependency can be hijacked to inject malicious code.
The zero-dependency ideal: For utility packages, aim for zero runtime dependencies. If you need a small function from a large library, consider inlining it (with attribution). Consumers will trust a package with zero dependencies far more than one with a deep dependency tree. Libraries like date-fns gained market share over moment.js partly because they are tree-shakeable with minimal transitive dependencies.

4. Use .npmignore

Prevent sensitive files from being published:

5. Enable 2FA on NPM

Performance Optimization

Bundle Size Optimization

Minimize Dependencies

Use Lazy Loading

Marketing Your Package

1. Write Great Documentation

  • Clear README with examples
  • API documentation
  • Usage examples
  • Troubleshooting guide

2. Add Badges

3. Choose Good Keywords

4. Create Examples

5. Share on Social Media

  • Tweet about your package
  • Post on Reddit (r/javascript, r/node)
  • Write blog post
  • Create demo on CodeSandbox

Common Pitfalls

1. Not Testing Before Publishing

Always run tests before publishing:

2. Including Unnecessary Files

Use files field or .npmignore:

3. Breaking Changes Without Major Version

Follow semantic versioning strictly. If you rename a function from toTitle() to toTitleCase() in a minor release, everyone who uses toTitle() gets a broken build on their next npm install. They will not be happy, and they will not trust your package again. When in doubt about whether something is “breaking,” err on the side of a major version bump.

4. No TypeScript Definitions

Always provide TypeScript definitions for better developer experience, even if your package is written in plain JavaScript. TypeScript has become the default for many teams, and without type definitions, your package shows up as any in their editor — no autocomplete, no parameter hints, no documentation on hover. This alone is enough for many developers to choose a competing package.

5. Poor Error Messages

Real-World Example: Complete Package

Let’s look at a complete, production-ready package structure:

Final Directory Structure

Complete package.json

Summary

Building an NPM package involves:
  1. Planning: Define purpose, name, and API
  2. Structure: Organize code logically
  3. Development: Write clean, tested code
  4. Documentation: Create comprehensive README
  5. Testing: Achieve high test coverage
  6. Configuration: Set up package.json correctly
  7. Publishing: Share with the community
  8. Maintenance: Keep package updated and secure
Start small, iterate, and gather feedback. Your first package doesn’t need to be perfect—shipping is more important than perfection!

Interview Deep-Dive

Strong Answer:“All my tests pass” is a necessary but insufficient signal. The first thing I need to understand is what broke and in whose environment. I ask the user for their Node.js version, module system (CommonJS vs ESM), package manager (npm, yarn, pnpm — they resolve dependency trees differently), and whether they are using the package in Node.js or a bundler like Webpack or Vite.Then I verify what actually shipped. I run npm pack --dry-run against my 1.2.0 tag and compare the file list to 1.1.0. The most common cause of “works locally, breaks for users” is a file missing from the published tarball. If I added a new internal module in src/helpers/newUtil.js and import it from index.js, but my "files" whitelist in package.json does not include it, my tests pass (they run against the source tree) but the published package is missing the file. The user sees MODULE_NOT_FOUND.If the tarball is correct, I check for behavioral breaking changes. Maybe a function that returned null for empty input now returns undefined, or an error that used to throw Error now throws TypeError. These are breaking changes that tests might not catch if they only assert on the happy path. I diff the code between 1.1.0 and 1.2.0 and look for any change to function signatures, return types, error types, or edge case behavior.Third possibility: a dependency resolution issue. If I updated a sub-dependency’s version range in my package.json, the user might get a different resolved version than I tested against. Their lockfile pins one version; my CI resolves another. This is why running npm ci (which uses the lockfile exactly) in CI is critical, and why publishing with as few dependencies as possible reduces this risk.For the fix: if it is genuinely a breaking change, I publish a 1.2.1 patch that reverts the behavior, deprecate 1.2.0 with a message pointing to 1.2.1, and if the original change was intentional, I queue it for a proper 2.0.0 major release with migration notes in the changelog. The rule is absolute: never re-publish the same version number with different contents. Lockfiles depend on version immutability.Going forward, I add a CI step that runs npm pack, installs the resulting tarball into a fresh project, and executes the README’s quick-start example. This catches tarball-level issues that unit tests against the source tree never will.
Strong Answer:These three fields control when and how a package’s dependencies are installed, and confusing them causes real production bugs.dependencies are installed whenever anyone installs your package. If my package lists lodash in dependencies, then npm install my-package also installs lodash into the consumer’s node_modules. These are runtime requirements — code that executes when the user calls your functions.devDependencies are installed only during development of your package. They are NOT installed when a consumer runs npm install my-package. Jest, ESLint, Prettier, build tools — everything needed to develop but not to run the package goes here.peerDependencies declare “I need this package at runtime, but the consumer must provide it.” They prevent duplicate installations of packages that must exist as a single instance. This is the critical one.The production bug scenario: suppose I am building an Express middleware package called express-request-logger. I mistakenly put express in dependencies instead of peerDependencies. When a user installs my package, npm installs a second, private copy of Express nested inside node_modules/express-request-logger/node_modules/express. Now there are two Express instances in memory. My middleware calls app.use() on one instance; the user’s routes are registered on the other instance. The middleware never fires because it is attached to a different Express application object than the one handling requests. The user sees zero logging and gets no error message — just silent failure that is extremely difficult to diagnose.The fix is straightforward: Express goes in peerDependencies. This tells npm to use the consumer’s already-installed copy of Express. My middleware’s require('express') resolves to the same singleton instance the user is using. The same pattern applies to React (two React instances break hooks), Webpack plugins, and any framework that relies on global or singleton state.A related mistake I see frequently: putting a build tool like TypeScript or Babel in dependencies instead of devDependencies. The package should ship compiled JavaScript. Putting the compiler in dependencies forces every consumer to download it (TypeScript is roughly 50MB), bloating their node_modules and npm install time for something they never use.
Strong Answer:This is a coordination problem as much as a technical one. The key constraint is that 15 services cannot all migrate simultaneously, so I need a transition period where both the old and new APIs work.Step one: I ship the new API alongside the old one in a minor version, say 2.3.0. The new functions are added, the old functions continue to work exactly as before. No breaking change yet. I add deprecation warnings to the old functions using process.emitWarning() or console.warn() on first call, so teams see them in their logs and know migration is coming. The warning message includes the replacement function name and a link to a migration guide.Step two: I write a migration guide as a markdown file in the package repo. It covers every changed function, shows before-and-after code, and explains the rationale for the change. I also write a codemod using jscodeshift if the change is mechanical enough to automate. For 15 services, a codemod saves hundreds of person-hours of manual find-and-replace.Step three: I set a migration deadline — typically 4 to 6 weeks for an internal package. I communicate this via Slack, the package’s CHANGELOG, and ideally a brief message in each team’s standup channel. I make myself available for questions and offer to pair on tricky migrations.Step four: after the deadline, I check dependency graphs to see which services still pin the old version. For any that have not migrated, I reach out directly rather than breaking them. Only when all 15 services have confirmed migration (or explicitly accepted the risk of pinning the old version) do I publish the major version bump (3.0.0) that removes the deprecated API.Step five: the major version is published. Services using ^2.x in their package.json are not affected because semver ranges do not auto-upgrade across major versions. They continue using 2.3.x until they explicitly opt into 3.0.0.The tooling that makes this manageable: a monorepo tool like Nx or Turborepo if the services are in the same repository (run the codemod once across all projects), or Renovate/Dependabot configured to auto-create PRs when the new major version is published. The anti-pattern is publishing a major version with no migration path and hoping teams figure it out — that erodes trust in the internal package and teams start copying the code locally instead of depending on it.
Strong Answer:package-lock.json records the exact resolved version of every package in your dependency tree — not just your direct dependencies, but their dependencies, and their dependencies’ dependencies, all the way down. When you run npm install, npm reads your package.json for the version ranges (for example "lodash": "^4.17.0") and resolves them to specific versions (for example 4.17.21). The lockfile stores those resolved versions so that every subsequent install reproduces the exact same tree.Without the lockfile, npm install resolves version ranges fresh every time. If lodash publishes 4.17.22 between when you installed and when your colleague installs, you get different versions. This is the “works on my machine” class of bugs — code that passes all tests on your laptop fails in CI or on a teammate’s machine because a transitive dependency resolved to a different patch version with a subtle behavior change.The practical impact of deleting it: your next npm install resolves all ranges from scratch, potentially pulling in newer versions of every dependency. If any of those newer versions have bugs or breaking changes (even in patch versions — maintainers make mistakes), your previously working project breaks. In CI, this is especially dangerous because every build might resolve to different versions, making failures non-reproducible.For application projects (services, apps), you should always commit the lockfile. This ensures that npm ci in CI installs the exact versions you tested against. npm ci is different from npm install — it deletes node_modules entirely and installs strictly from the lockfile, failing if the lockfile does not match package.json. This is the correct command for CI pipelines.For library packages (NPM packages you publish), the convention is more nuanced. You should still commit the lockfile for reproducible development and CI, but consumers of your package never see your lockfile — npm ignores it when installing your package as a dependency. This means your library’s dependencies are resolved by the consumer’s npm install using the ranges in your package.json. This is by design: it allows the consumer’s lockfile to be the single source of truth for the entire dependency tree.The worst anti-pattern: adding package-lock.json to .gitignore. Teams sometimes do this because merge conflicts in the lockfile are annoying. But the fix is to use npm install --package-lock-only to regenerate the lockfile after resolving conflicts, not to eliminate deterministic builds entirely. Non-deterministic dependency resolution is far more expensive to debug than merge conflicts.

Next Steps

After publishing your package:
  1. Monitor GitHub issues and respond to users
  2. Keep dependencies updated
  3. Release patches for bugs promptly
  4. Consider feature requests carefully
  5. Build a community around your package
  6. Document breaking changes clearly
  7. Celebrate your contribution to open source