šŖ Make Strapi load as CommonJS from ESM code with a Node resolve hook
We moved part of our Strapi 5 codebase to ES modules. Type-checking passed, unit tests passed, and then Strapi crashed at boot with an error about lodash, a library we never import ourselves. The fix for that one crash turned out to be the easy part: underneath it was a second problem that would have quietly turned our 401s and 403s into 500s š¬.
tl;dr When an ES module imports
@strapi/*, Node picks the packagesā ESM builds, which donāt load in Node, and which would give you a second Strapi instance even if they did. Preload amodule.registerHooksresolve hook that swaps theimportcondition forrequirefor every@strapi/*specifier, and pass it throughNODE_OPTIONSsostrapi developās child process gets it too.
// preload/strapi-commonjs.cjs
"use strict";
const { registerHooks } = require("node:module");
const STRAPI_SCOPE = "@strapi/";
registerHooks({
resolve(specifier, context, nextResolve) {
const isStrapi = specifier.startsWith(STRAPI_SCOPE);
const hasImport = context.conditions.includes("import");
if (!isStrapi || !hasImport) {
return nextResolve(specifier, context);
}
return nextResolve(specifier, {
...context,
// Not just dropping "import": @strapi/provider-email-nodemailer has no "default" to fall back to.
conditions: [
...context.conditions.filter(
(condition) => condition !== "import",
),
"require",
],
});
},
});
{
"scripts": {
"develop": "NODE_OPTIONS=\"$NODE_OPTIONS --require ./preload/strapi-commonjs.cjs\" strapi develop",
"start": "NODE_OPTIONS=\"$NODE_OPTIONS --require ./preload/strapi-commonjs.cjs\" strapi start"
}
}
Hereās what goes wrong, why the obvious fixes donāt help, and how to wire the hook into every way Strapi runs.
š Everything here was seen on Strapi 5.57.0 and Node 24.21.
The crash
Most @strapi/* packages have an import condition in their exports map that points at an ESM .mjs build. That includes the . entry of @strapi/strapi and @strapi/core, and most */strapi-server entries. So as soon as an ES module does something as ordinary as
import { factories } from "@strapi/strapi";
Node resolves the import condition and loads the .mjs files. And those donāt load in Node:
SyntaxError: The requested module 'lodash' does not provide an export named 'get'
The first one to fail is @strapi/core/dist/configuration/get-dirs.mjs. The ESM builds use named imports from CommonJS lodash:
import { get } from "lodash";
Bundlers accept that. Node doesnāt, because it canāt statically detect get as a named export of lodashās CommonJS build.
The bug hiding behind the crash
Say the ESM builds did load. You still wouldnāt want them š .
The Strapi CLI is CommonJS, so it loads Strapiās CommonJS build. Your ES module would load the ESM build, which is a different set of files, so youād get a second Strapi instance next to the one the CLI runs. Each one has its own copy of Strapiās error classes, and instanceof checks between the two fail. An error your code throws from one copy isnāt recognized by the other, so a 401 or 403 becomes a 500.
Whoās affected
Any ES module that imports a runtime value from @strapi/*:
- a
"type": "module"Strapi app; - a
"type": "module"library loaded by a CommonJS app withrequire(). The app is CommonJS, but the libraryās own imports still resolve theimportcondition.
Type-only imports are fine, because theyāre erased before anything runs.
ā ļø Unit tests wonāt catch this. Vitest bundles your code and copes with the
lodashnamed imports just fine. It only fails when Strapi boots in plain Node.
Why peer dependencies donāt fix it
The usual advice for duplicate instances is āmake it a peer dependencyā. That guarantees a single installed copy of @strapi/core. But both builds live inside that one copy, and which file loads is decided by the importerās module format, not by how many copies are installed. One copy, two instances.
Why not a pnpm patch
You can pnpm patch @strapi/strapi and drop its import condition. It works, but only for that one package. Covering every @strapi/* package you load directly or indirectly means about 12 patches to carry and re-create on every Strapi upgrade.
A resolve hook covers all of them, including indirect ones, and isnāt tied to a version.
The resolve hook
module.registerHooks (Node 22.15+ and 24) registers synchronous, in-thread hooks that run for both import and require. The resolve hook gets every specifier along with the conditions Node is about to match against the exports map. For @strapi/* specifiers resolved with import, the hook from the tl;dr resolves them as if they had been required. Everything else passes through untouched.
š” Add
require, donāt just removeimport. In 32 of the 34@strapientries with animportcondition,defaultpoints at the same file asrequire. But@strapi/provider-email-nodemailer(.and./utils) has nodefault, so removingimportalone throwsERR_PACKAGE_PATH_NOT_EXPORTED.
The admin panel isnāt affected: Vite doesnāt use Nodeās resolver, so the admin bundles keep using the ESM builds, which is what you want there.
Wiring it into every way Strapi runs
The hook only helps if itās registered before anything else loads, everywhere Strapi starts.
1. package.json scripts
Use NODE_OPTIONS rather than a bare --require, as shown in the tl;dr. strapi develop forks a child process for the server, and only NODE_OPTIONS reaches it.
2. Docker
Call the CLIās entry file through node directly:
CMD ["node", "--require", "./preload/strapi-commonjs.cjs", "node_modules/@strapi/strapi/bin/strapi.js", "start"]
If you build the image with pnpm deploy, add the preload to files in package.json, or it wonāt be in the deploy output:
{
"files": ["dist", "preload", "public"]
}
3. Everything else
Smoke tests, CI scripts and any direct strapi CLI call need the preload too. Grep for strapi and strapi.js and check each one.
If your app itself is ātypeā: āmoduleā
With the hook in place, a "type": "module" Strapi app boots: in our case REST, GraphQL and the admin all served, and strapi develop reloaded on edits. A few things to know:
- Strapi loads
config/,src/index, the APIs and the migrations with a synchronousrequire(). On Node 24,require(esm)works and returns a namespace with__esModule: true, so StrapiāsimportDefaultpicks up.default. - Because of that, avoid top-level
awaitanywhere in the appās module graph:require(esm)rejects it withERR_REQUIRE_ASYNC_MODULE. - Replace
__dirnamewithimport.meta.dirname. - Some packages ship a single
.d.tsthat TypeScript reads as CommonJS, for example@strapi/email/strapi-server. An ESM default import of those is typed as the whole module. Import the type and index it instead:
import type emailServer from "@strapi/email/strapi-server";
type EmailServer = (typeof emailServer)["default"];
Know when to delete it
The hook is a workaround, so add a regression test that tells you when itās no longer needed. Spawn Node with the preload and check that an ESM import gets the same instance as require:
node --require ./preload/strapi-commonjs.cjs --input-type=module -e '
import { createRequire } from "node:module";
import * as core from "@strapi/core";
let require = createRequire(import.meta.url);
if (core.default !== require("@strapi/core")) process.exit(1);
'
Then assert that the same import fails without the preload. The day that second assertion starts failing, Strapiās ESM builds work in Node and you can delete the hook š.
Thanks for reading my blog posts! š
- Created At
- 10/27/2026
- Published At
- 10/27/2026