Skip to main content

What is package.json and how does npm work?

package.json is the configuration file at the root of every Node.js project. It lists dependencies, defines scripts, and stores metadata. npm reads it to install packages from the public registry and run the commands you define.

Theory

TL;DR

  • package.json is your project's shopping list; npm is the delivery service that fetches everything on it
  • dependencies go to production; devDependencies are for build tools and tests only
  • package-lock.json locks exact resolved versions so every npm install produces the same result
  • Never commit node_modules/; always commit package-lock.json
  • Use npm ci in CI pipelines instead of npm install

Quick example

bash
mkdir my-app && cd my-app npm init -y # generates package.json instantly npm install express # adds to dependencies npm install --save-dev jest # adds to devDependencies npm start # runs the "start" script
json
{ "name": "my-app", "version": "1.0.0", "scripts": { "start": "node index.js" }, "dependencies": { "express": "^4.19.2" }, "devDependencies": { "jest": "^29.0.0" } }

Running npm start triggers the "start" entry in scripts. That is the whole idea.

Key fields

FieldWhat it does
namePackage name, unique if published to the npm registry
versionSemVer: major.minor.patch
mainEntry file Node loads when someone require()s your package
scriptsCommands accessible via npm run <name>
dependenciesPackages required at runtime in production
devDependenciesPackages for development only, excluded with --production
peerDependenciesPackages the consumer of your library must install themselves
enginesMinimum required Node.js version

Version ranges and SemVer

json
"express": "4.19.2" // exact, never auto-updates "express": "^4.19.2" // compatible: >=4.19.2 <5.0.0 (npm default) "express": "~4.19.2" // patch only: >=4.19.2 <4.20.0 "express": "*" // any version - avoid this in real projects

The ^ prefix is what npm writes by default on npm install. It allows minor and patch updates, which semver says should be backward compatible. The ~ is stricter: patches only. Pinning exact versions sounds safer but means you miss security patches unless you bump versions manually.

How npm install works

When you run npm install, npm does three things in sequence. It parses package.json for required version ranges. Then it checks package-lock.json for exact versions already resolved on a previous run. Finally, it downloads tarballs from registry.npmjs.org and unpacks them into node_modules/. If no lockfile exists yet, npm resolves the latest matching version for each package and creates one.

npm ci skips the resolution step entirely. It reads the lockfile directly and errors out if the file is out of sync with package.json. That is why CI/CD pipelines prefer it: the result is deterministic and the install is faster.

I have seen projects break in staging because someone deleted package-lock.json to "clean things up" and minor version bumps quietly changed behavior. The lockfile exists for a reason.

npm scripts and lifecycle hooks

json
{ "scripts": { "start": "node dist/index.js", "dev": "nodemon src/index.ts", "build": "tsc", "test": "jest --coverage", "prestart": "npm run build", "posttest": "echo 'Tests done'" } }

The pre and post prefixes are lifecycle hooks. prestart runs before start automatically, no extra call needed. You run scripts with npm run build, but npm start and npm test have built-in shortcuts that skip the run keyword.

When to use

  • New project from scratch: npm init -y
  • Add a runtime library: npm install express
  • Add a dev tool: npm install --save-dev typescript jest
  • Share the project: commit package.json + package-lock.json, teammates run npm install
  • CI/CD or production deploy: npm ci instead of npm install
  • Check for known vulnerabilities: npm audit

Common mistakes

Not committing package-lock.json. Without it, npm install fetches the latest version matching your range. Your ^4.19.2 could resolve to 4.20.1 for a colleague next month if a new minor version dropped. Things break.

bash
# Wrong echo "package-lock.json" >> .gitignore git commit -m "cleanup" # teammates now get different installs # Right git add package-lock.json # always commit this file

Committing node_modules. This folder can reach 200MB on a modest project. npm rebuilds it in seconds from the lockfile. No reason to track it in git.

Putting dev tools in dependencies. TypeScript, Jest, nodemon: none of these run your app in production. They belong in devDependencies. If they end up in dependencies, they get installed on every production deploy for nothing.

Wildcard engines field. "node": "*" means your code might land on Node 12, which does not support top-level await or several newer APIs. Pin a minimum: "node": ">=20.0.0".

