Create your own
Lesson illustration

Organizing Browser and Node.js Code with ES Modules

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.
  • export defines what other files are allowed to use.
  • import states 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.

The image shows a browser module entry file, `app.js`, importing the named `tag` function from `html.js`; the HTML page loads the entry file with `<script type="module" src="app.js">`.

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:

  1. Use type="module" on the HTML script tag that loads your entry file.
  2. Use a server while developing. Opening index.html directly through a file:// URL commonly produces module-loading or CORS errors. Serve the project through a local development server instead.
  3. 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 defer attribute 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 APIExportImport
One or more explicitly named valuesexport function calculateTotal() {}import { calculateTotal } from "./pricing.js";
One primary valueexport default function createApiClient() {}import createApiClient from "./createApiClient.js";
Default plus selected named valuesexport default App; export { version };import App, { version } from "./app.js";
A renamed named importexport { 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. The node: 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 .js extension.

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.js contains product-related business logic or data access.
  • product.controller.js receives HTTP-level input and calls service functions.
  • product.routes.js maps URLs to controller functions.
  • app.js composes 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

  1. Create index.html, src/dom.js, and src/main.js using the earlier browser example.
  2. Serve the project with a local server rather than opening the HTML file directly.
  3. Confirm the heading appears in the page.
  4. Add a second named export to dom.js, such as clearElement(element), and import it into main.js.
// src/dom.js

export function clearElement(element) {
  element.textContent = "";
}

Node pass

  1. Create a separate folder for a small Node project.
  2. Add a package.json containing "type": "module".
  3. Create src/config.js and src/server.js from the Node example.
  4. Run node src/server.js.
  5. Visit http://localhost:3000 in 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:

ConcernNative browser moduleNode.js ES module
Entry configuration<script type="module">"type": "module" or .mjs
Local import./dom.js./config.js
Package importNeeds tooling or an import mapimport express from "express"
Development executionA local web servernode src/server.js
Global top-level variablesNot added to windowNot added globally

Debugging module errors systematically

Most early module errors are configuration or path errors rather than logic errors.

SymptomLikely causeCheck first
Cannot use import statement outside a module in NodeNode is treating the file as CommonJSAdd "type": "module" or use .mjs
Browser reports an import/CORS error from a file:// URLThe HTML was opened directly from the filesystemRun a local server
Failed to resolve module specifierIncorrect path, missing ./, or missing extensionCompare the importing file location and the exact filename
“does not provide an export named …”Import and export names do not matchCheck named exports, spelling, and aliases
require is not defined in an ESM fileCommonJS syntax was used in an ES moduleReplace it with import, or intentionally use a CommonJS project

When an import fails, do not guess randomly. Inspect these four items in order:

  1. Is this file definitely running as an ES module?
  2. Is the path relative to the importing file, not the project root?
  3. Does the path include the correct extension?
  4. 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" in package.json or use .mjs files.
  • 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