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
mkdir my-app && cd my-app
npm init -y
# Creates a minimal package.jsonThat'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
{
"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 reloadnpm run build- compile/bundle for productionnpm start- run the production build (norunneeded;startandtestare special-cased)npm test- run the test suite
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.
npm install hono # adds to dependencies
npm install -D typescript # adds to devDependencies
npm install --save-exact zod # pins exact version, no caretdevDependencies. 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.
// 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, exports tells 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.
"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.
"engines": {
"node": ">=22.0.0",
"pnpm": ">=9.0.0"
}packageManager
Pin your package manager exactly, so every machine and every CI runner installs with the same tool and produces the same lockfile.
"packageManager": "pnpm@11.22.0"nodejs.org/api/corepack.html now redirects to the GitHub repo. The project is alive, just unbundled: install it yourself with npm install -g corepack and then corepack enable. The field itself is still worth setting either way, because it documents intent and some CI providers read it.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_modulesis a tree of symlinks. Disk-efficient and fast. The current default in most serious teams. - bun - Bun's own installer. The fastest of the four on a cold cache, and it reads the same npm registry and
package.jsonas the others. - yarn - still around (Yarn 4 / Berry), but the JS community has largely moved to pnpm.
# All three read the same package.json:
npm install
pnpm install
bun install
# Adding deps:
npm add hono
pnpm add hono
bun add honoBoth npm and pnpm got stricter about supply chain
If you learned these tools before 2026, two defaults changed under you, both in response to npm packages being used to spread malware.
- Install scripts no longer run by default. pnpm 10 started this and npm 12 followed: a dependency's
postinstallis blocked unless you allow it. In pnpm that is theallowBuildsmap; in npm it is anallowScriptspolicy followed bynpm rebuild. The symptom is a native module that silently did not compile. - pnpm 11 waits a day before resolving new versions.
minimumReleaseAgenow defaults to 1440 minutes, so a version published ten minutes ago will not be installed. That is deliberate: most malicious packages are caught and pulled within hours. Set it to0if you are the one publishing and testing.
npm 12 also removed npm shrinkwrap entirely, stopped reading npm-shrinkwrap.json, and now rejects unknown config keys and abbreviated flags instead of warning about them. And note the version skew: npm 12 does not ship with any Node release, so Node 26 still bundles npm 11. You install 12 yourself.
npm login now issues a session token that expires in two hours. If you have a publish step in CI still using a long-lived classic token, it stopped working. Granular tokens or trusted publishing are the replacements.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.
npmwritespackage-lock.jsonpnpmwritespnpm-lock.yamlbunwritesbun.lockyarnwritesyarn.lock
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
"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^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.
npx create-next-app my-site
npx auth secret # generate an Auth.js secret
npx prisma init
npx serve ./dist # quick static serverpnpm's equivalent is pnpm dlx; Bun's is bunx.
Quiz
What goes in devDependencies vs dependencies?
Recap
package.jsonis the project manifest: name, version, scripts, deps, engines.- dependencies = runtime imports. devDependencies = build/test/lint tools.
- Set
"type": "module"for ESM.exportscontrols 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.
npxruns CLIs from npm without installing them locally.