Lesson 2 of 9 · 25m
ES Modules in Node and the Browser
Static and dynamic imports, Node's strict resolution rules, CJS interop seams, and why `import(userInput)` is a loaded gun.
Every greenfield JavaScript project since 2020 should be ES modules. Node has supported them natively since Node 14, browsers ship them in <script type="module">, and bundlers optimize for them. Yet most codebases still carry CJS scars, and the interop seams are where the bugs live.
Static import/export
// crypto-util.js
export function sha256(input) { /* ... */ }
export const ALGORITHM = 'sha256';
// app.js
import { sha256 } from './crypto-util.js';
import assert from 'node:assert';Imports are hoisted and bindings are live — if the module re-exports a let, consumers see updates. That matters for things like connection pools initialized once at module load.
Node ESM rules that surprise CJS veterans
- Extensions are mandatory.
./utilfails;./util.jsworks. ESM resolution is exact — no extension probing, noindex.jsdirectory resolution for relative imports. "type": "module"inpackage.jsonmakes.jsfiles ESM. Without it, use.mjs. CJS files can coexist as.cjs.- No
require,__dirname, or__filename. Get your path back with:
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));- Top-level
awaitworks inside modules (standardized in ES2022):const config = await loadConfig();at module scope is legal. Use it sparingly — it serializes module loading. - CJS interop is asymmetric.
import pkg from './legacy.cjs'gives youmodule.exportsas the default export — but named exports from CJS are detected heuristically and often wrong for non-trivial files. When you needrequire, opt in explicitly:import { createRequire } from 'node:module';
Browsers: <script type="module">
Module scripts are deferred by default, run in strict mode, and are subject to CORS — a cross-origin <script type="module"> without Access-Control-Allow-Origin fails where a classic script wouldn't. Bare specifiers (import 'lodash') don't resolve; you need an import map or a bundler. This is why CDN examples that "work" in one tutorial silently 404 in your app.
Dynamic import()
const { render } = await import(`./views/${viewName}.js`);It returns a promise — the mechanism behind route-level code splitting. Keep the specifier predictable: import(userInput) is server-side code injection if userInput reaches it, and in Node it can load absolute file:// paths. Whitelist the allowed module names.
Try this
Convert a CommonJS file to ESM: flip require to import, module.exports to export, add the .js extension to relative imports, replace __dirname, and fix anything that depended on require being callable inside a function (CJS loads lazily; ESM doesn't — move the import or use import()).
Field notes
package.json's"exports"map controls whatimport 'pkg'resolves to — read it before assuming a file path works.node_modulespackages can still be CJS-only; the default-export interop usually covers them, but checkrequire-only entry points.