jsmanifest logojsmanifest

Node.js ES Modules vs CommonJS: Migration Guide 2026

Node.js ES Modules vs CommonJS: Migration Guide 2026

Learn how to migrate your Node.js projects from CommonJS to ES Modules with practical examples, edge case handling, and proven strategies to avoid common pitfalls.

While I was looking over some legacy Node.js projects the other day, I realized just how many codebases are still stuck on CommonJS in 2026. I was once guilty of putting off the migration myself, thinking "if it ain't broke, don't fix it." Little did I know that the ecosystem was rapidly moving forward, and staying behind meant missing out on better tooling, improved tree-shaking, and top-level await support.

The truth is, ES Modules aren't just another JavaScript trend—they're the standardized future of Node.js. And if you're still using require() everywhere, you're fighting against the current.

Why ES Modules Are the Future of Node.js in 2026

When I finally decided to migrate one of my production applications, I discovered something fascinating: the performance improvements weren't just theoretical. ESM's static analysis enables bundlers to eliminate dead code far more effectively than CommonJS ever could. I watched my bundle sizes shrink by nearly 30% without changing a single line of business logic.

But beyond performance, there's a more practical reason to make the switch now. The Node.js ecosystem has reached a tipping point. Major frameworks, libraries, and tools are dropping CommonJS support or treating it as a second-class citizen. I came across several packages that only shipped ESM versions, forcing me to deal with complicated workarounds just to use them in my CommonJS projects.

The writing is on the wall. ES Modules provide native browser compatibility, better static analysis, and genuine asynchronous loading. More importantly, they're the JavaScript standard—not just a Node.js convention.

CommonJS vs ES Modules: Key Differences That Matter

Let me show you the fundamental differences that actually impact your code. When I started learning about ESM, I thought it was just syntactic sugar. I was wrong.

CommonJS loads modules synchronously at runtime. This means require() can happen anywhere in your code, even inside conditional blocks. ES Modules, on the other hand, are loaded asynchronously and their import statements are hoisted to the top. This isn't just a quirk—it's a fundamental design decision that enables better optimization.

Here's where it gets interesting: CommonJS gives you module.exports as a mutable object. You can modify it at runtime. ESM exports are read-only bindings to values. When I realized this, it explained why some of my hacky CommonJS patterns wouldn't translate directly to ESM.

ES Modules vs CommonJS comparison diagram

The scope differences matter too. CommonJS wraps your code in a function, giving you access to __dirname, __filename, require, module, and exports. ESM doesn't provide these by default because modules are meant to be environment-agnostic. This caused me more headaches during migration than I care to admit.

Pre-Migration Checklist: Is Your Project Ready?

Before you change a single line of code, you need to assess your situation. I cannot stress this enough! I once jumped into a migration without checking my dependencies, and it cost me three days of debugging.

First, audit your dependencies. Run npm ls and check if your critical packages support ESM. In 2026, most major packages do, but you'll still find holdouts. Look for packages that only ship CommonJS builds—these will require special handling.

Second, evaluate your build tooling. If you're using Webpack, Rollup, or Vite, you're probably fine. These bundlers handle ESM beautifully. But if you're relying on older tools or custom build scripts, you might need updates.

Third, check your Node.js version. While ESM has been stable since Node.js 12, you'll want at least Node.js 18 for the best experience. The ecosystem assumes modern Node.js versions in 2026.

Finally, set up a test environment. Clone your project, create a feature branch, and prepare to iterate. This isn't a one-and-done change—you'll discover edge cases along the way.

Step-by-Step Migration: Converting CommonJS to ESM

Luckily we can break this migration into manageable steps. When I finally decided to tackle my first migration, I learned that going all-in at once was a recipe for disaster. Here's the approach that worked for me.

Start by adding "type": "module" to your package.json. This tells Node.js to treat all .js files as ES Modules by default:

// package.json
{
  "name": "my-project",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js"
}

Now convert your imports. This is where the real work begins. Every require() becomes an import, and every module.exports becomes an export:

// Before (CommonJS)
const express = require('express');
const { validateUser } = require('./utils/validation');
const config = require('./config');
 
function createServer() {
  const app = express();
  // ... server setup
  return app;
}
 
module.exports = { createServer };
 
// After (ESM)
import express from 'express';
import { validateUser } from './utils/validation.js';
import config from './config.js';
 
function createServer() {
  const app = express();
  // ... server setup
  return app;
}
 
export { createServer };

Notice the .js extensions? ES Modules require explicit file extensions. This tripped me up constantly at first. Node.js won't automatically resolve ./utils/validation to ./utils/validation.js anymore.

