Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #17

JavaScript Modules in the Browser: import, export, Dynamic import() and Import Maps

Mert Özen Nov 3, 2026 8 min 5 views
JavaScript Modules in the Browser: import, export, Dynamic import() and Import Maps

We write a first module with type="module", see why file:// fails, and learn named and default exports, live bindings, dynamic import(), import maps and ES2025's JSON modules with import attributes.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Picture two small files. The first keeps a counter and exposes it; the second imports that counter and uses it:

// counter.js
export let count = 0;

export function increment() {
  count++;
}

// main.js
import { count, increment } from "./counter.js";

increment();
increment();
console.log(count);

count = 10;

Two questions. What does console.log print? What does the last line do? Make your guess before you read on. If you're thinking "what I imported is a copy", your first answer will be 0; we'll put that idea to the test in the section on how modules behave.

Why Modules?

Earlier, when you added files to a page with several <script> tags, they all shared the same global scope. A let total in one file collides with a let total in another, and if one file's function isn't loaded before the other needs it, the code blows up. As the number of files grows, "which script depends on which, and why does the order matter" becomes a job of its own.

Think of a one-room house: everyone's stuff is out in the open, and nobody knows who put what where. Modules split the house into rooms. Each module is its own room, and the variables inside aren't visible from outside. If you want to share something, you put it by the door (export); if you want something from another room, you ask for it by name (import). Dependencies are no longer set up by ordering but by explicit declarations at the top of each file. JavaScript's own module system arrived with ES2015, and today it's built into every current browser.

Your First Module and a Local Server

Create a folder and put three files in it. An HTML file, a helper module and an entry module:

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>First module</title>
  <script type="module" src="./main.js"></script>
</head>
<body>
  <p id="result"></p>
</body>
</html>
// math.js
export const PI = 3.14159;

export function circleArea(radius) {
  return PI * radius ** 2;
}
// main.js
import { circleArea } from "./math.js";

document.querySelector("#result").textContent =
  `Area of a circle with radius 2: ${circleArea(2).toFixed(2)}`;

Now double-click index.html to open it. Most likely the page stays blank and you see a red CORS error in the console. Here's why: the browser doesn't load module files like ordinary scripts but with a request that's subject to CORS rules. When you open a file from disk, the address starts with file://, and such a page has no real origin. For security reasons, the browser won't let a page with no clear origin pull other files from your disk as modules; otherwise any HTML file you downloaded could start reading files on your computer.

The fix is to open the folder through a small local server; an editor extension like Live Server or any simple static server will do. Once the address starts with http://localhost:..., the page works. One more detail: the server must send .js files with a JavaScript MIME type, that is, text/javascript. Off-the-shelf static servers all do this already.

export and import: Named and Default

You can expose several things from a module by name. These are called named exports. When importing, you write the same names inside curly braces, and you can rename them with as if you like:

// format.js
export function formatPrice(value) {
  return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(value);
}

function slugify(text) {
  return text.toLowerCase().trim().replaceAll(" ", "-");
}

export { slugify as toSlug };

// main.js
import { formatPrice, toSlug as makeSlug } from "./format.js";
import * as format from "./format.js";

console.log(formatPrice(1499.9));        // "₺1.499,90"
console.log(makeSlug("Summer Sale"));    // "summer-sale"
console.log(format.formatPrice(10));     // access through the namespace

import * as format gathers all the named exports into a single object; this is called a namespace import. Then there's the default export: it exposes a module's "main" value without a name, and each module can have at most one. When importing it, you don't use curly braces, and you pick whatever name you like:

