Skip to main content

2 posts tagged with "ESM"

View all tags

Node.js require('nanoid') Throws ERR_REQUIRE_ESM? Alternatives After v5 Went ESM-Only

ยท 5 min read

In a CommonJS project, require('nanoid') to generate a unique ID throws ERR_REQUIRE_ESM the moment the process starts, and it exits immediately.

Encountered this while building the ecommerce data collection tool for a client โ€” a browser-side scraper that captures product images, SKUs, prices, and reviews in real time, then cleans and exports them as structured files. The server needed a stable traceId per request for cross-service log correlation.

TL;DRโ€‹

From v5 onward, nanoid is an ESM-only package, and CommonJS require() cannot load it โ€” it always throws ERR_REQUIRE_ESM. If your project is still CJS, the simplest replacement is Node's built-in crypto.randomUUID(): zero dependencies, works in both CJS and ESM, and produces a standard UUID.

The Problemโ€‹

A perfectly ordinary import in a CJS project:

// server.js (CommonJS)
const { nanoid } = require('nanoid');

const traceId = nanoid();

It crashes on startup, the stack pointing at nanoid's entry file:

node server.js

internal/modules/cjs/loader.js:905
Error [ERR_REQUIRE_ESM]: require() of ES Module
/node_modules/nanoid/index.js from server.js not supported.

Instead change the require of index.js in server.js to a CommonJS module,
or use a dynamic import() call.

Note that this isn't an intermittent or environment-specific error โ€” it's a deterministic crash. Once you're on v5, the CJS path simply does not work.

Root Causeโ€‹

In v5, nanoid completed its ESM-only migration: its package.json no longer ships a CommonJS entry, only ESM. Node's CommonJS loader, require(), is synchronous and cannot load an ESM module, so it throws ERR_REQUIRE_ESM.

This isn't a nanoid bug โ€” it's the ecosystem's module-format evolution. More and more packages ship ESM-only (got v12+, node-fetch v3, uuid v7+ all do the same). As long as your host project is CommonJS, you'll hit the same wall with every one of them.

If you've also hit "module not found" with dynamic import(), that's the same ESM resolution rules at work โ€” see Node.js ESM dynamic import can't find the module? Check the file extension.

Solutionโ€‹

Three options, ordered by how little they cost to adopt.

When you're generating unique IDs, nanoid's core value is "short and unique." But as long as the ID doesn't need to fit in a URL or be aggressively shortened, a standard UUID is more than enough โ€” and it's built into Node 14.17+, with zero dependencies:

// Works identically in CommonJS and ESM
const { randomUUID } = require('node:crypto');

const traceId = randomUUID();
// => '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'

This single change solves three problems at once:

  • Zero dependencies: no more third-party package, so its module format can never hold you hostage;
  • Format alignment: UUID is a universal cross-language, cross-service format, handy for log correlation and database primary keys;
  • CJS/ESM agnostic: node:crypto is built into Node and behaves the same under both module systems.

The only tradeoff is length โ€” a UUID is 36 characters versus nanoid's default 21. For traceIds and primary keys that cost is negligible; only if you need it in a short link should you keep reading.

Option 2: pin nanoid v3โ€‹

nanoid's v3.x is the last major version that supports CommonJS, and require works directly:

// package.json โ€” explicitly pin v3
{
"dependencies": {
"nanoid": "^3.3.7"
}
}
const { nanoid } = require('nanoid');
const id = nanoid(); // 21-char short ID

Good for when you genuinely want short IDs but can't migrate the project to ESM yet. The cost is staying on an old version and missing v5's later updates.

Option 3: async dynamic importโ€‹

If you must use v5, the only way in is ESM's async loader:

// In CommonJS, load the ESM package with dynamic import()
async function makeId() {
const { nanoid } = await import('nanoid');
return nanoid();
}

// The call site itself has to be async
const id = await makeId();

It works, but nanoid is fundamentally a synchronous ID generator โ€” wrapping it in async/await forces async to propagate up the entire call chain, which is rarely worth it.

