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
- 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.
- 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.
- 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.
- Version Control: Maintain and update code systematically using semantic versioning, so consumers know exactly what to expect from each release.
- 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
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
- 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:"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 publishingtest/,.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 beforenpm publishand 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 userequire() (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)
Testing Your Package
Setting Up Jest
test/index.test.js
Running Tests
Coverage Report Example
Documentation
README.md Structure
Usage
API
toTitleCase(str)
Converts a string to Title Case.
Parameters:
str(string): The input string
toCamelCase(str)
Converts a string to camelCase.
Parameters:
str(string): The input string
Browser Usage
TypeScript
Contributing
Contributions are welcome! Please read CONTRIBUTING.md for details.License
MIT © Your NameChangelog
See CHANGELOG.md for version history.Configuring Package Files
.gitignore
.npmignore
LICENSE (MIT Example)
Testing Locally
Using npm link
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.
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.
Updating Versions
Version Lifecycle Scripts
Publishing Your Package
Step 1: Create NPM Account
Step 2: Prepare for Publishing
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
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 infamousleft-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.
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
Usefiles field or .npmignore:
3. Breaking Changes Without Major Version
Follow semantic versioning strictly. If you rename a function fromtoTitle() 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 asany 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:- Planning: Define purpose, name, and API
- Structure: Organize code logically
- Development: Write clean, tested code
- Documentation: Create comprehensive README
- Testing: Achieve high test coverage
- Configuration: Set up package.json correctly
- Publishing: Share with the community
- Maintenance: Keep package updated and secure
Interview Deep-Dive
'You publish version 1.2.0 of your NPM package and a user reports it broke their build, but all your tests pass. Walk me through how you investigate.'
'You publish version 1.2.0 of your NPM package and a user reports it broke their build, but all your tests pass. Walk me through how you investigate.'
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.'Explain the difference between dependencies, devDependencies, and peerDependencies. Give a scenario where choosing the wrong category causes a production bug.'
'Explain the difference between dependencies, devDependencies, and peerDependencies. Give a scenario where choosing the wrong category causes a production bug.'
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.'Your team maintains an internal NPM package used by 15 other services. You need to make a breaking change to the API. How do you manage this rollout?'
'Your team maintains an internal NPM package used by 15 other services. You need to make a breaking change to the API. How do you manage this rollout?'
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.'What is the purpose of package-lock.json, and what goes wrong if you delete it or do not commit it?'
'What is the purpose of package-lock.json, and what goes wrong if you delete it or do not commit it?'
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:- Monitor GitHub issues and respond to users
- Keep dependencies updated
- Release patches for bugs promptly
- Consider feature requests carefully
- Build a community around your package
- Document breaking changes clearly
- Celebrate your contribution to open source