Modules & npm
CommonJS vs ES modules, and npm basics: package.json, dependencies, and installing packages.
Two module systems, one runtime
Node.js supports two different ways of splitting code across files and sharing it between them: CommonJS (Node's original module system) and ES modules (the standard JavaScript module system, the same import/export syntax used in front-end bundlers). Understanding both — and which one a given file is using — is essential, since mixing them incorrectly is one of the most common sources of confusing errors in Node projects.
CommonJS: require and module.exports
CommonJS is Node's original module format, and it's still extremely common in existing codebases and many npm packages:
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
// app.js
const { add, subtract } = require('./math');
console.log(add(2, 3)); // 5
require() is a synchronous function call — it loads and executes the target module immediately, right where it's called, and returns whatever that module assigned to module.exports. This synchronous loading is part of why CommonJS was well-suited to Node's original design, even though it's not how modern JavaScript modules work in browsers.
ES modules: import and export
ES modules (often shortened to "ESM") are the standardized JavaScript module system — the same syntax you'd use in a browser or in TypeScript:
// math.mjs
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// app.mjs
import { add, subtract } from './math.mjs';
console.log(add(2, 3)); // 5
Telling Node which system a file uses
By default, Node treats .js files as CommonJS. To use ES module syntax in .js files (not just .mjs), add "type": "module" to package.json:
{
"name": "my-app",
"type": "module"
}
| File extension / setting | Module system |
|---|---|
.cjs |
Always CommonJS, regardless of package.json |
.mjs |
Always ES modules, regardless of package.json |
.js, no "type" field in package.json |
CommonJS (the default) |
.js, with "type": "module" in package.json |
ES modules |
One practical ES modules advantage: top-level await is allowed directly in a module's top-level code (no wrapping async function needed) — something CommonJS's synchronous require()-based design never supported:
// Only valid in an ES module
const response = await fetch('https://api.example.com/config');
const config = await response.json();
npm basics
npm (Node Package Manager) is both a command-line tool and the public registry of open-source JavaScript packages it downloads from. Installing a package adds it to node_modules/ and records it in package.json:
npm install express
{
"dependencies": {
"express": "^4.19.2"
}
}
dependencies vs devDependencies
package.json separates two categories of packages:
npm install express # a runtime dependency — needed for the app to actually run
npm install --save-dev jest # a devDependency — only needed while developing (testing, building, linting)
{
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
The distinction matters most in production: a deployment typically runs npm install --omit=dev (or NODE_ENV=production npm install), skipping devDependencies entirely — a testing framework or linter has no reason to be installed on a production server.
Installing and locking dependencies
npm install # installs everything listed in package.json
npm install lodash # adds a new dependency and installs it
npm uninstall lodash # removes it
Every npm install also generates or updates package-lock.json, which pins the exact resolved version of every dependency (and every dependency of every dependency). Committing this file to version control is important — it guarantees every developer, and every deployment, installs the identical dependency tree, rather than whatever the loosely-versioned ranges in package.json (like ^4.19.2) happen to resolve to on a given day.
Common mistakes
- Mixing
require()andimportin the same file — a file is either CommonJS or an ES module, not both; Node throws a syntax error if you useimportsyntax in a file it's treating as CommonJS. - Installing a package needed only for testing/building as a regular dependency instead of a devDependency, unnecessarily bloating what gets installed in production.
- Not committing
package-lock.json, which can lead to different machines (or a CI pipeline vs. a developer's laptop) silently installing slightly different dependency versions.
Interview questions
Q: What's the practical difference between CommonJS and ES modules in Node.js?
CommonJS uses require()/module.exports, loads modules synchronously, and has been Node's default since its earliest versions. ES modules use standard import/export syntax, support top-level await, and are the same module system used in browsers and modern front-end tooling. A file's extension (.cjs/.mjs) or the "type" field in the nearest package.json determines which system a plain .js file is treated as.
Q: What's the difference between dependencies and devDependencies in package.json?
dependencies are packages the application needs to actually run in production (like a web framework). devDependencies are only needed during development — testing frameworks, linters, build tools. Production installs commonly skip devDependencies entirely (npm install --omit=dev), so miscategorizing a package can either bloat a production install or, worse, leave a genuinely required runtime package out of it.
Q: Why is package-lock.json committed to version control?
Because package.json typically specifies version ranges (like ^4.19.2, meaning "any compatible 4.x version"), which could resolve to a different exact version over time as new releases come out. package-lock.json pins the exact resolved version of every dependency (and sub-dependency) at the moment it was generated, so every install — on any machine or CI pipeline — reproduces an identical dependency tree.