MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr šŸ”„ tagline

Hey there šŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Understanding NPM: Package Management for Node.js

Complete guide to npm: package.json, semver, lockfiles, scripts, workspaces, and dependency management for Node.js projects.

npmNode.jsPackage ManagementJavaScript

By MinhVo

Introduction

npm (Node Package Manager) is the default package manager for Node.js and the largest software registry in the world. With over 2 million packages and billions of weekly downloads, npm is the backbone of the JavaScript ecosystem. Understanding npm deeply — from semantic versioning to lockfiles, workspaces, and security — is essential for every JavaScript developer. This guide covers everything from basics to advanced patterns that will help you manage dependencies confidently and securely.

npm was originally created in 2010 by Isaac Schlueter to manage dependencies for Node.js projects. Since then, it has evolved into a comprehensive package management platform that handles installation, versioning, publishing, and security auditing. The npm registry hosts packages for both server-side Node.js and client-side JavaScript, making it the central hub for sharing and consuming JavaScript code. Modern npm includes features like workspaces for monorepo management, automatic vulnerability scanning, and provenance tracking for supply chain security.

Understanding npm is not just about knowing which commands to run. It is about understanding the principles of dependency management that keep your applications stable, secure, and maintainable. The decisions you make about version ranges, lockfile management, and dependency organization directly affect your ability to ship reliably and respond to security incidents. This guide will give you the knowledge to make those decisions confidently.

Package management

Core Concepts

npm operates on several foundational concepts that every developer should understand. The package.json file is the manifest that describes your project, its dependencies, and its scripts. Semantic versioning governs how package versions are specified and resolved. The lockfile ensures deterministic installations across different machines and environments. The node_modules directory is where npm installs packages locally, organized in a flat structure with nesting only when version conflicts require it. The npm registry is the public server that hosts packages and serves them to developers worldwide. The npm CLI is the command-line tool that interacts with the registry and manages the local project.

The npm Registry

The npm registry is a massive database of JavaScript packages. Each package has a name, version history, metadata, and tarball archives. When you run npm install, the CLI contacts the registry to resolve package versions and download tarballs. The registry uses a content delivery network to serve packages quickly from edge locations worldwide. The registry also hosts package metadata including README files, dependency lists, and security audit information. Package authors publish their work to the registry using npm publish, making it available to millions of developers. The registry enforces naming conventions, version format requirements, and package size limits to maintain quality and reliability.

The registry supports both public and private packages. Public packages are available to anyone and are the foundation of the open-source ecosystem. Private packages are restricted to authorized users and are typically used for proprietary code within organizations. Scoped packages use a namespace prefix like @myorg/package-name to prevent naming conflicts and enable organization-level access control. The registry also supports package provenance, which links published packages to their source repository and CI build for supply chain security verification.

How npm Installs Packages

When you run npm install, the CLI performs several steps. First, it reads the package.json to determine which dependencies are needed. Then it resolves the latest versions that satisfy the version ranges specified, consulting the lockfile if one exists. Next, it downloads the package tarballs from the registry, verifies their integrity using checksums, and extracts them into the node_modules directory. Finally, it runs any post-install scripts defined by the packages. The entire process is designed to be deterministic, meaning that running npm install with the same lockfile should produce the same node_modules tree every time.

The installation process also handles lifecycle scripts. Packages can define preinstall, install, and postinstall scripts that run during installation. These scripts are used for tasks like compiling native modules, downloading platform-specific binaries, or generating configuration files. While scripts are powerful, they are also a security risk because malicious packages can execute arbitrary code during installation. The --ignore-scripts flag disables script execution during installation, which is recommended when installing packages from untrusted sources.

package.json Deep Dive

Essential Fields

The package.json file is the central configuration file for any Node.js project. It defines the project's name, version, entry points, dependencies, and scripts. Understanding each field and its purpose helps you create well-structured packages that work reliably across different environments.

{
  "name": "my-app",
  "version": "1.0.0",
  "description": "My application",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "dev": "ts-node src/index.ts"
  },
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "^4.17.21"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "jest": "^29.0.0"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "license": "MIT"
}

