Skip to content
Codeloom
Node.js

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.

·8 min read · By Codeloom
Beginner 11 min read

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

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:

  1. npm downloads lodash from the registry into node_modules/
  2. It adds an entry to the dependencies field of package.json
  3. 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.0 may break code that worked on 1.x.
  • MINOR — new features, backwards compatible. 1.5.0 adds things without breaking 1.4 users.
  • PATCH — bug fixes only. 1.5.3 fixes 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 any 4.x.x that is >= 4.18.0. The default for npm install.
  • ~1.3.0 (tilde) — accepts any 1.3.x that 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:

  1. Different projects need different versions. Global installs are one-size-fits-all.
  2. 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 -y creates package.json — your project manifest
  • npm install <pkg> adds a runtime dependency; -D makes it a dev dependency
  • package-lock.json pins exact versions and should be committed
  • The caret ^ range is the default and allows minor/patch updates
  • npm run <name> runs a script; binaries in node_modules/.bin/ are on the PATH
  • npx runs 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.