Menu

package.json Explained: Scripts, Dependencies, and Versions

What lives inside package.json — the fields that matter, how scripts work, and how semver ranges decide which versions npm installs.

The Manifest File for a Node Project

Every Node.js project has a package.json at its root. It's a plain JSON file that describes the project — its name, its version, what it depends on, what commands it exposes — and it's what npm reads whenever it does anything. Delete it and npm has no idea what your project is.

The fastest way to create one is npm init:

npm init -y

The -y flag skips the prompts and accepts the defaults. You end up with something like this:

{
  "name": "my-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

That's the bare skeleton. Most of those fields don't do much on their own — they become useful as you add dependencies and scripts.

Dependencies vs devDependencies

Two fields do almost all the heavy lifting: dependencies and devDependencies. Both are maps of package names to version ranges.

index.js
Output
Click Run to see the output here.

The split matters for one reason: dependencies are packages your code needs to run. devDependencies are packages you only need while developing — test runners, linters, build tools, type checkers. When someone installs your package as a dependency of theirs, npm downloads your dependencies and skips your devDependencies.

npm updates these fields automatically. npm install express adds a line to dependencies. npm install --save-dev vitest adds one to devDependencies. You rarely edit them by hand.

Version Ranges: ^, ~, and Exact

Those version strings like ^4.19.0 aren't exact versions — they're ranges. npm follows semver, which splits versions into MAJOR.MINOR.PATCH:

  • MAJOR bumps break backward compatibility.
  • MINOR bumps add features but don't break anything.
  • PATCH bumps fix bugs.

The two operators you'll see everywhere:

"express": "^4.19.0"   // >= 4.19.0 and < 5.0.0  (any 4.x.x at or above 4.19.0)
"express": "~4.19.0"   // >= 4.19.0 and < 4.20.0 (any 4.19.x at or above 4.19.0)
"express": "4.19.0"    // exactly 4.19.0

^ is the default npm uses when you install a package. It trusts minor and patch bumps to stay compatible. ~ is more conservative — only patch updates. A bare version pins exactly.

The catch: "what I just installed" and "what the range allows" aren't the same thing. If you install express@4.19.0 today and a teammate installs your project in a month, ^4.19.0 might resolve to 4.19.5. That's where package-lock.json comes in — it records the exact versions that were resolved, so everyone gets the same tree. Commit it.

Scripts: Your Project's Command Surface

The scripts field is where you define shortcuts for common commands. Anything you put in there can be run with npm run <name>:

index.js
Output
Click Run to see the output here.

A few things to know about scripts:

  • npm start, npm test, and a handful of other names work without the run keyword. Everything else needs npm run <name>.
  • Scripts execute in a shell with node_modules/.bin on PATH, so you can call binaries from installed packages directly. "test": "vitest" works even though vitest isn't globally installed.
  • You can chain scripts: "build": "npm run lint && npm run compile". Use && for "run in sequence, stop on failure."
  • pre<name> and post<name> scripts run automatically. If you have prebuild, it runs before build with no extra wiring.

Scripts are the project's command surface. A good package.json means a new contributor can clone, run npm install, and then npm run dev / npm test without reading a wiki.

Entry Points: main, exports, type

These fields tell Node (and bundlers) how to load your package.

index.js
Output
Click Run to see the output here.
  • type decides how .js files are parsed. "module" means ESM (import / export). Omit it or set "commonjs" for CommonJS (require). See the CommonJS vs ESM doc for the full story.
  • main is the legacy entry point — what require("my-lib") resolves to. Still respected by older tools.
  • exports is the modern, stricter replacement. It defines exactly which files consumers can import and under what subpath. If a file isn't listed here, importing it fails — which is a feature, not a bug. You control the public API.

If you're just building an app (not publishing a package), type is the only one of these you probably care about.

A Realistic package.json

Putting it together, here's roughly what a small Node app's package.json looks like in practice:

index.js
Output
Click Run to see the output here.

Notice engines.node. It's advisory — npm warns (or errors with engine-strict) if the user's Node version doesn't match. Good hygiene for anything you publish.

Fields Worth Knowing About

A few more fields you'll run into:

  • private: true — stops you from accidentally publishing the package to npm. Set it on any project that isn't meant to be published.
  • license — SPDX identifier like "MIT" or "ISC". Matters for anything public.
  • repository, bugs, homepage — shown on the npm registry page.
  • bin — if your package ships a CLI, map command names to script files here. After install, those commands become runnable.
  • workspaces — for monorepos; tells npm to treat subdirectories as linked packages.

You don't need all of these. You need the right ones for what you're doing.

Common Pitfalls

A handful of things that trip people up:

  • Committing node_modules. Don't. Add it to .gitignore. package.json plus package-lock.json is enough for anyone to rebuild it with npm install.
  • Not committing package-lock.json. Do commit it. Without the lockfile, "works on my machine" becomes a real possibility because semver ranges can resolve to different versions over time.
  • Putting runtime deps in devDependencies. Your app might work locally because dev deps are installed, then break in production where they're skipped. If the code you ship uses it, it belongs in dependencies.
  • Hand-editing versions without reinstalling. Change a version in package.json and run npm install — otherwise node_modules and the lockfile drift out of sync.

Next: The Node Runtime

package.json tells Node what your project is. The Node runtime decides how it runs — module resolution, built-in modules, globals, the event loop under the hood. That's the next page.

Frequently Asked Questions

What is package.json used for?

It's the manifest file for a Node.js project. It records the project's name and version, which packages it depends on, which scripts you can run with npm run, and metadata like the entry point and module type. npm install reads it to decide what to download.

What's the difference between dependencies and devDependencies?

dependencies are packages your code needs at runtime — things like express or react. devDependencies are only needed while developing or building — test runners, bundlers, linters. When someone installs your package as a dependency of theirs, npm skips your devDependencies.

What do the ^ and ~ mean in package.json versions?

They're semver range operators. ^1.2.3 allows any 1.x.x version at or above 1.2.3 (same major). ~1.2.3 is stricter — it allows 1.2.x at or above 1.2.3 (same minor). A bare 1.2.3 pins the exact version. package-lock.json records the exact resolved versions so installs stay reproducible.

How do I create a package.json file?

Run npm init in an empty directory and answer the prompts, or run npm init -y to accept the defaults and get a file instantly. You can also write one by hand — it's just JSON. The only truly required fields are name and version.

Learn to code with Coddy

GET STARTED