The name field identifies your package in the registry. For scoped packages, use the format @scope/package-name. The version field follows semantic versioning and must be updated with each publish. The main field points to the entry point of your package when it is required by other code. The types field points to the TypeScript declaration file for type support. The engines field specifies which Node.js versions your package supports, and the engine-strict .npmrc setting can enforce this requirement during installation.

Dependencies vs DevDependencies

The distinction between dependencies and devDependencies is critical for understanding what gets installed where. Runtime dependencies are packages that your application needs to function in production — express for serving HTTP requests, react for rendering UI, or lodash for utility functions. Development dependencies are packages that are only needed during development — typescript for type checking, jest for testing, eslint for linting, or prettier for formatting.

This distinction matters because when someone installs your package as a dependency, only your runtime dependencies are installed, not your dev dependencies. This keeps the installation footprint small and avoids pulling in tools that consumers do not need. It also affects the security surface area of your production deployment, since only runtime dependencies are included in the final bundle.

{
  "dependencies": {
    "react": "^18.2.0"
  },
  "peerDependencies": {
    "react": ">=16.8.0"
  }
}

Peer dependencies are a special category that declares compatibility requirements. A peer dependency says I expect the consumer to provide this package at a compatible version. For example, a React component library declares react as a peer dependency because it works with React but does not bundle its own copy. This prevents duplicate copies of React in the final bundle, which would cause runtime errors. npm 7 and later automatically install peer dependencies, but earlier versions only warn about missing peer dependencies.

Scripts

npm scripts are the primary way to automate tasks in Node.js projects. They are defined in the scripts field of package.json and can run any shell command. Scripts have access to locally installed binaries in node_modules/.bin, so you can run tools like jest, tsc, or eslint without installing them globally. Special script names like prebuild and postbuild are hooks that run automatically before or after the corresponding script.

The prepare script runs after npm install and before npm publish, making it ideal for building the project. The prepublishOnly script runs only before npm publish, useful for running tests and linting before publishing. The pretest and posttest scripts run before and after the test script, useful for setting up and tearing down test environments. The start script is the conventional way to start an application.

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "jest --coverage",
    "lint": "eslint src/",
    "format": "prettier --write src/",
    "prepare": "husky install",
    "prepublishOnly": "npm run build && npm test"
  }
}

Scripts can reference other scripts using npm run, enabling composition of complex workflows. You can chain commands with && for sequential execution or & for parallel execution. Cross-platform compatibility is a concern because shell syntax differs between Windows and Unix systems. Use tools like cross-env or shx to write scripts that work on both platforms. The npm-run-all package provides run-s for sequential and run-p for parallel execution of multiple scripts.

Dependency resolution

Semantic Versioning (Semver)

Version Format

Semantic versioning is the foundation of npm's dependency resolution. Every package follows the MAJOR.MINOR.PATCH format, where each number has a specific meaning. PATCH versions contain bug fixes that do not change the public API. MINOR versions add new features in a backward-compatible way. MAJOR versions contain breaking changes that may require consumers to update their code.

MAJOR.MINOR.PATCH
  |     |     |
  |     |     └── Bug fixes (backward compatible)
  |     └──────── New features (backward compatible)
  └────────────── Breaking changes

Pre-release versions use a suffix like 1.0.0-beta.1, 1.0.0-rc.1, or 1.0.0-alpha.3. Pre-release versions are considered less stable than the corresponding release version and are sorted alphabetically after the hyphen. This convention allows package authors to publish early versions for testing without affecting users who depend on the stable release.

Version Ranges

Version ranges in npm use operators to specify acceptable versions. The caret operator allows updates that do not change the leftmost non-zero digit, which for most packages means allowing minor and patch updates. The tilde operator is more restrictive, allowing only patch updates. Exact versions pin a specific version with no flexibility. The greater-than-or-equal and less-than operators create explicit ranges.

Understanding these operators is critical for balancing stability and getting bug fixes. Using the caret operator for most dependencies is the recommended default because it allows automatic patch and minor updates that fix bugs and add features without breaking your code. The tilde operator is appropriate for dependencies where you want only bug fixes and no new features. Exact versions are appropriate for dependencies where any change could cause problems.