Caveats

  • This trap isn't unique to nanoid: uuid v7+, node-fetch v3, and got v12+ are all ESM-only, and require-ing them in a CJS project throws the identical ERR_REQUIRE_ESM. The way to tell is to check the target package's package.json for "type": "module" or an "import"-only entry.
  • crypto.randomUUID() requires Node 14.17+; on older runtimes, assemble one yourself with crypto.randomBytes(16).toString('hex').
  • Don't require('nanoid') in a CJS project while also import-ing nanoid in an ESM one โ€” mixing them leaves both old and new copies in the dependency tree, making behavior much harder to predict.

FAQโ€‹

Why does require('nanoid') throw ERR_REQUIRE_ESM in Node.js?โ€‹

Because nanoid has shipped only ESM artifacts since v5, and Node's CommonJS require() loads synchronously and cannot load an ESM module โ€” it throws ERR_REQUIRE_ESM the moment it hits nanoid's entry. This is a hard boundary between the CJS and ESM module systems, not a configuration issue.

Can I still use nanoid v5 in a CommonJS project?โ€‹

Yes, but either load it asynchronously with await import('nanoid') (which forces the whole call chain async) or pin the version to v3.x, which is still CJS-compatible. If you only need a unique ID, Node's built-in crypto.randomUUID() is the simplest path โ€” zero dependencies and supported under both module systems.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Node.js ESM Dynamic Import Can't Find Module? Check the File Extension

ยท 3 min read

Encountered this issue while building a SaaS analytics platform for a client. Here's the root cause and solution.

TL;DRโ€‹

In Node.js ESM mode, import('./path/to/module') doesn't auto-resolve ./path/to/module.js. If the TypeScript build output is missing .js extensions, the module throws ERR_MODULE_NOT_FOUND. When this import is inside deferred logic (timers, conditionals), the app starts fine but crashes later โ€” PM2 shows a climbing restart count.

Fix: Ensure all ESM dynamic imports include .js extensions, and automate this in the build pipeline.

Problemโ€‹

PM2 showed the app restarting continuously:

โ”‚ name             โ”‚ โ†บ    โ”‚ status โ”‚ uptime โ”‚
โ”‚ analytics-api โ”‚ 9 โ”‚ online โ”‚ 28m โ”‚

Error log repeated every few minutes:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

But the file clearly exists:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

Root Causeโ€‹

ESM doesn't auto-resolve file extensionsโ€‹

Node.js CommonJS (require()) automatically tries .js, .json, and other extensions. ESM (import) does not.

// โŒ ESM can't find the module
import('./domains/video/cleanup')
// Node.js looks for: ./domains/video/cleanup (exact path, no extension)
// Actual file: ./domains/video/cleanup.js

// โœ… Must include .js extension
import('./domains/video/cleanup.js')

Deferred imports hide the problemโ€‹

The import was inside a deferred execution:

// server.ts โ€” doesn't execute immediately on startup
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // triggers seconds later
});

The app starts successfully (DB connection, port binding all fine). When the timer fires, the import fails โ†’ process crashes โ†’ PM2 restarts โ†’ starts fine again โ†’ timer fires again โ†’ crashes again. This creates a crash loop.

Why did it work before?โ€‹

Previous deployment used a build script that included a post-build step to fix import paths. One deployment skipped this step and deployed raw tsc output โ€” tsc doesn't modify import paths in output files.

Solutionโ€‹

1. Write .js extensions in TypeScript sourceโ€‹

TypeScript officially recommends writing .js extensions even in .ts files:

// โœ… Write .js even in .ts source files
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Automated post-build fix (recommended)โ€‹

Add an import-fixing script to the build pipeline:

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

Core logic of fix-imports.js:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// Fix dynamic imports
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// Fix static imports
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

The build pipeline automatically appends .js to all relative import paths, keeping TypeScript source extension-free while preventing module-not-found errors in ESM deployments.

Note

  • This only affects Node.js ESM mode ("type": "module" or .mjs files). CommonJS is unaffected.
  • Static imports (import ... from './foo') have the same limitation, not just dynamic import(); import hoisting also causes another common issue โ€” dotenv runs after the import chain, leaving env vars undefined
  • Using tsx or ts-node in development won't show this error (they auto-resolve extensions), but node dist/server.js in production will fail.
  • PM2 crash loop signature: restart count (โ†บ) keeps growing, uptime never exceeds a few minutes.