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
dependenciesgo to production;devDependenciesare for build tools and tests onlypackage-lock.jsonlocks exact resolved versions so everynpm installproduces the same result- Never commit
node_modules/; always commitpackage-lock.json - Use
npm ciin CI pipelines instead ofnpm install
Quick example
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{
"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
| Field | What it does |
|---|---|
name | Package name, unique if published to the npm registry |
version | SemVer: major.minor.patch |
main | Entry file Node loads when someone require()s your package |
scripts | Commands accessible via npm run <name> |
dependencies | Packages required at runtime in production |
devDependencies | Packages for development only, excluded with --production |
peerDependencies | Packages the consumer of your library must install themselves |
engines | Minimum required Node.js version |
Version ranges and SemVer
"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 projectsThe ^ 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
{
"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 runnpm install - CI/CD or production deploy:
npm ciinstead ofnpm 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.
# Wrong
echo "package-lock.json" >> .gitignore
git commit -m "cleanup" # teammates now get different installs
# Right
git add package-lock.json # always commit this fileCommitting 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.2in dependencies - Create React App:
react,react-dom,react-scriptsin dependencies;npm startspins up webpack dev server - Next.js:
"build": "next build","start": "next start"for SSR in production - NestJS:
@nestjs/core,@nestjs/commonin 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
// package.json
{
"name": "basic-api",
"version": "1.0.0",
"main": "index.js",
"scripts": { "start": "node index.js" },
"dependencies": { "express": "^4.19.2" }
}// 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
// 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" }
}// 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
// 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.
// 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 readyA concise answer to help you respond confidently on this topic during an interview.