{
  "dependencies": {
    "exact": "1.2.3",
    "patch": "~1.2.3",
    "minor": "^1.2.3",
    "latest": "*",
    "range": ">=1.0.0 <2.0.0",
    "pre": "1.0.0-beta.1"
  }
}

Practical Versioning Strategy

A practical versioning strategy uses different range operators for different categories of dependencies. Use the caret operator for most dependencies because it allows minor and patch updates that fix bugs and add features without breaking your code. Use the tilde operator for critical dependencies where you want only bug fixes and no new features. Use exact versions for dependencies where any change, even a patch, could cause problems such as cryptographic libraries or build tools.

This strategy balances the desire for automatic updates with the need for stability. It also simplifies dependency management because you do not need to manually update every dependency when a new patch is released. The lockfile ensures that updates are applied intentionally rather than accidentally, so you can run npm update to pick up new versions when you are ready.

{
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "^4.17.21",
    "jsonwebtoken": "~9.0.0",
    "crypto-lib": "2.1.0"
  }
}

Lockfiles

Why Lockfiles Matter

Lockfiles are the mechanism that ensures deterministic installations across different machines and environments. Without a lockfile, running npm install at different times might install different versions because new patch or minor versions may have been published. Developer A installs on Monday and gets lodash 4.17.21. Developer B installs on Wednesday and gets lodash 4.17.22 because a patch was published on Tuesday. The CI system installs on Thursday and gets yet another version. This inconsistency leads to bugs that are difficult to reproduce and debug.

With a lockfile, everyone gets the exact same versions regardless of when they install. The lockfile records the exact version, integrity hash, and resolved URL for every package in the dependency tree. When you run npm ci in CI or npm install with an existing lockfile, npm uses the lockfile to determine exactly which versions to install. This guarantees that all environments have identical dependencies, eliminating a major source of production bugs.

package-lock.json Structure

The lockfile records every package in the dependency tree with its exact version, the URL where it was downloaded from, and an integrity hash that verifies the package has not been tampered with. The lockfile version 3 format uses a flat structure that mirrors the node_modules directory layout. Each entry includes the package name, version, resolved URL, and integrity checksum.

The integrity hash is a SHA-512 hash of the package tarball. npm verifies this hash after downloading the package to ensure it has not been modified in transit or at rest on the registry. This provides a basic level of supply chain security by detecting tampering. If the hash does not match, npm rejects the installation and reports an error.

{
  "name": "my-app",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-..."
    }
  }
}

npm install vs npm ci

The difference between npm install and npm ci is critical for reproducible builds. npm install resolves versions based on the ranges in package.json and may update the lockfile if new versions are available. This is appropriate during development when you want to pick up updates. npm ci does a clean install from the lockfile, installing exactly the versions specified without resolving ranges. If the lockfile is out of sync with package.json, npm ci fails rather than silently updating.

This makes npm ci the correct choice for CI/CD pipelines and production builds where reproducibility is essential. Using npm install in CI can lead to situations where the lockfile is silently updated, causing the CI build to install different versions than what developers have locally. This undermines the purpose of the lockfile and makes debugging production issues much harder.

# npm install — Use during development
# - Resolves versions from package.json ranges
# - Updates package-lock.json if new versions are available
# - May install different versions than lockfile specifies
 
# npm ci — Use in CI/CD and production
# - Installs exactly what's in package-lock.json
# - Deletes node_modules first for a clean install
# - Fails if lockfile is out of date with package.json
# - Faster than npm install because it skips resolution

When to Update the Lockfile

Updating the lockfile should be an intentional decision, not an accidental side effect. Run npm update to update all packages to the latest versions that satisfy the ranges in package.json, which updates the lockfile. Run npm outdated to see which packages have newer versions available before deciding to update. Commit lockfile changes as separate commits with clear messages about what was updated and why.

This makes it easy to revert if an update causes problems. In CI, always use npm ci to ensure the lockfile is the source of truth and not accidentally modified. When reviewing dependency updates, check the changelog of each updated package to understand what changed and whether the update is safe for your use case.

Dependency Resolution

How npm Resolves Dependencies

