Skip to content
Node.js fundamentals 3 min read

Modules & npm

Modules let you split code into reusable files, and npm gives you access to the largest software registry in the world. Node supports two module systems—CommonJS and ECMAScript Modules—and understanding both is essential because you’ll encounter each in the wild.

CommonJS (require / module.exports)

CommonJS is Node’s original module system. You export with module.exports and import with require. It loads modules synchronously.

// math.js
function add(a, b) {
  return a + b;
}
module.exports = { add };
// app.js
const { add } = require("./math");
console.log(add(2, 3));

Output:

5

require is synchronous, resolves paths relative to the current file, and caches each module after first load so repeated requires return the same instance.

ESM (import / export)

ECMAScript Modules are the standard JavaScript module system, shared with browsers. Use export and import. ESM is asynchronous and supports top-level await.

// math.mjs
export function add(a, b) {
  return a + b;
}
// app.mjs
import { add } from "./math.mjs";
console.log(add(2, 3));

Enable ESM in one of two ways: name files .mjs, or set "type": "module" in package.json (then .js files become ESM).

FeatureCommonJSESM
Syntaxrequire / module.exportsimport / export
LoadingSynchronousAsynchronous
Top-level awaitNoYes
File hint.cjs or default.mjs or "type":"module"

Prefer ESM for new projects—it’s the future standard and aligns Node with the browser. Reach for CommonJS only when a dependency or legacy codebase requires it.

package.json

Every Node project has a package.json manifest describing it. Create one with npm init -y.

{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node app.js",
    "dev": "node --watch app.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.21.0"
  },
  "devDependencies": {
    "eslint": "^9.13.0"
  }
}

Installing packages and running scripts

npm install express          # add a runtime dependency
npm install --save-dev eslint # add a dev-only dependency
npm install                  # install everything in package.json
npm run dev                  # run a script defined under "scripts"
npm start                    # shorthand for the "start" script

npm install writes a package-lock.json that pins the exact resolved versions of your entire dependency tree, guaranteeing reproducible installs across machines and CI.

Dependencies vs devDependencies

  • dependencies — required at runtime in production (web frameworks, database drivers).
  • devDependencies — only needed during development (linters, test runners, bundlers).

Running npm install --production or npm ci --omit=dev skips devDependencies, keeping production images lean.

Semantic versioning

Versions follow MAJOR.MINOR.PATCH, and range prefixes control which updates you accept:

RangeMeaningAllows
^4.21.0Compatible with 4.x>=4.21.0 <5.0.0
~4.21.0Patch updates only>=4.21.0 <4.22.0
4.21.0Exact pinonly 4.21.0

A bump in MAJOR signals breaking changes, MINOR adds features backward-compatibly, and PATCH is bug fixes. The ^ caret is npm’s default and a sensible choice for most dependencies.

Best Practices

  • Commit package-lock.json so everyone installs identical trees.
  • Use npm ci in CI for clean, deterministic installs.
  • Keep secrets out of package.json; use environment variables.
  • Prefer ESM for new code and avoid mixing module systems in one package.
  • Audit regularly with npm audit and update with intent, reading changelogs before major bumps.
  • Place build and dev tooling in devDependencies, runtime libraries in dependencies.
Last updated June 1, 2026
Was this helpful?