webdev.complete
🟢 Node.js & npm
🟢The Backend
Lesson 66 of 117
25 min

npm & package.json

scripts, dependencies, exports, type: module, lockfiles.

Every Node project has a package.json. It's a manifest: what the project is called, what it depends on, how to run it, what Node version it expects. Read one well and you can guess everything else about a codebase before opening a single source file. Let's learn how to read and write them.

Starting a project

bash
mkdir my-app && cd my-app
npm init -y
# Creates a minimal package.json

That's the manual route. Most real projects start from a template (create-next-app, create-vite) which writes a smarter package.json for you.

Anatomy of a real-world package.json

package.json
{
  "name": "@acme/api",
  "version": "1.4.0",
  "type": "module",
  "private": true,
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=22.0.0"
  },
  "main": "./dist/index.js",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./client": {
      "types": "./dist/client.d.ts",
      "import": "./dist/client.js"
    }
  },
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js",
    "test": "vitest",
    "lint": "eslint . --max-warnings=0",
    "db:migrate": "drizzle-kit migrate"
  },
  "dependencies": {
    "hono": "^4.7.0",
    "zod": "^3.24.0"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0",
    "vitest": "^2.1.0"
  }
}

Every field above is doing real work. Let's unpack the important ones.

scripts: your project's commands

Scripts are shortcuts. npm run dev looks up dev in the scripts object and runs its value. Convention: keep the same script names across projects so newcomers know exactly what to type.

  • npm run dev - local development with hot reload
  • npm run build - compile/bundle for production
  • npm start - run the production build (no run needed; start and test are special-cased)
  • npm test - run the test suite
Composing scripts
Reference one script from another with npm run other-script, or use && to chain. Example: "ci": "npm run lint && npm run test && npm run build".

dependencies vs devDependencies

Two arrays, both holding package names. The split matters:

  • dependencies - packages your shipped code uses at runtime. hono, zod, react, a database driver.
  • devDependencies - only needed while developing: TypeScript, ESLint, testing libraries, tsx, build tools.

Production installs (npm install --omit=dev or NODE_ENV=production npm install) skip dev deps, so your Docker images stay small.

bash
npm install hono           # adds to dependencies
npm install -D typescript  # adds to devDependencies
npm install --save-exact zod  # pins exact version, no caret
When in doubt, devDep
If a package is only used by a script (linter, test runner, build tool) but never imported by code that runs in production, it belongs in devDependencies. Mis-classifying is a common mistake.

type: "module"

Set "type": "module" and your .js files become ES modules. You can use import and export instead of require(). Omit it and Node falls back to CommonJS, the older module system.

js
// With "type": "module"
import { readFile } from "node:fs/promises";
export const greet = (name) => "hi " + name;

// Without - CommonJS
const { readFile } = require("node:fs/promises");
module.exports.greet = (name) => "hi " + name;

New projects in 2026: always set type: module. CommonJS is a legacy concern unless you're working in an old codebase.

The exports field

When you publish a library, exportstells consumers which files they're allowed to import. Without it, anyone can import "your-pkg/lib/internals/secret.js". With it, only the paths you whitelist are reachable.

json
"exports": {
  ".": "./dist/index.js",
  "./client": "./dist/client.js"
}

// Consumers can now do:
// import { x } from "your-pkg"
// import { y } from "your-pkg/client"
// But NOT: import { z } from "your-pkg/internals"

engines

Declare which Node version your project supports. npm warns on install if the user's Node is out of range; pnpm errors by default.

json
"engines": {
  "node": ">=22.0.0",
  "pnpm": ">=9.0.0"
}

packageManager

Modern tip: pin your package manager exactly. Tools like corepack (built into Node) read this and install the right pnpm/yarn version automatically.

json
"packageManager": "pnpm@9.15.0"

npm vs pnpm vs bun

Three popular package managers, all reading the same package.json. They differ in speed and how they store files.

  • npm - ships with Node. Fine. Slow. Duplicates files.
  • pnpm - uses a content-addressed global store; your node_modules is a tree of symlinks. Disk-efficient and fast. The current default in most serious teams.
  • bun- Bun's own installer. Extremely fast (often 10x npm). Compatible with npm registries.
  • yarn - still around (Yarn 4 / Berry), but the JS community has largely moved to pnpm.
bash
# All three read the same package.json:
npm install
pnpm install
bun install

# Adding deps:
npm add hono
pnpm add hono
bun add hono

Lockfiles: don't .gitignore these

Every install writes a lockfile pinning the exact resolved version of every package and every transitive dependency. Without it, running install on a teammate's machine a week later can pull different versions and your "works on my machine" bug ships.

  • npm writes package-lock.json
  • pnpm writes pnpm-lock.yaml
  • bun writes bun.lock
  • yarn writes yarn.lock
One lockfile per repo
Never commit two lockfiles for two different package managers. Pick one, delete the others, and add a CI check.

semver: what ^ and ~ mean

Version strings like ^4.7.0 are not literal. They're ranges. semver versions read MAJOR.MINOR.PATCH:

  • PATCH bumps for bug fixes: 4.7.0 to 4.7.1
  • MINOR bumps for new (backward-compatible) features: 4.7.1 to 4.8.0
  • MAJOR bumps for breaking changes: 4.8.0 to 5.0.0
json
"hono": "^4.7.0"   // caret: allows 4.7.0 up to (but not including) 5.0.0
"hono": "~4.7.0"   // tilde: allows 4.7.0 up to (but not including) 4.8.0
"hono": "4.7.0"    // exact: only 4.7.0
"hono": "*"        // anything (chaotic, don't do it)
"hono": "latest"   // tag, not a range - risky
Why the lockfile still matters
Even with ^4.7.0 in package.json, the lockfile pins to one resolved version. Your teammates install the exact same tree. CI installs the exact same tree. The caret only matters when you explicitly upgrade.

npx: run packages without installing them

npx (ships with npm) downloads and runs a CLI from npm without adding it to your project. Useful for one-shot generators.

bash
npx create-next-app my-site
npx auth secret              # generate an Auth.js secret
npx prisma init
npx serve ./dist             # quick static server

pnpm's equivalent is pnpm dlx; Bun's is bunx.

Quiz

Quiz1 / 4

What goes in devDependencies vs dependencies?

Recap

  • package.json is the project manifest: name, version, scripts, deps, engines.
  • dependencies = runtime imports. devDependencies = build/test/lint tools.
  • Set "type": "module" for ESM. exports controls what consumers can import.
  • pnpm is the sensible default in 2026. Bun is the fast one. npm is the one that ships with Node.
  • Always commit the lockfile. Caret ^ allows minor+patch, tilde ~ allows patch only.
  • npx runs CLIs from npm without installing them locally.