TypeScript

Fix ERR_REQUIRE_ESM on cPanel Node.js apps (Passenger)

What Phusion Passenger's own loader script does, why it throws ERR_REQUIRE_ESM on an ESM app.js, and the fix, reproduced on Node 14, 18 and 22.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

cPanel starts a Node app through Phusion Passenger, and Passenger's own loader script loads the configured entry file with a plain require() — never import(), and nothing retries when require() fails. On a Node release before require() could load synchronous ES modules, or on any release when the entry file uses top-level await, that throws ERR_REQUIRE_ESM (or ERR_REQUIRE_ASYNC_MODULE) and the app never starts. The fix is not to convert the whole app back to CommonJS: write a tiny CommonJS loader file that dynamically import()s the real app, and point Passenger's entry-file setting at that loader instead of app.js directly.

The error#

The Stack Overflow question behind this page deployed a fully-ESM app to cPanel, on a host running Node v14.20.1 with Passenger handling the Node app, and got:

text
App 1153856 output: internal/modules/cjs/loader.js:948
App 1153856 output:     throw new ERR_REQUIRE_ESM(filename);
App 1153856 output:     ^
 
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /<serverlocation>/app.js
    App 1153856 output:     at new NodeError (internal/errors.js:322:7)
    App 1153856 output:     at Module.load (internal/modules/cjs/loader.js:948:11)
    App 1153856 output:     at Function.Module._load (internal/modules/cjs/loader.js:790:12)
    App 1153856 output:     at Module.require (internal/modules/cjs/loader.js:974:19)
    App 1153856 output:     at Module.require (/opt/cpanel/ea-ruby27/root/usr/share/passenger/helper-scripts/node-loader.js:80:25)

Every file in the project already used import/export; there was no require() anywhere in the app's own code. The last frame is the giveaway: it does not point at anything the asker wrote.

Check your own trace#

Paste the full error and its stack, including the at lines, to see which file Node treated as which module type, and why require() or import() refused it. It runs in your browser, nothing is sent.

Runs entirely in your browser — nothing you paste is sent anywhere. Tokens and passwords that appear in paths or URLs are masked in the output.

Optional: package.json, file extension, Node.js version (sharper advice)
Try:

Why it happens#

cPanel does not run a Node app directly with node. Its Node.js app interface hands the app to Phusion Passenger — the evidence's own path, /opt/cpanel/ea-ruby27/root/usr/share/passenger/helper-scripts/node-loader.js, names the ea-ruby27 profile directly, reused here to run a Node.js app. At the pinned commit of Passenger's own source, node-loader.js starts the app with:

js
function loadApplication() {
	var appRoot = PhusionPassenger.options.app_root || process.cwd();
	var startupFile = PhusionPassenger.options.startup_file || (appRoot + '/' + 'app.js');
	require(startupFile);
}

That is a plain CommonJS require(), defaulting to app.js when no startup file is configured — exactly the file name in the question's own path. Near the top of the same file, before any of this runs, Passenger also replaces Module.prototype.require with a wrapper that shields the app from Node's cluster module; for anything else, that wrapper falls through to return originalRequire.apply(this, arguments);. That line is what the reader's own trace names as Module.require inside node-loader.js — Passenger's own require machinery, the frame that calls into Node's internal loader, which is the one that actually throws. A separate report of this same error, on the same file but a different app (reported in phusion/passenger#2366), shares that exact node-loader.js:80 frame, and its trace continues further than the question's own: it also names loadApplication and setupEnvironment, which — at the pinned commit quoted above — are exactly the require(startupFile); line inside loadApplication() and the loadApplication(); call inside setupEnvironment().

Node's error reference confirms what is being refused: an attempt to require() an ES module is ERR_REQUIRE_ESM, and the reference now marks the error itself as deprecated — "This error has been deprecated since require() now supports loading synchronous ES modules" (Node.js errors reference, which lists v20.19.0 and v22.12.0 as the releases that changed it). The question's own Node v14.20.1 predates that by years, so on that runtime require() of any ES module failed unconditionally, with no exception for a module that has no top-level await.