npm uses a flat dependency tree with hoisting to minimize duplication and disk usage. When multiple packages depend on the same library, npm installs the library once at the top level of node_modules and hoists it so all packages can access it. When packages require different versions of the same library, npm installs the most commonly needed version at the top level and nests the conflicting version inside the package that requires it.

my-app
ā”œā”€ā”€ express@4.18.0
│   ā”œā”€ā”€ body-parser@1.20.0
│   ā”œā”€ā”€ cookie@0.5.0
│   └── ... (flat in node_modules)
ā”œā”€ā”€ lodash@4.17.21
└── my-lib@1.0.0
    └── lodash@4.17.20  ← Different version, nested

The hoisting algorithm considers the version constraints of all packages that depend on a library and places the most commonly satisfied version at the top level. This minimizes the total number of installed packages and reduces disk usage. However, hoisting has side effects that can cause subtle bugs, particularly phantom dependencies.

Phantom Dependencies

Phantom dependencies are a subtle but dangerous consequence of hoisting. When npm hoists a package to the top level of node_modules, it becomes importable by any package in the tree, even packages that do not declare it as a dependency. This means your code might accidentally import a package that is not listed in your package.json. If that package is later removed or moved to a nested location because the dependency tree changes, your code breaks unexpectedly.

This is one of the most common sources of hard-to-debug production issues. A developer imports a package that happens to be hoisted, everything works locally, and the code passes CI. But when the dependency tree changes — perhaps because another package was added or updated — the hoisted package moves to a nested location and the import fails. The fix is straightforward: always declare every package you import in your package.json, even if it seems to be available without declaring it.

npm dedupe

The dedupe command flattens the dependency tree where possible by finding opportunities to hoist packages that are currently nested. Running npm dedupe after adding or updating dependencies can reduce the total number of installed packages and disk usage. This is particularly useful in large projects with many transitive dependencies. The dedupe operation is safe because it only moves packages when the version constraints allow it.

npm dedupe

npm ls

The ls command shows the installed dependency tree, which is essential for debugging dependency issues. Use npm ls to see all installed packages and their versions. Use npm ls lodash to see why a specific package is installed and which packages depend on it. The --all flag shows the full tree including transitive dependencies. The --depth flag controls how many levels deep to display.

This information is invaluable when debugging version conflicts, phantom dependencies, or unexpected package versions. If you see a package version that you did not expect, use npm ls to trace the dependency chain and understand why that version was installed. This helps you decide whether to use overrides to force a specific version or update a direct dependency.

npm ls
npm ls lodash
npm ls --all --depth=3

Security

Security

npm audit

npm audit is the built-in vulnerability scanner that checks your dependency tree against the npm security advisory database. It reports known vulnerabilities with severity ratings, affected versions, and recommended fixes. Running npm audit before every release is a critical security practice that catches vulnerabilities before they reach production.

The audit command supports several output formats. The default format prints a human-readable report to the terminal. The JSON format generates a machine-readable report that can be integrated with CI pipelines and security dashboards. The audit fix command automatically updates vulnerable packages to patched versions when possible. The audit fix --force command applies fixes that may include breaking changes, which should be tested carefully.

# Check for vulnerabilities
npm audit
 
# Fix vulnerabilities automatically
npm audit fix
 
# Fix with breaking changes allowed
npm audit fix --force
 
# Generate machine-readable report
npm audit --json > audit-report.json

Supply Chain Security

Supply chain security is a growing concern as attackers increasingly target package registries. npm provides several tools to protect against supply chain attacks. The npm audit signatures command verifies that installed packages have valid cryptographic signatures from their publishers. The npm publish --provenance command adds provenance information to published packages, linking them to their source repository and CI build.

Provenance makes it possible to verify that a published package was built from the source code in the repository and not tampered with during the build process. This is particularly important for packages that are widely used, as a compromised package can affect millions of downstream projects. Tools like Socket and Snyk provide additional supply chain security analysis beyond what npm provides natively, including detection of suspicious package behavior and typosquatting attacks.

.npmrc Configuration

The .npmrc file configures npm's behavior for your project, user, or system. Project-level .npmrc files are committed to version control and shared with all developers. User-level .npmrc files configure npm for your user account, including authentication tokens. System-level .npmrc files configure npm for all users on a machine.

