Módulos ESM vs CommonJS
Entendiendo los dos sistemas de módulos de Node.js - cuándo usar cada uno, cómo migrar y cómo admitir ambos consumidores desde un único paquete.
Busca en todas las páginas de la documentación
Entendiendo los dos sistemas de módulos de Node.js - cuándo usar cada uno, cómo migrar y cómo admitir ambos consumidores desde un único paquete.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
// package.json - declara este paquete como ESM
{
"name": "my-pkg",
"version": "1.0.0",
"type": "module"
}// ESM - import / export
import { readFile } from 'node:fs/promises';
export async function loadConfig(path) {
return JSON.parse(await readFile(path, 'utf8'));
}// CommonJS - require / module.exports
const { readFileSync } = require('node:fs');
module.exports.loadConfig = function (path) {
return JSON.parse(readFileSync(path, 'utf8'));
};// Import dinámico - funciona en AMBOS sistemas
const mod = await import('some-esm-only-package');// Usa createRequire para cargar CJS desde ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('some-cjs-only-package');Extensiones explícitas:
.mjs - siempre ESM, independientemente de package.json..cjs - siempre CommonJS, independientemente de package.json..js - depende del campo "type" del package.json más cercano.Cuándo usarlo: Cada vez que encontres un error "ERR_REQUIRE_ESM", un error "Cannot use import statement outside a module", o estés decidiendo cómo publicar un paquete.
Un script ESM que importa tanto un paquete ESM moderno como un paquete CJS heredado.
// package.json
{
"name": "mixed-modules",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"chalk": "^5.3.0",
"lodash": "^4.17.21"
}
}// src/index.js - entrada ESM que mezcla ambos sistemas de módulos
import chalk from 'chalk'; // paquete solo ESM
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFile } from 'node:fs/promises';
// Reconstruir __dirname en ESM
const __dirname = dirname(fileURLToPath(import.meta.url));
// Cargar un paquete solo CommonJS desde un archivo ESM
const require = createRequire(import.meta.url);
const _ = require('lodash');
// Cargar JSON en ESM (sintaxis de atributos de importación)
const pkg = JSON.parse(
await readFile(join(__dirname, '..', 'package.json'), 'utf8'),
);
console.log(chalk.green(`Running ${pkg.name} v${pkg.version}`));
console.log(chalk.cyan('chunked:'), _.chunk([1, 2, 3, 4, 5], 2));Lo que esto demuestra:
chalk v5+)createRequire desde node:module para cargar un paquete solo CommonJS desde ESMimport.meta.url + fileURLToPath para reconstruir __dirnamefs para evitar la sintaxis de atributos de importación aún en evoluciónawait - solo legal en ESMCommonJS fue el sistema de módulos original de Node.js. Cada archivo .js se envuelve en una función síncrona que recibe require, module, exports, __filename y __dirname. Porque es síncrono, require('x') se bloquea hasta que x se carga completamente.
Los módulos de ES (ES Modules) son el estándar de JavaScript. Son asíncronos, analizables estáticamente y admiten características que CommonJS nunca podría - top-level await, tree-shaking y enlaces en vivo. El trade-off es que require no está disponible dentro del alcance ESM, las extensiones de archivo son obligatorias y el gráfico de módulos se resuelve antes de que se ejecute cualquier código.
Node.js elige un sistema de módulos por archivo basándose en:
.mjs es siempre ESM, .cjs es siempre CommonJS."type" del package.json padre más cercano - "module" significa ESM, "commonjs" (o faltante) significa CommonJS.Desde ESM siempre puedes cargar CommonJS con una importación por defecto o createRequire. Desde CommonJS no puedes require estáticamente un módulo ESM - debes usar import() dinámico, que devuelve una promesa.
Convertir un archivo CJS a ESM:
// Antes - CommonJS
const path = require('node:path');
const { readFile } = require('node:fs/promises');
module.exports.read = (p) => readFile(path.resolve(p), 'utf8');
// Después - ESM
import path from 'node:path';
import { readFile } from 'node:fs/promises';
export const read = (p) => readFile(path.resolve(p), 'utf8');Exportaciones de paquete dual - envía tanto ESM como CJS:
// package.json
{
"name": "dual-pkg",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}Importar JSON en ESM - usa la sintaxis de atributos de importación (escapando las llaves aquí porque esto es prosa markdown): import data from './data.json' with \{ type: 'json' \};. Está detrás de una bandera en versiones antiguas de Node, así que muchos proyectos aún prefieren readFile + JSON.parse.
Polyfill de __dirname en ESM:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));Para TypeScript, "module": "NodeNext" es casi siempre la opción correcta para código de Node.js. Esto:
"type" del package.json más cercano..js en importaciones relativas (aunque la fuente sea .ts).exports (import, require, types)..mts (fuerza ESM) y .cts (fuerza CJS)."module": "ES2022" compila a ESM pero no obliga las reglas de resolución de Node - úsalo solo si un bundler consumirá el resultado.
Siempre empareja "module": "NodeNext" con "moduleResolution": "NodeNext". Cualquier otro modo de resolución divergirá silenciosamente de cómo Node realmente carga los archivos.
.js. import './foo' lanza ERR_MODULE_NOT_FOUND. TypeScript con NodeNext obliga esto en tiempo de compilación..js en un paquete "type": "module" es ESM; su archivo hermano .cjs es CommonJS. Esto funciona, pero los nombres e importaciones se confunden rápidamente - prefiere un sistema por paquete.import data from './data.json' lanza en Node moderno a menos que agregues with { type: 'json' } (escapado aquí porque es markdown). En caso de duda, JSON.parse(await readFile(...)).require no está disponible en el alcance ESM. Intentar llamar require('x') dentro de un archivo ESM lanza ReferenceError: require is not defined. Usa createRequire(import.meta.url) cuando realmente lo necesites.__dirname y __filename no están definidos en ESM. Reconstruyelos desde import.meta.url con fileURLToPath.require()-ing un paquete ESM. Obtienes ERR_REQUIRE_ESM. Migra a ESM o usa import() dinámico, que es asincrónico y devuelve una promesa."type": "module" afecta a cada archivo .js debajo. Agregar el campo voltea un proyecto completo de una vez. Renombra cualquier cosa que deba mantenerse CJS a .cjs.| Runtime / Opción | Fortalezas | Debilidades |
|---|---|---|
| Node.js ESM | Estándar, a prueba de futuro, top-level await | Extensiones estrictas, más nuevas para algunas librerías |
| Node.js CJS | Maduro, funciona con cada librería | Sin top-level await, sin tree-shaking |
| Paquete dual (ESM + CJS) | Admite a cada consumidor | Configuración de compilación compleja, fácil de enviar tipos rotos |
| Bun | Admite ambos sistemas de módulos nativamente, muy rápido | Runtime diferente, ecosistema aún en maduración |
| Deno | Solo ESM, seguro por defecto, TS nativo | Resolución de módulos diferente, importaciones basadas en URL |
| Proyecto CJS-only heredado | Cero costo de migración | No puede usar librerías ESM modernas de forma estática |
Nómbralo .mjs, o agrega "type": "module" al package.json más cercano. De lo contrario, Node trata archivos .js como CommonJS.
No directamente. require es un global solo de CommonJS. Usa createRequire(import.meta.url) desde node:module si realmente necesitas cargar un módulo CJS desde ESM.
Solo la forma dinámica: const mod = await import('some-pkg'). Las sentencias estáticas de import solo funcionan en archivos ESM.
.mjs es siempre ESM, .cjs es siempre CommonJS, y .js depende del campo "type" del package.json más cercano. Si "type" falta, .js por defecto es CommonJS.
Porque CommonJS require() no puede cargar un paquete ESM de forma síncrona. Convierte tu archivo a ESM, o usa import() dinámico que devuelve una promesa.
Casi siempre una extensión de archivo faltante. ESM requiere el especificador completo - escribe ./foo.js, no ./foo. CommonJS es más permisivo y agrega la extensión por ti.
Usa "NodeNext" para cualquier código que Node.js ejecute directamente. Obliga las reglas de resolución reales de Node, incluyendo extensiones .js obligatorias y el campo exports. Usa "ES2022" solo cuando un bundler (Vite, webpack, esbuild) procesa el resultado.
Porque NodeNext modela el especificador de runtime. En tiempo de ejecución Node carga foo.js - ya sea compilado desde foo.ts, o resuelto de vuelta a foo.ts por tsx/ts-node. Escribir ./foo.js es la única forma que funciona en ambos casos.
Reconstruyelo desde import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));Usa atributos de importación - import data from './data.json' with { type: 'json' } - o léelo y analízalo tú mismo con fs.readFile y JSON.parse. Este último evita problemas de compatibilidad de bandera en versiones de Node.
No. El top-level await es una característica solo de ESM. En CommonJS debes envolver código asincrónico en una función async y llamarla.
Es la forma moderna de declarar los puntos de entrada públicos de un paquete. Admite exportaciones condicionales - diferentes archivos para import, require, types, browser, y así sucesivamente - y oculta archivos que no quieres que los consumidores accedan.
Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