Node.jsBackendFull-Stack

Node.js Modules: require, exports, and ES Modules

TT
TopicTrick Team
Node.js Modules: require, exports, and ES Modules

Node.js Modules: require, exports, and ES Modules

Every non-trivial Node.js application is split across multiple files. The module system is the mechanism that lets those files share code — importing functions, classes, and values from one file into another. Node.js supports two module systems: the original CommonJS (CJS) with require() and module.exports, and the modern ES Modules (ESM) with import and export.

Understanding both — and knowing when to use each — is fundamental to everything that follows in this course.

This is Module 4 of the Node.js Full‑Stack Developer course.


Why Modules Exist

Without a module system, every JavaScript file would share a single global scope. Any variable declared in one file would leak into every other file, causing name collisions and bugs that are nearly impossible to trace.

Modules solve this by giving each file its own scope. Variables, functions, and classes defined in a file are private to that file by default. You explicitly choose what to expose to the outside world by exporting it, and you explicitly choose what to bring in by importing it.

This is how every production Node.js application is structured — hundreds or thousands of small, focused files, each responsible for one thing, connected through explicit imports and exports.


Part 1: CommonJS — require() and module.exports

CommonJS is the original Node.js module format, introduced in 2009. It is still the default for .js files and the format used by the majority of npm packages.

Exporting from a Module

Create a file called math.js:

javascript
// math.js

function add(a, b) {
    return a + b
}

function subtract(a, b) {
    return a - b
}

function multiply(a, b) {
    return a * b
}

// Export everything you want to share
module.exports = {
    add,
    subtract,
    multiply,
}

Importing with require()

In another file, index.js:

javascript
// index.js
const math = require('./math')

console.log(math.add(10, 5))       // 15
console.log(math.subtract(10, 5))  // 5
console.log(math.multiply(10, 5))  // 50

The ./ prefix tells Node.js to look for a local file relative to the current directory. The .js extension is optional — Node.js adds it automatically.

Destructuring Imports

You can destructure the exported object directly:

javascript
const { add, multiply } = require('./math')

console.log(add(3, 4))       // 7
console.log(multiply(3, 4))  // 12

Exporting a Single Value

If your module exports just one thing (a function, class, or value), assign it directly to module.exports:

javascript
// greet.js
module.exports = function greet(name) {
    return `Hello, ${name}!`
}
javascript
// index.js
const greet = require('./greet')
console.log(greet("Alice"))  // Hello, Alice!

Exporting a Class

javascript
// User.js
class User {
    constructor(name, email) {
        this.name = name
        this.email = email
    }

    toString() {
        return `${this.name} <${this.email}>`
    }
}

module.exports = User
javascript
// index.js
const User = require('./User')
const alice = new User("Alice", "alice@example.com")
console.log(alice.toString())  // Alice <alice@example.com>

The exports Shorthand — and Its Trap

exports is a shorthand reference to module.exports. These two are equivalent:

javascript
// ✅ Both work — adding properties
exports.add = (a, b) => a + b
module.exports.add = (a, b) => a + b

But there is one critical trap: never reassign exports directly. This breaks the link between exports and module.exports:

javascript
// ❌ WRONG — this does NOT export the function
exports = function() { return 42 }

// ✅ CORRECT — use module.exports for reassignment
module.exports = function() { return 42 }

When in doubt, always use module.exports. Save exports.x = only for adding properties to an existing object.


How require() Resolves Paths

When you call require(), Node.js uses a specific lookup algorithm depending on the argument:

1. Core Modules (built-ins)

javascript
const fs = require('fs')       // built-in — no lookup needed
const path = require('path')   // built-in — no lookup needed
const http = require('http')   // built-in — no lookup needed

Core modules are resolved first, before anything else.

2. Relative Paths (starts with ./ or ../)

javascript
require('./utils')      // looks for: utils.js → utils.json → utils/index.js
require('../config')    // one directory up, same resolution order

3. Bare Specifiers (npm packages)

javascript
require('express')   // looks in: ./node_modules/express → ../node_modules/express → ...

Node.js walks up the directory tree looking for node_modules until it reaches the filesystem root.

Resolution Order for a File Path

When you require('./utils') Node.js checks in this exact order:

  1. ./utils.js
  2. ./utils.json
  3. ./utils.node (native addon)
  4. ./utils/index.js
  5. ./utils/index.json
  6. ./utils/index.node

This is why you can require('./routes') and Node.js will find ./routes/index.js automatically — a pattern used heavily in Express apps.

Module Caching

Node.js caches every module after the first require(). The second time you require the same module, Node.js returns the cached version without re-executing the file:

javascript
// counter.js
let count = 0
module.exports = {
    increment: () => ++count,
    get: () => count,
}
javascript
// index.js
const counter = require('./counter')
const counter2 = require('./counter')  // same cached instance

counter.increment()
counter.increment()
console.log(counter2.get())  // 2 — same object, not a fresh copy

This caching behaviour is intentional and useful — it means singletons (database connections, config objects) are shared across your entire application.


