ES Modules
As programs grow, putting everything in one JavaScript file becomes hard to maintain. ES modules let you split code into files and explicitly choose what each file shares.
Modules help you:
- Reuse code without copying it
- Keep private helper functions private
- Avoid global variables
- Make dependencies visible with
importstatements - Let browsers and tools load only the code they need
The website project in Build a Website already uses modules for Web Components.
Named exports
A file can export multiple named values:
// math.js
export const pi = 3.14159;
export function add(a, b) {
return a + b;
}
export function circleArea(radius) {
return pi * radius * radius;
}
Import named exports with matching names:
// main.js
import { add, circleArea } from "./math.js";
console.log(add(2, 3));
console.log(circleArea(2));
Result:
5
12.56636
Named exports are usually the clearest choice because the imported names are explicit.
Default exports
A module can also have one default export:
// logger.js
export default function log(message) {
console.log(`[app] ${message}`);
}
Import a default export with any local name:
// main.js
import log from "./logger.js";
log("Started");
Result:
[app] Started
Default exports are common for a file whose main purpose is one class, function, or component. Prefer named exports when a file exposes several related values.
Export lists
You can define values first and export them at the end:
const taxRate = 0.19;
function calculateTax(amount) {
return amount * taxRate;
}
function calculateTotal(amount) {
return amount + calculateTax(amount);
}
export { calculateTax, calculateTotal };
This style keeps the public API visible in one place.
Renaming imports and exports
Use as when names would conflict or when a local name should be clearer:
// text.js
function format(value) {
return String(value).trim().toLowerCase();
}
export { format as formatText };
// main.js
import { formatText as normalizeText } from "./text.js";
console.log(normalizeText(" Hello "));
Result:
hello
Namespace imports
Use * as to import a module as an object:
// math.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// main.js
import * as math from "./math.js";
console.log(math.add(2, 3));
console.log(math.multiply(4, 5));
Result:
5
20
Namespace imports are useful when a module has many related exports, but avoid using them as a dumping ground.
Re-exports
Re-export from another module to create a small public entry point:
// shapes/circle.js
export function circleArea(radius) {
return Math.PI * radius * radius;
}
// shapes/square.js
export function squareArea(side) {
return side * side;
}
// shapes/index.js
export { circleArea } from "./circle.js";
export { squareArea } from "./square.js";
// main.js
import { circleArea, squareArea } from "./shapes/index.js";
console.log(circleArea(2).toFixed(2));
console.log(squareArea(4));
Result:
12.57
16
Re-export files are sometimes called "barrels". Use them when they simplify imports, but avoid very large barrels that hide what code is actually used.
Browser modules
In browsers, load an ES module with type="module":
<script type="module" src="./main.js"></script>
Browser modules have important rules:
- They are deferred by default, so they run after the HTML document has been parsed.
- They always run in strict mode.
- Top-level variables stay in module scope; they do not become properties on
window. - Module files are loaded with CORS rules. Opening an HTML file with
file://usually does not work. Use a local HTTP server instead. - Relative imports need
./,../, or/. - In browsers without a bundler, relative imports need the file extension:
./math.js, not./math.
// main.js
import { add } from "./math.js";
console.log(add(2, 3));
The ./ is required. A bare specifier such as "math" only works when a bundler or import map knows what it means.
Module scope
Each module has its own top-level scope:
// user.js
const name = "Ada";
export function getName() {
return name;
}
// main.js
import { getName } from "./user.js";
const name = "Grace";
console.log(getName());
console.log(name);
Result:
Ada
Grace
The two name variables do not conflict because they live in different modules.
Modules evaluate once
A module runs once, then its exports are reused by every importer:
// counter.js
let count = 0;
export function increment() {
count += 1;
return count;
}
// first.js
import { increment } from "./counter.js";
console.log(increment());
// second.js
import { increment } from "./counter.js";
console.log(increment());
If first.js and second.js run in the same module graph, they share the same count.
Result:
1
2
This behavior is useful for singletons such as shared configuration, caches, or connection managers. Be careful with mutable shared state because every importer sees the same module instance.
Live bindings
Imported bindings are live. If the exporting module changes a value, importers see the updated value:
// counter.js
export let count = 0;
export function increment() {
count += 1;
}
// main.js
import { count, increment } from "./counter.js";
console.log(count);
increment();
console.log(count);
Result:
0
1
The imported count binding is read-only from the importing module. You can read updates, but you cannot assign to
count directly in main.js.
Dynamic import()
Static imports run before the module body. Dynamic import() loads a module when the code reaches that line:
async function loadMath() {
const math = await import("./math.js");
return math.add(2, 3);
}
console.log(await loadMath());
Result:
5
Dynamic import is useful for:
- Loading optional features
- Splitting large applications into smaller chunks
- Loading code only after a user action
import() returns a promise that resolves to the module namespace object.
Top-level await
In ES modules, await is allowed at the top level:
// user.js
const response = await Promise.resolve({ name: "Ada" });
export const user = response;
// main.js
import { user } from "./user.js";
console.log(user.name);
Result:
Ada
Modules that import user.js wait until its top-level await finishes. Use this carefully - it can delay the startup of
the whole module graph.
import.meta.url
import.meta.url contains the URL of the current module. It is useful for building paths relative to the module file:
const dataUrl = new URL("./data/users.json", import.meta.url);
console.log(dataUrl.pathname.endsWith("/data/users.json"));
Result:
true
In Node.js, import.meta.url is a file: URL for local files. In browsers, it is the URL from which the module was
loaded.
CommonJS vs ES modules in Node.js
Node.js supports both CommonJS and ES modules:
| Format | Export syntax | Import syntax | Common file extensions |
|---|---|---|---|
| ESM | export function add() {} | import { add } from "./math.js" | .mjs, or .js with ESM |
| CommonJS | module.exports = { add } | const { add } = require("./math.cjs") | .cjs, or .js with CJS |
Use "type": "module" in package.json when .js files should be ES modules:
{
"type": "module"
}
Then .js files in that package are treated as ES modules. Use .cjs for a CommonJS file inside an ESM package, and
.mjs for an ESM file inside a CommonJS package. Being explicit avoids surprises.
// math.mjs
export function add(a, b) {
return a + b;
}
// legacy.cjs
const math = require("./math.mjs");
console.log(math.add(2, 3));
Result:
5
In Node.js 22.12+ and 20.19+, require() of ES modules is unflagged for modules that do not use top-level await. If
the ES module uses top-level await, load it from CommonJS with dynamic import() instead:
// legacy.cjs
async function main() {
const math = await import("./math.mjs");
console.log(math.add(2, 3));
}
main();
For new Node.js code, prefer one module system per package. ESM is the modern default for browser-aligned JavaScript.
Import maps
Import maps let browsers resolve bare specifiers without a bundler:
<script type="importmap">
{
"imports": {
"utils/": "/assets/js/utils/"
}
}
</script>
<script type="module">
import { formatDate } from "utils/dates.js";
console.log(formatDate(new Date("2026-09-24T00:00:00Z")));
</script>
Import maps are useful for small browser projects, demos, and controlling CDN URLs. In larger applications, a bundler usually manages this mapping for you.
Bundlers
Bundlers such as Vite read your module graph and prepare it for production:
- They let you import packages from
node_modules. - They transform modern syntax when needed.
- They combine and split files for faster loading.
- They fingerprint assets for caching.
- They provide a development server with hot reload.
For a new browser project, Vite is a common starting point:
pnpm create vite my-app
cd my-app
pnpm install
pnpm dev
Even with a bundler, writing clear ES modules matters. The bundler follows your imports and exports.
Common mistakes
Missing ./ in browser imports
// Bad in browsers without a bundler or import map
import { add } from "math.js";
// Good
import { add } from "./math.js";
Forgetting the file extension in browser imports
// Bad in browsers without a bundler
import { add } from "./math";
// Good
import { add } from "./math.js";
Expecting module variables to be global
// main.js
var appName = "Docs";
console.log(window.appName);
Result:
undefined
In a classic <script>, a top-level var becomes a property of window. In a module, even var stays in module scope.
Export values if another module needs them.
Summary
- ES modules split code into files with explicit
importandexportstatements. - Named exports are explicit and work well for most modules; default exports are best when one value is the clear main export.
- Imports can be renamed, grouped with
* as, or re-exported through an entry file. - Browser modules use
<script type="module">, are deferred by default, run in strict mode, and need HTTP rather thanfile://. - Relative browser imports need
./or../and usually need the file extension without a bundler. - Modules evaluate once and exports are live bindings.
- Dynamic
import()loads modules on demand; top-level await can delay modules that depend on it. import.meta.urlgives the current module URL.- Node.js supports both CommonJS and ESM. Use
"type": "module",.mjs, and.cjsintentionally. - Import maps and bundlers help resolve module specifiers and prepare code for browsers.
Next up: Maps & Sets - use keyed collections and unique-value sets effectively.