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.
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.
Descriptive: Clearly indicate what the package does
Lowercase: NPM package names are case-insensitive
URL-safe: No spaces, only hyphens allowed
# Check if name is availablenpm view package-name-here# If not found, it's available!# Error: npm ERR! 404 'package-name-here' is not in this registry.
// Good names (descriptive, clear -- a developer should guess what it does from the name)'date-formatter' // Formats dates -- clear and specific'json-validator' // Validates JSON -- no ambiguity'log-colorizer' // Adds color to logs -- searchable'express-rate-limiter' // Rate limiting for Express -- framework prefix helps discovery// Scoped packages (for personal/organization use)// The @ prefix creates a namespace, like a folder for your packages'@username/utilities' // Personal namespace -- avoids collision with "utilities"'@company/api-client' // Organization namespace -- common for internal tooling'@myorg/design-system' // Scoped packages can be public or private
Scoped packages (starting with @) allow you to namespace your packages and avoid naming conflicts. They’re free for public packages!
"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.
/** * String Manipulator - A utility library for advanced string operations * @module string-manipulator *//** * Converts a string to title case * @param {string} str - The input string * @returns {string} The title-cased string * @example * toTitleCase('hello world') // 'Hello World' */function toTitleCase(str) { // Defensive type checking -- public APIs should never silently coerce types. // Throwing a TypeError (not a generic Error) follows Node.js conventions // and helps consumers debug issues in their calling code. if (typeof str !== 'string') { throw new TypeError('Expected a string'); } return str .toLowerCase() // Normalize first: "HELLO WORLD" -> "hello world" .split(' ') // Split into words by space .map(word => word.charAt(0).toUpperCase() + word.slice(1)) // Capitalize first letter .join(' '); // Rejoin into a single string}/** * Converts a string to camelCase * @param {string} str - The input string * @returns {string} The camelCased string * @example * toCamelCase('hello world') // 'helloWorld' */function toCamelCase(str) { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } return str .toLowerCase() // This regex finds any non-alphanumeric character(s) followed by a letter, // and replaces the whole match with just the uppercase letter. // "hello-world" -> match "-w" -> replace with "W" -> "helloWorld" .replace(/[^a-zA-Z0-9]+(.)/g, (_, char) => char.toUpperCase());}/** * Converts a string to snake_case * @param {string} str - The input string * @returns {string} The snake_cased string * @example * toSnakeCase('Hello World') // 'hello_world' */function toSnakeCase(str) { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } return str .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/\s+/g, '_') .replace(/^_/, '');}/** * Converts a string to kebab-case * @param {string} str - The input string * @returns {string} The kebab-cased string * @example * toKebabCase('Hello World') // 'hello-world' */function toKebabCase(str) { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } return str .replace(/([A-Z])/g, '-$1') .toLowerCase() .replace(/\s+/g, '-') .replace(/^-/, '');}/** * Truncates a string to a specified length * @param {string} str - The input string * @param {number} length - Maximum length * @param {string} [suffix='...'] - Suffix to append if truncated * @returns {string} The truncated string * @example * truncate('Hello World', 8) // 'Hello...' */function truncate(str, length, suffix = '...') { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } // If the string is already short enough, return it unchanged. // This avoids the edge case where slicing + suffix makes it longer. if (str.length <= length) { return str; } // Subtract the suffix length so the total output fits within `length`. // "Hello World" truncated to 8 with "..." -> "Hello" + "..." = "Hello..." (8 chars) return str.slice(0, length - suffix.length) + suffix;}/** * Reverses a string * @param {string} str - The input string * @returns {string} The reversed string * @example * reverse('hello') // 'olleh' */function reverse(str) { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } // Note: This simple approach breaks with Unicode characters like emojis // or multi-byte characters (e.g., "hello 🌍" reversed incorrectly). // For production use, consider Array.from(str).reverse().join('') // which handles Unicode code points correctly. return str.split('').reverse().join('');}/** * Counts the words in a string * @param {string} str - The input string * @returns {number} The word count * @example * wordCount('Hello world') // 2 */function wordCount(str) { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } // trim() removes leading/trailing whitespace to avoid empty matches. // split(/\s+/) splits on one or more whitespace characters (handles tabs, newlines, double spaces). // filter(Boolean) removes empty strings from the array (e.g., if the input was all whitespace). return str.trim().split(/\s+/).filter(Boolean).length;}/** * Capitalizes the first letter of a string * @param {string} str - The input string * @returns {string} The capitalized string * @example * capitalize('hello') // 'Hello' */function capitalize(str) { if (typeof str !== 'string') { throw new TypeError('Expected a string'); } return str.charAt(0).toUpperCase() + str.slice(1);}// Export all functionsmodule.exports = { toTitleCase, toCamelCase, toSnakeCase, toKebabCase, truncate, reverse, wordCount, capitalize};
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.
{ "name": "string-manipulator", "version": "1.0.0", "main": "./dist/index.cjs", // Entry point for require() -- CommonJS consumers "module": "./dist/index.mjs", // Entry point for bundlers (Webpack, Rollup) -- enables tree-shaking "exports": { ".": { "require": "./dist/index.cjs", // When someone does: const x = require('string-manipulator') "import": "./dist/index.mjs", // When someone does: import x from 'string-manipulator' "types": "./dist/index.d.ts" // TypeScript type definitions -- resolved automatically } }, "type": "module" // Tells Node.js to treat .js files as ES Modules by default}
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.
# String Manipulator> A lightweight, zero-dependency utility library for advanced string manipulation in Node.js and browsers.[](https://www.npmjs.com/package/string-manipulator)[](https://github.com/yourusername/string-manipulator/actions)[](https://codecov.io/gh/yourusername/string-manipulator)[](LICENSE)## Features✅ Zero dependencies✅ Lightweight (< 2KB gzipped)✅ TypeScript support✅ Full test coverage✅ Works in Node.js and browsers✅ ESM and CommonJS support## Installation```bashnpm install string-manipulator
### CHANGELOG.md```markdown# ChangelogAll notable changes to this project will be documented in this file.The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).## [Unreleased]## [1.0.0] - 2024-01-15### Added- Initial release- `toTitleCase()` function- `toCamelCase()` function- `toSnakeCase()` function- `toKebabCase()` function- `truncate()` function- `reverse()` function- `wordCount()` function- `capitalize()` function### Changed- Nothing### Fixed- Nothing## [0.1.0] - 2024-01-10### Added- Beta release- Basic string manipulation functions
# Source files (if you build to dist/)src/test/# Development files.github/.vscode/coverage/*.test.jsjest.config.js.eslintrc.json.prettierrc# Documentation (keep README!)docs/examples/# Git.git/.gitignore
If .npmignore exists, it overrides .gitignore. Make sure not to accidentally exclude important files!
MIT LicenseCopyright (c) 2024 Your NamePermission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
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.
# Step 1: In your package directory -- register this package as a global linknpm link# This creates a symlink: global node_modules/string-manipulator -> your local directory# Step 2: In your test project -- connect to the linked packagenpm link string-manipulator# This creates a symlink: test-project/node_modules/string-manipulator -> global link -> your source# Step 3: Now changes in your package source are instantly availableconst { toTitleCase } = require('string-manipulator');# Edits to src/index.js take effect immediately -- no reinstall needed
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.
# In your test projectnpm install ../path/to/string-manipulator# Or in package.json{ "dependencies": { "string-manipulator": "file:../string-manipulator" }}
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.
# Patch version (1.0.0 → 1.0.1)npm version patch# Minor version (1.0.0 → 1.1.0)npm version minor# Major version (1.0.0 → 2.0.0)npm version major# Specific versionnpm version 1.2.3# Pre-release versionsnpm version prepatch # 1.0.0 → 1.0.1-0npm version preminor # 1.0.0 → 1.1.0-0npm version premajor # 1.0.0 → 2.0.0-0
# Run tests -- never publish without passing testsnpm test# CRITICAL: Check what will be published BEFORE actually publishing# This shows you every file that will be in the published packagenpm pack --dry-run# Create an actual tarball -- you can inspect it and install it locallynpm pack# This creates string-manipulator-1.0.0.tgz# Install it in a test project: npm install ./string-manipulator-1.0.0.tgz
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.
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:
name: CIon: push: branches: [ main ] # Run on every push to main pull_request: branches: [ main ] # Run on PRs targeting mainjobs: test: runs-on: ubuntu-latest strategy: matrix: # Test against every Node.js version you claim to support in "engines" # This catches compatibility issues before your users do node-version: [14.x, 16.x, 18.x, 20.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm ci # Use 'ci' (not 'install') for reproducible installs from lockfile - name: Run linter run: npm run lint # Catch style issues and potential bugs - name: Run tests run: npm test # Your core quality gate - name: Generate coverage run: npm run test:coverage # Track that test coverage does not regress - name: Upload coverage uses: codecov/codecov-action@v3 with: file: ./coverage/coverage-final.json
/** * Converts a string to title case */export function toTitleCase(str: string): string;/** * Converts a string to camelCase */export function toCamelCase(str: string): string;/** * Converts a string to snake_case */export function toSnakeCase(str: string): string;/** * Converts a string to kebab-case */export function toKebabCase(str: string): string;/** * Truncates a string to specified length */export function truncate(str: string, length: number, suffix?: string): string;/** * Reverses a string */export function reverse(str: string): string;/** * Counts words in a string */export function wordCount(str: string): number;/** * Capitalizes first letter */export function capitalize(str: string): string;
# Deprecate specific versionnpm deprecate string-manipulator@1.0.0 "Please upgrade to 1.0.1"# Deprecate all versionsnpm deprecate string-manipulator "Package is no longer maintained"
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.
# Regularly audit dependencies for known vulnerabilitiesnpm audit# Fix vulnerabilities automatically (patches and minor versions only)npm audit fix# Check for outdated packages -- old dependencies accumulate vulnerabilitiesnpm outdated# See why a dependency is in your tree (often transitive deps are the issue)npm explain <package-name>
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.
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.
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.
'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.'
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.
'Explain the difference between dependencies, devDependencies, and peerDependencies. Give a scenario where choosing the wrong category causes a production bug.'
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.
'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?'
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.
'What is the purpose of package-lock.json, and what goes wrong if you delete it or do not commit 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.