If you have ever opened a Node.js project and seen the words import, export, require, module.exports, "type": "module", and wanted to throw your laptop into the sea, this article is for you. JavaScript modules are not actually that complicated once you understand the small number of rules behind them. The confusion is mostly historical: Node.js and the browser chose different systems, then Node.js changed its mind, and now there are three flavours that all look slightly different.
By the end of this article you will know exactly what each of those words means, when to use which, and how to set up a small project so your bundler stops yelling at you.
What is a module?
A module is just a JavaScript file that explicitly says which of its values are available to other files. The way it does that is with the export keyword. Other files then pull those values in with import. That is the entire mechanism. Everything else is just variations.
Before modules existed, JavaScript developers used globals (variables attached to window) and a mountain of <script> tags, loaded in the right order. It was a nightmare. Modules solve that by giving every file its own scope and an explicit way to share things.
The ES module syntax (the one you should use)
ES modules are the standard. They work in every modern browser and in Node.js since version 14. Here is the simplest possible example. Save these two files in the same folder:
// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14159;
// app.js
import { add, PI } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
To run app.js in the browser, you need a special script tag:
<script type="module" src="app.js"></script>
The type="module" attribute is what turns the script into a module. Without it, import is a syntax error.
Named exports versus default exports
There are two flavours of export. Named exports, which you have just seen, can appear many times in a file:
// utils.js
export const a = 1;
export const b = 2;
export function foo() {}
Default exports, on the other hand, are one-per-file:
// logger.js
export default function log(message) {
console.log(message);
}
You import a default export with a different syntax:
import log from "./logger.js";
log("Hello!");
Most modern codebases prefer named exports because they are explicit about what you are pulling in. Default exports are still common in library entry points (for example, React exports its main Component class as default).
Renaming on import
Sometimes the name in the file does not match what you want locally. You can rename with the as keyword:
import { add as sum, PI as π } from "./math.js";
console.log(sum(2, 3));
console.log(π);
This is also how you resolve name clashes. If two libraries export format, you can import one as formatDate and the other as formatCurrency.
Default + named exports in one file
You can combine them, but it gets messy. Generally pick one:
// api.js
export function get(url) { /* ... */ }
export function post(url, body) { /* ... */ }
export default { get, post };
A consumer can then do either:
import http, { get } from "./api.js";
// or
import * as http from "./api.js";
The import * as syntax pulls everything into a namespace object. Useful for utility libraries.
How modules differ from regular scripts
Modules have three properties that ordinary scripts do not:
- Strict mode by default. No need for
"use strict"at the top. - Top-level scope. Variables declared in a module are not global. They live in the module's own scope.
- Deferred execution. Modules wait for the HTML to be parsed before running, the same as
<script defer>.
The third point is the one that surprises people. If you put a module script in the <head>, it will not run before the body has loaded. You can put modules anywhere and they will Just Work.
The CommonJS history
When Node.js was first released in 2009, it needed a way to share code between files immediately. The browser standard was nowhere near ready. So Node.js invented its own system, called CommonJS, which uses require and module.exports:
// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
// app.cjs
const { add } = require("./math.cjs");
console.log(add(2, 3));
For a decade, every Node.js tutorial taught CommonJS. It is still everywhere in legacy code. Then ES modules finally landed in Node.js (v14, with stability in v22), and now there are two parallel systems in the same runtime. That is the source of most of the confusion.
CommonJS versus ES modules
The two systems are not interoperable without some friction. You cannot import a CommonJS module using ES module syntax directly, and vice versa. Most bundlers (Vite, esbuild, Webpack) handle the conversion for you. If you are writing a new Node.js project today, use ES modules — they are the future.
To force a Node.js project to use ES modules, add this to your package.json:
{
"type": "module"
}
Or, more surgically, rename your files to use the .mjs extension. Either approach tells Node.js to treat .js files as ES modules.
Why your bundler hates you
Modern web projects almost always use a bundler: Vite, esbuild, Webpack, or Parcel. The bundler takes all your import statements, follows the chain, and produces a single (or a few) JavaScript files that the browser can load. The reason it sometimes yells at you is usually one of four things:
- You forgot the file extension in an
import. ES modules require the full./math.js, not just./math. Some bundlers relax this for convenience, but it is the spec. - You have a circular import. File A imports B, which imports A. This sometimes works but often produces
undefinedvalues. Refactor. - You are mixing CJS and ESM in the same project. Decide on one. Add the appropriate package.json field or use
.mjs/.cjsextensions consistently. - You are importing a CommonJS module from ESM. Many libraries ship CommonJS only. The default import gives you
{ default: module }instead of the module itself. Destructure or use a dynamic import.
Dynamic imports for code splitting
Sometimes you want to load a module only when needed (a big charting library, an admin panel the user rarely visits). Use a dynamic import:
button.addEventListener("click", async () => {
const { drawChart } = await import("./chart.js");
drawChart();
});
That returns a Promise. The browser downloads chart.js only when the user clicks. This is how modern apps stay fast on first load.
Going one step further: barrel files
Once a project grows past a handful of files, you find yourself writing long import paths everywhere. The fix is a barrel file: an index.js at the folder root that re-exports everything in the folder.
// utils/index.js
export { add, sub } from "./math.js";
export { formatDate } from "./dates.js";
export { log } from "./logger.js";
Now consumers can write:
import { add, formatDate } from "./utils/index.js";
Instead of three separate imports. The catch: barrel files can hurt tree-shaking if a bundler is not smart enough to follow the re-exp
Further reading
JavaScript’s module story was a mess for a decade; it has now converged on ES modules everywhere. The two sources we trust are MDN for the language itself and the bundler documentation for the build pipeline.
- MDN ES modules guide — the canonical reference for import/export syntax, module records, and how modules load in browsers.
- MDN import statement — the full reference for the import statement, including dynamic import and module namespace objects.
- webpack modules documentation — the official webpack reference for how it resolves modules, applies loaders, and tree-shakes exports.
Module resolution: how import "./math" actually finds the file
When you write import x from "./math" without an extension, the runtime or bundler goes looking. It tries ./math.js, then ./math.cjs, then ./math.mjs, then a folder with an index.js inside. That is the resolution algorithm. Most bundlers also let you configure aliases — @/components instead of ../../../components — through a config file.
The bare specifier case (import react from "react") is different. That goes through node_modules — Node.js walks up the directory tree looking for a folder named react with a package.json that has a main or exports field. That is how npm packages get pulled in.
FAQ
Should I use CommonJS or ES modules in Node.js?
ES modules for new projects. CommonJS for old projects you cannot easily migrate. If you are starting fresh in 2026, ES modules are the default everywhere.
What is "type": "module" in package.json?
It tells Node.js to treat all .js files in that folder as ES modules. Without it, Node.js assumes CommonJS. The alternative is to name your files .mjs.
Why does my import work locally but break in production?
Almost always a file-extension issue. ES modules require the full extension. Webpack and Vite usually relax this, but the raw spec does not.
What is tree-shaking?
It is the process by which a bundler removes exports you do not use. ES modules make tree-shaking possible because the import and export statements are statically analysable. CommonJS, with its dynamic require, does not allow it as easily.
How do I import a CommonJS module from ESM?
The default import gives you the entire module.exports object. Either destructure it or use a namespace import: import * as foo from "./foo.cjs".
Can I have a circular import?
Sometimes, but only if the cycle involves only function definitions or exports, not top-level side effects. As soon as one file reads a value from another at top level, you will get undefined. Refactor.
What is the difference between side effects and pure modules?
A pure module exports values and has no top-level side effects. A side-effectful module runs code as soon as it is imported — registering a global, patching a prototype, attaching event listeners. Side-effectful imports are written as import "./polyfill.js" with no bindings. They are mostly used for polyfills and CSS imports.
Homework
Take any small project — even the click counter from the JavaScript beginners article — and split it into three files:
counter.js— exports a function that increments and returns a number.ui.js— exports a function that wires up the click listener.main.js— imports from both, runs the UI function.
Load main.js with <script type="module">. If everything is wired correctly, the click counter will work just like before. Bonus: add a dynamic import for a "reset to zero" feature that only loads the counter module when a button is clicked.