npm and package.json Explained
A practical tour of npm and package.json — npm init, dependencies vs devDependencies, scripts, the lockfile, semver basics, and the difference between npm install and npx.
What you'll learn
- ✓What package.json is and how to create it
- ✓The difference between dependencies and devDependencies
- ✓How npm scripts work and why they replace makefiles
- ✓What the lockfile does and why you commit it
- ✓Semver in 90 seconds — caret, tilde, and exact versions
- ✓When to use npx instead of installing globally
Prerequisites
- •Node installed locally — see Install Node.js and Run Your First Script
- •Some idea of what a module is — see CommonJS vs ES Modules
npm is two things at once: a command-line tool that ships with Node, and a package registry at npmjs.com hosting more than two million open-source libraries. Most of your day-to-day Node work runs through it.
This post walks through the parts you will use every day.
Create a project
In an empty folder, run:
npm init -y
The -y flag accepts every default. You will get a file called package.json:
{
"name": "my-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
This file is the manifest for your project. It records the name, version, entry point, dependencies, and scripts. Every Node project has one.
Drop -y if you want to be prompted for each field:
npm init
For modern projects, add "type": "module" so your .js files use ES Modules — see CommonJS vs ES Modules for why.
Install a package
To add a library, use npm install (or its alias npm i):
npm install lodash
Three things happen:
- npm downloads
lodashfrom the registry intonode_modules/ - It adds an entry to the
dependenciesfield ofpackage.json - It records the exact version in
package-lock.json
Your package.json now contains:
{
"dependencies": {
"lodash": "^4.17.21"
}
}
To remove a package:
npm uninstall lodash
Dependencies vs devDependencies
There are two main buckets:
dependencies— packages your code needs at runtime. If a user runs your app, they need these installed.devDependencies— packages you need only during development: test runners, linters, type checkers, build tools.
Install something as a dev dependency with the -D (or --save-dev) flag:
npm install -D vitest
npm install -D typescript prettier
The distinction matters in production. When you deploy, you usually run:
npm install --omit=dev
This installs only dependencies, skipping the dev tools you do not need at runtime. The result is a smaller, faster install.
Scripts
The scripts field lets you define shortcuts. Edit package.json:
{
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js",
"test": "vitest",
"lint": "eslint ."
}
}
Run any of them with npm run <name>:
npm run dev
npm run test
npm run lint
A handful of names have shortcuts — start, test, stop, restart — that you can call without run:
npm start
npm test
Scripts can call each other and chain with &&:
{
"scripts": {
"build": "tsc",
"prebuild": "rm -rf dist",
"ship": "npm run build && npm test"
}
}
prebuild runs automatically before build — npm honours pre<name> and post<name> hooks.
The big advantage of scripts: any binary inside node_modules/.bin/ is on the PATH while a script runs. You can write "test": "vitest" without spelling out ./node_modules/.bin/vitest.
Try it yourself. Create a new folder, run npm init -y, install chalk (npm install chalk), and write index.js that uses chalk.blue('hello') to log a coloured message. Add "start": "node index.js" to scripts. Run npm start and confirm it works.
node_modules and the lockfile
After npm install, you will see two new things in your folder:
node_modules/ # all installed packages and their dependencies
package-lock.json # exact resolved versions of every package
node_modules/ is enormous — even a small project might have thousands of files. Never commit it to git. A standard .gitignore line:
node_modules/
package-lock.json is the opposite — you should commit it. It records the exact version of every package, transitively, that npm resolved on your machine. When a teammate runs npm install, the lockfile guarantees they get the same versions.
Without the lockfile, npm would re-resolve versions on every install, and you would get the “works on my machine” bug.
Semver in 90 seconds
Package versions follow semantic versioning: MAJOR.MINOR.PATCH.
- MAJOR — breaking changes.
2.0.0may break code that worked on1.x. - MINOR — new features, backwards compatible.
1.5.0adds things without breaking1.4users. - PATCH — bug fixes only.
1.5.3fixes things without changing the API.
In package.json, version ranges look like:
{
"dependencies": {
"express": "^4.18.0",
"left-pad": "~1.3.0",
"lodash": "4.17.21"
}
}
The leading character matters:
^4.18.0(caret) — accepts any4.x.xthat is>= 4.18.0. The default fornpm install.~1.3.0(tilde) — accepts any1.3.xthat is>= 1.3.0. More restrictive.4.17.21(exact) — only that exact version.
The lockfile pins the actual resolved version regardless of the range. The range only matters when you re-resolve (during npm install for a new package, or npm update).
Updating packages
Two main commands:
npm outdated
Shows which packages have newer versions available, separated into:
- Wanted — the highest version allowed by your range
- Latest — the highest version in the registry
npm update
Updates packages to the wanted column — within your existing semver ranges. To upgrade past a major version, change the range manually or use a tool like npm-check-updates.
Global installs
You can install a package globally with -g:
npm install -g typescript
This puts tsc (TypeScript’s compiler) on your PATH so you can run it from anywhere. Tempting, but avoid this for most things. Two problems:
- Different projects need different versions. Global installs are one-size-fits-all.
- You forget what is installed. New machine = mystery toolchain.
The modern answer is to install tools locally as devDependencies and run them via scripts. Or use npx.
npx
npx runs a package’s binary without installing it permanently. Two main uses.
Run a tool you do not want to install:
npx create-react-app my-app
npx degit user/repo my-clone
npx downloads the package, runs it, and forgets about it.
Run a binary from a local devDependency:
npx vitest
npx prettier --write .
This finds the binary in node_modules/.bin/. Inside an npm run script you can just write vitest — but at the bare terminal, npx vitest is the convenient form.
npx ships with npm. There is nothing to install.
Try it yourself. Run npx cowsay "Hello from npx". npx will fetch the cowsay package, run it once, print an ASCII cow, and tidy up. Nothing gets permanently installed in your project.
A realistic package.json
After a week of work, your file might look like this:
{
"name": "todo-api",
"version": "0.3.1",
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "vitest",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"express": "^4.19.2",
"zod": "^3.23.8"
},
"devDependencies": {
"eslint": "^9.5.0",
"prettier": "^3.3.2",
"vitest": "^1.6.0"
},
"engines": {
"node": ">=20"
}
}
The engines field is optional but useful — it tells npm (and deployment tools) which Node versions this project supports.
Alternative package managers
You will hear about pnpm, yarn, and bun. They read the same package.json and broadly do the same job. Headline differences:
- pnpm — faster, uses a content-addressable store so disk space is shared between projects
- yarn — predates npm’s modern features; still common in some codebases
- bun — newer; bundles runtime, package manager, and test runner together
Stick with npm until you have a reason to switch. The skills transfer almost directly.
Common mistakes
Committing node_modules/ — your repo balloons. Add node_modules/ to .gitignore.
Not committing package-lock.json — builds become non-reproducible. Always commit it.
Using sudo npm install — never needed if you installed Node via nvm. If you see permission errors, your Node install is the wrong kind.
Installing the wrong bucket — a build tool that lands in dependencies ships to production unnecessarily. A runtime library that lands in devDependencies breaks the prod install. Pause before you press Enter.
Recap
You now know:
npm init -ycreatespackage.json— your project manifestnpm install <pkg>adds a runtime dependency;-Dmakes it a dev dependencypackage-lock.jsonpins exact versions and should be committed- The caret
^range is the default and allows minor/patch updates npm run <name>runs a script; binaries innode_modules/.bin/are on the PATHnpxruns a package binary on demand without a global install
Next steps
You can now install libraries, but a real Node app also reads and writes files on disk. The fs and path modules are next.
Next: Node.js fs and path — Reading and Writing Files
Questions or feedback? Email codeloomdevv@gmail.com.
Related articles
- Node.js Install Node.js and Run Your First Script
A practical guide to installing Node.js with nvm on macOS, Linux, and Windows — checking versions, running .js files, and using the REPL for quick experiments.
- Node.js Node.js fs and path: Reading and Writing Files
A practical guide to file I/O in Node — fs/promises vs callback vs sync, path.join for safe paths, and how to find the current file with __dirname or import.meta.url.
- Node.js Node.js Modules: CommonJS vs ES Modules
A practical comparison of CommonJS and ES Modules in Node — require vs import, module.exports vs export, the type field in package.json, and the interop traps to avoid.
- Node.js What Is Node.js? JavaScript Outside the Browser
A clear introduction to Node.js — the V8 engine, the event loop, non-blocking I/O, the npm ecosystem, and when Node is the right runtime for your project.