For default exports, the syntax changes slightly but the concept remains the same. I found that most of my module.exports = something patterns converted cleanly to export default something.

Handling Edge Cases: __dirname, JSON Imports, and Dynamic Requires

Here's where things get fascinating—and frustrating. The edge cases I encountered during migration taught me more about Node.js internals than I'd learned in years of CommonJS development.

The __dirname and __filename problem hit me immediately. These globals don't exist in ESM. When I finally discovered the solution, it felt obvious:

// utils/paths.js
import { fileURLToPath } from 'url';
import { dirname } from 'path';
 
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
 
export { __filename, __dirname };
 
// Now use it in other modules
import { __dirname } from './utils/paths.js';
import { join } from 'path';
 
const configPath = join(__dirname, 'config', 'app.json');

In other words, you need to reconstruct these values from import.meta.url. It's more verbose, but it works reliably across environments.

JSON imports used to be straightforward with require(). ESM handles them differently. As of 2026, Node.js supports import assertions:

// Import JSON with assertions
import config from './config.json' assert { type: 'json' };
 
// Or use the newer 'with' syntax (Node.js 20+)
import settings from './settings.json' with { type: 'json' };

Dynamic imports are your friend when you need conditional loading. I came across situations where I genuinely needed runtime module resolution. Instead of putting require() inside an if statement, use dynamic import():

// Dynamic ESM import
async function loadPlugin(pluginName) {
  try {
    const plugin = await import(`./plugins/${pluginName}.js`);
    return plugin.default;
  } catch (error) {
    console.error(`Failed to load plugin: ${pluginName}`, error);
    return null;
  }
}
 
// Use it
const authPlugin = await loadPlugin('auth');
if (authPlugin) {
  authPlugin.initialize();
}

Notice that dynamic imports return promises. This actually improved my code by making asynchronous operations explicit.

Code example showing ESM migration patterns

Dual Package Support: Maintaining Backward Compatibility

While I was migrating my own projects, I realized package maintainers face a unique challenge: supporting both module systems simultaneously. If you're publishing packages to npm, you might need dual support during the transition period.

The most straightforward approach is publishing separate ESM and CommonJS builds. Your package.json uses conditional exports to direct Node.js to the right version:

{
  "name": "my-library",
  "version": "2.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    }
  },
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js"
}

This configuration tells modern tools to use ESM while older tools fall back to CommonJS. I found this approach more reliable than trying to make a single build work everywhere.

The key insight? Don't try to be too clever. Ship two builds, use clear file extensions (.js for ESM, .cjs for CommonJS), and let Node.js's module resolution do the work.

Common Migration Pitfalls and How to Avoid Them

Let me share the mistakes I made so you don't have to repeat them.

First, I forgot about circular dependencies. CommonJS is surprisingly tolerant of circular imports because of its synchronous nature. ESM is much stricter. When I migrated, I discovered several circular dependencies that had been lurking in my codebase. The solution was restructuring—usually extracting shared code into a separate module.

Second, I underestimated the extension requirement. Leaving off .js extensions worked fine in my local development because my IDE autocompleted them. Then I deployed to production and everything broke. Always add explicit extensions. No exceptions.

Third, I tried to mix module systems in a single project without a clear strategy. Some files were ESM, others CommonJS, and the interop was a mess. If you need to migrate gradually, use clear boundaries—maybe migrate by feature or directory, not randomly file by file.

Fourth, I assumed top-level await would just work everywhere. It doesn't. You can only use top-level await in ES Modules, and even then, it can cause issues with bundlers that don't expect it. Use it judiciously, especially in library code.

Making the Switch: Your Path to Modern Node.js

The migration from CommonJS to ES Modules isn't just about following trends—it's about positioning your codebase for the next decade of JavaScript development. When I finally completed my first major migration, the benefits were immediate. Better IDE support, improved tree-shaking, and access to the latest packages made the effort worthwhile.

Start small. Pick a non-critical project or a new feature branch. Get comfortable with the syntax differences and edge cases in a low-risk environment. Once you've done it once, subsequent migrations become much faster.

Remember that this is a one-way door in many ways. Once the ecosystem moves fully to ESM—and we're nearly there in 2026—going back will be painful. The good news? You're not pioneering anything risky. ES Modules are mature, well-supported, and genuinely better for most use cases.

The transition might feel daunting, but I promise you'll look back and wonder why you waited so long. Modern JavaScript is wonderful, and ES Modules are a big part of what makes it that way.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!