ESM como estándar actual de módulos
El sistema ECMAScript Modules (ESM) es el estándar oficial de módulos en JavaScript. A diferencia de CommonJS (el sistema tradicional de Node.js con require/module.exports), ESM está soportado nativamente en navegadores y en todas las versiones recientes de Node.js, Bun y Deno. Aprender ESM a fondo permite escribir código que funciona sin transpilación en cualquier entorno moderno.
En Node.js se activa ESM de dos formas: declarando "type": "module" en el package.json (todo el proyecto usa ESM), o usando la extensión .mjs en los archivos concretos. Los archivos .cjs fuerzan CommonJS cuando el paquete es tipo módulo por defecto.
{
"name": "mi-proyecto",
"type": "module",
"exports": {
".": "./index.js",
"./utils": "./lib/utils.js"
}
}
La recomendación para proyectos nuevos en 2026 es ESM siempre: mejor interoperabilidad, soporte nativo en runtimes y herramientas, y eliminación gradual de CommonJS en el ecosistema.
Diferencias clave con CommonJS
ESM cambia algunas reglas importantes respecto a CommonJS. Las imports y exports son estáticos, no asignables en tiempo de ejecución. Las rutas deben incluir la extensión del archivo (./utils.js, no ./utils). No existen __dirname ni __filename directamente; en su lugar se usa import.meta.url. La carga de módulos es asíncrona, lo que habilita capacidades nuevas como top-level await.
// CommonJS (antiguo)
const fs = require("fs");
console.log(__dirname);
// ESM (actual)
import fs from "node:fs";
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
Los nombres de módulos built-in de Node.js usan el prefijo node: (node:fs, node:path, node:http). Esta convención elimina ambigüedad con paquetes npm que pudieran llamarse igual y es la práctica recomendada.
Top-level await: promesas a nivel de módulo
Antes de ES2022, un await solo podía aparecer dentro de funciones async. Esto obligaba a envolver código de inicialización en una función autoinvocada inmediatamente ((async () => { ... })()). Top-level await elimina esa restricción: dentro de un módulo ESM puedes usar await directamente en el nivel superior.
// config.js
const respuesta = await fetch("https://api.ejemplo.com/config");
const configuracion = await respuesta.json();
export default configuracion;
Cualquier módulo que importe config.js esperará implícitamente a que la inicialización termine antes de ejecutar su propio código. El sistema de módulos coordina estas dependencias de forma transparente.
// app.js
import config from "./config.js";
// En este punto, config ya esta listo
console.log(config.version);
Esta capacidad es útil para carga de configuración externa, conexiones a bases de datos que deben estar abiertas antes de aceptar requests, o inicialización de WebAssembly. Evita el patrón repetitivo de funciones initialize() que hay que recordar invocar.
Top-level await no es gratis: el módulo bloquea su propia carga hasta que la promesa se resuelva. Úsalo para inicializaciones legítimas, no para operaciones que deberían ser bajo demanda.
Import dinámico para carga perezosa
El operador import() (con paréntesis, a diferencia del import ... from estático) carga un módulo de forma asíncrona y devuelve una promesa. Permite diferir la carga hasta que realmente se necesite, habilita code splitting en bundlers modernos y desbloquea imports condicionales.
// Carga perezosa: el modulo solo se descarga si el usuario lo pide
botonExportar.addEventListener("click", async () => {
const { exportarCSV } = await import("./exportador-csv.js");
await exportarCSV(datos);
});
En aplicaciones web, el import dinámico es la piedra base del code splitting. El bundler (Vite, esbuild, webpack) detecta cada import() y genera un chunk separado que se descarga solo cuando se invoca. El resultado es un bundle inicial más pequeño y páginas que arrancan antes.
// Imports condicionales por entorno
if (typeof window === "undefined") {
const fs = await import("node:fs/promises");
// Logica especifica de servidor
}
Retrasar dependencias pesadas
Muchas librerías son grandes pero solo se usan en ciertas páginas. Cargarlas bajo demanda reduce el tiempo hasta interactivo (TTI) de la aplicación. Un router moderno emplea dynamic import para cargar vistas cuando el usuario navega a ellas.
const rutas = {
"/dashboard": () => import("./pages/dashboard.js"),
"/reportes": () => import("./pages/reportes.js"),
"/admin": () => import("./pages/admin.js")
};
async function navegar(ruta) {
const modulo = await rutas[ruta]();
modulo.render();
}
El code splitting por ruta es la estrategia más efectiva para mejorar tiempos de carga iniciales en aplicaciones medianas y grandes.
Import attributes para JSON y más
Los import attributes son una característica reciente que permite declarar el tipo del recurso importado. Antes era necesario parsear JSON manualmente con fs o usar plugins del bundler; ahora se puede importar JSON como un módulo con sintaxis estándar.
// JSON como import estatico
import configuracion from "./config.json" with { type: "json" };
console.log(configuracion.version);
En Node.js la sintaxis es estable desde la versión 22. En navegadores y bundlers modernos también está soportada. La sintaxis antigua assert { type: "json" } queda reemplazada por with { type: "json" }.
// Import dinamico con atributos
const { default: traducciones } = await import(`./i18n/${idioma}.json`, {
with: { type: "json" }
});
Los atributos son extensibles: los bundlers pueden definir tipos personalizados (css, text, url) mientras la sintaxis base es estándar. Esto unifica la forma de importar recursos no JavaScript sin pluginerías incompatibles entre herramientas.
import.meta: información sobre el módulo actual
Cada módulo ESM tiene acceso a un objeto import.meta que contiene metadatos sobre sí mismo. La propiedad más usada es import.meta.url, la URL del módulo actual. En Node.js es una URL file://; en navegador, la URL HTTP.
console.log(import.meta.url);
// En Node: file:///C:/proyectos/app/servidor.js
// En navegador: https://ejemplo.com/modules/app.js
Para trabajar con rutas relativas al módulo en Node.js, el patrón estándar es convertir import.meta.url a una ruta de archivo con fileURLToPath.
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { readFile } from "node:fs/promises";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const datos = await readFile(join(__dirname, "datos.json"), "utf-8");
Node.js recientes exponen también import.meta.dirname e import.meta.filename como atajos, evitando la conversión manual. El soporte ha ido generalizándose en los runtimes modernos.
// En Node 20.11+ y runtimes modernos
const datos = await readFile(join(import.meta.dirname, "datos.json"), "utf-8");
Resolver URLs de recursos
Cuando un módulo necesita referirse a otro recurso del proyecto (un WASM, un fichero de traducciones, un asset), construir la URL con new URL("./recurso", import.meta.url) es portable entre navegador y Node.
const urlWorker = new URL("./worker.js", import.meta.url);
const worker = new Worker(urlWorker, { type: "module" });
Esta expresión funciona porque import.meta.url es siempre una URL absoluta. El mismo código resuelve correctamente la ruta en Vite, en Node y en un navegador servido desde cualquier dominio.
Estrategia práctica en proyectos profesionales
Un proyecto JavaScript moderno combina estas piezas con cuidado. La configuración base del proyecto usa ESM ("type": "module") y apunta a un runtime moderno. Las dependencias grandes se cargan con dynamic import. Los archivos de configuración se importan como JSON con import attributes. El código de inicialización usa top-level await para asegurarse de que todo está listo antes de aceptar trabajo.
// servidor.js
import configuracion from "./config.json" with { type: "json" };
import { conectar } from "./db.js";
// Inicializacion bloqueante con top-level await
await conectar(configuracion.db);
// Handler que carga perezosamente modulos raramente usados
export async function handler(req, res) {
if (req.url.startsWith("/admin/")) {
const { router } = await import("./admin.js");
return router(req, res);
}
return respuestaNormal(req, res);
}
Combinar ESM estándar + import dinámico + top-level await lleva a código más claro y eficiente, con menos boilerplate y mejor rendimiento. Es la forma recomendada de estructurar aplicaciones en 2026.
Dominar estas capacidades convierte el sistema de módulos de una restricción a una herramienta expresiva. La combinación de carga estática predecible con carga dinámica bajo demanda habilita arquitecturas que eran impracticables en la era de CommonJS.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, JavaScript es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de JavaScript
Explora más contenido relacionado con JavaScript y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Configurar type module en package.json. Usar top-level await en módulos ESM. Aplicar import dinámico para code splitting. Importar JSON con import attributes. Acceder a import.meta.url y rutas relativas al módulo.