Nothing in node-loader.js catches that failure and retries with import(). That is a Passenger choice, not a Node limitation: Node's own module docs say plainly that "import statements are permitted only in ES modules, but dynamic import() expressions are supported in CommonJS for loading ES modules" (Node.js ESM docs). The same #2366 pull request proposed replacing loadApplication()'s require() with child_process.fork(), specifically so Passenger could start both CommonJS and ESM apps; it was closed without being merged. Passenger's own CHANGELOG has no entry mentioning ES modules or ESM, and its current helper-scripts folder still has exactly one Node loader — this same node-loader.js, alongside the Ruby, Python and Meteor ones — so nothing suggests the require()-only approach has changed since.

Fix#

1. Write a CommonJS loader that imports the real app#

Create a new file next to the app's entry point. The extension matters: .cjs is always CommonJS regardless of "type" in package.json, which is what lets Passenger's own require() load this file without throwing, even though the real app next to it is ESM (the same rule the exports is not defined in ES module scope fix explains from the other direction):

js
// loader.cjs
async function loadApp() {
  await import('./app.js');
}
 
loadApp();

Reproduced in Docker: with "type": "module" set and app.js written as an ES module, a loader that does require('./app.js') throws ERR_REQUIRE_ESM — the same failure as the question, from the same kind of code node-loader.js runs. Changing only that one line to the loadApp() function above, with nothing else touched, loads the app without error — reproduced on Node 18.20.8, and again on the question's own exact release, Node v14.20.1. These runs stand in for Passenger's own require(startupFile) with plain node; neither Passenger nor cPanel itself was run here. That the same loader fixes the real cPanel deployment comes from the question's own answers: the accepted one ends "Then it worked", and the second says "I followed the steps above", using the identical pattern with import('./server.js').

One detail is worth being precise about rather than assumed. The question's own message is a single line, "Must use import to load ES Module: <path>", thrown from Node's Module.load with no further explanation — the same shape (one-argument constructor, Module.load, no extra text) as the trace in phusion/passenger#2366, whose app was an index.mjs file, though that report ran a different Node build (its line is loader.js:926, not :948). Reproduced on the question's own exact release, v14.20.1: the :948 line and that same one-line shape come from require()-ing a file named .mjs specifically. A plain .js file under "type": "module", on the identical Node release, throws a longer message instead, from a different internal function, naming the package.json reason. The question's own path is redacted as /<serverlocation>/app.js, which this reproduction cannot resolve either way — what does not change is the fix, which is the same one-line change regardless of which extension the real entry file has, reproduced on v14.20.1 for both.

2. Point Passenger at the loader, not at app.js#

