CommonJS Modules (require / module.exports)
A real application is not one long file. It is many small files that each do one job and hand their work to each other, and the mechanism for that handing-over is the module system. Node.js has two of them. The older one, and still the one you will meet in most tutorials and most existing codebases, is called CommonJS: you export with module.exports and import with require().
The most important property of a module is that it has its own scope. A variable declared at the top of math.js is invisible in app.js unless you deliberately export it. This is the opposite of what happens with old-style browser script tags, where everything lands in one shared global space and two files can silently overwrite each other's variables. In Node, nothing leaks unless you say so, which is why large Node projects stay manageable.
module.exports starts life as an empty object. Whatever it holds at the moment the file finishes running is what require() hands back to the caller. You can attach several things to it, as in the example below, or replace it entirely with a single function or class. Both are common; exporting an object of named functions is easier to grow, because adding a sixth function does not change how the other five are imported.
When you call require('something'), Node decides what you mean from the shape of the string. A bare name like 'fs' or 'express' means a built-in module first, then a package inside node_modules. A string starting with ./ or ../ means a file on disk, relative to the file doing the requiring. That distinction is not cosmetic: require('math') and require('./math') look almost the same and mean completely different things, and the first will fail with Cannot find module 'math' because Node went looking in node_modules for a package by that name.
One more convenience worth knowing: the file extension is optional for JavaScript files, so require('./math') finds math.js. If you point at a folder instead of a file, Node looks for an index.js inside it, which is why a routes/index.js pattern is so common — require('./routes') just works.
// math.js — Exporting a module
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// app.js — Importing the module
const { add, multiply } = require('./math');
console.log(add(5, 3)); // 8
console.log(multiply(4, 2)); // 8
// You can also export a single value
// module.exports = function greet(name) {
// return `Hello, ${name}!`;
// }; require('fs')— a bare name means a built-in module, or a package innode_modulesrequire('./math')— a leading./or../means a file relative to the current filerequire('./routes')— pointing at a folder loadsindex.jsinside itmodule.exports— whatever this holds when the file finishes is what the caller receives- Each file has its own scope — nothing is shared unless it is exported
- Modules are cached — the file body runs once, no matter how many files require it
- There is a shorthand,
exports.add = add, which works only for adding properties. Writingexports = { add }silently exports nothing, because you have reassigned a local variable instead of changing the object Node actually returns. If you want to replace the whole export, you must writemodule.exports = ....
Modules Are Cached — and Why That Is Useful
The first time a file is required, Node runs it from top to bottom and stores the result. Every later require() of that same file returns the stored result without running the file again. This surprises people the first time they notice it, and then quickly becomes a tool they rely on.
The surprise is that top-level code in a module is startup code, not per-use code. If you put console.log('loading config') at the top of config.js and require it from six different files, you will see that message once, not six times. Beginners occasionally put a database connection or a timer in a module expecting it to happen per import, and are then confused when it happens exactly once.
The usefulness is that caching gives you a shared single instance for free. A db.js that creates a connection pool and exports it will hand the same pool to every file that requires it, which is exactly what you want — one pool of connections for the whole application, not one per file. The same pattern works for a configuration object, a logger, or an Express app instance.
The trap sits at the other end of that behaviour. Because the export is shared, if one file mutates the exported object, every other file sees the change. A module that exports { currentUser: null } and gets written to from a request handler becomes a global variable in disguise, shared across every request your server is handling. That is a real source of bugs where one user sees another user's data. Export functions and configuration; do not export mutable per-request state.
// db.js — runs once, no matter how many files require it
console.log('creating the pool'); // you will see this exactly once
const pool = createPool({ max: 10 });
module.exports = pool;
// users.js
const pool = require('./db'); // creates the pool, prints the message
// orders.js
const pool = require('./db'); // SAME pool object, no message printed
// The trap: exported state is shared by everyone
// session.js — DO NOT DO THIS on a server
module.exports = { currentUser: null };
// One request sets it...
require('./session').currentUser = 'ananya';
// ...and every other request in flight now sees 'ananya' as well. - Caching is keyed on the resolved file path, so
require('./db')andrequire('./db.js')return the same object, but requiring the same package from two differentnode_modulesfolders can genuinely load it twice. That is the usual cause of the puzzling "two copies of the same library are loaded" warning some packages print.
ES Modules (import / export)
The second module system is the official JavaScript one, usually called ES Modules or ESM. It is the import and export syntax you already write in React or any modern frontend project, and Node.js supports it too. Whether a given .js file is treated as CommonJS or as an ES module is decided by your package.json: add "type": "module" and every .js file in the project becomes an ES module. Leave it out and they stay CommonJS. The .mjs extension forces ESM for a single file, and .cjs forces CommonJS, which is handy when you need one exception.
ES modules are not simply nicer syntax for the same thing. They are resolved statically: Node reads the import statements before running any code, which is what allows a bundler to strip unused exports and what makes tooling able to follow your imports without executing anything. A consequence you will feel immediately is that imports must be at the top level — you cannot put an import inside an if block the way you can put a require() there. When you genuinely need a conditional load, the function form await import('./thing.js') is the escape hatch, and it returns a promise.
Two everyday differences bite when you switch a project over. First, ESM requires the full filename including the extension: import { add } from './math.js', not './math'. That extensionless habit from CommonJS produces ERR_MODULE_NOT_FOUND and is the single most common error when migrating. Second, __dirname and __filename do not exist in ES modules, because they were CommonJS wrapper variables rather than language features. The replacement is shown in the lesson on paths.
In exchange you get top-level await — you can write await at the outermost level of a module file, without wrapping it in an async function. That is a real convenience for startup work such as loading configuration or connecting to a database before the server starts listening.
Which should you use? For a brand new project, ES modules, because that is where the language is going and because your frontend code already looks like this. For an existing codebase or a tutorial you are following, stay with whatever it already uses. The one thing you must not do is drift between them file by file, for the reason the next section explains.
// First, add to package.json: "type": "module"
// math.mjs — Named exports
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// app.mjs — Importing named exports
import { add, multiply } from './math.mjs';
console.log(add(5, 3)); // 8
// Default export
export default class Calculator {
add(a, b) { return a + b; }
}
// Importing default export
import Calculator from './Calculator.mjs'; "type": "module"in package.json — every.jsfile becomes an ES module.mjsforces ESM for one file;.cjsforces CommonJS for one file- Named exports —
export function add(), imported as{ add } - Default export — one main value per file, imported without braces
- Extensions are required —
'./math.js', never'./math' - Top-level
awaitworks;__dirnameand__filenamedo not exist
importstatements are hoisted and evaluated before the rest of the file runs, so an import placed halfway down a file still executes first. Keep them all at the top so the code matches what actually happens.
Why Mixing the Two Systems Causes Trouble
You cannot use require() and import in the same file, and the error you get when you try is one of the most frequently searched Node.js messages: ReferenceError: require is not defined in ES module scope, or its mirror image, Cannot use import statement outside a module. Both mean the same thing — the file is being treated as one kind of module while its contents are written in the other.
The usual cause is copying a snippet from a tutorial written in the other style. You added "type": "module" to package.json last week, then pasted const express = require('express') from a blog post today. Nothing about your code is wrong in isolation; the two halves simply disagree about which system this project uses. The fix is to convert the snippet, not to fight the error.
There is a second, subtler asymmetry between the systems. An ES module can import a CommonJS package, and Node makes the package's module.exports available as the default import. Going the other way is harder: a CommonJS file cannot require() a package that only ships ES modules, and must use await import() instead. Several popular libraries have moved to ESM-only releases, which is why upgrading a package sometimes produces ERR_REQUIRE_ESM in a project that was working fine. When you meet that error, check the library's release notes before assuming your code broke.
The practical advice is unglamorous: decide once, at the start of the project, and be consistent. Write it in the README if other people will work on the code. Mixed-style codebases are not just untidy; they generate errors that look like configuration problems and cost hours to diagnose.
// ---- CommonJS project (no "type" field in package.json) ----
const express = require('express');
module.exports = { start };
// Pasting this into the same project breaks it:
// import express from 'express';
// SyntaxError: Cannot use import statement outside a module
// ---- ESM project ("type": "module" in package.json) ----
import express from 'express';
export { start };
// Pasting this into the same project breaks it:
// const express = require('express');
// ReferenceError: require is not defined in ES module scope
// An ESM file CAN load a CommonJS package — the exports arrive as default
import pkg from 'some-commonjs-package';
// A CommonJS file loading an ESM-only package needs the dynamic form
async function load() {
const mod = await import('some-esm-only-package');
return mod.default;
} - If you inherit a project and are not sure which system it uses, look at
package.jsonfor a"type"field. No field means CommonJS. This one glance answers most "why does this import not work" questions before you write a line.
Built-in Modules
A large amount of what a backend needs is already inside Node.js, with nothing to install. These are the built-in or "core" modules, and they are available the moment Node is installed: reading and writing files, building HTTP servers, hashing, working with paths, spawning other programs, emitting and listening for events.
Reaching for a package too quickly is a habit worth resisting. Every dependency you add is code you did not write, running with the same privileges as your own, that somebody else can change. If a built-in module does the job, use it. crypto.randomUUID() replaces a package that used to be installed by nearly everybody; the built-in fetch covers most of what a separate HTTP client library was needed for; node --test can run a small test suite without a test framework.
Modern Node lets you write built-in imports with a node: prefix — require('node:fs') or import fs from 'node:fs'. It is worth adopting. It makes it obvious to a reader that this is core Node and not a dependency, and it removes any chance of a package in node_modules called fs or path quietly shadowing the real one.
The modules below are the ones this course actually uses. There are many more, but knowing these six well takes you further than skimming the whole list.
// Common built-in modules
const fs = require('fs'); // File system operations
const path = require('path'); // File path utilities
const http = require('http'); // HTTP server and client
const os = require('os'); // Operating system info
const crypto = require('crypto'); // Cryptographic functions
const url = require('url'); // URL parsing
const events = require('events'); // Event emitter
// Example: Generate a random ID
const id = crypto.randomUUID();
console.log('Random ID:', id);
// Random ID: 550e8400-e29b-41d4-a716-446655440000
// Example: Get system info
console.log('Home Directory:', os.homedir());
console.log('Hostname:', os.hostname()); - Use the
node:prefix for built-in modules —require('node:fs')orimport path from 'node:path'. It signals to every reader that no dependency is involved, and it cannot be shadowed by a package of the same name.
Organising a Real Project into Modules
Knowing the syntax is the easy half. The harder half is deciding what belongs in which file, and here the useful rule is that a module should have one reason to change. A file called emailService.js that sends email is a good module. A file called utils.js that has grown to four hundred lines of unrelated helpers is not, because every part of the application now depends on it and nobody can tell what breaking it would affect.
For a typical API, the layering that most Node projects settle on separates routes, business logic and data access. The route file knows about HTTP — status codes, request bodies, response shapes. The service or controller layer knows the rules of your application — that a task must belong to a user, that an order cannot be cancelled after dispatch. The model layer knows the database. Keeping those apart means you can change your database without touching a single route, and you can test the rules without starting a server.
Export the smallest useful surface. If a helper is only used inside one module, do not export it — leaving it private means you are free to rename or delete it later without checking the whole codebase. This is the practical benefit of module scope, and it disappears the moment you export everything by reflex.
One structural mistake is worth naming because it produces a confusing failure: circular imports, where a.js requires b.js and b.js requires a.js. Node does not crash on this. It gives one of the two files a half-finished copy of the other, and you get a mysterious undefined is not a function at runtime with nothing obviously wrong at either end. If you hit that, the cycle is the bug: pull the shared piece into a third module that both can depend on.
src/
├── routes/
│ └── taskRoutes.js // HTTP concerns only
├── services/
│ └── taskService.js // the rules of your application
├── models/
│ └── Task.js // how a task is stored
├── middleware/
│ └── auth.js
├── utils/
│ └── validateTask.js // one focused helper per file
└── app.js
// services/taskService.js — no mention of req or res anywhere
const Task = require('../models/Task');
async function completeTask(taskId, userId) {
const task = await Task.findOne({ _id: taskId, owner: userId });
if (!task) return null;
task.completed = true;
await task.save();
return task;
}
module.exports = { completeTask }; // export only what others need
// routes/taskRoutes.js — knows HTTP, knows nothing about the database
const { completeTask } = require('../services/taskService');
router.patch('/:id/complete', async (req, res) => {
const task = await completeTask(req.params.id, req.user.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
}); - One reason to change per module — resist the ever-growing
utils.js - Routes handle HTTP; services handle rules; models handle storage
- Export the minimum — unexported helpers stay free to rename
- Group by feature once the project is large enough that folders of forty files appear
- Circular imports do not crash; they hand you a half-initialised object at runtime
- A quick test of whether your layering is right: could you call your service functions from a command-line script, with no Express involved at all? If yes, the HTTP layer is properly separated. If the service reads
req.bodydirectly, it is not.
