Módulos en el navegador vs en Node
La misma sintaxis ESM, dos runtimes distintos: el navegador con script type=module e import maps sobre HTTP, y Node con extensiones, el campo type de package.json y su algoritmo de resolución en disco.
ESM te da una sintaxis única, pero se ejecuta en dos mundos que resuelven y cargan módulos de formas opuestas. El navegador los trae por HTTP con especificadores completos e import maps; Node los busca en el disco con extensiones, el campo type y el algoritmo del campo exports. Escribes lo mismo; el runtime hace cosas muy distintas.
- Usar módulos en el navegador con
script type=moduley su orden de carga. - Emplear import maps para alias de especificadores desnudos en el navegador.
- Configurar Node con el campo
typey las extensiones.mjs/.cjs. - Contrastar los dos algoritmos de resolución: URL en web vs disco en Node.
El navegador: script type=module
Marcar un script como módulo cambia su semántica por completo respecto a un script clásico:
<script type="module">
import { app } from "./app.js";
app();
</script>
Un módulo se difiere por defecto (se ejecuta tras parsear el HTML, en orden), corre en modo estricto, tiene su propio ámbito (nada de globales accidentales), se descarga con CORS y se evalúa una sola vez aunque se incluya varias veces. La restricción clave: el navegador solo entiende URLs. Un especificador desnudo como import x from "lodash" no es una URL válida y falla, salvo que un import map lo traduzca.
Frente a un script clásico, el módulo cambia varias reglas de golpe:
| Rasgo | script clásico | script type=module |
|---|---|---|
| Ejecución | inmediata y bloqueante | diferida, tras el parseo |
| Modo | flexible | estricto siempre |
| Ámbito | global compartido | propio del módulo |
| Repetición | se re-ejecuta | se evalúa una vez |
| Especificador desnudo | irrelevante | prohibido sin import map |
Dos atributos afinan la carga: async en un módulo lo desacopla del orden y lo ejecuta en cuanto llega (útil para analítica independiente), y nomodule marca un script de respaldo que solo ejecutan los navegadores sin soporte de módulos —hoy casi extintos, pero la técnica sigue siendo el patrón de degradación—.
Cuando el navegador carga un módulo, lo parsea, descubre sus import y va a buscar cada dependencia por la red antes de evaluar, repitiendo el proceso en cascada por todo el grafo. Sin bundler, un árbol profundo se traduce en muchas peticiones encadenadas: es exactamente la latencia que el pre-bundle de Vite y el bundling de producción eliminan al aplanar el grafo en pocos archivos.
Un script type=module se descarga con CORS, así que abrir el .html con doble clic (protocolo file:) falla: el navegador bloquea la petición. Necesitas servirlo por HTTP, aunque sea con un servidor estático local. Es el primer tropiezo de casi todo el que prueba ESM nativo sin build.
Import maps: especificadores desnudos en la web
Un import map le enseña al navegador a resolver nombres desnudos hacia URLs reales, sin build step:
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/lodash-es@4",
"@/": "/src/"
}
}
</script>
Con eso, import _ from "lodash" y import util from "@/util.js" funcionan directamente en el navegador. En 2026 los import maps son baseline en todos los navegadores modernos, e incluso se admiten múltiples import maps que se fusionan, lo que facilita composiciones e inyección de dependencias sin empaquetar.
Combinados con CDNs de ESM como esm.sh o jsDelivr —que sirven cualquier paquete de npm ya convertido a ESM—, los import maps permiten prototipar una app con dependencias reales sin instalar nada ni levantar un bundler. Para producción, una etiqueta link rel=modulepreload sobre las dependencias del mapa elimina la cascada de descubrimiento, precargando los módulos en paralelo al HTML.
Los import maps también admiten scopes: reglas de resolución que solo aplican bajo cierta ruta, de modo que dos partes de la app usen versiones distintas de la misma dependencia. Es una capacidad que el bundling tradicional no ofrece con tanta naturalidad, y anticipa un futuro de micro-frontends compuestos en el propio navegador.
En desarrollo, Vite sirve ESM nativo al navegador y reescribe los especificadores desnudos hacia rutas que el navegador entiende, imitando lo que haría un import map. Por eso el dev server arranca al instante: no empaqueta, delega la carga en el propio navegador.
Node: el campo type y las extensiones
Node decide si un archivo es ESM o CommonJS con dos señales. La extensión manda de forma absoluta cuando es explícita; si no, decide el campo type del package.json más cercano:
flowchart TD J[archivo .js] -->|type module| C[ESM] J -->|type commonjs o ausente| K[CommonJS] M[archivo .mjs] --> C N[archivo .cjs] --> K style C fill:#a6e3a1,color:#11111b style K fill:#fab387,color:#11111b
Es decir: .mjs es siempre ESM, .cjs es siempre CommonJS, y un .js depende de si el package.json declara "type": "module". Sin ese campo, el .js se interpreta como CommonJS por compatibilidad histórica. La recomendación en 2026 para proyectos nuevos:
{
"name": "mi-app",
"type": "module"
}
En ESM no hay __dirname; para resolver rutas relativas a un archivo se usa import.meta.url, que ambos runtimes exponen. Es el puente portable entre los dos mundos:
const datos = new URL("./datos.json", import.meta.url);
const config = import.meta.resolve("./config.js");
La ambigüedad histórica del .js —que podía ser ESM o CJS según el contexto— fue una fuente inagotable de confusión. La guía en 2026 es inequívoca: declara "type": "module" en todo proyecto nuevo, usa .js para tu código ESM y reserva .cjs para los pocos scripts heredados que aún dependan de require. Explícito y sin sorpresas.
Resolución: dos algoritmos distintos
Aquí está la diferencia de fondo. El navegador hace resolución de URL: un especificador relativo se resuelve contra la URL del módulo actual, las extensiones son obligatorias (./util no existe, ./util.js sí) y no hay índices de directorio. Node hace resolución de disco: recorre node_modules hacia arriba, aplica el campo exports e imports, prueba extensiones y respeta las conditions (node, browser, import, require, default).
Las conditions son el punto donde ambos mundos se tocan: un mismo paquete puede exponer una implementación bajo la condition browser (que usa APIs del DOM) y otra bajo node (que usa node:fs). El bundler, al empaquetar para web, activa browser y elige la variante correcta; por eso una librería isomórfica funciona igual en servidor y cliente sin que tú cambies el import.
Node también admite subpath imports internos: un mapa imports en package.json con claves que empiezan por almohadilla (como #utils) crea alias privados del paquete, invisibles desde fuera. Es la versión de Node de los alias que en el navegador da el import map y en el bundler da resolve.alias. Tres mecanismos, un mismo objetivo: desacoplar el nombre lógico de la ruta física.
Un detalle que unifica el ecosistema en 2026: los módulos internos de Node se importan con el prefijo explícito node:, como en import { readFile } from "node:fs/promises". Ese prefijo deja claro que es un builtin y no un paquete de node_modules, y permite a los bundlers y a runtimes como Deno o Bun tratarlos sin ambigüedad.
Cliente y servidor ya no son los dos únicos destinos. Los runtimes de edge (Cloudflare Workers, Deno Deploy) ejecutan ESM con un subconjunto de APIs web, sin node:fs ni acceso a disco. Por eso el campo exports admite conditions como worker o edge-light: un mismo paquete puede servir tres implementaciones —browser, node, edge— y que cada runtime elija la suya. La Environment API de Vite (lección 2.15) existe justo para modelar estos tres mundos a la vez.
Navegador
Especificadores = URLs. Extensión obligatoria, CORS, import maps para nombres desnudos, carga por red.
Node
Especificadores = algoritmo en disco. Campo exports, conditions, walk de node_modules, extensiones opcionales.
Un bundler existe, en buena parte, para reconciliar ambos: aplica el algoritmo de Node en tiempo de build (resuelve node_modules, respeta exports, elige la condition browser) y produce archivos con URLs y extensiones que el navegador sí carga. Escribes import _ from "lodash" una vez y funciona en los dos mundos gracias a esa traducción.
Los runtimes nuevos reducen la fricción: Deno resuelve por URL como el navegador y entiende import maps de forma nativa; Bun ejecuta TypeScript directamente y resuelve node_modules a gran velocidad. Ambos aceptan el prefijo node: y apuntan a un futuro donde la distancia navegador/servidor se acorta, aunque Node y su algoritmo sigan siendo el estándar de facto que todo bundler debe emular.
La lección que unifica todo el nivel: import x from "y" no tiene un único significado. En el navegador ordena “descarga esta URL y evalúala”; en Node ordena “corre este algoritmo de resolución sobre el disco y carga el archivo que salga”. La sintaxis es idéntica, la semántica de carga es radicalmente distinta, y esa brecha es exactamente el hueco que un bundler llena. Cuando entiendes que el especificador es una URL para uno y una entrada de un algoritmo para el otro, dejas de ver el tooling como magia: optimizeDeps de Vite existe para convertir CJS a ESM que el navegador acepte; el campo exports existe para que un mismo paquete resuelva distinto en Node, en el navegador y en el edge; los import maps existen para dar al navegador el poder de resolución que Node ya tenía. Todo el ecosistema de build de 2026 es, en el fondo, una máquina para que escribas un import neutral y funcione en cualquier runtime. Dominar módulos es dominar esa traducción.
- Sirve un
.htmlconscript type=moduleque importe un módulo local; omite la extensión y observa el fallo, luego añádela. - Añade un import map y consigue que
importde un especificador desnudo funcione en el navegador sin bundler. - Crea un proyecto Node con
"type": "module"y comprueba que un.jses ESM; añade un.cjsy confirma que sigue siendo CommonJS. - Empaqueta con Vite el mismo código con un especificador desnudo y explica qué algoritmo de resolución aplicó el bundler para que el navegador lo cargue.
- Publica un paquete con conditions
browserynodedistintas y verifica que cada runtime recibe la variante correcta sin cambiar elimport.