jonathanjuliani
NodeJS — Understanding `require`, `exports`, and `module.exports` Once and for All
require, exports, and module.exports in Node.js with examples and common pitfalls. Finally understand CommonJS for real.
require vs exports vs module.exports: not the same thing
If you write code every day you've probably searched "how to export a function from one JS file to another" at some point, got confused, or just had one of those blank moments that happen when you're juggling multiple files, languages, and projects all day long.
It happens to everyone. A good developer doesn't need to have everything memorised—they need to know where to look and how to apply it. But the web doesn't always give clean answers, and you're left guessing.
Let's fix that for good: there's going to be deliberate errors, live code, and by the end you'll know exactly what Node does when it reads a require.
What you'll learn:
- How
require()works under the hood — what Node actually does when it hits that line - The real difference between
exports,module.exports, and when each one breaks - Why
moduleandexportsexist in every.jsfile without you declaring them — Node's hidden variables - The
=assignment gotcha that causes silent bugs in production - The four ways to export — and which one to use
Prerequisites: basic JavaScript and Node.js — functions, objects, and how to run a .js file. I won't cover how to create or run the project from scratch.
require(): from node_modules to what your file receives
When you export something from a Node file to use elsewhere, you're creating a module. A module is just a piece of reusable code—like a plugin, or a library package. When you install something with npm and do require('uuid'), you're importing a module.
Here's an example. Let's say we want to use the uuid package:
const uuid = require('uuid')If you open node_modules/uuid/index.js, you'll find a module.exports in there:
// ... rest of the code
var uuid = v4
module.exports = uuidThat line is telling Node:
"When someone calls require('uuid'), send them the
uuidvariable."
So whenever you call require('something'), Node looks inside 'something' for module.exports and returns whatever is assigned to it.
And in that same uuid example, they're using other requires and exports internally:
var v4 = require('./v4')
var uuid = v4
module.exports = uuidSo when you require uuid, Node returns the value of uuid—which happens to be v4—which is itself a variable loaded from another file with its own require.
In other words: in v4.js there's a module.exports, its value gets assigned to var v4, which then gets assigned to var uuid, and when you call require('uuid') you get the final result of all that—which is the module.exports value from v4.js.
A lot of machinery to pull in one little function... but Node does this so fast you never notice it. And there's a good reason why libraries structure things this way—we'll get to that.
But what's actually inside v4.js? Good question.
// ...some code and then:
module.exports = v4So when you do require('uuid') you ultimately get the v4() function.
I get it so far. But why all this indirection just to get a function? Couldn't it all be in the first
index.js?
It could. But imagine you have 50 functions in that index.js—that file would be a mess to read and maintain, with a high chance of someone editing the wrong function. So most libraries, projects, and teams split functions into separate files (each with its own responsibility), then re-export them from index.js. That way consumers pick what they need.
Makes sense. So how do I create a module? How do I export something?
Node modules: the hidden variables in every .js file
There are a few ways to do it. But before we get there, we need to understand the module variable. Every time you create a JS file in Node, there's a hidden variable in that file. Can you guess what it's called?
It's called module. That's why you can write module.exports without declaring var module = something—it's already there, hidden.
Check out this code and follow along (create an empty folder so our tests don't conflict with other .js files):
Proving it: module and exports exist without you declaring them
Create a file called hi.js with this content:
exports.sayHi = () => {
console.log('Hi!')
}Then create an index.js in the same folder:
const module = require('./hi')
module.sayHi()Will this print "Hi" to the console? Let's see...
> node index.js
(function (exports, require, module, __filename, __dirname) { const module = require('./hi')
SyntaxError: Identifier 'module' has already been declaredWait — what?!
Yep. You tried to declare a constant named module, but that identifier is already taken! Let's prove it. Change index.js to:
module = require('./hi')
module.sayHi()Run it:
> node index.js
Hi!There it is. module really is a hidden variable. When we assigned to it without a const declaration it worked fine, and "Hi!" printed to the console.
Now, you might be thinking: "But in JavaScript you don't need const to create a variable — so aren't you still creating a new module variable?"
Fair point—but no. When we explicitly declared const module, we got a duplicate error, which means module already exists. I'll also show you another hidden variable in a second.
Have you already guessed it? If you thought exports—you're on the right track. Let's see it in action. Replace index.js with:
console.log(module)
console.log(exports)Run it:
> node index.js
Module {
// some lines
exports: {},
// other lines
}
{}We printed the module object and the exports object! Both exist without us declaring them.
So beyond module, Node also quietly creates an exports variable in every JS file. And it's not just any object—exports points directly to module.exports.
Imagine Node secretly adds this at the top of every file:
var module = {
// ...
exports: {}
// ...
}
var exports = module.exports = {}So exports starts pointing at the same empty object as module.exports. This is why that hi.js code worked earlier:
exports.sayHi = () => {
console.log('Hi!')
}We're adding a new attribute called sayHi to the exports object. Since exports === module.exports, that attribute becomes available when you require('./hi').
That's also why you can use exports.X and module.exports.X interchangeably for adding properties. Let's test it properly. Update hi.js:
exports.sayHi = () => {
console.log('Hi!')
}
exports.whatIsInModule = () => {
console.log(module)
}
exports.whatIsInExports = () => {
console.log(exports)
}And index.js:
hi = require('./hi')
console.log(hi.sayHi())
console.log(hi.whatIsInModule())
console.log(hi.whatIsInExports())Run it:
> node index.jsYou'll get three outputs. First, "Hi!":
Hi!Then the module object with our exported functions:
Module {
// some lines
exports: {
sayHi: [Function],
whatIsInModule: [Function],
whatIsInExports: [Function]
},
// more lines
}And then the exports object, which is the same thing:
{
sayHi: [Function],
whatIsInModule: [Function],
whatIsInExports: [Function]
}So why not just use module.exports everywhere?
You can! If you replace hi.js with module.exports.X syntax instead of exports.X, it behaves identically. And mixing them? Also fine:
exports.sayHi = () => {
console.log('Hi!')
}
module.exports.whatIsInModule = () => {
console.log(module)
}Works exactly the same.
The = assignment gotcha: when exports and module.exports diverge
The important thing to watch: adding a property with dot notation is safe, but reassigning with = replaces the entire object.
When you do:
exports.functionName
module.exports.functionNameYou're adding a property to the existing object. No problem.
But when you do exports = something, you're replacing the exports variable with a new value, cutting its link to module.exports. The functions you added before exports = ... remain in module.exports, but exports now points somewhere else entirely.
Test it. Put this in hi.js:
exports.sayHi = () => {
console.log('Hi!')
}
module.exports.whatIsInModule = () => {
console.log(module)
}
module.exports.whatIsInExports = () => {
console.log(exports)
}
exports = 'Reassigning exports with ='Run index.js (which still calls our three functions). The module print will show all functions intact, but exports will be:
> Reassigning exports with =The functions are still in module.exports. Only the exports shorthand variable was replaced.
Now flip it. Put module.exports = 'Reassigning module.exports with =' at the end of hi.js instead, and try calling the functions from index.js. You'll get an error—because module.exports was replaced with a string, and strings don't have those function properties.
Change index.js to just console.log(require('./hi')) and run again:
> node index.js
Reassigning module.exports with =That's the string, not an object with functions. Everything before the module.exports = ... line was wiped out.
Export patterns: the four ways and which one to use
Here's a quick summary of the patterns, from simplest to most common:
Using exports.X directly:
exports.sayHi = () => {
console.log('Hi!')
}
exports.sayGoodbye = () => {
console.log('Goodbye')
}Using module.exports.X (identical result):
module.exports.sayHi = () => {
console.log('Hi!')
}
module.exports.sayGoodbye = () => {
console.log('Goodbye')
}Assigning an object directly to module.exports:
module.exports = {
sayHi: () => {
console.log('Hi')
},
sayGoodbye: () => {
console.log('Goodbye')
},
}The pattern you'll see most often in the wild:
const sayHi = () => {
console.log('Hi!')
}
const sayGoodbye = () => {
console.log('Goodbye')
}
module.exports = {
sayHi,
sayGoodbye,
}Declare functions at the top, export them as an object at the bottom. This is clean, easy to read, and easy to maintain. When you have more complex or larger functions, split them into separate files (like the uuid example) and export/require across files.
When using the object assignment pattern, use module.exports = { ... } and not exports = { ... }. Since you're doing a full reassignment, only module.exports matters—exports just won't apply.
Everyone codes in their own style, and there's no single "right" way. This is just what I use and see most often in real projects. Take it as a reference, not a rule.
TL;DR — require / exports quick reference
| Concept | What it is | Gotcha |
|---|---|---|
require('something') | Imports whatever is in module.exports | Always returns module.exports, never bare exports |
module.exports | The object the caller receives from require | Assigning with = replaces everything set before |
exports.X = ... | Shorthand for module.exports.X = ... | Works fine as long as you don't do exports = X |
exports = X | ⚠️ Breaks the link to module.exports | Always use module.exports = X for full reassignment |
module and exports | Hidden variables Node injects into every .js file | No declaration needed — they're already there |
Next steps
Now that you know what's really happening under a require:
- Modern ES Modules: How
import/exportfrom ES6 coexists withrequirein modern Node — see the differences in practice: ECMAScript ES7 to ES10: features you use without knowing where they came from - Data Structures in Node.js: See this module pattern in action across larger projects — the series uses exactly this file-separation approach: Data Structures: a summary of the most used ones
Before you go
Missing something? Found a mistake? Drop a comment and let's sort it out together.
If this cleared up a bug or a long-standing confusion, pass it on to someone dealing with the same thing.
Subscribe to the Engineering Ledger
Get architecture and performance notes in your inbox. Same list as the timed prompt—subscribe here anytime.
Related articles
Promises in JavaScript: from zero to async/await
JavaScript Promises and async/await from the ground up: states, chaining, error handling, and the gotchas official docs tend to skip.
Read storySpread and Rest in JavaScript: arrays, objects, and the shallow copy gotchas
Spread operator and rest in JavaScript: how they work in arrays, objects, and functions, and the shallow copy gotchas that catch every developer.
Read story