ES modules: import and export

Split a program into files that state exactly what they share and what they need, and load code only when it is used.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Export and import between files
  • Load a module on demand
  • Use modules in the browser and in Node.js
  • Choose between named and default exports

Before you start

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:

Plain text
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:

JavaScript
// 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:

JavaScript
// 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:

JavaScript
// 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:

  1. 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.
  2. 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

JavaScript
// 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:

JavaScript
// modules/format-date.js
export default function formatDate(isoDate) {
  return new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" })
    .format(new Date(isoDate));
}
JavaScript
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:

JavaScript
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 getJSON finds every use.
  • Editors can auto-import and rename named exports reliably.
  • Typos fail loudly: importing a name that does not exist is a SyntaxError when 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:

JavaScript
// 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":

HTML
<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 scriptModule script
Runs as soon as it downloads, blocking parsing, unless defer or async is setDeferred by default: runs after the HTML is parsed, in order
Shares the global scopeHas its own scope
Works from file://Must be served over HTTP, like fetch() (see lesson 33)
Runs every time it is includedRuns 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:

JavaScript
// modules/counter.js
export let count = 0;
export function increment() {
  count += 1;
}
JavaScript
// 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:

JavaScript
// 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.json contains "type": "module" and the file ends in .js.
JSON
{
  "name": "dashboard-tools",
  "type": "module"
}

Node's built-in modules are imported with the node: prefix:

JavaScript
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 export on declarations or in an export { ... } 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 .mjs or "type": "module", and the node: prefix for built-ins.

Further reading