
Node.js originally embraced CommonJS modules, but ES modules (ESM) have now become a first-class citizen with a syntax that aligns perfectly with modern JavaScript standards.
At its core, ES modules use import and export statements. Unlike CommonJS’s require() and module.exports, ES modules are statically analyzable. This allows for better optimization and makes the tooling ecosystem richer.
Here’s the fundamental syntax to export a value from a module:
export const myValue = 42;
export function myFunction() {
return "This is ES module syntax!";
}
And to consume what another file exports, you use the import statement like so:
import { myValue, myFunction } from './myModule.js';
console.log(myValue); // Outputs: 42
console.log(myFunction()); // Outputs: That is ES module syntax!
Note that ES modules require you to use explicit file extensions like .js in imports—something that trips up many coming from CommonJS background where the extension is optional.
Using the default keyword lets you export a single “main” value from a module. This is the counterpart to the CommonJS default object export.
export default function greet(name) {
return Hello, ${name}!;
}
To import a default export, the syntax is different compared to named imports:
import greet from './greet.js';
console.log(greet('Michael')); // Outputs: Hello, Michael!
One subtlety worth calling out is that ES modules operate in strict mode implicitly, so no need to write "use strict";. This means your variables must be declared properly and some older sloppy behaviors are disallowed automatically.
Another important difference lies in how ES modules are loaded. They are asynchronous by nature. That means you cannot conditionally use import statements anywhere in your code, unlike require(). However, dynamic imports exist and provide a promise-based way to import modules on-the-fly.
async function loadModule() {
const module = await import('./dynamicModule.js');
module.doSomething();
}
This pattern gives you more granular control over loading dependencies and can optimize startup performance or reduce initial bundle size in complex applications.
Ultimately, mastering these basics is important because they form the foundation for cleaner and more future-proof Node.js code.
Moving on to how Node.js treats files when dealing with ES modules, the precise understanding of file extensions and configuration through package.json makes all the difference when it comes to smooth module resolution and avoiding obscure errors.
Misxi 2-Pack Waterproof Hard Case with Tempered Glass Compatible with Apple Watch SE 3 SE 2 SE Series 6 Series 5 Series 4 44mm, Ultra-Thin Protective Cover for iWatch Screen Protector, Matte Black
$9.96 (as of June 2, 2026 22:39 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Handling module file extensions and the package.json configuration
Node.js differentiates between CommonJS and ES modules primarily based on file extensions and the type field in package.json. By default, files with the .js extension are treated as CommonJS unless specified otherwise. To opt into ES modules without renaming files, you explicitly tell Node.js how to interpret your code via package.json.
Setting "type": "module" in your package.json changes the module system interpretation for all .js files within that package root and its subdirectories, marking them as ES modules:
{
"name": "my-package",
"version": "1.0.0",
"type": "module"
}
With this setting, you can write ES module syntax directly in .js files without renaming them to .mjs. Otherwise, to use ES modules without specifying type, you must rename files with the .mjs extension.
The .mjs extension stands for “module JavaScript” and was introduced to explicitly mark files as ES modules in Node.js. Unlike most web bundlers or browsers that infer module type from or file context, Node requires either the type field or the .mjs to confirm module type.
This extension-based mechanism exists because Node.js must support both module systems side by side for historical reasons. Mixing the two can cause cryptic errors such as ERR_REQUIRE_ESM or SyntaxError: Cannot use import statement outside a module.
The following patterns are valid for ES modules in Node.js:
// Using "type":"module" in package.json
import fs from 'fs';
import { myUtil } from './util.js';
// Using explicit .mjs extension without package.json type
import fs from 'fs';
import { myUtil } from './util.mjs';
Note the explicit file extensions in relative imports. If you forget them, Node throws an error like:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module './util'
Also, when mixing module systems inside a project, avoid importing CommonJS modules directly via import if they use named exports. Instead, import the CommonJS module’s entire export as the default import:
import pkg from './commonjsModule.cjs';
const { namedExport } = pkg;
Here, CommonJS files conventionally use the .cjs extension to explicitly mark their module system when type is set to module in package.json. This distinction allows Node to parse and execute files correctly, preventing internal conflicts.
Dynamic import also respects these rules, so the module specifier in import() must include extensions and point to the right file type, or you’ll face resolution errors:
const mod = await import('./dynamicModule.mjs');
To sum up how Node resolves modules:
.jsfiles are CommonJS by default unlesstype: "module"is defined..mjsfiles are always treated as ES modules..cjsfiles are always treated as CommonJS.package.json’stypeapplies recursively to all files underneath the package root.- Explicit extensions are mandatory when importing relative paths in ES modules.
Grasping these details prevents subtle bugs caused by Node’s dual module system support and prepares your codebase to fully embrace modern JavaScript standards.
