Welcome back. In the previous lesson, you used spread, rest, map(), and filter() to keep application data updates predictable. Those patterns become much more useful when code is separated into focused files: one module can own cart calculations, another can handle DOM rendering, and a third can start an HTTP server.
In this lesson, you will organize both browser and Node.js code with ECMAScript modules (usually called ES modules or ESM). You will learn the boundary between a module’s private implementation and its public API, use named and default exports, configure modules in a browser and in Node.js, and recognize the import paths that work in each environment.
Modules create useful boundaries
A module is a JavaScript file that can explicitly export selected values and another module can explicitly import them.
Without modules, a growing application tends to accumulate unrelated code in a few large files. A main.js file may contain DOM rendering, validation helpers, API calls, formatting functions, and event handlers. It works at first, but the dependencies become unclear: changing a helper may accidentally affect code far away.
A module makes a small contract:
- The module’s top-level variables and helper functions are private by default.
exportdefines what other files are allowed to use.importstates exactly what a file depends on.
For example, a pricing module can expose only the functions that the rest of the application needs:
// pricing.js
const TAX_RATE = 0.18;
function roundToTwoDecimals(value) {
return Math.round(value * 100) / 100;
}
export function calculateTax(amount) {
return roundToTwoDecimals(amount * TAX_RATE);
}
export function calculateTotal(amount) {
return roundToTwoDecimals(amount + calculateTax(amount));
}
TAX_RATE and roundToTwoDecimals() are available inside pricing.js, but not outside it. The public interface consists only of calculateTax and calculateTotal.
Another file can use that interface:
// checkout.js
import { calculateTotal } from "./pricing.js";
const subtotal = 2499;
const total = calculateTotal(subtotal);
console.log(total);
The braces in the import statement mean: “Import the named export called calculateTotal.” The imported name must match the exported name unless you deliberately create an alias.
Before continuing, watch the browser-first explanation and then its Node.js transition.
ES Modules in NodeJS and the Browser
Watch “ES Modules in NodeJS and the Browser” by Steve Griffith. It gives a practical visual introduction to module boundaries, named exports, default exports, and the different setup needed by browser and Node.js environments.
Watch browser basics for the first named export and import, especially the reason a browser script needs type="module". Continue with export forms to compare named exports, default exports, and aliases. Then watch Node setup for the common “Cannot use import statement outside a module” error and one way to enable ESM in Node.
Using modules in the browser
A browser needs to know that your entry script is a module. In HTML, add type="module":
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Module demo</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="./src/main.js"></script>
</body>
</html>
That script tag identifies main.js as the entry module. The browser loads main.js, sees its imports, and then loads the dependency files it needs.

