Skip to content

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. ./util fails; ./util.js works. ESM resolution is exact — no extension probing, no index.js directory resolution for relative imports.
  • "type": "module" in package.json makes .js files 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 await works 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 you module.exports as the default export — but named exports from CJS are detected heuristically and often wrong for non-trivial files. When you need require, 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 what import 'pkg' resolves to — read it before assuming a file path works.
  • node_modules packages can still be CJS-only; the default-export interop usually covers them, but check require-only entry points.