Passenger decides which file to run from its PassengerStartupFile directive. Its own documented default is "Autodetected" (Passenger's Apache reference): the same reference lists app.js as the convention Passenger autodetects for a Node.js app when the directive is left unset — the literal fallback, (appRoot + '/' + 'app.js'), is the line already quoted above from node-loader.js itself. Set PassengerStartupFile and you must also set PassengerAppRoot and PassengerAppType, or Passenger cannot tell what kind of app it is; its context includes server config, virtual host, directory and .htaccess. Whether cPanel's own Node.js app interface exposes a field for this, or a plan needs an .htaccess override instead, varies by host — either way, the value that has to change is the startup file, from app.js to loader.cjs. The real app keeps its own name and keeps being ESM; only the file Passenger calls require() on changes.

3. Keep the real app as an ES module, and match the extension to the type#

The loader's import('./app.js') only works if Node actually treats app.js as an ES module. Node decides that per file, from the nearest package.json, the extension, and — on newer releases — the syntax itself; the fix for exports is not defined in ES module scope covers that decision in full. A .mjs file is always an ES module, on every release tested here (Node 14, 18 and 22). Reproduced on node:18-alpine: the same app.js, with identical ESM content, loaded fine through import() when package.json set "type": "module"; with that field removed, the same import() call instead failed with SyntaxError: Cannot use 'import.meta' outside a module, because that release parsed the unmarked .js file as CommonJS first. Reproduced again on node:22.12.0-alpine, the same two files and the same missing "type" field loaded without error instead, with a warning naming the reason: Reparsing as ES module because module syntax was detected. Which behaviour a given cPanel host's Node gives depends on its exact release; renaming the entry file to .mjs removes the question entirely, on every release tested here.

One correction to the accepted answer on the original question: it suggests that import("/path/to/app.js") will "still correctly look for" app.mjs if that is the real file. Reproduced: it does not. Node's own docs require a fully-specified extension on a relative or absolute import() specifier, with no substitution — a folder containing only app.mjs, imported as ./app.js, threw ERR_MODULE_NOT_FOUND: Cannot find module '/repro/app.js'. Whichever extension the real file has, the loader's import() call must name that exact file.

Verify the fix#

Deploy with the loader as the startup file, then check the log where the app's output appears with the same App … output: prefix the question's trace shows (with your own app number), for lines like this reproduction's, after the fix:

text
ESM app loaded, import.meta.url = file:///repro/app.js
dynamic import() resolved

The second line is this reproduction's own confirmation print, not something your app needs — any log line from your real app's own startup code is the real proof it ran. If the log still shows ERR_REQUIRE_ESM, confirm the startup-file setting actually changed, and that the loader file's extension is .cjs.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

If your app has no top-level await, a newer Node alone can be enough#

Node's require() of an ES module stopped being a hard failure once v20.19.0 and v22.12.0 shipped, for one specific case: a module with no top-level await. Reproduced: the exact same unmodified require('./app.js') loader that threw ERR_REQUIRE_ESM on Node 14 and 18 ran without any error on node:22.12.0-alpine, printing require() returned without throwing and only an ExperimentalWarning. If cPanel's Node.js app interface offers that release or newer on your account, Passenger's own require(startupFile) call may simply stop failing, with no loader file at all.

That stops being true the moment the app uses top-level await. Reproduced on the same Node 22.12.0 image: a require() of an ES module that does use top-level await still fails, now with ERR_REQUIRE_ASYNC_MODULE instead of ERR_REQUIRE_ESM — Node's own docs state this explicitly: "require() now supports loading synchronous ES modules" but "[w]hen require() encounters an ES module that contains top-level await, it will throw ERR_REQUIRE_ASYNC_MODULE instead" (Node.js errors reference). Passenger's loader still does not retry with import(). The same loader.cjs from Fix 1, unchanged, loaded that top-level-await app without error in the same reproduction — which is why the loader is the fix to keep even on a newer Node, rather than something to remove once the account's Node release is recent enough.

If the next error names a different file#

A loader that imports the wrong path fails with ERR_MODULE_NOT_FOUND: Cannot find module, naming the exact specifier it tried — check that name against the real file on disk first (see Fix 3). If your own app's relative imports are missing their extension once it runs as an ES module, Node refuses the same way for a different reason; ERR_MODULE_NOT_FOUND: relative import missing .js covers that case, and Directory import is not supported covers the related case of a relative import that names a folder instead of a file — both common the first time a CommonJS app's own imports are rewritten for ESM. If some other file in the app still writes exports.foo = … once "type": "module" is set, the next error is exports is not defined in ES module scope, which this site's fix for that error covers in full.

ERR_REQUIRE_ESM is not specific to cPanel or Passenger: it is Node's generic refusal, and other loaders hit the same wall from different sides. ts-node's own CommonJS compiler triggers it by compiling an import of an ESM-only package back into a require(). gulp's CLI hits it loading an ESM-only plugin, and — unlike Passenger — gulp-cli's loader actually catches ERR_REQUIRE_ESM and retries with import() on its own; renaming a gulpfile to .mjs is enough there only when the plugin has no top-level await, or with a gulp-cli new enough to retry that case too — with an older gulp-cli and a plugin that does use top-level await, renaming alone trades ERR_REQUIRE_ESM for ERR_REQUIRE_ASYNC_MODULE instead. A Docker build can print the same error code for a reason that has nothing to do with your own code at all, when a yarn.lock entry that merges two conflicting package ranges hoists an ESM-only transitive dependency — reproduced there only by hand-editing the lockfile into that shape, since Yarn itself was never observed writing it. The error code always means the same thing — require() met an ES module — but what has to change depends entirely on which piece of software called require() in the first place, and on cPanel that piece is Passenger's own loader script, not your code.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote