For a while, you can keep an entire project inside one .js file. It works — right up until it doesn’t. As the file grows past a few hundred lines, finding anything becomes a chore, two functions accidentally share a variable name, and every script you add to the page can quietly stomp on every other one because they all live in the same global space. Modules are JavaScript’s answer to that mess: a way to split your code across many files, where each file clearly states what it shares with the outside world (export) and what it pulls in from elsewhere (import).
If you’ve written any non-trivial JavaScript, you’ve felt the pain modules solve. Once you’re comfortable with export and import, you’ll never want to go back to one giant file again. This article covers the modern ES module system — the standard that ships in every current browser and in Node.js — from the ground up.
What a module is, and why you want one
A module is simply a JavaScript file that uses export and import. That’s the whole definition. The moment a file participates in this system, three useful things happen:
- Its top-level variables stay private. Anything you declare in a module is local to that module unless you explicitly
exportit. No more accidental global collisions. - Dependencies become explicit. You can see at the top of a file exactly what it relies on, instead of hoping some other
<script>tag loaded first. - Code becomes reusable. A function written once in
math.jscan be imported into ten other files without copy-paste.
Before modules existed, the only way to share code across <script> tags was to dump everything onto the global window object and pray nothing clashed. Modules retire that whole era. Each file gets its own clean scope — closely related to the idea of scope and closures you may have seen — and you wire files together deliberately.
Named exports
The most common way to share something is a named export. You attach the export keyword to a declaration, and the name you used becomes the name others import by.
Say you have a file of utility functions:
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
Three things are now exported from math.js: a constant PI, and two functions add and multiply. Everything else you might write in that file (helper variables, internal functions) stays private to it.
In another file, you import them by name, inside curly braces:
// app.js
import { add, multiply, PI } from "./math.js";
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(PI); // 3.14159
The path "./math.js" is a relative path — the ./ means “in the same folder as this file.” The names inside { } have to match the exported names exactly, because that’s how the two files agree on what’s being shared.
You don’t have to import everything a module offers. Grab only what you need:
import { add } from "./math.js"; // multiply and PI are never loaded into this file
Export inline or all at once
You can write export directly in front of each declaration, as above, or list everything in one statement at the bottom of the file: export { add, multiply, PI };. Both produce identical named exports — pick whichever reads more clearly for the file. The bottom-of-file list is handy when you want a single place to see everything a module hands out.
Renaming with as
Sometimes an imported name would clash with something you already have, or it’s just unclear in the new context. Use as to rename it on the way in:
import { add as sum } from "./math.js";
console.log(sum(10, 20)); // 30
You can rename on the export side too, but renaming during import is what you’ll reach for most. The same as keyword also powers a handy trick — pulling in everything a module exports under one namespace object:
import * as math from "./math.js";
console.log(math.add(2, 3)); // 5
console.log(math.PI); // 3.14159
Now math is an object whose properties are every named export. This keeps things tidy when a module exports a lot and you’d rather not list each name — though listing names is usually clearer for the reader.
Default exports
A module can also have one default export — a single, “main” thing the file is really about. You mark it with export default:
// User.js
export default function createUser(name) {
return { name, createdAt: Date.now() };
}
When you import a default, you do not use curly braces, and — this is the key difference — you choose the name yourself:
import createUser from "./User.js";
// or, equally valid:
import makeUser from "./User.js";
const u = createUser("John Doe");
Because there’s only one default per file, the importer is free to call it whatever fits. A file can mix one default export with several named exports, and you import both styles in a single line:
// User.js
export default function createUser(name) { /* ... */ }
export const ROLES = ["admin", "editor", "viewer"];
import createUser, { ROLES } from "./User.js";
The default comes first (no braces), then the named ones in braces.
Default vs named: pick a convention and stick to it
Default and named exports are easy to confuse. The rules: a file can have at most one default export but any number of named exports. You import a default with no braces and any name you like; you import named exports inside braces with their exact names. Mixing them up — putting a named import outside braces, or a default inside them — is one of the most common module errors. Many teams pick a rule like “one main thing per file → default; collections of helpers → named” and apply it consistently so imports stay predictable.
How the browser loads modules
A regular <script> tag does not understand import and export. To run a module in the browser, you mark the script with type="module":
<script type="module" src="app.js"></script>
That one attribute changes several behaviors at once, and they’re worth knowing:
- The browser will follow the
importstatements insideapp.js, fetch every dependency, and run them in the right order. You only point it at your entry file. - Module scripts are deferred by default — they wait until the HTML is parsed before running, so you don’t need to place them at the bottom of
<body>or wrap code in a load listener. - Each module runs in strict mode automatically, and its top-level variables are not added to the global
window.
There’s one practical catch worth flagging early.
Modules need a server, not the file:// protocol
If you open an HTML file with type="module" by double-clicking it (so the address bar shows file://...), the imports will fail with a CORS error. Browsers refuse to load modules over the file:// protocol for security reasons. The fix is to serve the files over HTTP — run any local dev server (for example npx serve, the “Live Server” editor extension, or your framework’s dev command) and open the page at http://localhost/.... This trips up nearly everyone the first time, and the error message rarely makes the cause obvious.
A realistic multi-file example
Let’s wire up something closer to real life: a small cart, split sensibly across files. First, a couple of helpers:
// format.js
export function formatPrice(amount) {
return "Rp " + amount.toLocaleString("id-ID");
}
// cart.js
export function getTotal(items) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
Now an entry file that pulls both in and uses them together:
// app.js
import { getTotal } from "./cart.js";
import { formatPrice } from "./format.js";
const items = [
{ name: "Keyboard", price: 250000, qty: 1 },
{ name: "Mouse", price: 120000, qty: 2 },
];
const total = getTotal(items);
console.log(formatPrice(total)); // "Rp 490.000"
Each file does one job: format.js formats money, cart.js does cart math, app.js ties them together. If a bug shows up in the total, you know exactly which file to open. That clarity is the entire point of modules — and it pays off more with every file you add. The reduce call here is one of the array methods worth getting comfortable with separately.
Modules vs older systems (CommonJS)
You’ll inevitably run into a different syntax in older tutorials and Node.js code: require() and module.exports. That’s CommonJS, the module system Node.js used before ES modules became standard.
// CommonJS — the OLDER style, mostly in older Node.js code
const math = require("./math.js");
module.exports = { add, multiply };
For new code, prefer ES modules — the import/export syntax in this article. It’s the official language standard, it works natively in browsers, and modern Node.js supports it fully (set "type": "module" in your package.json, or use the .mjs extension). Knowing CommonJS exists is useful for reading older code, but you generally won’t reach for it when starting something fresh today.
A quick way to tell the two apart
If you see import / export, it’s an ES module — the modern standard. If you see require() / module.exports, it’s CommonJS — the older Node.js convention. They aren’t fully interchangeable, and mixing them in one project can cause confusing errors, so it’s worth knowing which one a given file (or project) is using before you start editing.
Dynamic imports
Everything so far has been static — imports written at the top of the file, resolved before the module runs. Sometimes you want to load a module only when it’s needed — a heavy chart library that’s only used on one page, say. For that there’s the import() function, which returns a promise:
button.addEventListener("click", async () => {
const { drawChart } = await import("./chart.js");
drawChart();
});
Here chart.js isn’t downloaded until the user actually clicks the button. This is the foundation of code splitting — shipping less JavaScript up front and loading the rest on demand. You won’t need it on day one, but it’s good to know the door exists, especially for larger apps where load time matters.
Best practices
A few habits that keep a module-based codebase pleasant to work in:
- One responsibility per file. A module should be about one clear thing. If you can’t summarize what a file does in a short phrase, it’s probably doing too much.
- Keep exports intentional. Export only what other files genuinely need. Everything else stays private — that privacy is a feature, not a limitation.
- Use clear, consistent paths. Relative paths (
./,../) are explicit about where a file lives. Be consistent so imports are easy to scan. - Don’t over-fragment. Modules are great, but a hundred ten-line files can be as hard to navigate as one giant file. Group related code sensibly.
- Avoid circular imports. If
a.jsimports fromb.jsandb.jsimports back froma.js, you can getundefinedvalues at load time. It usually signals that some shared code belongs in a third file both can import.
Wrapping up
Modules turn a sprawling script into an organized, reusable set of files:
- A module is a
.jsfile that usesexportto share things andimportto bring them in. Its top-level variables stay private by default. - Named exports are imported inside
{ }using their exact names; rename them withas, or grab them all withimport * as name. - A file can have one default export (
export default), imported with no braces and any name you choose. You can mix one default with named exports in the same file. - In the browser, load modules with
<script type="module">, and serve over HTTP —file://won’t work. - Prefer ES modules (
import/export) over the older CommonJS (require/module.exports) for new code. Reach for dynamicimport()when you want to load a module on demand.
With your code split cleanly across files, the next natural step is organizing the data and behavior inside those files into reusable blueprints. That’s the job of classes — the topic of the next article — which pair beautifully with the module system you just learned.