Every modern JavaScript project — browser, Node.js or bundled — is organised as modules.
An ES module is a file with its own scope that declares what it shares with export and what it needs with import:
// math.js
export const PI = 3.14159;
export function area(r) { return PI * r * r; }
export default class Circle { /* ... */ }
// app.js
import Circle, { area, PI as pi } from "./math.js";
import * as math from "./math.js";Named exports are imported by their exact name (renaming with as). A module may also have one default export, imported with any name you like. Many teams prefer named exports only, because they keep names consistent and work better with tooling.
Key properties:
- Imports are static: they must be at the top level and use string literals, so tools can analyse and tree-shake them.
- Imports are live, read-only bindings: if the exporting module updates an exported
let, importers see the new value, but they can't assign to it. - A module is evaluated once, however many times it's imported.
- Modules are always in strict mode and support top-level `await`.
When you need to load code conditionally or lazily, use dynamic import: const { chart } = await import("./chart.js") returns a promise for the module.
In browsers, load the entry file with <script type="module">; relative specifiers need the file extension. In Node.js, files are ES modules when they end in .mjs or the nearest package.json has "type": "module"; otherwise they're CommonJS (require/module.exports). Bare specifiers like "react" are resolved by Node or your bundler.
ES2025 adds import attributes for non-JavaScript modules: import data from "./data.json" with { type: "json" }.
Example
// --- cart.js ---
export const TAX = 0.2;
let count = 0;
export { count }; // live binding
export function addItem() { count++; }
export default function total(prices) {
return prices.reduce((s, p) => s + p, 0) * (1 + TAX);
}
// --- main.js (run with: node main.js, "type": "module" in package.json) ---
import total, { addItem, count, TAX as tax } from "./cart.js";
addItem();
addItem();
console.log(count); // 2 (live binding sees updates)
console.log(total([10, 20]), tax); // 36 0.2
// count = 5; // TypeError: assignment to constant variable
if (process.argv.includes("--report")) {
const { writeFile } = await import("node:fs/promises"); // lazy, top-level await
await writeFile("report.txt", String(count));
}Remember
- Named exports are imported by name; a default export by any name
- Imports are static, live and read-only; each module runs once
- Use
import()for lazy or conditional loading
Watch out for
- Omitting the
.jsextension in browser or Node ESM relative imports.