Think of dependencies as the ingredients you must have for your cake to be edible. devDependencies are the fancy decorations – nice to have for presentation, but the cake still works without them.

Here’s dependencies in action. Imagine a simple Node.js project that needs to make HTTP requests.

package.json:

{
  "name": "my-http-client",
  "version": "1.0.0",
  "dependencies": {
    "axios": "^1.6.0"
  }
}

When you run npm install, npm reads the dependencies section. It downloads axios version 1.6.0 (or a compatible later version) and places it in the node_modules directory. Critically, it also adds axios to the dependencies list in your package-lock.json.

Now, if someone else clones your project and runs npm install, npm will look at package-lock.json and install exactly the same version of axios that you used. This ensures that their "cake" is edible in the same way yours is. When you deploy your application to a server, only the dependencies are installed. Servers don’t need your linters or testing frameworks; they just need the code that makes the application run.

devDependencies are for tools you use during development, not for the production runtime. Think of code formatters, linters, testing frameworks, bundlers, and build tools.

package.json:

{
  "name": "my-web-app",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "eslint": "^8.53.0",
    "prettier": "^3.0.3",
    "webpack": "^5.89.0",
    "jest": "^29.7.0"
  }
}

When you run npm install on this project, both dependencies and devDependencies are installed. However, if you were to run npm install --production (which is what happens on a production server), only the packages listed under dependencies would be installed. The devDependencies like eslint, prettier, webpack, and jest would be skipped. This keeps your production deployment lean and secure, as it doesn’t include any tools that aren’t strictly necessary for the application to function.

The primary problem developers face is not understanding this distinction, leading to devDependencies being installed in production environments, increasing bundle sizes unnecessarily, or dependencies being mistakenly placed in devDependencies, causing runtime errors when the code is deployed without those essential packages.

The core problem this solves is package management for different environments. You need one set of packages to build and test your software, and a different, smaller set to run it. npm (and yarn) provide these two distinct installation scopes.

The mental model to build is that npm install without flags installs everything. npm install --production installs only dependencies. npm install --only=dev installs only devDependencies.

Crucially, when you install a package, you must explicitly choose the correct section. If you’re adding a linter, use npm install --save-dev eslint or npm install -D eslint. If you’re adding a library your application needs to make API calls, use npm install --save axios or npm install axios. The --save flag is actually the default behavior for npm install when you don’t specify --save-dev or --save-optional, but being explicit helps reinforce the mental model.

What most people don’t realize is that npm uses the NODE_ENV environment variable to determine whether to install devDependencies during a regular npm install. If NODE_ENV is set to production, then npm install behaves identically to npm install --production. This is why many deployment scripts explicitly set NODE_ENV=production before running npm install.

The next thing to understand is how to manage transitive dependencies, especially when dealing with version conflicts between your direct dependencies and those of your dependencies.

Want structured learning?

Take the full Npm course →