Hardhat 3 in a CommonJS Application: Isolating ESM Tooling Without Breaking CI
Modernising blockchain tooling inside an established application is rarely just a dependency upgrade.
Our repository is primarily a CommonJS Node.js application: the server, most tests, and existing runtime scripts use require(). At the same time, we needed to move the NFT toolchain to Hardhat 3, which is ESM-first. A naïve migration—setting "type": "module" at the repository root—would have changed the module semantics of the whole application and created unnecessary runtime risk.
This article documents the approach we used instead: isolate the ESM toolchain, make mixed test execution deterministic, remove a high-severity dependency path, and verify the result without deploying anything to a public chain.
The constraints
The change had four non-negotiable constraints:
- The existing CommonJS application had to remain CommonJS.
- Solidity contracts and NFT scripts needed to run under Hardhat 3.
- CommonJS Jest tests and ESM Node tests needed to run correctly in both local development and CI.
- Dependency auditing had to fail on high and critical vulnerabilities without relying on unsafe automatic upgrades.
The goal was not to “make the build pass” at any cost. The goal was to make the boundaries explicit and testable.
Why converting the repository root was the wrong move
Hardhat 3 expects an ESM project. The obvious reaction is to add this to the root package.json:
{
"type": "module"
}
That would make every .js file in the repository ESM by default. In a mature CommonJS application, that affects server startup, test helpers, scripts, configuration files, and dependencies that expect require().
The blockchain toolchain has different module requirements from the application runtime. Treating them as one module system turns a local migration into a repository-wide compatibility project.
The safer design was to create a narrow ESM boundary.
Create an isolated ESM workspace
We moved Solidity-specific tooling into a dedicated directory:
nft-tools/
package.json
hardhat.config.js
contracts/
scripts/
Its package.json is intentionally small:
{
"name": "@myzubster/nft-tools",
"private": true,
"type": "module"
}
The root application remains CommonJS. Only the NFT workspace is ESM.
This lets the Hardhat configuration use native imports:
import { defineConfig } from "hardhat/config";
import hardhatEthers from "@nomicfoundation/hardhat-ethers";
export default defineConfig({
plugins: [hardhatEthers],
solidity: "0.8.28",
paths: {
sources: "./contracts",
cache: "./cache",
artifacts: "./artifacts"
}
});
The separation is more than cosmetic. Hardhat resolves project files relative to its project boundary, so contracts must live inside that ESM workspace rather than outside it.
Migrate scripts deliberately
The previous NFT scripts used CommonJS:
const hre = require("hardhat");
In the isolated ESM workspace, they now use the Hardhat 3 runtime model:
import { network } from "hardhat";
const { ethers } = await network.getOrCreate();
const [owner] = await ethers.getSigners();
The scripts retain their original safety characteristics:
- deployment and minting remain explicit commands;
- public-chain operations require an external private-key environment variable;
- local tests use Hardhat’s simulated network;
- no real transaction is sent during compilation or local verification.
Root-level scripts make the intended boundary visible:
{
"scripts": {
"nft:compile": "cd nft-tools && ../node_modules/.bin/hardhat compile",
"nft:test:local": "cd nft-tools && ../node_modules/.bin/hardhat run scripts/test-comic-nft.mjs"
}
}
A local NFT test deploys the contract, mints a test token, and verifies ownership and metadata URI. It does not use a wallet, a public RPC account, or production funds.
Dependency audit: avoid destructive “fixes”
During the audit, the high-severity issue was not application code. It came from the development watcher dependency path:
nodemon → chokidar → braces
An automated npm audit fix --force proposed breaking downgrades, including old versions of development tooling. That is not a reliable security remediation strategy.
Instead, we removed the unnecessary watcher dependency and used Node’s built-in watch mode:
{
"scripts": {
"dev": "node --watch server.js"
}
}
The dependency audit then had no high or critical findings. Moderate findings remain in Jest coverage-related transitive dependencies and are documented rather than hidden. The CI gate continues to fail on high or critical findings:
npm audit --audit-level=high
Security work should reduce exposure without silently introducing a different operational risk.
Mixed CommonJS and ESM tests need two runners
The repository contains two valid kinds of tests:
- a large Jest suite for CommonJS application tests;
- a small set of
.test.mjsfiles that must run through Node’s native ESM test runner.
Jest can discover .mjs files, but attempting to execute them as CommonJS produces errors such as:
Must use import to load ES Module
The solution is not to force ESM tests through a CommonJS runner. Instead, the test command is an explicit two-stage process:
const { spawnSync } = require("node:child_process");
const esmTests = [
"identity/credential/credential.test.mjs",
"identity/verifier/verify.test.mjs",
"identity/hash-verification/verify-hash.test.mjs",
"workshop/app/domain.test.mjs",
"workshop/app/integrate.test.mjs"
];
function run(command, args) {
const result = spawnSync(command, args, {
stdio: "inherit",
env: process.env
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status || 1);
}
Jest is restricted to JavaScript test files:
module.exports = {
testMatch: [
"**/?(*.)+(test).js"
]
};
Then the ESM files run separately:
run(process.execPath, ["--test", ...esmTests]);
This gives each test type the runtime it actually requires.
The CI argument-forwarding trap
One subtle failure only appeared in CI.
The workflow called:
npm test -- --runInBand --testPathIgnorePatterns=/frontend/
Jest command-line options can override settings declared in jest.config.js. In this case, the workflow’s testPathIgnorePatterns replaced the repository configuration, allowing Jest to discover the ESM test files again.
The wrapper now filters that externally supplied ignore-pattern override before invoking Jest:
const jestArgs = process.argv.slice(2).filter(
(arg) => !arg.startsWith("--testPathIgnorePatterns=")
);
run(process.execPath, [
require.resolve("jest/bin/jest"),
...jestArgs
]);
The repository configuration remains the source of truth for test discovery; CI still passes useful execution options such as --runInBand.
This is a general lesson: forwarding CLI arguments blindly can change test semantics. A wrapper should forward only the arguments that are safe for that stage of the pipeline.
Verification results
The final verification was intentionally layered:
Jest: 221 suites / 969 tests passed
Node ESM runner: 26 tests passed
Hardhat compile: 4 Solidity contracts compiled
Local NFT test: deploy + mint + ownership + URI passed
Security audit: no high or critical vulnerabilities
No deployment and no public-chain transaction were performed.
The pull request also passed the repository’s test, evidence, policy, security-audit, and secret-scanning checks. A Vercel status remained blocked by external deployment authorization, so it was treated as an environment-integration issue—not as evidence that the code had been deployed or independently validated.
What this migration changed
The important result is not simply “Hardhat 3 works.”
The repository now has clearer operational boundaries:
- the application runtime remains CommonJS;
- blockchain tooling is isolated in an ESM workspace;
- CommonJS and ESM tests run with their native runners;
- CI argument handling cannot accidentally re-enable incompatible test discovery;
- local blockchain verification is explicit and does not touch public networks;
- high- and critical-severity audit findings are gated without unsafe forced upgrades.
Tooling migrations are safest when they reduce ambiguity. In this case, separating module systems, test runners, and deployment intent made the codebase easier to verify—and harder to accidentally misuse.
Top comments (0)