Modules, npm & Semantic Versioning
Open any serious JavaScript project and the first thing you notice is that almost none of it is code you wrote. There’s a src/ folder with your files, and next to it a node_modules/ folder with tens of thousands of files you’ve never read, pulled from hundreds of packages you didn’t publish. Somehow that pile boots up as one coherent program.
The machinery that makes that work is small and worth understanding completely: a module system that says how files reference each other, a manifest (package.json) that describes each package, a registry you download from, a lockfile that makes the download reproducible, and a versioning convention so upgrades don’t silently break you. Get these five right and dependency management stops being scary. Get them fuzzy and you’ll spend afternoons debugging why the same code runs on your laptop and dies in CI.
Let’s wire it up from the inside out.
Modules, at the ecosystem level
You already know how to split code across files: export a value from one, import it into another. At the scale of a whole project, the thing that matters is which module system your files speak, because there are two and they don’t blend cleanly.
ES modules (ESM) is the standardized one — part of the language, the same syntax in browsers and in Node.
// math.mjs
export function add(a, b) { return a + b; }
export default function mul(a, b) { return a * b; }
// app.mjs
import mul, { add } from './math.mjs';
CommonJS (CJS) is the older Node-only system, built before the language had modules of its own. It uses require and module.exports:
// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
// app.cjs
const { add } = require('./math.cjs');
The two differ in more than spelling. ESM imports are static — resolved before any code runs, which is what lets bundlers tree-shake unused exports away. CJS require is a function call that runs during execution, so it’s dynamic but opaque to static analysis. ESM is asynchronous-friendly and supports top-level await; CJS is strictly synchronous.
Telling Node which one a file is
Node decides a file’s module type from two signals:
- The extension. A
.mjsfile is always ESM; a.cjsfile is always CJS. - The nearest
package.jsontypefield for plain.jsfiles."type": "module"makes.jsmean ESM;"type": "commonjs"(or an absent field) makes.jsmean CJS.
So "type": "module" in your package.json is the switch that flips every .js in the project to ESM. New projects in 2026 almost always set it.
The whole ecosystem has converged on ESM-first. New libraries ship ESM as their primary format, browsers run it natively, and every bundler treats it as the default. CommonJS isn’t dead — billions of installs depend on it — but it’s now the compatibility layer, not the destination. When you start something new, write ESM.
The manifest: package.json
Every package — yours and every dependency — is described by a package.json at its root. It’s the identity card and the wiring diagram in one file.
{
"name": "@acme/parser",
"version": "2.4.1",
"type": "module",
"exports": {
".": "./dist/index.js",
"./cli": "./dist/cli.js"
},
"scripts": {
"build": "tsc -p .",
"test": "vitest run"
},
"dependencies": {
"picocolors": "^1.1.0"
},
"devDependencies": {
"vitest": "^3.0.0",
"typescript": "^5.7.0"
},
"peerDependencies": {
"react": ">=18"
}
}
name — registry-unique, may be scoped like @acme/xversion — semver, one per publishtype — module or commonjsexports — the public API map (preferred)main / module — legacy single entriesbin — CLI executablesdependencies — needed at runtimedevDependencies — build/test onlypeerDependencies — host must supplyscripts — named commandsThe three kinds of dependency
The split matters more than beginners expect, because it decides what ends up in a production install.
dependencies— packages your codeimports and needs when it actually runs. A date library, an HTTP client. These get installed for anyone who installs you.devDependencies— tools you need to build and test but that never ship: the test runner, the type checker, the bundler. When someone installs your package as a dependency, these are skipped entirely.peerDependencies— a contract that says “I plug into your copy of this.” A React component library declaresreactas a peer so it uses the host app’s single React instance instead of bundling a second one. The consumer is responsible for installing a compatible version.
scripts are named shell commands you run with npm run build, pnpm test, and so on. A few names are special — test, start, and lifecycle hooks like prepublishOnly — but most are just aliases you invent.
The exports field: your package’s front door
For most of npm’s history a package’s entry point was one line, "main": "./index.js", and every file inside the package was reachable by deep path. If you shipped ./src/internal/secret.js, a consumer could import it, and now that private file is part of your API forever.
The exports field fixes this. It’s a map from the paths consumers are allowed to import to the real files on disk — and crucially, it’s an allowlist. Anything not listed is unreachable, even if the file exists.
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./package.json": "./package.json"
}
}
With this map, import x from '@acme/parser' resolves to dist/index.js, and import u from '@acme/parser/utils' resolves to dist/utils.js. But import '@acme/parser/dist/internal/secret.js' now throws — that path isn’t in the map, so it doesn’t exist as far as consumers are concerned.
Conditional exports
The map values can branch on how the package is loaded. This is how one package ships both an ESM and a CommonJS build, plus the right TypeScript types for each:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
Node reads these conditions top to bottom and picks the first that applies. An import statement takes the import branch; a require() call takes require; a bundler for the browser might match a browser condition if you add one. Order matters — put the most specific conditions first and always end with default.
Installing: registries, npm, and pnpm
A registry is a server that hosts published packages; the default is the public npm registry. A package manager is the client that talks to it, resolves your dependency ranges into concrete versions, downloads the tarballs, and lays them out in node_modules/.
Three clients dominate:
- npm — bundled with Node, the baseline everyone has.
- pnpm — faster, far more disk-efficient, strict about which packages you can import. The common choice for new and large projects in 2026.
- Yarn — the pioneer of lockfiles and workspaces; still widely used, especially older Yarn-Classic and the newer Yarn Berry with its Plug’n’Play mode.
They read the same package.json and mostly agree on results. Where they differ is how they lay out node_modules and how strictly they enforce the dependency graph — and that difference is worth seeing.
The lockfile
Your package.json records ranges ("picocolors": "^1.1.0" means “some 1.x at or above 1.1.0”). Ranges are deliberately loose — they let you pick up bug fixes. But loose is the enemy of reproducibility: install today and you might get 1.1.0; install next week and get 1.1.4, with a subtle behavior change you never asked for.
The lockfile (package-lock.json for npm, pnpm-lock.yaml for pnpm, yarn.lock for Yarn) closes that gap. After resolving ranges once, the package manager writes down the exact version of every package in the entire tree — including your dependencies’ dependencies — plus a cryptographic integrity hash of each tarball. The next install reads the lockfile and reproduces that exact tree, byte for byte.
How the packages land on disk
Here npm and pnpm diverge sharply. npm builds a flattened node_modules: it hoists most packages to the top level and copies files per project. Install the same library in ten projects and you have ten copies on disk. Flattening also means a package can import something it never declared, just because a different dependency happened to hoist it to the top — a “phantom dependency” that works until the day it doesn’t.
pnpm takes a different route. Every version of every package is stored once, globally, in a content-addressed store on your disk. Files in your project’s node_modules are hard links into that store — no copying, near-zero extra space. The visible layout is built from symlinks: only your declared dependencies appear at the top of node_modules, and each package’s own dependencies are linked in beside it. If you didn’t declare it, you can’t import it. Phantom dependencies simply can’t happen.
Workspaces and monorepos
A workspace is one repository holding many packages that depend on each other — a shared UI kit, a couple of apps, some internal utilities, each with its own package.json. You declare the members once at the root:
{
"name": "acme-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
Now one install at the root resolves and links all of them together. When apps/web depends on @acme/ui, the package manager symlinks your local packages/ui straight into apps/web/node_modules — no publishing, no version juggling. Edit the UI kit, the app sees the change immediately. pnpm and Yarn have first-class workspace support; npm supports it too. Tools like Turborepo or Nx sit on top to cache and orchestrate builds across the members.
Semantic versioning
A version like 2.4.1 isn’t arbitrary. Under semantic versioning (semver) it’s three numbers with agreed meanings, and the whole range system depends on maintainers honoring them.
- MAJOR bumps when you make a breaking change — removing a function, renaming an option, changing what a call returns. Consumers may have to edit code to upgrade.
- MINOR bumps when you add functionality that existing code keeps working through. New optional parameter, new export.
- PATCH bumps for bug fixes that change nothing about the interface.
A “breaking change” is defined from the consumer’s side: if code that worked on the old version could stop working on the new one, it’s breaking, full stop — even a “small” behavior tweak.
Ranges: ^, ~, and exact
The prefix in your package.json tells the resolver how far it’s allowed to move. The two you’ll see everywhere are caret and tilde.
^1.2.3(caret) — allow anything that doesn’t change the leftmost non-zero digit. For a 1.x package that’s “any 1.x at or above 1.2.3, below 2.0.0.” This is npm’s default when younpm install a-package.~1.2.3(tilde) — allow patch updates only:>= 1.2.3and< 1.3.0. Tighter; picks up fixes but not new features.1.2.3(no prefix) — exactly that version. Nothing moves.
Versions can also carry a prerelease tag: 2.0.0-beta.1, 3.1.0-rc.2. These sort before the final release (2.0.0-beta.1 < 2.0.0) and are opt-in — a normal range like ^1.0.0 will not pull in a prerelease unless you ask for one explicitly. On the registry, prereleases usually sit behind a dist-tag like next or beta instead of latest, so npm install pkg@next gets you the bleeding edge while npm install pkg stays on stable.
Publishing and supply-chain hygiene
Shipping a package is short: bump the version, then npm publish (scoped packages need --access public the first time). What deserves your attention is everything around that command, because installing dependencies means running code from strangers.
Control what actually ships. By default the tarball includes your whole directory minus a few ignored paths. Set a "files" allowlist in package.json (or a .npmignore) so you publish dist/ and nothing else — no source, no secrets, no .env that slipped in. Run npm publish --dry-run first to see the exact file list.
Audit and pin. npm audit cross-checks your locked tree against a vulnerability database and reports advisories with a severity and, often, a fixed version. Treat it as a signal, not gospel — noisy transitive warnings are common — but wire it into CI so a critical advisory can fail the build. For anything that runs in production, lean toward tighter ranges and always commit the lockfile.
Verify provenance. Modern npm supports provenance: when a package is published from a CI pipeline (GitHub Actions, say) with the right settings, it’s cryptographically signed via Sigstore and recorded in a public transparency log that ties the published bytes to a specific source commit and build. You can check the signatures on your installed tree with npm audit signatures. The important caveat, as of 2026: provenance is not enforced at install time. It’s a verification tool you opt into, not a gate that stops an unsigned or tampered package from installing. Use it, but don’t assume it’s protecting you automatically.
Summary
- Write ESM. It’s the language standard, static (so bundlers can tree-shake), and the ecosystem’s default in 2026. CommonJS is now the compatibility layer, and
require()of a synchronous ES module is finally stable across current Node LTS lines — but a module with top-levelawaitstill can’t berequire()d. package.jsonis the manifest.name/version/typeare identity;exportsdefines entry points; and the split betweendependencies,devDependencies, andpeerDependenciesdecides what ships, what’s build-only, and what the host must provide.exportsis an allowlist. Listed subpaths resolve to real files; unlisted ones are unreachable, keeping internals private. Conditional exports (import/require/types/default) let one package serve ESM and CJS consumers — mind the ordering and remember to expose./package.json.- Lockfiles buy reproducibility.
package.jsonholds loose ranges; the lockfile pins exact versions plus integrity hashes for the whole transitive tree. Commit it; install withnpm ciin CI. - npm copies and flattens; pnpm links. pnpm stores each version once in a content-addressed store, hard-links files in, and uses symlinks to recreate the true graph — less disk, and no phantom dependencies.
- Semver is a promise. MAJOR breaks, MINOR adds, PATCH fixes.
^moves up to the next major (with special zero-major rules),~allows only patches, no prefix pins exactly, and prereleases stay opt-in. - Treat installs as running strangers’ code. Control the published file list, audit and pin, and verify provenance with
npm audit signatures— while knowing it isn’t enforced at install time.