Skip to content
Node.js nd modules 4 min read

ES Modules (import & export)

ES modules (ESM) are the standard, language-level module system shared by browsers and Node.js. Instead of CommonJS’s require() and module.exports, ESM uses the import and export keywords defined by the ECMAScript specification. Node.js has supported native ESM since v12 and it is fully stable in the modern 20 and 22 LTS lines. This page covers how to enable ESM, the import/export syntax, named versus default exports, and why the static nature of imports matters.

Enabling ES modules in Node

Node decides whether a file is an ES module or a CommonJS module before it runs a single line. There are two ways to tell Node a file is ESM.

The first is the file extension. A file ending in .mjs is always treated as an ES module, regardless of any configuration. A file ending in .cjs is always CommonJS.

The second, and more common for whole projects, is the "type" field in package.json. Setting "type": "module" makes every .js file in that package an ES module:

{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module"
}

With that field present, plain .js files use import/export. If you omit "type" (or set "type": "commonjs"), .js files default to CommonJS, and you must use the .mjs extension for any individual ESM file.

File / settingModule system
*.mjsES module
*.cjsCommonJS
*.js + "type": "module"ES module
*.js + no "type" fieldCommonJS

Exporting values

An ES module exposes values to other files through export. There are two flavours: named exports and a single default export.

Named exports attach a name to each value you share. A module can have any number of them:

// math.js
export const PI = 3.14159;

export function square(n) {
  return n * n;
}

// You can also declare first, then export by name
function cube(n) {
  return n * n * n;
}

export { cube };

A default export marks the module’s single “main” value. Each module may have at most one:

// logger.js
export default function log(message) {
  console.log(`[app] ${message}`);
}

You can mix one default export with named exports in the same file, though many style guides prefer named exports for clarity and better tooling support.

Importing values

How you import depends on which kind of export you are consuming. Named imports use braces and must match the exported names exactly:

// app.js
import { PI, square, cube } from "./math.js";

console.log(PI);
console.log(square(5));
console.log(cube(3));

Output:

3.14159
25
27

A default import has no braces, and you choose any name you like for it:

import log from "./logger.js";

log("server started");

Output:

[app] server started

Relative imports in ESM require the full file extension—write "./math.js", not "./math". Unlike CommonJS, Node’s ESM resolver does not guess extensions for relative paths. Forgetting the .js is the most common error when migrating to ESM.

You can also rename imports with as, pull everything into a namespace object, and combine forms:

// Rename to avoid a collision
import { square as sq } from "./math.js";

// Import every named export under one namespace
import * as math from "./math.js";
console.log(math.PI, math.square(4));

// Default and named together in one statement
import log, { PI } from "./bundle.js";

Importing core and third-party modules

Built-in Node modules are imported with the node: prefix, and packages from node_modules are imported by their bare name:

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

const data = await readFile("./config.json", "utf8");
console.log(JSON.parse(data).port);

Note the top-level await above—ES modules support await at the top level of a file without wrapping it in an async function. CommonJS does not.

Imports are static

The defining feature of ESM is that imports are static. The import and export statements are analysed before any code executes, so all import lines are effectively hoisted to the top of the module and resolved first. You cannot place a top-level import inside an if block or call it conditionally:

// This is a syntax error — import is not a function call
if (process.env.DEBUG) {
  import { trace } from "./debug.js"; // ❌ SyntaxError
}

This static structure is what enables tools to perform tree shaking—dead-code elimination that drops exports you never import—and lets editors give accurate autocomplete and refactoring. When you genuinely need conditional or lazy loading, use the asynchronous dynamic import(), which returns a promise and is allowed anywhere:

if (process.env.DEBUG) {
  const { trace } = await import("./debug.js");
  trace("debug mode enabled");
}

Because bindings are live, an imported value reflects later changes the exporting module makes to it—imports are read-only references to the original binding, not copies.

Best Practices

  • Set "type": "module" in package.json for new projects so plain .js files are ESM by default.
  • Always include the file extension in relative imports ("./util.js"), since the ESM resolver does not infer it.
  • Prefer named exports for shared utilities; they are explicit, refactor-safely, and enable tree shaking.
  • Use the node: prefix for built-in modules (node:fs, node:path) to make intent clear and avoid name clashes.
  • Reach for dynamic import() only when you need lazy or conditional loading—keep static import at the top level otherwise.
  • Take advantage of top-level await in ESM instead of wrapping startup logic in an IIFE.
Last updated June 14, 2026
Was this helpful?