Skip to main content
โœจintermediate

ES Modules

ES modules (ESM) are the official standard for organizing JavaScript into reusable files. Learn import/export syntax, module loading, and how ESM differs from CommonJS.

Why Modules?

Before modules, all JavaScript on a page shared a single global scope. Libraries would pollute global variables, and there was no standard way to share code between files. Modules solve this:

  • Each module has its own scope
  • You explicitly choose what to export and import
  • Dependencies are explicit โ€” no hidden globals
  • Enables tree-shaking (dead code elimination)

Named Exports

Export multiple values from a module:

hljs javascript
// math.js
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export function mul(a, b) { return a * b; }
export const PI = 3.14159;

// Or export at the end:
function divide(a, b) { return a / b; }
const E = 2.71828;
export { divide, E };

Import specific names:

hljs javascript
// main.js
import { add, sub, PI } from "./math.js";

console.log(add(2, 3)); // 5
console.log(PI);        // 3.14159

// Rename on import
import { add as sum, PI as pi } from "./math.js";

Default Exports

Each module can have one default export โ€” the "main" thing the module provides:

hljs javascript
// user.js
export default class User {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hello, I'm ${this.name}`;
  }
}

// Or a function:
// export default function createUser(name) { ... }

Import without braces (any name you choose):

hljs javascript
import User from "./user.js";
import CreateUser from "./user.js"; // same export, different name โ€” valid

const alice = new User("Alice");
console.log(alice.greet());

Named + Default Together

hljs javascript
// config.js
export const VERSION = "1.0.0";
export const DEBUG = false;

export default {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};
hljs javascript
import config, { VERSION, DEBUG } from "./config.js";
console.log(VERSION, config.apiUrl);

Import All as Namespace

hljs javascript
import * as math from "./math.js";

console.log(math.add(2, 3));
console.log(math.PI);

Re-exporting

Modules can re-export from other modules โ€” useful for creating "barrel" files:

hljs javascript
// utils/index.js โ€” barrel file
export { add, sub } from "./math.js";
export { formatDate } from "./date.js";
export { clamp } from "./number.js";
export { default as User } from "./user.js";

// Now consumers can import from one place:
import { add, formatDate, User } from "./utils/index.js";

Dynamic Imports

Load modules lazily, on demand:

hljs javascript
// Loads only when needed
async function loadChart() {
  const { Chart } = await import("./chart.js");
  return new Chart(data);
}

// Conditional loading
async function loadFeature() {
  if (user.isPremium) {
    const { PremiumFeature } = await import("./premium.js");
    return new PremiumFeature();
  }
  return null;
}

Dynamic imports return Promises and enable code splitting โ€” a key optimization in modern bundlers.

Module Characteristics

hljs javascript
// modules are always in strict mode (no "use strict" needed)
// modules execute once, then are cached
// this === undefined at the top level
// await can be used at the top level in modules (ES2022)

// top-level await (in module):
const data = await fetch("/api/config").then(r => r.json());
export const config = data;

ESM vs CommonJS (CJS)

Node.js originally used CommonJS. Understanding the difference matters when working in Node.js environments:

hljs javascript
// CommonJS (Node.js traditional)
const fs = require("fs");                     // synchronous, always
module.exports = { myFunction };              // export
const { myFunction } = require("./module");   // import

// ES Modules (modern standard)
import fs from "fs";                          // static, analyzed at parse time
export { myFunction };                        // export
import { myFunction } from "./module.js";     // import

// Key differences:
// - ESM: static (analyzed before execution), async loading, tree-shakeable
// - CJS: dynamic (can require() inside conditions), synchronous
// - ESM files: .mjs extension, or "type": "module" in package.json

๐Ÿ’กIn Next.js and React projects

When using a bundler (Webpack, Vite, etc.), you always use ESM syntax (import/export). The bundler handles compatibility with CJS libraries automatically. Don't use require() in modern frontend code.

Circular Dependencies

Modules can reference each other, but circular dependencies can cause issues:

hljs javascript
// a.js
import { b } from "./b.js";
export const a = "A: " + b; // b might be undefined!

// b.js
import { a } from "./a.js";
export const b = "B: " + a; // a might be undefined!

ESM handles circular dependencies with live bindings, but values may not be initialized yet when first accessed. Avoid circular dependencies when possible.

โ–ถTry it yourself

Key Takeaways

  • export exposes values from a module; import brings them in
  • Named exports: export { name } / import { name } โ€” use {}
  • Default export: export default value / import anything โ€” no {}
  • Dynamic import() loads modules lazily (returns a Promise)
  • Barrel files (index.js) re-export from multiple modules for clean import paths
  • ESM is static and tree-shakeable; CJS is dynamic and synchronous

Ready to test your knowledge?

Take a quiz on what you just learned.

Take the Quiz โ†’