ES Modules in Node.js

Published by Programming Line in Node.js. Last updated .

ES Modules (ESM) are the official, standardized module system defined by the ECMAScript specification — the same import/export syntax used in modern browsers. Node.js has supported ESM natively since version 12, and it is now the module system recommended for new projects. Understanding how Node decides whether a file is an ES module, how the module graph loads, and how ESM differs from the older CommonJS system is essential for writing modern Node.js code and for reading the ecosystem’s growing supply of ESM-only packages.

Overview: How ES Modules Work in Node.js

Every JavaScript file Node.js runs is parsed with one of two parse goals: CommonJS (a script, using require/module.exports) or ES Module (using import/export). Node decides which parse goal to use for a given .js file by looking at the "type" field in the nearest package.json file walking up the directory tree:

  • "type": "module" — every .js file in that package is parsed as an ES module.
  • "type": "commonjs", or the field missing entirely — every .js file is parsed as CommonJS (Node’s historical default).
  • The extensions .mjs and .cjs always override the package.json setting: a .mjs file is always an ES module and a .cjs file is always CommonJS, regardless of "type".

ES Modules differ from CommonJS in several fundamental ways, not just syntax:

  • Strict mode is automatic. Every ES module runs in strict mode; you never need "use strict".
  • No require, module, exports, __dirname, or __filename. These CommonJS-only variables simply do not exist inside an ES module. ESM gives you import.meta.url and import.meta.dirname instead.
  • Static structure. import/export declarations must appear at the top level of a file (never inside an if block or function) and their specifiers are largely fixed at parse time. This lets Node (and bundlers) build the entire dependency graph before running any code, which is what makes live bindings possible: an imported name is a read-only reference to the exporting module’s actual variable, not a copy. If the exporting module later reassigns an exported let, every importer sees the new value immediately.
  • Top-level await is allowed. An ES module can await a promise directly in its top-level code, pausing the rest of the module graph until it resolves. This is not possible in CommonJS.
  • Relative specifiers require a file extension. CommonJS’s require("./math") happily resolves ./math.js, ./math/index.js, and so on. ESM’s import does not guess — you must write "./math.js" in full.

Under the hood, loading an ES module graph happens in three phases: Node first parses every reachable module to discover its import/export statements (without executing any code), then links the graph by connecting each import to the live binding it refers to, and only then evaluates each module’s body, starting from the deepest dependency and working back up to the entry file. CommonJS, by contrast, loads and executes modules synchronously and eagerly the moment require() is called, one line at a time, top to bottom.

Syntax

The general shapes of import and export look like this:

// Named exports
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}

// Default export (one per module)
export default function multiply(a, b) {
  return a * b;
}

// Re-exporting names from another module
export { PI as CircleConstant } from "./constants.js";

// Named import
import { readFile } from "node:fs/promises";

// Default import (any local name you choose)
import multiply from "./math.js";

// Namespace import (all named exports as one object)
import * as math from "./math.js";

// Dynamic import: returns a Promise, usable anywhere
const mod = await import("./math.js");
Form Meaning
export const x = 1; Named export, bound to the identifier x
export default ... The module’s single default export (any expression or declaration)
import { x } from "..." Imports one or more named exports
import x from "..." Imports the default export under the local name x
import * as ns from "..." Imports everything as properties of a namespace object
import("...") Dynamic import; returns a Promise resolving to the module’s namespace object
import.meta.url The file:// URL of the current module
import.meta.dirname The directory containing the current module (Node 20.11+)

Examples

Example 1: Named exports and imports

Create a small utility module with named exports, and consume it from another file. This requires "type": "module" in package.json so Node parses both files as ESM.

{
  "name": "esm-demo",
  "version": "1.0.0",
  "type": "module"
}
// math.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

export const PI = 3.14159;
// app.js
import { add, multiply, PI } from "./math.js";

console.log(add(2, 3));
console.log(multiply(4, PI));
node app.js

Output:

5
12.56636

Node resolves the relative specifier "./math.js", loads and evaluates math.js first (it has no dependencies of its own), then evaluates app.js, which now has live bindings to add, multiply, and PI.

Example 2: Combining a default export with named exports

A module can have at most one default export alongside any number of named exports — a common pattern for a module whose primary job is a single class or function, plus a few related constants.

// calculator.js
export const VERSION = "1.0.0";

export default class Calculator {
  add(a, b) {
    return a + b;
  }

  multiply(a, b) {
    return a * b;
  }
}
// use-calculator.js
import Calculator, { VERSION } from "./calculator.js";

const calc = new Calculator();
console.log(`Calculator v${VERSION}`);
console.log(calc.add(10, 5));
console.log(calc.multiply(10, 5));

Output:

Calculator v1.0.0
15
50

Notice the default import (Calculator) has no braces, while the named import ({ VERSION }) does. You can rename a default import to anything — it isn’t tied to the exported identifier’s name at all.

Example 3: import.meta.dirname and top-level await

Because ESM has no __dirname, use import.meta.dirname (Node 20.11+) to build paths relative to the current module — useful for loading a config file that sits next to your script no matter where the process’s working directory is. This example also uses top-level await, which is only legal inside an ES module.

// config-loader.mjs
import { readFile } from "node:fs/promises";
import path from "node:path";

const configPath = path.join(import.meta.dirname, "config.json");

async function loadConfig() {
  try {
    const raw = await readFile(configPath, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === "ENOENT") {
      console.error(`Config file not found at ${configPath}`);
      return {};
    }
    throw err;
  }
}