Using npm install in CI pipelines. It can mutate the lockfile if it finds drift between package.json and the current lockfile state. Use npm ci in pipelines; it errors on mismatch, which surfaces the problem instead of silently patching it.

Real-world usage

  • Express projects: "start": "node server.js" in scripts, express@^4.19.2 in dependencies
  • Create React App: react, react-dom, react-scripts in dependencies; npm start spins up webpack dev server
  • Next.js: "build": "next build", "start": "next start" for SSR in production
  • NestJS: @nestjs/core, @nestjs/common in dependencies, "node": ">=18" in engines
  • For a single-app repo, npm works fine. Monorepos with multiple packages often switch to pnpm for better workspace support and lower disk usage.

Follow-up questions

Q: What is the difference between dependencies and devDependencies?
A: dependencies get installed in production. devDependencies are skipped when you run npm install --production. Jest, TypeScript, and nodemon belong in devDependencies because they serve no purpose at runtime.

Q: Explain what "^4.19.2" means in semver terms.
A: The ^ allows minor and patch updates: any version >=4.19.2 <5.0.0. The ~ is stricter: patches only, >=4.19.2 <4.20.0. Major version bumps are never automatic because semver treats them as breaking changes.

Q: What happens if package-lock.json does not exist?
A: npm resolves the latest version matching each range in package.json and creates a new lockfile. Two separate npm install runs at different times can resolve different versions, which causes the classic "works on my machine" problem.

Q: What is the difference between npm install and npm ci?
A: npm install resolves versions and can update the lockfile as a side effect. npm ci reads the lockfile exactly as written and errors if it is out of sync with package.json. Use npm ci in automated pipelines.

Q: (Senior) How do npm workspaces manage shared dependencies in a monorepo?
A: Adding "workspaces": ["packages/*"] to the root package.json tells npm to hoist shared dependencies to the root node_modules. If three packages all depend on React, npm installs it once instead of three times. The overrides field forces a specific version across all sub-packages when peer dependency conflicts come up.

Examples

Basic: Express server with npm start

json
// package.json { "name": "basic-api", "version": "1.0.0", "main": "index.js", "scripts": { "start": "node index.js" }, "dependencies": { "express": "^4.19.2" } }
js
// index.js const express = require('express'); const app = express(); app.get('/', (req, res) => res.send('Hello World')); app.listen(3000, () => console.log('Server on port 3000'));

Run npm install to get Express, then npm start. You see "Server on port 3000" in the terminal. GET to localhost:3000 returns "Hello World". This is the minimal setup every Express project starts with.

Intermediate: TypeScript dev setup with build and watch scripts

json
// package.json { "name": "ts-api", "version": "1.0.0", "scripts": { "start": "node dist/index.js", "dev": "nodemon src/index.ts", "build": "tsc", "prestart": "npm run build" // compiles before starting }, "dependencies": { "express": "^4.19.2", "dotenv": "^16.4.5" }, "devDependencies": { "nodemon": "^3.1.4", "typescript": "^5.6.2", "@types/express": "^4.17.21" }, "engines": { "node": ">=20.0.0" } }
ts
// src/index.ts import express from 'express'; import dotenv from 'dotenv'; dotenv.config(); const app = express(); const port = process.env.PORT || 3000; app.get('/', (req, res) => res.send(`Running on port ${port}`)); app.listen(port);

npm run dev starts nodemon watching TypeScript files and restarts on every change. npm start triggers prestart first, which compiles TypeScript to dist/, then starts the compiled output. The split between devDependencies (nodemon, typescript, @types) and dependencies (express, dotenv) is the key pattern here.

Advanced: Peer dependency conflict and the overrides fix

json
// packages/ui-lib/package.json (sub-package in a monorepo) { "name": "ui-lib", "peerDependencies": { "react": "^18.0.0" }, "dependencies": { "lodash": "^4.17.21" } }

If the root app installs React 17 but ui-lib declares react@^18 as a peer, npm warns and installs anyway. At runtime, components using React 18 APIs on a React 17 runtime throw errors.

json
// Root package.json - forces unified React version { "workspaces": ["packages/*"], "overrides": { "react": "^18.3.0" } }

The overrides field forces every sub-package to resolve React to ^18.3.0, eliminating the mismatch. Without it: Module not found or runtime type errors. With it: one React version across the whole workspace.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?