Node.js ESM Patterns
Write Node.js ES modules correctly using import.meta.url, package.json type, and CJS interop
When to Use
- Setting up a Node.js project with ES modules
- Converting CommonJS code to ES modules
- Handling
__dirnameand__filenameequivalents in ESM - Interoperating between ESM and CommonJS modules
Instructions
- Enable ESM in
package.json:
{ "type": "module" }
All .js files are now ESM. Use .cjs extension for CommonJS files.
- Replace
__dirnameand__filename:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');
- Dynamic imports:
const { default: chalk } = await import('chalk');
// Conditional imports
const db =
process.env.DB === 'postgres' ? await import('./db/postgres.js') : await import('./db/sqlite.js');
- Import JSON with import assertions:
import config from './config.json' with { type: 'json' };
// Or dynamically
const pkg = await import('./package.json', { with: { type: 'json' } });
- Import CommonJS modules from ESM:
// Default import for CJS modules
import express from 'express';
// Named imports may not work for all CJS modules
// Use default import + destructure instead
import pkg from 'lodash';
const { pick, omit } = pkg;
- Top-level await:
// Works at module top level in ESM (not in CJS)
const config = await loadConfig();
const db = await connectDatabase(config.dbUrl);
export { db, config };
- File extensions are required in imports:
// CJS: works without extension
const { foo } = require('./utils');
// ESM: extension required
import { foo } from './utils.js'; // .js even for .ts files with Node16 module resolution
- Configure TypeScript for ESM:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022"
}
}
Details
ES modules are the standard module system for JavaScript. Node.js supports them natively since v12 (stable since v14). ESM is statically analyzable, supports tree-shaking, and uses import/export syntax.
ESM vs CJS differences:
- ESM:
import/export, static analysis, async loading, strict mode by default - CJS:
require/module.exports, dynamic, synchronous, non-strict by default - ESM can import CJS; CJS cannot
require()ESM (use dynamicimport()instead)
import.meta: ESM-only global with module metadata:
import.meta.url— file URL of the current module (file:///path/to/module.js)import.meta.resolve()— resolve a module specifier relative to the current module
Dual package publishing: Libraries can support both ESM and CJS consumers:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Trade-offs:
- ESM enables tree-shaking — but requires file extensions in imports
- Top-level await is convenient — but blocks module loading for all importers
- Static imports enable bundler optimizations — but dynamic imports are needed for conditional loading
- CJS interop mostly works — but named imports from CJS can fail depending on how the CJS module exports
Source
https://nodejs.org/api/esm.html
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.