As a program grows, one long script file becomes hard to read, test and change. ES modules let you split code into files, where each file chooses what to export and each file that needs something imports it by name. They are the standard module system of JavaScript, supported natively in every current browser and in Node.js.
What a module is
A module is just a JavaScript file loaded as a module. Three things make it different from a classic script:
- Its own scope. Top-level variables and functions stay private to the file. Nothing leaks onto
window. - Strict mode is always on. You do not need
"use strict". - Explicit connections. A module can only use what other modules export, and only if it imports it.
The course dashboard from lesson 33 is a good candidate. Its single app.js fetches data, describes errors, renders the list and wires up events. We will split it into three files:
dashboard/
index.html
app.js
modules/
api.js
render.js
data/
courses.json
Named exports
Put export in front of any declaration you want to share:
// modules/api.js
export async function getJSON(url, { timeout = 8000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`The server answered ${response.status} for ${url}.`);
}
return await response.json();
} finally {
clearTimeout(timer);
}
}
export function describeError(error) {
if (error.name === "AbortError") return "The server took too long to respond.";
if (error instanceof SyntaxError) return "The course data could not be read.";
if (error instanceof TypeError) return "Could not reach the server.";
return error.message;
}
You can also export at the end of the file, which gives a quick overview of the module's public API:
// modules/render.js
const list = document.querySelector("#course-list");
const status = document.querySelector("#status");
function createCourseItem(course) {
const item = document.createElement("li");
item.dataset.id = course.id;
const heading = document.createElement("h2");
heading.textContent = course.title;
const details = document.createElement("p");
details.textContent = `${course.topic}, ${course.level}, ${course.lessons} lessons`;
item.append(heading, details);
return item;
}
function renderCourses(items) {
list.replaceChildren(...items.map(createCourseItem));
status.textContent = `Showing ${items.length} courses.`;
}
export { renderCourses };
These are the functions from lesson 28. createCourseItem, list and status are not exported, so they stay private to render.js. Other modules cannot reach them. Module scripts run after the HTML has been parsed, so the querySelector calls find their elements.
Importing
Import named exports with curly braces, using the exact exported names:
// app.js
import { getJSON, describeError } from "./modules/api.js";
import { renderCourses } from "./modules/render.js";
const status = document.querySelector("#status");
try {
const courses = await getJSON("data/courses.json");
renderCourses(courses);
} catch (error) {
console.error(error);
status.textContent = describeError(error);
}
Notice the top-level await: it is allowed in modules, so app.js no longer needs a wrapper function.
Import paths in the browser follow two rules:
- Relative paths start with
./or../. A bare name such as"api.js"or"lodash"is an error in the browser unless an import map defines it. - Include the file extension. The browser fetches exactly the URL you write; it does not guess
.js.
Paths are resolved against the importing file, not the HTML page. render.js would import from api.js with "./api.js", because both live in modules/.
Renaming and namespaces
// Rename on import to avoid a clash or clarify meaning
import { getJSON as fetchJSON } from "./modules/api.js";
// Rename on export (in render.js, where renderCourses is defined)
export { renderCourses as render };
// Import everything as one namespace object
import * as api from "./modules/api.js";
api.getJSON("data/courses.json");
Default exports
A module can also have one default export. It is imported without braces, under any name you choose:
// modules/format-date.js
export default function formatDate(isoDate) {
return new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" })
.format(new Date(isoDate));
}
import formatDate from "./modules/format-date.js";
import niceDate from "./modules/format-date.js"; // also works: same function
console.log(formatDate("2026-09-14")); // → 14 Sept 2026
A module can mix one default export with any number of named ones:
import formatDate, { otherHelper } from "./modules/format-date.js";
Named or default?
Many teams prefer named exports for almost everything:
- The name is fixed by the module, so a search for
getJSONfinds every use. - Editors can auto-import and rename named exports reliably.
- Typos fail loudly: importing a name that does not exist is a
SyntaxErrorwhen the module loads.
Default exports suit a module that has one obvious main thing. Whichever you choose, be consistent within a project.
Re-exporting
A module can gather exports from other modules into one entry point:
// modules/index.js
export { getJSON, describeError } from "./api.js";
export { renderCourses } from "./render.js";
Other code then imports everything from ./modules/index.js. Use this sparingly: very large re-export files make it harder for tools to drop unused code.
Modules in the browser
Load the entry module with type="module":
<script type="module" src="app.js"></script>
Only the entry file needs a script tag. The browser follows the import statements and downloads the rest.
Module scripts behave differently from classic scripts:
| Classic script | Module script |
|---|---|
Runs as soon as it downloads, blocking parsing, unless defer or async is set | Deferred by default: runs after the HTML is parsed, in order |
| Shares the global scope | Has its own scope |
Works from file:// | Must be served over HTTP, like fetch() (see lesson 33) |
| Runs every time it is included | Runs once, however many times it is imported |
The last row matters. Every importer of api.js gets the same module instance, so state held in a module (a cache, for example) is shared. Script loading attributes are covered in depth in the HTML course.
Imports are live, read-only bindings
An import is not a copy. It is a live view of the exporting module's variable:
// modules/counter.js
export let count = 0;
export function increment() {
count += 1;
}
// app.js
import { count, increment } from "./modules/counter.js";
console.log(count); // → 0
increment();
console.log(count); // → 1
count = 5; // TypeError: Assignment to constant variable.
Only the exporting module can change the value. Importers can read it but never reassign it.
Dynamic import: loading code on demand
Static import statements must sit at the top level of a module, and everything they name is loaded before the module runs. Sometimes you want code only when it is needed, for example a statistics panel that most visitors never open. The import() function loads a module at runtime and returns a promise for its namespace object:
// app.js
const statsButton = document.querySelector("#show-stats");
statsButton.addEventListener("click", async () => {
statsButton.disabled = true;
try {
const { renderStats } = await import("./modules/stats.js");
renderStats(document.querySelector("#stats"));
} catch (error) {
console.error(error);
status.textContent = "The statistics could not be loaded.";
} finally {
statsButton.disabled = false;
}
});
stats.js is not downloaded until the first click. Later clicks reuse the already loaded module. Unlike static imports, import() works in classic scripts too, and its path can be any string built at runtime, such as a file chosen by the user's language.
Modules in Node.js
Node.js 20+ treats a file as an ES module when:
- the file name ends in
.mjs, or - the nearest
package.jsoncontains"type": "module"and the file ends in.js.
{
"name": "dashboard-tools",
"type": "module"
}
Node's built-in modules are imported with the node: prefix:
import { readFile } from "node:fs/promises";
const text = await readFile("data/courses.json", "utf8");
console.log(JSON.parse(text).length); // → 6
Older Node.js code uses CommonJS, with require() and module.exports. You will still see it, but new code should use ES modules. Packages, npm and bundlers belong to a separate tooling course; lesson 50 looks at structuring modules so they are easy to test.
Summary
- A module has its own scope, runs in strict mode and shares only what it exports.
- Use
exporton declarations or in anexport { ... }list; import named exports with{ }and the exact name. - A module can have one
export default, imported without braces under any name. - In the browser, load the entry file with
<script type="module">, use./paths with extensions, and serve over HTTP. - Modules are deferred, run once, and their imports are live, read-only bindings.
import()loads a module on demand and returns a promise.- In Node.js, use
.mjsor"type": "module", and thenode:prefix for built-ins.