Part 2: ES Modules — import and export

ES Modules (ESM) are the official JavaScript module standard, used in browsers and increasingly in Node.js. They use import and export keywords instead of require and module.exports.

Enabling ES Modules in Node.js

You have two options:

Option A: Add "type": "module" to package.json (applies to all .js files in the project)

json
{
  "name": "my-app",
  "type": "module"
}

Option B: Use .mjs extension (file-by-file, no package.json change needed)

text
math.mjs
index.mjs

Named Exports

javascript
// math.js (with "type": "module" in package.json)

export function add(a, b) {
    return a + b
}

export function subtract(a, b) {
    return a - b
}

export const PI = 3.14159

Named Imports

javascript
// index.js
import { add, subtract, PI } from './math.js'

// ⚠️ In ESM you MUST include the .js extension
console.log(add(10, 5))    // 15
console.log(PI)            // 3.14159

Important: In ESM, file extensions are required in import paths. import { add } from './math' will fail — you must write './math.js'.

Default Exports

javascript
// greet.js
export default function greet(name) {
    return `Hello, ${name}!`
}
javascript
// index.js
import greet from './greet.js'          // no curly braces for default
import sayHello from './greet.js'       // you can rename it freely
console.log(greet("Alice"))            // Hello, Alice!

Combining Default and Named Exports

javascript
// user.js
export default class User {
    constructor(name) { this.name = name }
}

export const MAX_USERS = 1000
export function validateEmail(email) {
    return email.includes('@')
}
javascript
// index.js
import User, { MAX_USERS, validateEmail } from './user.js'

Re-exporting (Barrel Files)

A common pattern is to create an index.js that re-exports from multiple files, giving consumers a single import point:

javascript
// utils/index.js
export { add, subtract } from './math.js'
export { greet } from './greet.js'
export { validateEmail } from './user.js'
javascript
// Anywhere in your app
import { add, greet, validateEmail } from './utils/index.js'

Dynamic Imports

ESM also supports dynamic imports — loading a module at runtime, returning a Promise:

javascript
// index.js
async function loadPlugin(name) {
    const plugin = await import(`./plugins/${name}.js`)
    return plugin.default
}

const myPlugin = await loadPlugin('analytics')

Dynamic imports are the ESM equivalent of calling require() inside a function. They are useful for lazy loading — only loading code when it is actually needed.


CJS vs ESM: Side-by-Side Comparison

FeatureCommonJSES Modules
Import syntaxrequire('./file')import x from './file.js'
Export syntaxmodule.exports = xexport default x / export { x }
File extension.js (default).mjs or .js with "type":"module"
Requires .js extensionNoYes
Synchronous loadingYesNo (async by design)
Top-level awaitNoYes
Tree-shakeableNoYes
Dynamic importsrequire() in a functionawait import()
__dirname / __filenameAvailableNot available (use import.meta.url)

__dirname in ES Modules

Since __dirname is not available in ESM, use this equivalent:

javascript
import { fileURLToPath } from 'url'
import { dirname } from 'path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

console.log(__dirname)  // works exactly like CommonJS __dirname

Which Should You Use in 2026?

For new projects: Use ES Modules. They are the standard, support top-level await, enable tree-shaking, and are what all major frameworks (Next.js, NestJS, Fastify) use by default.

For existing CJS projects: No need to migrate unless you have a specific reason. CJS still works perfectly and is supported long-term.

For this course: We will use CommonJS for the early modules (easier to learn, no extension headaches) and transition to ESM when we reach Express and full-stack projects — the same progression you will encounter in real codebases.


Practical Project Structure

Here is how a real Node.js project uses the module system:

text
src/
├── index.js           ← entry point
├── config/
│   └── index.js       ← re-exports all config
├── routes/
│   ├── index.js       ← barrel re-export
│   ├── users.js
│   └── products.js
├── controllers/
│   ├── userController.js
│   └── productController.js
├── models/
│   └── User.js
└── utils/
    ├── index.js       ← barrel re-export
    ├── validation.js
    └── formatting.js

Each folder has an index.js that re-exports its contents. This means the rest of your app imports from './routes' rather than './routes/users' — clean, flat imports regardless of internal folder depth.


Node.js Full‑Stack Course — Module 4 of 32

You now understand the full Node.js module system. Continue to Module 5 to master the event loop — the engine behind Node's non-blocking I/O.


    Summary

    The Node.js module system is the foundation of every application you will build. Here is what to remember:

    • CommonJS (require / module.exports) — the default, synchronous, cached, no extension needed
    • ES Modules (import / export) — the modern standard, async, tree-shakeable, .js extension required
    • require() resolution order: core → relative → node_modules
    • Modules are cached after first load — great for singletons
    • Never reassign exports directly — always use module.exports
    • Use barrel index.js files to create clean import paths across your project

    Continue to Module 5: The Node.js Event Loop — How Async Really Works →


    Content powered by SearchFit.ai — for automated content at scale, visit https://searchfit.ai