// Cart.js
export default class Cart {
  #items = [];
  add(item) { this.#items.push(item); }
  get size() { return this.#items.length; }
}

// main.js
import Cart from "./Cart.js";
import ShoppingCart from "./Cart.js"; // same class, different name

const cart = new Cart();
cart.add({ id: 1 });
console.log(cart.size); // 1

A default export's flexibility is also its weakness: anyone can give it a different name, and you lose track of it when searching in your editor. That's why many teams make named exports their default choice. One more rule: in the browser, paths must start with ./ or ../ and include the file extension. A bare name like import { x } from "math" won't work without the import maps we'll see below.

Why Do Modules Behave Differently?

Module scripts differ from ordinary scripts in a few important ways. Knowing them explains most of those "the code is the same, so why doesn't it work" moments.

Modules always run in strict mode; you don't need to write the "use strict" we saw earlier. Each module has its own scope: the let, const, function and even var you declare at the top level are not added to window. At the top level, this is undefined, not window. Module scripts behave like defer by default: they wait until HTML parsing is finished and run in their order on the page, which is why the script we put in the head could find the DOM without trouble. The top-level await we saw in the async/await post also only works in modules.

A module runs only once, no matter how many places on the page import it. The browser keeps every module in a map keyed by its address; a second import doesn't create a new copy, it hands back the same instance. If a module needs its own address, import.meta.url tells it.

Now back to the opening question. console.log(count) prints 2. What you import isn't a copy but a live connection (a live binding): as count changes inside counter.js, the count in main.js shows the same value. You can think of it as a window: you're looking into the next room, but you can't rearrange what's inside. That's why the last line throws a TypeError: imported bindings are read-only for the importing module. If you want to change the value, you call a function the owning module provides, like increment.

Dynamic import(): Load It When You Need It

The import lines at the top of a file are static: they all load when the page opens, and they can't be written inside an if block. Some code, though, you only need once the user does something: a large charting library, a settings panel that's rarely opened. import() is a dynamic import that you call like a function and that returns a promise:

// main.js
document.querySelector("#show-report").addEventListener("click", async () => {
  const { drawReport } = await import("./report.js");
  drawReport(document.querySelector("#report"));
});

// Picking a translation file based on language
const lang = navigator.language.startsWith("tr") ? "tr" : "en";
const { default: messages } = await import(`./i18n/${lang}.js`);
console.log(messages.welcome);

The promise returned by import() resolves to the module's namespace object; you take named exports with destructuring and the default export under the name default. Being able to build the path from a variable is something a static import can't do. And import() also works inside an ordinary, non-module <script>; it's the easiest way to bring code written as modules into an older page.

Import Maps: Short Names

Imagine importing a helper file from ten different folders: "./utils.js" in some places, "../../lib/utils.js" in others. The day you move the file, you have to fix them all. An import map is a guide that tells the browser "when you see this short name, go to this address":

<script type="importmap">
{
  "imports": {
    "utils": "./lib/utils.js",
    "ui/": "./lib/ui/"
  }
}
</script>
<script type="module">
  import { debounce } from "utils";
  import { showToast } from "ui/toast.js";
</script>

A key like "utils" maps a single file. A key ending in /, like "ui/", defines a prefix: "ui/toast.js" becomes "./lib/ui/toast.js". You can also use a CDN address as the value. Put the import map on the page before the module scripts that use it; a mapping added late won't change imports the browser has already resolved. Import maps are widely supported across all current browsers today.

JSON Modules and Import Attributes

For years we read JSON files such as settings or static data with fetch and decoded them with response.json(). Thanks to import attributes, which arrived with ES2025, you can import a JSON file directly as a module:

// config.json
{ "apiUrl": "https://api.example.com", "pageSize": 20 }

// main.js
import config from "./config.json" with { type: "json" };
console.log(config.pageSize); // 20

// The dynamic form
const { default: settings } = await import("./config.json", {
  with: { type: "json" }
});

The with { type: "json" } part isn't decoration. By default, the browser treats an import as JavaScript and runs it. If one day the server sent executable code in place of a file you expected to be plain data, without the attribute that code would run on your page. The attribute tells the browser "this file must be JSON; reject it otherwise", and if the server sends the file with any MIME type other than application/json, the import fails. Forget the attribute, and the browser assumes the file is JavaScript and throws a MIME type error.

Two details. JSON modules only have a default export; import { pageSize } from "./config.json" won't work. If you see assert { type: "json" } in older articles, that's the earlier, now non-standard syntax; today you use with. The feature has been in current versions of Chrome, Edge, Firefox and Safari since 2025.

Common Pitfalls

Opening a module page via file://. If you see a CORS error in the console, make this your first suspect; use a local server.

Forgetting the extension or the ./. import "./math" or import "math.js" won't work in the browser. The browser doesn't go looking for files; you give it the full path or a name defined in an import map.

Writing onclick="save()" in HTML and trying to call a function from a module. A module's functions aren't global, the inline handler can't see them, and you get a save is not defined error. We'd already advised against inline handlers earlier; use addEventListener inside the module.

Trying to make a static import conditional. An import line inside an if is a syntax error. If you need conditional loading, use import().

A Small Experiment

★☆☆ Add a second named export called circlePerimeter to math.js from the first example and use both in main.js. Then do the same thing with the import * as math form.

★★☆ Predict the output, then try it: put console.log("loaded") at the top of a module and import that module from two different modules. How many times do you see "loaded" in the console? Then add console.log(this) at the top of the module; what does it print?

★★★ Prepare two translation files named tr.json and en.json. When a language picker on the page changes, load the right file with import() and with { type: "json" }, and update the text of the heading and buttons. Use the Network tab in DevTools to confirm that switching to the same language a second time doesn't download the file again.

You now have a way to split code into tidy pieces. But when you make one of those pieces a dates.js and start writing date helpers, you'll notice that dates and times are a far thornier subject than they look. In the next post: time zones, UTC, ISO 8601, advanced formatting with Intl, and relative times like "3 days ago".