const config = await loadConfig();
console.log(config);

The top-level await loadConfig() call pauses evaluation of config-loader.mjs (and anything that imports it) until the file has been read and parsed, without wrapping the whole module in an async IIFE.

Example 4: Dynamic import for conditional loading

import() is a function-like expression, so unlike static import it can be called conditionally, inside a function, or at any point in the program. It always returns a Promise.

async function loadLogger(useJson) {
  const loggerModule = useJson
    ? await import("./json-logger.mjs")
    : await import("./text-logger.mjs");
  return loggerModule.default;
}

loadLogger(true).then((Logger) => {
  const logger = new Logger();
  logger.log("Hello from a dynamically imported module");
});

This pattern is genuinely useful for lazy-loading a heavy dependency only when a feature is actually used, or for picking between two implementations at runtime — but it should not be used as a substitute for ordinary static imports, since it loses the static-analysis benefits (like early syntax and reference checking) that make ESM predictable.

Under the Hood: Order of Operations

For the two-file example (app.js importing from math.js), Node does the following, in order:

  1. Node reads app.js, sees it has a "type": "module" ancestor package.json, and parses it as an ES module without running any code yet.
  2. The parser finds the static import ... from "./math.js" declaration and adds math.js to the module graph as a dependency.
  3. Node parses math.js the same way. It has no further imports, so the graph is now complete.
  4. Linking: Node connects the add, multiply, and PI bindings imported in app.js to the actual exported bindings in math.js — these are references, not copies.
  5. Evaluation begins at the leaves of the graph: math.js runs first, populating its exports, then app.js runs, calling add() and multiply() with real, already-linked values.

This deferred, graph-first evaluation is also why circular ESM imports behave more gracefully than circular CommonJS require calls in many cases: a function declaration is hoisted and available even before its module finishes evaluating, whereas a const or let accessed before its module’s evaluation reaches that line throws a ReferenceError (the same temporal-dead-zone behavior you already know from ordinary JavaScript, just applied across module boundaries).

Common Mistakes

1. Forgetting “type”: “module” in package.json

Writing import/export syntax in a plain .js file without telling Node it’s an ES module is the single most common ESM error.

// app.js  (package.json has no "type" field, so this defaults to CommonJS)
import { add } from "./math.js";

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

Running this fails immediately with SyntaxError: Cannot use import statement outside a module. Fix it by adding "type": "module" to the nearest package.json, or by renaming the file to app.mjs:

{
  "name": "esm-demo",
  "type": "module"
}

2. Omitting the file extension in a relative import

CommonJS’s require resolves extensionless paths for you; ESM’s import deliberately does not.

import { add } from "./math";

This throws Error: Cannot find module '/path/to/math' imported from /path/to/app.js even though math.js exists right next to it. Always spell out the extension:

import { add } from "./math.js";

3. Using __dirname or __filename in an ES module

These CommonJS globals simply don’t exist in ESM.

import path from "node:path";

const configPath = path.join(__dirname, "config.json");
console.log(configPath);

This throws ReferenceError: __dirname is not defined in ES module scope. Use import.meta.dirname (Node 20.11+) instead, or the more portable path.dirname(fileURLToPath(import.meta.url)) for older Node 20 versions:

import path from "node:path";

const configPath = path.join(import.meta.dirname, "config.json");
console.log(configPath);

Best Practices

  • For new projects, set "type": "module" in package.json right away and write ESM throughout, rather than mixing module systems file by file.
  • Always include the file extension (.js) in relative import specifiers — ESM resolution will not guess it for you.
  • Use import.meta.dirname and import.meta.url instead of __dirname/__filename; fall back to fileURLToPath(import.meta.url) only if you must support Node versions older than 20.11.
  • Prefer named exports for modules that expose several related utilities, and reserve export default for a module whose whole purpose is one primary class or function.
  • Use dynamic import() for genuinely conditional or lazy loading (feature flags, optional dependencies), not as a routine replacement for static import.
  • Remember that a package can still publish a CommonJS build for consumers via the "exports" field’s require/import conditions — you don’t have to choose only one format when authoring a library.
  • If you need to load an ESM-only package from a CommonJS file, use dynamic import() (an ordinary require() cannot load an ES module).

Practice Exercises

1. Take a small CommonJS module you’ve written before (using require/module.exports) and convert it to ESM: add "type": "module" to package.json, replace module.exports with export/export default, replace require with import, and add the missing file extensions.

2. Write a module greeter.js with a default export function greet(name) that returns a greeting string, plus a named export constant VERSION. Import both into a separate main.js and log the results.

3. Using node:fs/promises and import.meta.dirname, write a module that reads a text file located in the same directory as the module itself and prints its contents, handling the case where the file does not exist.

Summary

  • Node parses a .js file as an ES module when the nearest package.json has "type": "module", or when the file uses the .mjs extension; .cjs is always CommonJS.
  • ESM uses static import/export declarations, runs in strict mode automatically, and creates live bindings between modules instead of copying values.
  • ESM has no require, module, exports, __dirname, or __filename — use import, export, and import.meta.dirname/import.meta.url instead.
  • Relative import specifiers must include the file extension.
  • Top-level await only works in ES modules; dynamic import() works anywhere and returns a Promise, making it the right tool for conditional loading.
  • Node builds the whole module graph (parse, then link, then evaluate leaves-first) before your code runs, which is why ESM error messages about missing modules appear before any console.log in the file executes.