Solving the ESM Migration Headache in Node.js Backends

Solving the ESM Migration Headache in Node.js Backends

Yuki MartinBy Yuki Martin
Tools & Workflowsnodejsjavascriptesmcommonjsbackend

You’re going to learn exactly how to move your Node.js project from the legacy CommonJS format to modern ECMAScript Modules (ESM) without breaking everything in the process. We're looking at the specific configuration changes, the syntax shifts that trip people up, and how to handle those globals that simply don't exist in the ESM world. It's a transition that's been years in the making—and it's finally time to get it right.

Node.js has lived with two module systems for a while now, and the friction between them hasn't exactly gone away. If you've been putting off the switch, you've probably run into the dreaded "cannot use import statement outside a module" error more times than you'd like to admit. The reality is that the ecosystem is moving toward ESM as the default. Modern tools like Vite or newer versions of popular libraries often expect it. Stick with CommonJS too long and you'll find yourself stuck on old versions of your favorite packages because the newer ones stopped supporting require() altogether.

Why is switching from CommonJS to ESM so difficult?

The biggest hurdle is that these two systems don't play well together. You can't just require() an ESM file from a CommonJS script. It throws a sync/async error because ESM is fundamentally asynchronous under the hood. While Node.js has tried to bridge the gap, the "interop" layer is often more of a headache than a help. When you decide to go full ESM, you're changing the way the Node.js runtime interprets every single file in your project. It’s not just a syntax change; it’s a runtime behavior change that affects how dependencies load and how variables are scoped.

To start, you have to tell Node.js that your project is now an ESM project. You do this by adding "type": "module"" to your package.json. Once you do that, every .js file is treated as ESM. This means require(), module.exports, and exports are suddenly gone. If you have legacy scripts that must stay as CommonJS, you have to rename them to .cjs. It’s a bit of a file-extension dance that can get messy if your build pipeline isn’t ready for it (and it often isn't).

How do you handle missing globals like __dirname?

This is usually the first thing that breaks. In CommonJS, __dirname and __filename are injected into every module automatically. They're very handy for resolving paths to config files or templates. In ESM, they're completely absent. If you try to use them, the runtime will complain they aren't defined. Instead, you have to use the import.meta.url property. It’s a bit more verbose, but it’s the standard way forward.

You'll end up writing a small utility or a snippet at the top of your files to recreate that functionality. It looks something like this: const __dirname = new URL('.', import.meta.url).pathname;. It feels like busywork—and it mostly is—but it's a requirement of the stricter ESM spec. This shift highlights a broader theme: ESM forces you to be more explicit about what you're doing. There's less "magic" happening behind the scenes, which is arguably better for long-term maintenance even if it's annoying during the initial migration.

What happens when your dependencies don't support ESM?

You'll inevitably hit a library that hasn't been updated in years. These CommonJS-only packages can still be imported into an ESM file, but the behavior is different. Usually, you have to use a default import: import pkg from 'legacy-library';. You can't always rely on named imports for these old packages because the ESM loader has to guess what the exports are based on a static analysis of the CommonJS code. Sometimes it gets it right; sometimes it doesn't.

If you're maintaining your own internal libraries, this is the perfect time to look at "dual-mode" publishing. This involves using the exports field in your package.json to point to different entry points for ESM and CommonJS. It’s the most responsible way to handle the transition for your users. The Node.js documentation has a detailed section on this, though the complexity of getting it right can be a bit overwhelming at first glance. It’s about ensuring that whether someone uses require() or import, they get a version of your code that actually works in their environment.

One of the quiet wins of this migration is gaining access to top-level await. In CommonJS, you always had to wrap your async startup logic in an IIFE. In an ESM module, you can just use await at the top level of your file. This makes database connections, config fetching, and initial handshakes much cleaner. It’s a small quality-of-life improvement, but after years of wrapping everything in async arrows, it feels like a breath of fresh air. You should also check the MDN Web Docs for a deeper look at how the module graph is constructed compared to the old way.

Don't expect the migration to be a one-afternoon job if your project is large. You'll likely run into issues with your test runner or your linter. Tools like Jest have historically struggled with ESM, and you might need to swap them out for something more modern like Vitest if the friction becomes too high. The goal isn't just to use new syntax—it's to move your stack toward the standard that the rest of the JavaScript world has already adopted. It takes some patience to untangle the module knots, but the end result is a cleaner, more future-proof codebase that won't leave you stuck on dead-end dependency paths.