How to use CommonJS modules in Node.js
Organizing Node.js code into reusable modules is fundamental for building maintainable applications, and understanding the module system is crucial for every Node.js developer.
With 10 years of experience in Node.js development since 2014 and as the creator of CoreUI, I’ve built countless server-side applications and npm packages using module systems.
From my expertise, CommonJS remains the traditional and widely-supported module format in Node.js, using require() to import and module.exports to export functionality.
This approach is synchronous, battle-tested, and compatible with the vast majority of npm packages in the ecosystem.
Use require() to import modules and module.exports to export functionality in CommonJS.
// math.js
const add = (a, b) => a + b
const subtract = (a, b) => a - b
module.exports = { add, subtract }
// app.js
const math = require('./math')
console.log(math.add(5, 3)) // 8
console.log(math.subtract(5, 3)) // 2
How CommonJS Works
CommonJS modules use require() for importing and module.exports for exporting. The require() function synchronously loads and executes the module, caching the result for subsequent imports. Each module has its own scope, preventing global namespace pollution. When you require a module, Node.js wraps your code in a function that provides module, exports, require, __dirname, and __filename variables.
Exporting Single Functions
Export a single function directly using module.exports:
// greet.js
module.exports = (name) => {
return `Hello, ${name}!`
}
// app.js
const greet = require('./greet')
console.log(greet('World')) // Hello, World!
This pattern is useful for utility functions or when your module exposes a single piece of functionality. The imported value can be used directly as a function.
Exporting Multiple Named Exports
Export multiple functions or values as properties of an object:
// user.js
const getUser = (id) => {
return { id, name: 'John Doe' }
}
const createUser = (data) => {
return { id: Date.now(), ...data }
}
const deleteUser = (id) => {
console.log(`User ${id} deleted`)
}
module.exports = {
getUser,
createUser,
deleteUser
}
// app.js
const { getUser, createUser } = require('./user')
const user = getUser(1)
const newUser = createUser({ name: 'Jane' })
Using destructuring when importing makes it clear which functions you’re using and keeps your code clean.
Using exports Shorthand
Use the exports object as a shorthand for module.exports:
// logger.js
exports.info = (message) => {
console.log(`[INFO] ${message}`)
}
exports.error = (message) => {
console.error(`[ERROR] ${message}`)
}
exports.warn = (message) => {
console.warn(`[WARN] ${message}`)
}
// app.js
const logger = require('./logger')
logger.info('Server started')
logger.error('Database connection failed')
The exports object is a reference to module.exports, so adding properties to exports adds them to the exported object. However, reassigning exports directly doesn’t work - always use module.exports for full reassignment.
Requiring Built-in Modules
Import Node.js core modules without file paths:
const fs = require('fs')
const path = require('path')
const http = require('http')
const filePath = path.join(__dirname, 'data.txt')
const content = fs.readFileSync(filePath, 'utf8')
console.log(content)
Node.js recognizes built-in module names and loads them from the core library. These modules are always available without installation.
Requiring npm Packages
Import installed npm packages by their package name:
const express = require('express')
const axios = require('axios')
const lodash = require('lodash')
const app = express()
const fetchData = async () => {
const response = await axios.get('https://api.example.com/data')
return lodash.pick(response.data, ['id', 'name'])
}
Node.js resolves package names by looking in the node_modules directory, traversing up the directory tree until it finds the package.
Module Resolution
Node.js follows specific rules to resolve module paths:
// Relative path - looks in current directory
const local = require('./utils')
// Relative path - looks in parent directory
const parent = require('../config')
// Core module - loads from Node.js core
const fs = require('fs')
// npm package - looks in node_modules
const express = require('express')
// Specific file with extension
const data = require('./data.json')
When you omit the file extension, Node.js tries .js, .json, and .node in that order. If the path points to a directory, Node.js looks for index.js or uses the main field from package.json.
Exporting Classes
Export ES6 classes using CommonJS:
// user.js
class User {
constructor(name, email) {
this.name = name
this.email = email
}
greet() {
return `Hello, I'm ${this.name}`
}
validate() {
return this.email.includes('@')
}
}
module.exports = User
// app.js
const User = require('./user')
const user = new User('John', '[email protected]')
console.log(user.greet()) // Hello, I'm John
console.log(user.validate()) // true
This pattern is common for models, services, and utility classes. The class is exported directly and can be instantiated in other modules.
Circular Dependencies
Handle circular dependencies between modules:
// a.js
exports.name = 'Module A'
const b = require('./b')
exports.greet = () => {
return `${exports.name} knows ${b.name}`
}
// b.js
exports.name = 'Module B'
const a = require('./a')
exports.greet = () => {
return `${exports.name} knows ${a.name}`
}
// app.js
const a = require('./a')
const b = require('./b')
console.log(a.greet()) // Module A knows Module B
console.log(b.greet()) // Module B knows Module A
When circular dependencies occur, Node.js returns an incomplete version of the module being required. The module is not fully executed until the circular require completes. This works because exports is populated incrementally as the module executes.
Caching Modules
Node.js caches required modules to improve performance:
// counter.js
let count = 0
module.exports = {
increment: () => {
count++
return count
},
getCount: () => count
}
// app.js
const counter1 = require('./counter')
const counter2 = require('./counter')
counter1.increment()
console.log(counter2.getCount()) // 1
// Both imports reference the same cached module
console.log(counter1 === counter2) // true
The first require() loads and executes the module, then caches the result. Subsequent require() calls return the cached version. This means modules are singletons by default.
Clearing Module Cache
Clear the require cache for testing or hot-reloading:
// Clear specific module
delete require.cache[require.resolve('./config')]
// Clear all cached modules
Object.keys(require.cache).forEach((key) => {
delete require.cache[key]
})
// Require fresh copy
const config = require('./config')
Clearing the cache forces Node.js to reload and re-execute the module. This is useful in development environments with hot-reloading or in test suites where you need fresh module state.
CommonJS vs ESM
Compare CommonJS with ES Modules:
// CommonJS
const fs = require('fs')
module.exports = { read, write }
// ES Modules
import fs from 'fs'
export { read, write }
CommonJS uses synchronous require() and is the default in Node.js. ESM uses asynchronous import and is the modern JavaScript standard. CommonJS has better compatibility with older packages, while ESM offers static analysis and tree-shaking benefits. For new projects, consider using ESM modules in Node.js for better modern JavaScript support.
Best Practice Note
At CoreUI, we maintain backward compatibility with CommonJS in our npm packages while offering ESM builds for modern environments. This dual approach ensures our components work across the entire Node.js ecosystem. CommonJS modules are synchronous and load immediately, making them perfect for server-side code that doesn’t need dynamic imports. For applications requiring dynamic module loading or building for browsers with better tree-shaking, consider migrating to ESM modules. The key is understanding your target environment and choosing the module system that best fits your needs.