# .npmrc
registry=https://registry.npmjs.org/
save-exact=true
engine-strict=true
audit-level=moderate

The engine-strict setting ensures that npm fails if the installed Node.js version does not match the engines field in package.json. This prevents subtle bugs caused by running code on an unsupported Node.js version. The audit-level setting configures the minimum severity level for audit failures, allowing you to block installations that have critical vulnerabilities while permitting low-severity issues. The save-exact setting makes npm save exact versions instead of ranges, which is useful for projects that prefer pinning all dependencies.

Workspaces

Setting Up Workspaces

npm workspaces provide native monorepo support that eliminates the need for tools like Lerna for basic use cases. Workspaces allow you to manage multiple packages in a single repository with a single root package.json. Each workspace is a directory that contains its own package.json with its own dependencies and scripts. npm automatically symlinks workspace packages during installation, so changes to one workspace are immediately available to others without requiring republishing.

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}
my-monorepo/
ā”œā”€ā”€ packages/
│   ā”œā”€ā”€ ui/
│   └── utils/
ā”œā”€ā”€ apps/
│   ā”œā”€ā”€ web/
│   └── api/
└── package.json

The root package.json should be marked as private to prevent accidental publishing of the monorepo root. The workspaces field accepts glob patterns that match directories containing workspace package.json files. npm resolves workspace dependencies first, so internal packages are always linked from the local source rather than downloaded from the registry.

Workspace Commands

Workspace commands allow you to run scripts in specific workspaces or across all workspaces. The --workspace flag targets a specific workspace by name. The --workspaces flag runs the command in all workspaces. This is useful for running tests across all packages or building all packages in the correct order.

The --if-present flag skips workspaces that do not have the specified script, preventing errors when not all workspaces have the same scripts. For example, running npm run build --workspaces --if-present builds all packages that have a build script without failing on packages that do not.

# Run script in specific workspace
npm run build --workspace=packages/ui
npm run build -w packages/ui
 
# Run script in all workspaces
npm run test --workspaces
npm run test -ws
 
# Add dependency to specific workspace
npm install lodash --workspace=packages/utils
npm install lodash -w packages/utils

Internal Dependencies

Workspace packages can depend on each other using the workspace protocol. This ensures that internal packages always use the local version during development, even if a published version exists on the registry. When publishing, npm replaces the workspace protocol with the actual version number. This makes it seamless to develop multiple related packages simultaneously while supporting proper publishing workflows.

{
  "dependencies": {
    "@myorg/ui": "workspace:*",
    "@myorg/utils": "workspace:*"
  }
}

The workspace:* protocol means use whatever version is available locally. This is the most common pattern for internal dependencies. You can also use workspace:^ or workspace:~ to specify that the published version should use the caret or tilde range operator. This gives you control over how consumers of your published packages receive version updates.

Advanced Features

npm Overrides

The overrides field allows you to force specific versions of transitive dependencies, providing control over packages that you do not directly depend on. This is essential for patching security vulnerabilities in deeply nested dependencies that have not yet released fixes. Overrides replace the version that would normally be resolved, ensuring that all instances of a package in the dependency tree use the specified version.

{
  "overrides": {
    "lodash": "^4.17.21",
    "minimist": "^1.2.8"
  }
}

Overrides should be used sparingly because they can cause compatibility issues. If you override a package to a version that is not compatible with the packages that depend on it, those packages may break at runtime. Always test thoroughly after adding an overrides field, and remove overrides when the packages that required them have been updated to compatible versions.

npm link creates symbolic links between local packages, enabling development of libraries alongside the applications that consume them. Run npm link in the library directory to create a global symlink. Then run npm link package-name in the application directory to link the library into node_modules. Changes to the library source are immediately available to the application without republishing.

This is invaluable for developing and testing libraries in realistic environments. Instead of publishing a test version to the registry every time you make a change, you can link the local library and see the changes immediately. When you are done, run npm unlink to remove the symlink and restore the original dependency.

# In the library directory
cd packages/ui
npm link
 
# In the app directory
cd apps/web
npm link @myorg/ui

npm Pack

The pack command creates a tarball of your package, including only the files that would be published. Use npm pack --dry-run to see exactly which files would be included without creating the tarball. This is essential for verifying that your package includes all necessary files and excludes sensitive data like environment files, node_modules, and build artifacts.