A minimal browser project might look like this:
module-demo/
index.html
src/
dom.js
main.js
Put a focused DOM helper in src/dom.js:
// src/dom.js
export function createTextElement(tagName, text) {
const element = document.createElement(tagName);
element.textContent = text;
return element;
}
Then import and use it from the entry module:
// src/main.js
import { createTextElement } from "./dom.js";
const app = document.querySelector("#app");
const heading = createTextElement(
"h1",
"Hello from a browser module"
);
app.append(heading);
Three browser rules matter immediately:
- Use
type="module"on the HTML script tag that loads your entry file. - Use a server while developing. Opening
index.htmldirectly through afile://URL commonly produces module-loading or CORS errors. Serve the project through a local development server instead. - Include the file extension in relative imports. Write
"./dom.js", not"./dom".
Module scripts also behave differently from traditional scripts:
- They run in strict mode automatically.
- They are deferred by default, so you do not need to add the
deferattribute separately. - Variables declared at a module’s top level do not become global variables on
window.
That last rule protects larger applications. A generic name such as config, state, or formatDate can exist in separate modules without automatically colliding.
Exporting and importing deliberately
Named exports
Use named exports when a file exposes several related capabilities.
// validation.js
export function isRequired(value) {
return value.trim().length > 0;
}
export function hasMinLength(value, minimum) {
return value.trim().length >= minimum;
}
Import exactly what you need:
import { isRequired, hasMinLength } from "./validation.js";
Named exports are especially clear in team projects because the imported identifiers document a file’s dependencies. If checkout.js imports calculateTotal, you can see its relationship to pricing logic immediately.
You can also export values after declaring them:
const API_BASE_URL = "http://localhost:3000";
const REQUEST_TIMEOUT_MS = 5000;
export {
API_BASE_URL,
REQUEST_TIMEOUT_MS
};
Both forms are valid. Inline exports are often convenient for small utilities; a final export block can make a module’s public API easy to scan.
Aliasing a named import
Sometimes a useful imported name conflicts with a local name or needs more context at the importing site.
import {
calculateTotal as calculateOrderTotal
} from "./pricing.js";
const calculateTotal = function (items) {
return items.length;
};
console.log(calculateOrderTotal(2499));
calculateTotal is the exported name. calculateOrderTotal is the local name used only in this importing file.
Default exports
A module can have one default export. It is often used when a file has one main responsibility, such as a class, React component, or factory function.
// createApiClient.js
export default function createApiClient(baseUrl) {
return {
get(path) {
return fetch(`${baseUrl}${path}`);
}
};
}
Import a default export without braces:
import createApiClient from "./createApiClient.js";
const api = createApiClient("http://localhost:3000");
The importing module chooses the local name:
import makeApiClient from "./createApiClient.js";
const api = makeApiClient("http://localhost:3000");
This works because the source module has declared one default export, not a named export called createApiClient.
Here is the practical syntax comparison:
| Module API | Export | Import |
|---|---|---|
| One or more explicitly named values | export function calculateTotal() {} | import { calculateTotal } from "./pricing.js"; |
| One primary value | export default function createApiClient() {} | import createApiClient from "./createApiClient.js"; |
| Default plus selected named values | export default App; export { version }; | import App, { version } from "./app.js"; |
| A renamed named import | export { calculateTotal }; | import { calculateTotal as calculateOrderTotal } from "./pricing.js"; |
A reasonable convention for a backend utility layer is to prefer named exports. A utility module often has multiple equally important functions, and named imports make usage explicit. Default exports are still common, particularly for React components and a module’s single principal class or function.
There is also a namespace import:
import * as validation from "./validation.js";
console.log(validation.isRequired("Ravi"));
console.log(validation.hasMinLength("Express", 5));
This collects exported members into a namespace object. It can be helpful when consuming a coherent library-like module, but importing selected names is usually clearer for ordinary application code.
Configuring ES modules in Node.js
Browsers know a script is a module through type="module" in HTML. Node.js has no HTML file, so it needs a different explicit signal.
For a modern Node.js project, the clearest approach is to use regular .js files and add this to package.json:
{
"name": "inventory-api",
"private": true,
"type": "module"
}
With "type": "module", Node interprets .js files in that package as ES modules.
inventory-api/
package.json
src/
config.js
server.js
// src/config.js
export const config = {
port: Number(process.env.PORT ?? 3000)
};
// src/server.js
import { createServer } from "node:http";
import { config } from "./config.js";
const server = createServer(function (request, response) {
response.writeHead(200, {
"Content-Type": "application/json"
});
response.end(JSON.stringify({
message: "Inventory API is running"
}));
});
server.listen(config.port, function () {
console.log(`Server listening on port ${config.port}`);
});
Run the entry file from the project root:
node src/server.js
Notice the two kinds of imports:
import { createServer } from "node:http";
import { config } from "./config.js";
"node:http"is a Node built-in module. Thenode:prefix makes that origin explicit."./config.js"is a relative specifier, meaning it starts from the current file’s directory. It requires both./and the.jsextension.
A package dependency uses a bare specifier:
import express from "express";
Node resolves "express" from the installed packages available to the project. In contrast, a raw browser cannot automatically resolve an npm package name such as "express" or "lodash". Browser tools such as Vite, which you will use in the React module, handle that development and build-time workflow.
Read the official Node documentation now to consolidate the configuration and path rules.
ECMAScript modules | Node.js v25.2.1 Documentation
Read the Node.js documentation sections “Enabling” and “Import Specifiers.” They establish the reliable project-level ways to enable ESM and explain why Node requires explicit extensions for local module paths.
In “Enabling,” read the configuration choices. Focus on why "type": "module" is usually convenient for an application, while .mjs is an alternative explicit file-level marker. Then, in “Import Specifiers”, read the “Terminology” subsection from the three specifier types, followed by “Mandatory file extensions.” Pay particular attention to the distinction between relative paths such as ./config.js and bare package names such as express.
The .mjs alternative
Instead of adding "type": "module" to package.json, you can use the .mjs extension:
src/
config.mjs
server.mjs
import { config } from "./config.mjs";
Then run:
node src/server.mjs
Both approaches are valid. For a full-stack application, choose one style and stay consistent. The "type": "module" approach usually keeps filenames familiar and works naturally with tooling.
You will still encounter CommonJS in older tutorials and existing codebases:
const express = require("express");
module.exports = {
someValue: 42
};
That is a different module system. Node supports interoperability in many situations, but a beginner-friendly project rule is simple: do not casually mix require() and import in your own files. Decide whether the project is CommonJS or ESM, then follow that convention consistently.
A focused module design for a full-stack feature
Consider a small “products” feature. A maintainable structure separates jobs by responsibility:
src/
products/
product.service.js
product.controller.js
product.routes.js
app.js
At this stage, focus on the dependency direction rather than Express details:
product.service.jscontains product-related business logic or data access.product.controller.jsreceives HTTP-level input and calls service functions.product.routes.jsmaps URLs to controller functions.app.jscomposes the application.
For example, a service module might expose one focused function:
// product.service.js
const products = [
{ id: "p1", title: "Mouse", price: 899 },
{ id: "p2", title: "Keyboard", price: 2499 }
];
export function findProductById(productId) {
return products.find(function (product) {
return product.id === productId;
});
}
A controller can depend on that function without needing to know how products are stored:
// product.controller.js
import { findProductById } from "./product.service.js";
export function getProduct(request, response) {
const product = findProductById(request.params.productId);
if (!product) {
response.status(404).json({
message: "Product not found"
});
return;
}
response.json(product);
}
This separation matters later when the service moves from an in-memory array to MongoDB. The controller’s responsibility can remain stable while the service implementation changes.
Keep module boundaries based on responsibility, not simply on file size. A file is not automatically well-organized because it is short; it should have a coherent reason to exist.
Build and run both environments
Use this short implementation pass to make the ideas concrete.
Browser pass
- Create
index.html,src/dom.js, andsrc/main.jsusing the earlier browser example. - Serve the project with a local server rather than opening the HTML file directly.
- Confirm the heading appears in the page.
- Add a second named export to
dom.js, such asclearElement(element), and import it intomain.js.
// src/dom.js
export function clearElement(element) {
element.textContent = "";
}
Node pass
- Create a separate folder for a small Node project.
- Add a
package.jsoncontaining"type": "module". - Create
src/config.jsandsrc/server.jsfrom the Node example. - Run
node src/server.js. - Visit
http://localhost:3000in the browser and confirm that Node returns JSON.
The browser and Node projects share ES module syntax, but they do not share the same loader rules. That distinction is important:
| Concern | Native browser module | Node.js ES module |
|---|---|---|
| Entry configuration | <script type="module"> | "type": "module" or .mjs |
| Local import | ./dom.js | ./config.js |
| Package import | Needs tooling or an import map | import express from "express" |
| Development execution | A local web server | node src/server.js |
| Global top-level variables | Not added to window | Not added globally |
Debugging module errors systematically
Most early module errors are configuration or path errors rather than logic errors.
| Symptom | Likely cause | Check first |
|---|---|---|
Cannot use import statement outside a module in Node | Node is treating the file as CommonJS | Add "type": "module" or use .mjs |
Browser reports an import/CORS error from a file:// URL | The HTML was opened directly from the filesystem | Run a local server |
Failed to resolve module specifier | Incorrect path, missing ./, or missing extension | Compare the importing file location and the exact filename |
| “does not provide an export named …” | Import and export names do not match | Check named exports, spelling, and aliases |
require is not defined in an ESM file | CommonJS syntax was used in an ES module | Replace it with import, or intentionally use a CommonJS project |
When an import fails, do not guess randomly. Inspect these four items in order:
- Is this file definitely running as an ES module?
- Is the path relative to the importing file, not the project root?
- Does the path include the correct extension?
- Does the exported name match the named import exactly?
That sequence resolves a large share of module setup issues.
Key takeaways
- ES modules divide a program into files with explicit, focused public APIs.
- Values are private to a module unless they are exported.
- Use named exports for multiple explicit capabilities and import them with braces.
- Use a default export for one primary module value and import it without braces.
- In a browser, load the entry file with
type="module"and develop through a local server. - In Node.js, enable ESM explicitly with
"type": "module"inpackage.jsonor use.mjsfiles. - Relative imports should include both a relative prefix such as
./and the file extension. - Browser module resolution and Node package resolution differ; a frontend tool such as Vite will later bridge much of that workflow.
Next, you will look beneath module boundaries at lexical scope and closures: how functions retain access to values from the place where they were created.
Can't find a good explanation? Sign up and we'll make it for you
Sign up