Modernizing Node.js Projects with ES Modules
Olivia Novak
Dev Intern · Leapcell

Introduction
For years, Node.js developers have primarily relied on CommonJS for module management, using require() and module.exports to organize and share code. This system served us well, fostering a robust ecosystem. However, with the standardization of ECMAScript Modules (ESM) in JavaScript itself, an inevitable shift began. ESM offers static analysis benefits, better tree-shaking capabilities, and aligns Node.js with browser-side module loading, paving the way for more unified JavaScript development. Many modern libraries and frameworks are now adopting ESM by default, leaving projects still on CommonJS feeling somewhat dated or facing compatibility issues. This evolution brings a significant question for many existing applications: how do we transition our established CommonJS Node.js projects to the future-proof ES Modules? The answer often lies with a simple yet powerful configuration in our package.json file: "type": "module". This article will guide you through understanding and executing this migration, ensuring your Node.js projects embrace the modern module system.
Understanding the Module Transition
Before diving into the migration, let's clarify some fundamental concepts central to this discussion.
Core Terminology
-
CommonJS (CJS): This is Node.js's original and default module system. It uses
require()to import modules andmodule.exportsorexportsto expose them. It's synchronous, meaning modules are loaded one by one.// CommonJS example const express = require('express'); function greet(name) { return `Hello, ${name}!`; } module.exports = greet; -
ECMAScript Modules (ESM): The official standard for modules in JavaScript, supported natively by browsers and Node.js. It uses
importandexportstatements. ESM is asynchronous by design, allowing for better performance optimizations.// ESM example import express from 'express'; export function greet(name) { return `Hello, ${name}!`; } -
package.jsontypefield: Introduced in Node.js 13.2, this field allows developers to specify the module system for all.jsfiles within a package."type": "commonjs": (Default if not specified) All.jsfiles are treated as CommonJS."type": "module": All.jsfiles are treated as ES Modules.
The Mechanism of "type": "module"
When you set "type": "module" in your package.json, Node.js changes its default parser for .js files within that package. Instead of interpreting them as CommonJS, it now treats them as ES Modules. This has several cascading effects:
- Default Module System Reversal: All
.jsfiles in your project (and subdirectories) attempting to userequire()ormodule.exportswill now throw errors, demandingimportandexportsyntax. - File Extension Overrides: You can still use CommonJS files within an ESM project (or ESM files within a CommonJS project) by explicitly using
.cjsor.mjsfile extensions.- A
.cjsfile is always treated as CommonJS, regardless ofpackage.json'stypefield. - An
.mjsfile is always treated as ES Module, regardless ofpackage.json'stypefield. This provides flexibility during migration, allowing a gradual transition.
- A
Practical Migration Steps
Let's walk through migrating a simple CommonJS Node.js project to ES Modules.
Initial CommonJS Project Structure
Consider a project with app.js and a utility module utils.js.
package.json (initial):
{ "name": "cjs-project", "version": "1.0.0", "main": "app.js", "scripts": { "start": "node app.js" } }
utils.js (CommonJS):
// utils.js function add(a, b) { return a + b; } function subtract(a, b) { return a - b; } module.exports = { add, subtract };
app.js (CommonJS):
// app.js const math = require('./utils'); const express = require('express'); // Assuming express is installed const app = express(); const port = 3000; console.log('Addition:', math.add(5, 3)); // Output: Addition: 8 console.log('Subtraction:', math.subtract(10, 4)); // Output: Subtraction: 6 app.get('/', (req, res) => { res.send('Hello from CommonJS app!'); }); app.listen(port, () => { console.log(`CommonJS app listening at http://localhost:${port}`); });
To run this, ensure express is installed (npm i express) and then npm start.
Step 1: Update package.json
The first and most crucial step is to add "type": "module" to your package.json.
package.json (modified):
{ "name": "cjs-project", "version": "1.0.0", "main": "app.js", "type": "module", <-- ADD THIS LINE "scripts": { "start": "node app.js" } }
Now, if you try npm start, you will likely encounter errors like SyntaxError: Unexpected token 'export' or ReferenceError: require is not defined, because Node.js is now trying to parse your old CommonJS files as ES Modules.
Step 2: Convert CommonJS Syntax to ES Module Syntax
You'll need to go through your CommonJS files and convert require()/module.exports to import/export.
utils.js (ESM converted):
// utils.js export function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; }
Note: For module.exports = { add, subtract }, you could also use export default { add, subtract } and then import math from './utils.js' in app.js. However, named exports are generally preferred.
app.js (ESM converted):
// app.js import { add, subtract } from './utils.js'; // Note the .js extension! import express from 'express'; // Regular import for npm packages const app = express(); const port = 3000; console.log('Addition:', add(5, 3)); console.log('Subtraction:', subtract(10, 4)); app.get('/', (req, res) => { res.send('Hello from ESM app!'); }); app.listen(port, () => { console.log(`ESM app listening at http://localhost:${port}`); });
Important Considerations During Conversion:
- File Extensions: When importing local ES Modules, you must include the full file extension (e.g.,
./utils.js). This is a crucial difference from CommonJS, where extensions were often omitted. Node.js resolves bare specifiers for installed packages (likeexpress), but not relative or absolute file paths. __dirnameand__filename: These CommonJS-specific global variables are not available in ES Modules. You can recreate their functionality usingimport.meta.urland Node.js'spathandfileURLToPathmodules.import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); console.log('Current directory:', __dirname); console.log('Current file:', __filename);- JSON Imports: In CommonJS, you could
require('./data.json'). In ESM, direct JSON imports are still experimental in some Node.js versions (usingimport data from './data.json' with { type: 'json' }). For broader compatibility, you might need to read JSON files usingfs.readFileandJSON.parse. requirefor Dynamic Imports / Interop: If you absolutely mustrequire()a CommonJS module from an ESM file (e.g., a legacy library that hasn't published an ESM version), you can useimport()for dynamic imports, but directrequire()is not available. For simpler interop without dynamic loading, often the package itself will have provided ESM compatibility or you might need a wrapper.
Step 3: Run the Migrated Project
After converting all relevant files, run your project again:
npm start
Your Node.js application should now be running entirely with ES Modules, logging the new messages and serving the correct HTTP response.
Handling Mixed Module Environments
What if you have a large project and cannot convert everything at once? Or you depend on a module that is stubbornly CommonJS-only (though most are now dual-published or ESM-compatible)? This is where .cjs and .mjs extensions become invaluable.
Example: Keeping a CommonJS file in an ESM project
Imagine legacy.cjs (a CommonJS file) that you can't convert immediately in your type: "module" project.
legacy.cjs:
// legacy.cjs module.exports = function () { return "This is a legacy CommonJS function!"; };
You can import this from an ESM file:
app.js (modified to import .cjs):
import { add, subtract } from './utils.js'; import express from 'express'; import legacyFunc from './legacy.cjs'; // Node.js knows this is CJS because of .cjs extension const app = express(); const port = 3000; console.log('Addition:', add(5, 3)); console.log('Subtraction:', subtract(10, 4)); console.log('Legacy:', legacyFunc()); // Output: Legacy: This is a legacy CommonJS function! app.get('/', (req, res) => { res.send('Hello from ESM app!'); }); app.listen(port, () => { console.log(`ESM app listening at http://localhost:${port}`); });
This demonstrates how type: "module" sets the default, but .cjs and .mjs provide crucial escape hatches for managing a mixed codebase during transition or for specific use cases.
Conclusion
Migrating a Node.js project from CommonJS to ES Modules by setting "type": "module" in package.json is a significant step towards modernizing your JavaScript codebase. While it involves a systematic conversion of require/module.exports to import/export and careful handling of file extensions, __dirname, and __filename, the benefits of alignment with the broader JavaScript ecosystem, improved tooling, and future compatibility are well worth the effort. Embrace ES Modules for cleaner, more maintainable, and modern Node.js applications. This small package.json change unlocks the future of modular JavaScript in Node.js.