npm pack
npm pack --dry-run

The files field in package.json and .npmignore file control which files are included. The files field is a whitelist that explicitly lists files and directories to include. The .npmignore file is a blacklist that lists patterns to exclude. Using the files field is preferred because it is more explicit and less likely to accidentally include unwanted files.

Registry Configuration

npm supports configuring different registries for different scopes, enabling teams to mix public and private packages. Set a scope-specific registry using npm config or .npmrc. This allows your project to pull public packages from the npm registry while resolving private packages from a corporate registry like Artifactory, Nexus, or GitHub Packages.

Authentication tokens can be configured per registry using environment variable substitution in .npmrc to keep tokens out of version control. The npm token create command generates granular access tokens with configurable permissions and expiration dates. Use read-only tokens for CI environments and publish tokens only in secure release pipelines.

# .npmrc — Scope-specific registry
@myorg:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

Common Pitfalls

PitfallImpactSolution
No lockfile in gitInconsistent builds across environmentsAlways commit package-lock.json
Using npm install in CINon-reproducible buildsUse npm ci in CI/CD pipelines
Not running auditSecurity vulnerabilities in productionRun npm audit before every release
Phantom dependenciesCode breaks when dependency tree changesDeclare every import in package.json
Version conflictsDeduplication issues and large node_modulesUse npm overrides to control transitive versions
Global installsInconsistent tool versions across machinesUse npx or local devDependencies
Ignoring peer dependency warningsRuntime errors from duplicate packagesInstall peer dependencies explicitly
Not pinning critical versionsUnexpected updates breaking productionUse exact versions for sensitive packages
Running lifecycle scripts from untrusted packagesArbitrary code execution during installUse --ignore-scripts when installing untrusted packages
Not using engine-strictRunning on unsupported Node.js versionsSet engine-strict=true in .npmrc

Best Practices

  1. Always commit the lockfile — The lockfile is the source of truth for dependency versions across all environments
  2. Use npm ci in CI/CD — Reproducible builds that fail fast if the lockfile is out of date
  3. Audit before releases — Run npm audit to catch vulnerabilities before they reach production
  4. Pin critical dependencies — Use exact versions for cryptographic libraries, build tools, and other sensitive packages
  5. Use workspaces for monorepos — Native monorepo support eliminates the need for external tools
  6. Keep dependencies updated — Run npm outdated regularly and update dependencies intentionally
  7. Configure .npmrc — Set engine-strict, audit-level, and registry configuration per project
  8. Minimize dependencies — Every dependency is a potential vulnerability; evaluate whether you truly need each one
  9. Use provenance — Publish with --provenance for supply chain security verification
  10. Review dependency changes — Use npm diff to understand what changed when updating dependencies

Conclusion

npm is a powerful tool that goes far beyond simple package installation. Understanding semver, lockfiles, workspaces, and security features helps you manage dependencies confidently and build reliable applications. The key is to use npm's tools consistently: lockfiles for reproducibility, workspaces for monorepos, and audits for security.

npm continues to evolve with features like provenance tracking, workspace support, and improved security scanning that address the growing needs of the JavaScript ecosystem. The introduction of workspaces has made npm a viable alternative to dedicated monorepo tools for many use cases. The security features, including audit, signatures, and provenance, reflect the growing importance of supply chain security in modern software development.

Mastering npm is not just about knowing the commands — it is about understanding the principles of dependency management that keep your applications stable, secure, and maintainable. By following the best practices outlined in this guide and staying current with npm's evolving features, you can manage dependencies confidently at any scale.

Key takeaways:

  1. Semver — Use ^ for most deps, ~ for critical ones, exact for sensitive ones
  2. Lockfiles — Always commit, use npm ci in CI/CD
  3. Workspaces — Native monorepo support in npm
  4. Security — npm audit catches vulnerabilities before they ship
  5. Scripts — Pre/post hooks for automation
  6. Overrides — Force specific versions of transitive dependencies
  7. Provenance — Supply chain security with package signing and verification
  8. Phantom dependencies — Be aware of hoisting side effects and use strict declarations