You have just mastered CommonJS: require, module.exports, the cache, the module wrapper. It is the system Node.js was born with and the one that an enormous amount of today's code runs on. But it is not JavaScript's module system.
While Node was solving the problem on its own in 2009, the committee that standardizes the language was working on an official solution. It arrived in 2015 with ES2015: ES modules (ESM), with import and export, defined inside the language and designed to work the same way in the browser and on the server. Node took years to support them stably, and today it lives with both.
This lesson is not a catalog of alternative syntax. The difference between the two systems is deep: CommonJS is dynamic and resolves while the program runs; ESM is static and resolves before a single line executes. Every other difference comes from that: why extensions are mandatory, why __dirname does not exist, why CommonJS cannot require ESM, and why only ESM has top-level await.
By the end you will know how to write ES modules, convert what you have built in Escena Viva, make both systems coexist in the same project knowing the real limits, and you will have a clear criterion for what to use and when.
Contents
- ES module syntax
- The essential difference: static versus dynamic
- A complete comparison table
- How ESM is enabled in Node
- Extensions are mandatory
- What does not exist in ESM and how to replace it
- Top-level
await - Dynamic import:
await import() - Interoperability in both directions
- The ESM version of the Escena Viva domain
- A practical criterion for the course
- ES module syntax
Named exports
The most common form, and the equivalent of our module.exports = { ... }:
// src/utils/format.mjs
// Option A: export in the declaration itself.
export function formatPrice(cents) {
const euros = Math.floor(cents / 100);
const remainder = String(cents % 100).padStart(2, '0');
return `${euros}.${remainder} EUR`;
}
export function formatDate(isoDate) {
const date = new Date(isoDate);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${day}/${month}/${date.getFullYear()}`;
}
export const CURRENCY_SYMBOL = 'EUR';// Option B: export as a block at the end. It matches the single module.exports
// of CommonJS and has the same advantage: an index of the public API.
function formatPrice(cents) { /* ... */ }
function formatDate(isoDate) { /* ... */ }
const CURRENCY_SYMBOL = 'EUR';
export { formatPrice, formatDate, CURRENCY_SYMBOL };And when importing:
// Import what you need, by name.
import { formatPrice, formatDate } from './utils/format.mjs';
// Rename, to avoid collisions.
import { formatPrice as price } from './utils/format.mjs';
// Import EVERYTHING into a namespace.
import * as format from './utils/format.mjs';
console.log(format.formatPrice(2500));
// Import purely for the side effect (rare, but it exists).
import './global-config.mjs';Default export
Each module can have one default export:
// When importing, you choose the name: no braces.
import Session from './domain/session.mjs';
import Whatever from './domain/session.mjs'; // Legal, and confusingBoth forms can be combined:
// src/domain/event.mjs
export default class Event { /* ... */ }
export const EVENT_STATUSES = ['draft', 'published', 'finished'];| Named | Default | |
|---|---|---|
| How many per module | As many as you like | One |
| Import syntax | import { X } from ... |
import X from ... |
| Is the name fixed? | Yes (except with as) |
No: the importer chooses it |
| Editor autocompletion | Good | Worse |
| Renaming in a refactor | Propagates | Has to be checked by hand |
Recommendation for the course: use named exports, not
export default. It is consistent with themodule.exports = { ... }we already use, the editor autocompletes better, and it stops the same module showing up under five different names in five files. Many professional style guides (including Node's own and those of several large projects) have reached the same conclusion.
Re-exporting: export * from
The equivalent of our index.js facade:
// src/domain/index.mjs
// Re-export EVERYTHING named from each module.
export * from './session.mjs';
export * from './event.mjs';
export * from './sales-manager.mjs';
// Or be selective, which is usually better.
export { Session } from './session.mjs';
export { Event } from './event.mjs';
export { SalesManager, LOW_CAPACITY_THRESHOLD } from './sales-manager.mjs';
// Re-export under a different name.
export { Session as EventSession } from './session.mjs';An important note: export * does not re-export the default export. If a module has export default, it has to be re-exported explicitly:
It is another reason to prefer named exports: facades work with no surprises.
- The essential difference: static versus dynamic
Everything else comes from here, so it is worth understanding properly.
CommonJS is dynamic
require is an ordinary function. It runs when the interpreter reaches it, and its argument can be any expression:
// All of this is legal in CommonJS.
const loaded = require('./' + moduleName + '.js');
if (process.env.MODE === 'production') {
logger = require('./production-logger.js');
}
for (const name of ['a', 'b', 'c']) {
modules[name] = require(`./plugins/${name}.js`);
}
function loadLazily() {
const heavy = require('./very-heavy-module.js'); // Only if it is called
return heavy.process();
}Node cannot know which modules a CommonJS program needs without running it. That is flexible and it has a price.
ESM is static
import is not a function: it is a language declaration. Its path must be a string literal, and the declarations can only appear at the top level of the module.
// ALL of this is a SyntaxError in ESM.
import loaded from './' + name + '.mjs'; // Non-literal path
if (production) {
import logger from './logger.mjs'; // Not inside a block
}
function load() {
import heavy from './heavy.mjs'; // Not inside a function
}Thanks to that rigidity, the engine can analyze the entire dependency graph before running anything. The process has three clearly separated phases:
flowchart TD
subgraph ESM["ES modules: three phases"]
A1["<b>1. Construction</b><br/>Every file is read and its<br/>import/export statements analyzed.<br/>The complete graph is built<br/><i>without running anything</i>"]
A2["<b>2. Instantiation</b><br/>Space is reserved for each<br/>export and the references<br/>between modules are wired up"]
A3["<b>3. Evaluation</b><br/>Each module's code is run,<br/>in dependency order"]
A1 --> A2 --> A3
end
subgraph CJS["CommonJS: a single phase"]
B1["<b>Execution</b><br/>The code runs top to bottom.<br/>Each require encountered<br/>loads and evaluates right then"]
end
The consequences of that up-front analysis are very concrete:
| Consequence | Why it matters |
|---|---|
| Import errors at analysis time | Importing something a module does not export fails before running, not halfway through a production request |
| Dead code elimination (tree-shaking) | A bundler knows which exports are unused and can strip them. With require it is impossible to tell |
| Imports are hoisted | Every import is processed before any of the module's code, wherever they are written |
| Bindings are live | An import does not copy the value: it binds to the original variable |
That last point is surprising and worth seeing:
// src/lab/use-counter.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 <-- The imported value CHANGEDWith CommonJS, const { count } = require('./counter.js') would have copied the 0 and it would never change. In ESM, count is a read-only live binding to the other module's variable. You can read it and see its changes, but you cannot assign to it (count = 5 is a TypeError).
And imports are hoisted:
// This works in ESM, even though it looks impossible.
console.log(formatPrice(2500)); // 25.00 EUR
import { formatPrice } from './format.mjs';The import is processed in phase 1, long before the console.log runs. It works, but always write your imports at the top: the language allowing it does not make it readable.
- A complete comparison table
| Aspect | CommonJS | ES modules |
|---|---|---|
| Import syntax | require('./m.js') |
import { x } from './m.js' |
| Export syntax | module.exports = {...} |
export { x } / export default |
| File extension | .js (or .cjs) |
.mjs, or .js with "type": "module" |
| Resolution | Dynamic, at run time | Static, before running |
| Import path | Any expression | String literal only |
| Can it import conditionally? | Yes | No (only with dynamic import()) |
| Extension in relative paths | Optional | Mandatory |
| Loading | Synchronous | Asynchronous |
| Imported value | A copy of module.exports |
A read-only live binding |
this at the top level |
module.exports ({}) |
undefined |
__dirname / __filename |
Available | Do not exist (use import.meta.url) |
require |
Available | Does not exist (use createRequire) |
import.meta |
Does not exist | Available |
Top-level await |
No | Yes |
| Evaluation order | When the require is reached |
Dependencies first, depth-first |
| Circular dependencies | An incomplete object ({}) |
Uninitialized bindings (ReferenceError) |
| Strict mode | Optional | Always on |
| Loading JSON | require('./d.json') directly |
Needs with { type: 'json' } |
| Dead code elimination | No | Yes |
| Browser compatible | No | Yes |
| Can it import the other system | Cannot require ESM |
It can import CJS |
Two rows deserve extra comment:
Strict mode always on. In ESM you do not need 'use strict': it is implicit. That means assigning to an undeclared variable throws an error, this in a loose function is undefined, and duplicate parameters are illegal. It is a good default.
Circular dependencies. In CommonJS you got an empty object and the failure showed up later, with a confusing message. In ESM, thanks to the static analysis, you get a ReferenceError: Cannot access 'X' before initialization at the exact point of the problem. It is far better: a clear, early error instead of an undefined travelling through your code.
- How ESM is enabled in Node
Node needs to know whether a .js file is CommonJS or ESM. There are two ways to tell it.
Option A: the file extension
| Extension | System | When to use it |
|---|---|---|
.mjs |
Always ESM | An ESM file in a CommonJS project |
.cjs |
Always CommonJS | A CommonJS file in an ESM project |
.js |
Depends on the nearest package.json |
The normal case |
It is explicit and depends on nothing else. Ideal for introducing a single file of the other system.
Option B: the package.json type field
With that line, every .js in the project becomes an ES module. Without it (or with "type": "commonjs", which is the default), they are CommonJS.
Here we only care about that one line. The complete
package.json— name, version, dependencies,scripts,exports— is the subject of Module 5. For now it is enough to know that apackage.jsonfile with that single field, at the root of your project, changes the interpretation of every.js.
The resolution rule
Node looks for the nearest package.json going upwards from the file it is about to load. That makes mixing possible:
escena-viva/
├── package.json { "type": "module" }
├── src/
│ ├── catalog.js -> ESM (from the root package.json)
│ ├── domain/
│ │ └── event.js -> ESM
│ └── legacy/
│ ├── package.json { "type": "commonjs" }
│ └── old.js -> CommonJS (from ITS package.json)
└── tools/
└── migrate.cjs -> CommonJS (from the extension)It is the mechanism that lets you migrate a large project piece by piece instead of all at once.
Checking it
# A .mjs file is always ESM
echo 'console.log(typeof require);' > test.mjs
node test.mjs
# ReferenceError: require is not defined in ES module scope
# The same content as .cjs
echo 'console.log(typeof require);' > test.cjs
node test.cjs
# function
- Extensions are mandatory
This is the number one stumble when migrating from CommonJS to ESM.
// CommonJS: all four forms work.
require('./session');
require('./session.js');
require('./domain'); // Finds domain/index.js
require('./domain/index.js');// ESM: only COMPLETE paths work.
import { Session } from './session.js'; // RIGHT
import { Session } from './session'; // ERROR
import { Event } from './domain'; // ERROR: it does not look for index.js
import { Event } from './domain/index.js'; // RIGHTError [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/joan/escena-viva/src/session' imported from /home/joan/escena-viva/src/catalog.js Did you mean to import ./session.js?
Node even suggests the fix, which is appreciated.
Why this rigidity? Because ESM is defined to work in the browser too, where an import './session' would force the browser to try session, session.js, session.json… each with an HTTP round trip. Unacceptable. The standard demands complete, unambiguous paths.
Two important nuances:
- The rule only applies to relative and absolute paths. Packages from
node_modulesare still imported by name:import express from 'express'is correct, because the package itself declares its entry point. index.jsis not special in ESM. You have to write the full path. Our facades becomeimport { Event } from './domain/index.js'.
A practical tip: always write the extensions in your CommonJS files as well — as we did throughout the previous lesson. The day you migrate, half the work will already be done.
- What does not exist in ESM and how to replace it
The five module wrapper variables you learned in the previous lesson do not exist in ESM. There is no wrapper: an ES module is a real module, defined in the language.
| Does not exist | Replacement |
|---|---|
__dirname |
path.dirname(fileURLToPath(import.meta.url)) |
__filename |
fileURLToPath(import.meta.url) |
require |
createRequire(import.meta.url) or await import() |
module.exports |
export |
exports |
export |
require.main === module |
import.meta.url === pathToFileURL(process.argv[1]).href |
require.cache |
No public equivalent API |
import.meta
ESM brings its own object with metadata about the current module:
// src/lab/metadata.mjs
console.log(import.meta.url);
// file:///home/joan/escena-viva/src/lab/metadata.mjsNotice it is a URL, not a filesystem path. That is consistent with the standard (in the browser it would be https://...), but it means you have to convert it before using it with fs or path.
Getting __dirname and __filename back
// src/utils/paths.mjs
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// Exact equivalents of the CommonJS variables.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__filename); // /home/joan/escena-viva/src/utils/paths.mjs
console.log(__dirname); // /home/joan/escena-viva/src/utils
// The usual use: a reliable path to the data.
const DATA_PATH = join(__dirname, '..', '..', 'data', 'events.json');In recent Node versions (20.11 and later) there is a shortcut:
const __dirname = import.meta.dirname; // Directly, with no conversion
const __filename = import.meta.filename;Check your version with node -v before using it; if your project must run on older versions, use the fileURLToPath form.
Getting require back
For the cases where you need a CommonJS module that does not import cleanly:
// src/lab/use-require.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Now it works as in CommonJS, JSON included.
const catalog = require('../data/events.json');
const legacyPackage = require('commonjs-only-package');
console.log(`${catalog.length} events`);createRequire needs to know where to resolve relative paths from, which is why it receives import.meta.url.
Detecting whether you are the main program
// src/reports/occupancy.mjs
import { pathToFileURL } from 'node:url';
const isMainProgram = import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMainProgram) {
// Only with: node src/reports/occupancy.mjs
runReport();
}Less elegant than require.main === module, but equivalent. In Node 20.11+ there is also import.meta.main in some configurations; check your version's documentation.
Importing JSON
// Import attributes syntax (Node 20.10+ / 22+).
import catalog from '../data/events.json' with { type: 'json' };
console.log(catalog.length);The with { type: 'json' } attribute is mandatory and it is a security measure: it prevents a malicious server from returning JavaScript where you expected data. As an alternative, you can always read the file with fs, which is what we will do in Escena Viva from Module 3 onwards.
- Top-level
await
awaitESM's exclusive advantage, and one of the most convenient.
// src/lab/load.mjs
import { readFile } from 'node:fs/promises';
// await directly, without wrapping it in any async function.
const content = await readFile('data/events.json', 'utf8');
const catalog = JSON.parse(content);
console.log(`Loaded ${catalog.length} events`);Compare it with what you had to write in CommonJS:
// CommonJS: you need a wrapper function and its .catch.
const { readFile } = require('node:fs/promises');
async function main() {
const content = await readFile('data/events.json', 'utf8');
const catalog = JSON.parse(content);
console.log(`Loaded ${catalog.length} events`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});Its natural uses:
// 1. Asynchronous initialization before exporting.
const configuration = JSON.parse(await readFile('./config.json', 'utf8'));
export { configuration };
// 2. Choosing a dependency at run time.
const logger = process.env.NODE_ENV === 'production'
? await import('./production-logger.mjs')
: await import('./development-logger.mjs');
// 3. Resources that must be ready before anyone uses the module.
const connection = await connectToDatabase();
export { connection };How it works: a module with top-level await becomes an asynchronous module. Modules that import it will wait for it to finish before running. It is powerful and it has a cost: if that await takes five seconds, every importer waits five seconds. Use it for real initialization, not as a convenient shortcut in any old file.
And a warning: if the top-level await fails, the whole module fails to load and the application does not start. Wrap it in try/catch if you want to degrade gracefully.
- Dynamic import:
await import()
await import()import() as a function is the escape hatch that gives back require's flexibility without giving up static analysis. It returns a promise that fulfills with the module's namespace.
// It works in both ESM and CommonJS.
const loaded = await import('./domain/event.mjs');
console.log(loaded.Event);
// With destructuring.
const { Event } = await import('./domain/event.mjs');Its three legitimate uses:
8.1 Conditional loading
// src/lab/conditional-load.mjs
const mode = process.env.NODE_ENV ?? 'development';
// The path can be an expression: here it can.
const { logger } = await import(`./loggers/${mode}.mjs`);
logger.info('Escena Viva starting up');8.2 Lazy loading of heavy modules
// The ticket PDF generator is heavy and rarely used.
// We do not want to load it at every process startup.
export async function generateTicketPdf(ticket) {
const { createPdf } = await import('./pdf/generator.mjs');
return createPdf(ticket);
}It is loaded the first time somebody generates a PDF, and from then on it stays cached.
8.3 Loading CommonJS from ESM (and ESM from CommonJS)
That is the interoperability bridge, and we look at it in the next section.
Static import |
Dynamic import() |
|
|---|---|---|
| Path | A string literal | Any expression |
| Where it can appear | Top level | Anywhere |
| Returns | Live bindings | A promise |
| Allows dead code elimination? | Yes | No |
| Works in CommonJS? | No | Yes |
Do not overuse
import(). Every dynamic import is a dependency tools cannot analyze. Use it when you have a reason (conditionality, size, interoperability), not out of habit.
- Interoperability in both directions
Here is the part that causes most frustration in real projects, and the rules are not symmetrical.
flowchart LR
ESM["ES module<br/>(.mjs)"]
CJS["CommonJS module<br/>(.cjs)"]
ESM -->|"import ... from<br/><b>YES, with caveats</b>"| CJS
CJS -->|"require<br/><b>NO</b>"| ESM
CJS -.->|"await import()<br/><b>YES</b>"| ESM
9.1 ESM importing CommonJS: yes, with caveats
// src/utils/format.cjs (CommonJS)
function formatPrice(cents) { /* ... */ }
function formatDate(isoDate) { /* ... */ }
module.exports = { formatPrice, formatDate };// src/catalog.mjs (ESM)
// The complete module.exports arrives as the DEFAULT export.
import format from './utils/format.cjs';
console.log(format.formatPrice(2500)); // Always works
// Named exports work... if Node manages to detect them.
import { formatPrice } from './utils/format.cjs'; // Usually worksThe exact rule: a CommonJS module's module.exports always arrives as the default export. In addition, Node runs a static analyzer (cjs-module-lexer) over the file to try to detect the named exports and offer them too. That analysis is syntactic, it does not run the code, so it fails as soon as the exports are built dynamically:
// CommonJS with dynamic exports: the analyzer CANNOT detect them.
const functions = { formatPrice, formatDate };
for (const [name, fn] of Object.entries(functions)) {
module.exports[name] = fn;
}// From ESM:
import { formatPrice } from './format.cjs';
// SyntaxError: The requested module './format.cjs' does not provide
// an export named 'formatPrice'The universal, always-safe solution:
// Import the whole object as the default and destructure afterwards.
import format from './utils/format.cjs';
const { formatPrice, formatDate } = format;If an npm package gives you that error when you import it with braces, this is the answer.
9.2 CommonJS requiring ESM: no
Error [ERR_REQUIRE_ESM]: require() of ES Module /home/joan/escena-viva/src/domain/event.mjs not supported. Instead change the require of event.mjs to a dynamic import() which is available in all CommonJS modules.
The reason is structural, not a whim: require is synchronous — it returns the value immediately — and ESM loading is asynchronous, because there can be top-level await anywhere in the dependency graph. There is no way for a synchronous function to return the result of an asynchronous process.
The solution is dynamic import, which does work in CommonJS:
// src/legacy.cjs
async function main() {
const { Event } = await import('./domain/event.mjs');
const event = new Event({ id: 'evt-001', title: 'Concierto de Otono' });
console.log(event.toString());
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});The price: the function that does it has to become asynchronous, and that asynchrony propagates upwards. This is known as the "function coloring" problem, and it is why migrating a large project from CommonJS to ESM is more work than it looks.
A note on recent versions. Node 22 introduced
require()of synchronous ES modules (with no top-levelawait) behind an experimental flag, and it has been stabilizing in later versions. It is a real improvement for migration, but do not take it for granted: it depends on the version, and the imported module cannot contain top-levelawaitanywhere in its graph. The general rule to keep in your head is still the one above.
9.3 Interoperability summary
| From | To | Does it work? | How |
|---|---|---|---|
| ESM | CommonJS | Yes | import x from './m.cjs' (default: always) |
| ESM | CommonJS, named exports | Almost always | Depends on the static analysis; if it fails, import the default |
| ESM | ESM | Yes | import { x } from './m.mjs' |
| CommonJS | ESM with require |
No | ERR_REQUIRE_ESM (except in recent, limited cases) |
| CommonJS | ESM with import() |
Yes | const m = await import('./m.mjs') |
| CommonJS | CommonJS | Yes | require('./m.cjs') |
- The ESM version of the Escena Viva domain
Let's translate what you built in the previous lesson. You will see that the logic does not change at all: only the input and output lines change.
src/utils/format.js
// ---------- CommonJS ----------
function formatPrice(cents) {
const euros = Math.floor(cents / 100);
const remainder = String(cents % 100).padStart(2, '0');
return `${euros}.${remainder} EUR`;
}
function formatDate(isoDate) { /* ... */ }
function generateTicketCode(year, sequence) { /* ... */ }
module.exports = { formatPrice, formatDate, generateTicketCode };// ---------- ES modules ----------
export function formatPrice(cents) {
const euros = Math.floor(cents / 100);
const remainder = String(cents % 100).padStart(2, '0');
return `${euros}.${remainder} EUR`;
}
export function formatDate(isoDate) { /* ... */ }
export function generateTicketCode(year, sequence) { /* ... */ }No final line: each function is exported where it is declared.
src/domain/session.js
// ---------- CommonJS ----------
const { formatPrice, formatDate } = require('../utils/format.js');
class Session {
#sold = 0;
/* ... the entire body, without a single change ... */
}
module.exports = { Session };// ---------- ES modules ----------
import { formatPrice, formatDate } from '../utils/format.js';
export class Session {
#sold = 0;
/* ... the entire body, without a single change ... */
}Private fields, getters, toJSON, validation: all identical. The module system does not touch the logic.
src/domain/sales-manager.js
// ---------- CommonJS ----------
const EventEmitter = require('node:events');
const LOW_CAPACITY_THRESHOLD = 0.10;
class SalesManager extends EventEmitter { /* ... */ }
module.exports = { SalesManager, LOW_CAPACITY_THRESHOLD };// ---------- ES modules ----------
import { EventEmitter } from 'node:events';
export const LOW_CAPACITY_THRESHOLD = 0.10;
export class SalesManager extends EventEmitter { /* ... */ }A real detail worth attention: in CommonJS we wrote require('node:events') and used the result directly as a class, because the events module exports the class as its module.exports. In ESM, the recommended form is the named import import { EventEmitter } from 'node:events'. Both work (the module offers both), but the named one is more explicit and is what you will see in modern documentation.
src/domain/index.js
// ---------- CommonJS ----------
const { Session } = require('./session.js');
const { Event } = require('./event.js');
const { SalesManager, LOW_CAPACITY_THRESHOLD } = require('./sales-manager.js');
module.exports = { Session, Event, SalesManager, LOW_CAPACITY_THRESHOLD };// ---------- ES modules ----------
export { Session } from './session.js';
export { Event } from './event.js';
export { SalesManager, LOW_CAPACITY_THRESHOLD } from './sales-manager.js';The ESM version is shorter and clearer: it re-exports directly, with no need to import first only to export again afterwards. It is one of the genuine improvements of the syntax.
src/catalog-data.js
// ---------- CommonJS ----------
const catalog = [ /* the 3 events */ ];
function getCatalog() {
return structuredClone(catalog);
}
function getEventById(id) { /* ... */ }
module.exports = { getCatalog, getEventById };// ---------- ES modules ----------
// The array stays PRIVATE: by not exporting it, nobody outside can touch it.
const catalog = [ /* the 3 events */ ];
export function getCatalog() {
return structuredClone(catalog);
}
export function getEventById(id) { /* ... */ }src/catalog.js
// ---------- CommonJS ----------
const { getCatalog } = require('./catalog-data.js');
const { Event } = require('./domain'); // No extension: valid
const { formatPrice } = require('./utils/format.js');
function main() { /* ... */ }
if (require.main === module) {
main();
}
module.exports = { readOptions, main };// ---------- ES modules ----------
import { pathToFileURL } from 'node:url';
import { getCatalog } from './catalog-data.js';
import { Event } from './domain/index.js'; // Extension MANDATORY
import { formatPrice } from './utils/format.js';
export function main() { /* ... */ }
// The equivalent of require.main === module
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}The three real changes in this file, and they are the three you will always see when migrating:
./domain→./domain/index.js: the extension and the explicitindex.js.require.main === module→ the URL comparison.module.exportsat the end →exporton each declaration.
The ESM version that takes advantage of top-level await
And here is a real, not cosmetic, advantage. Getting ahead of what we will do in Module 3, this is how loading the catalog from disk will look:
// src/catalog-data.mjs (a preview of module 3)
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_PATH = join(__dirname, '..', 'data', 'events.json');
// Top-level await: the catalog is loaded ONCE, when the module is imported.
// Whoever imports this module will get the data ready to use.
const catalog = JSON.parse(await readFile(DATA_PATH, 'utf8'));
export function getCatalog() {
return structuredClone(catalog);
}
export function getEventById(id) {
const event = catalog.find((e) => e.id === id);
return event ? structuredClone(event) : undefined;
}This has no equivalent in CommonJS. There you would have to choose between a blocking synchronous read (readFileSync) or exporting a promise every consumer would have to await. With ESM, the asynchronous initialization happens at module load, transparently to whoever uses it.
- A practical criterion for the course
What we will use
For the rest of the course we will keep using CommonJS as the main system, and these are the reasons:
- It is what you are going to run into. Millions of projects, countless tutorials and an enormous chunk of npm are still CommonJS. Being able to read it is not optional.
- Less friction while learning.
require.main === moduleis simpler than comparing URLs,__dirnameis just there with no ceremony, and you do not have to think about interoperability while learningfsor Express. - Express and much of the classic ecosystem document their examples in CommonJS.
But every time a topic has a relevant ESM version, we will show it, and in Module 12 we will do the complete migration of Escena Viva as a closing exercise.
What to use in your new projects
| Situation | Recommendation |
|---|---|
| A new project from scratch | ESM. It is the language standard and the future |
| A large existing CommonJS project | Stay on CommonJS, or migrate piece by piece with .mjs |
| A library you are going to publish on npm | Publish both formats (the exports field from Module 5) |
| Code that also runs in the browser | ESM, no argument |
A quick script with await at the top level |
ESM (or a standalone .mjs) |
| A config tool for another project | Whatever that project expects |
Why the ecosystem lives with both
It is not an accident nor collective laziness. There are underlying reasons:
- Backwards compatibility is a value. Node cannot break millions of production projects overnight.
- Migration is contagious. If your module moves to ESM, whoever consumed it from CommonJS can no longer use
require. The pressure propagates upwards through the dependency tree. - Many tools expect CommonJS. Config files, plugins and some test runners still assume it.
- The libraries' solution is to publish both formats (dual package), at the cost of a more complex setup and a real risk: loading two copies of the same library, one per system, with independent state.
The transition has taken almost a decade and has years to go. The professional skill is not picking a side: it is being able to work with both and recognize instantly which one you are looking at.
Common Mistakes and Tips
Mistake 1: omitting the extension in a relative import.
ERR_MODULE_NOT_FOUND. It is the number one stumble when migrating. Always ./session.js.
Mistake 2: expecting ./domain to find index.js.
There is no folder resolution in ESM. Write ./domain/index.js.
Mistake 3: using __dirname in an ESM file.
ReferenceError. Use import.meta.dirname or fileURLToPath(import.meta.url).
Mistake 4: require() of an ES module.
ERR_REQUIRE_ESM. Use await import() and accept that the function becomes asynchronous.
Mistake 5: importing with braces from a CommonJS package and having it fail. The analyzer did not detect the named exports. Import the default and destructure.
Mistake 6: adding "type": "module" to an existing project just like that.
Every .js switches system at once and everything breaks. Migrate piece by piece with .mjs, or rename to .cjs whatever must stay as it is.
Mistake 7: top-level await in a module imported by many others.
They all wait for it to finish. Reserve it for real initialization.
Mistake 8: overusing dynamic import().
You lose static analysis and dead code elimination. Only with a concrete reason.
Tip 1: write the extensions in CommonJS too. When you migrate, that work will already be done.
Tip 2: use named exports, not export default. Better autocompletion, consistent names and facades with no surprises.
Tip 3: learn to recognize the system in three seconds. See import? ESM. See require? CommonJS. Is it a .js? Check the nearest package.json.
Tip 4: when an npm package gives you import trouble, look at its package.json. The exports field (Module 5) and the type field tell you exactly what it offers.
Exercises
Exercise 1: migrating the domain to ESM
Create a copy of the project in escena-viva-esm/ and migrate to ES modules everything built in the previous lesson:
- A
package.jsoncontaining only{ "type": "module" }. src/utils/format.js,src/domain/session.js,src/domain/event.js,src/domain/sales-manager.js,src/domain/index.js,src/catalog-data.jsandsrc/catalog.js.- Every relative path with its extension, and an explicit
./domain/index.js. require.main === modulereplaced by the comparison withimport.meta.url.src/reports/occupancy.jsmigrated and directly runnable.
Verify that node src/catalog.js --table produces exactly the same output as the CommonJS version, with the same totals (3000 capacity, 1811 sold, 1189 available). Note down how many changes you had to make and of what kind.
Exercise 2: interoperability in both directions
Create escena-viva-mixed/ with a project that experimentally demonstrates the rules of section 9:
src/utils/format.cjs— CommonJS, withmodule.exports = { formatPrice, formatDate }.src/utils/dynamic-legacy.cjs— CommonJS that builds its exports dynamically in a loop.src/domain/session.mjs— ESM that importsformat.cjs(which works) and tries to import with braces fromdynamic-legacy.cjs(which fails), with the solution applied.src/report.cjs— CommonJS that needs theSessionclass from the ESM file. First demonstrate thatrequirefails (catch the error and show itscode) and then solve it withawait import().- A
README.mdwith a table of which combination works, which does not and why.
Exercise 3: the catalog loader with top-level await
In an ESM project, write src/catalog-data.mjs that really takes advantage of what is exclusive to ESM:
- Loads
data/events.jsonwithreadFilefromnode:fs/promisesand top-levelawait, usingimport.meta.urlto build a reliable path. - Handles the read failure: if the file does not exist, it should log a clear warning on
stderrand carry on with an empty catalog instead of preventing the application from starting. - Exports
getCatalog(),getEventById(id)andgetSession(sessionId), all returning copies. - Also exports a
LOADED_ATconstant with the ISO timestamp of the moment of loading, and demonstrates — by importing it from three different files — that the value is the same in all three: the module cache exists in ESM too. - A
src/main.mjsthat uses all of the above with top-levelawait, with no wrappingmain()function.
Answer: what would happen if the top-level await took 3 seconds? And if it threw an uncaught exception?
Solutions
Solution 1
// src/utils/format.js
export function formatPrice(cents) {
const euros = Math.floor(cents / 100);
const remainder = String(cents % 100).padStart(2, '0');
return `${euros}.${remainder} EUR`;
}
export function formatDate(isoDate) {
const date = new Date(isoDate);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hour}:${minute}`;
}
export function generateTicketCode(year, sequence) {
return `EV-${year}-${String(sequence).padStart(6, '0')}`;
}// src/domain/session.js
import { formatPrice, formatDate } from '../utils/format.js';
export class Session {
#sold = 0;
constructor({ id, dateTime, capacity, sold = 0, priceCents }) {
this.id = id;
this.dateTime = dateTime;
this.capacity = capacity;
this.priceCents = priceCents;
this.#sold = sold;
}
get sold() { return this.#sold; }
get available() { return this.capacity - this.#sold; }
get occupancy() { return Math.round((this.#sold / this.capacity) * 100); }
get soldOut() { return this.available === 0; }
get priceEuros() { return (this.priceCents / 100).toFixed(2); }
get revenueCents() { return this.#sold * this.priceCents; }
sell(quantity = 1) {
if (!Number.isInteger(quantity) || quantity < 1) {
const error = new Error('The quantity must be a positive integer');
error.code = 'INVALID_QUANTITY';
throw error;
}
if (quantity > this.available) {
const error = new Error(`Insufficient capacity in ${this.id}: ${this.available} left`);
error.code = 'INSUFFICIENT_CAPACITY';
throw error;
}
this.#sold += quantity;
return this.#sold;
}
describe() {
return (
`${formatDate(this.dateTime)} ${formatPrice(this.priceCents)} ` +
`${this.available}/${this.capacity} available (${this.occupancy}% occupied)` +
(this.soldOut ? ' [SOLD OUT]' : '')
);
}
toJSON() {
return {
id: this.id,
dateTime: this.dateTime,
capacity: this.capacity,
sold: this.#sold,
priceCents: this.priceCents
};
}
}// src/domain/index.js
export { Session } from './session.js';
export { Event } from './event.js';
export { SalesManager, LOW_CAPACITY_THRESHOLD } from './sales-manager.js';// src/catalog.js (only the parts that change)
import { pathToFileURL } from 'node:url';
import { getCatalog } from './catalog-data.js';
import { Event } from './domain/index.js';
import { formatPrice } from './utils/format.js';
export function readOptions(args) { /* identical */ }
export function main() { /* identical */ }
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}node src/catalog.js --table
# IDENTICAL output to the CommonJS version
# 3 events | capacity 3000 | sold 1811 | available 1189 | revenue 62298.00 EURAn inventory of changes. Migrating seven files required exactly four kinds of change, and none of them touched the logic:
| Kind of change | How many times | Detail |
|---|---|---|
const {...} = require(...) → import {...} from ... |
9 | Mechanical |
module.exports = {...} → export on the declaration |
7 | One per file |
require('./domain') → './domain/index.js' |
2 | Folder resolution does not exist |
require.main === module → URL comparison |
2 | Plus import { pathToFileURL } |
Zero changes to classes, private fields, getters, validations, calculations or formatting. It is the practical proof that the module system is infrastructure: changing it should not touch your business logic, and if it does, they were too tightly coupled.
Solution 2
// src/utils/format.cjs
// CommonJS with STATIC exports: Node's analyzer detects them.
function formatPrice(cents) {
const euros = Math.floor(cents / 100);
return `${euros}.${String(cents % 100).padStart(2, '0')} EUR`;
}
function formatDate(isoDate) {
return isoDate.slice(0, 10).split('-').reverse().join('/');
}
module.exports = { formatPrice, formatDate };// src/utils/dynamic-legacy.cjs
// CommonJS with DYNAMIC exports: the analyzer CANNOT detect them.
function calculateOccupancy(session) {
return Math.round((session.sold / session.capacity) * 100);
}
function calculateAvailable(session) {
return session.capacity - session.sold;
}
// Dynamic construction: only known at run time.
const functions = { calculateOccupancy, calculateAvailable };
for (const [name, fn] of Object.entries(functions)) {
module.exports[name] = fn;
}// src/domain/session.mjs
// ESM importing CommonJS in its two variants.
// 1. Static exports: the named import WORKS.
import { formatPrice } from '../utils/format.cjs';
// 2. Dynamic exports: the named one WOULD FAIL.
// import { calculateOccupancy } from '../utils/dynamic-legacy.cjs';
// SyntaxError: does not provide an export named 'calculateOccupancy'
//
// Universal solution: import the default and destructure.
import legacy from '../utils/dynamic-legacy.cjs';
const { calculateOccupancy, calculateAvailable } = legacy;
export class Session {
constructor({ id, capacity, sold = 0, priceCents }) {
this.id = id;
this.capacity = capacity;
this.sold = sold;
this.priceCents = priceCents;
}
describe() {
return (
`${this.id} ${formatPrice(this.priceCents)} ` +
`${calculateAvailable(this)} available (${calculateOccupancy(this)}%)`
);
}
}// src/report.cjs
// CommonJS that needs a class defined in an ES module.
// 1. Demonstration that require FAILS.
try {
const { Session } = require('./domain/session.mjs');
console.log('This is not printed:', Session);
} catch (error) {
console.error(`require failed with code: ${error.code}`);
console.error(` ${error.message.split('\n')[0]}`);
}
// 2. The solution with dynamic import.
async function main() {
const { Session } = await import('./domain/session.mjs');
const session = new Session({
id: 'ses-002-1', capacity: 120, sold: 118, priceCents: 1800
});
console.log('');
console.log('With await import() it DOES work:');
console.log(` ${session.describe()}`);
}
main().catch((error) => {
console.error(`Failure: ${error.message}`);
process.exitCode = 1;
});require failed with code: ERR_REQUIRE_ESM require() of ES Module /home/joan/escena-viva-mixed/src/domain/session.mjs not supported. With await import() it DOES work: ses-002-1 18.00 EUR 2 available (98%)
The README.md table:
| From | To | Syntax | Does it work? | Reason |
|---|---|---|---|---|
.mjs |
.cjs with static exports |
import { x } from |
Yes | The analyzer detects the names |
.mjs |
.cjs with dynamic exports |
import { x } from |
No | The analyzer is syntactic, it does not run the code |
.mjs |
.cjs with dynamic exports |
import m from + destructure |
Yes | The complete module.exports always arrives as the default |
.cjs |
.mjs |
require() |
No | require is synchronous; ESM loading is asynchronous |
.cjs |
.mjs |
await import() |
Yes | It returns a promise, compatible with asynchronous loading |
Solution 3
// src/catalog-data.mjs
// Loading the catalog with top-level await. Exclusive to ESM.
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_PATH = join(__dirname, '..', 'data', 'events.json');
// 1 and 2. Loading with top-level await and graceful degradation on failure.
let catalog = [];
try {
const content = await readFile(DATA_PATH, 'utf8');
catalog = JSON.parse(content);
console.error(`[catalog] loaded ${catalog.length} events from ${DATA_PATH}`);
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`[catalog] WARNING: ${DATA_PATH} not found. Empty catalog.`);
} else if (error instanceof SyntaxError) {
console.error(`[catalog] WARNING: the file is not valid JSON (${error.message}).`);
} else {
console.error(`[catalog] WARNING: read failure (${error.message}).`);
}
// We do not rethrow: the application starts with an empty catalog.
}
// 4. Timestamp of the moment of loading.
export const LOADED_AT = new Date().toISOString();
// 3. Data access, always with copies.
export function getCatalog() {
return structuredClone(catalog);
}
export function getEventById(id) {
const event = catalog.find((e) => e.id === id);
return event ? structuredClone(event) : undefined;
}
export function getSession(sessionId) {
for (const event of catalog) {
const session = event.sessions.find((s) => s.id === sessionId);
if (session) {
return structuredClone({ event: { id: event.id, title: event.title }, session });
}
}
return undefined;
}// src/consumer-a.mjs
import { LOADED_AT, getCatalog } from './catalog-data.mjs';
export function report() {
return { module: 'consumer-a', loadedAt: LOADED_AT, events: getCatalog().length };
}// src/consumer-b.mjs
import { LOADED_AT, getEventById } from './catalog-data.mjs';
export function report() {
const event = getEventById('evt-002');
return { module: 'consumer-b', loadedAt: LOADED_AT, title: event?.title ?? '(no data)' };
}// src/main.mjs
// No wrapper function: top-level await throughout the file.
import { setTimeout as sleep } from 'node:timers/promises';
import { LOADED_AT, getCatalog, getSession } from './catalog-data.mjs';
import { report as reportA } from './consumer-a.mjs';
import { report as reportB } from './consumer-b.mjs';
const events = getCatalog();
console.log('CATALOG');
console.table(events.map((e) => ({
id: e.id,
title: e.title,
venue: e.venue,
sessions: e.sessions.length,
capacity: e.sessions.reduce((t, s) => t + s.capacity, 0),
sold: e.sessions.reduce((t, s) => t + s.sold, 0)
})));
const found = getSession('ses-002-1');
console.log('');
console.log(`Session ses-002-1: ${found.event.title}, ` +
`${found.session.capacity - found.session.sold} available`);
// A pause with top-level await: impossible in CommonJS.
await sleep(50);
// 4. The timestamp is the SAME in all three modules.
console.log('');
console.log('MODULE CACHE IN ESM');
console.table([
{ module: 'main', loadedAt: LOADED_AT, data: `${events.length} events` },
{ ...reportA(), data: `${reportA().events} events` },
{ ...reportB(), data: reportB().title }
]);
const allEqual = LOADED_AT === reportA().loadedAt && LOADED_AT === reportB().loadedAt;
console.log('');
console.log(`Same timestamp in all three modules? ${allEqual}`);[catalog] loaded 3 events from /home/joan/escena-viva-esm/data/events.json CATALOG ┌─────────┬───────────┬─────────────────────────────────┬────────────────────┬──────────┬──────────┬──────┐ │ (index) │ id │ title │ venue │ sessions │ capacity │ sold │ ├─────────┼───────────┼─────────────────────────────────┼────────────────────┼──────────┼──────────┼──────┤ │ 0 │ 'evt-001' │ 'Concierto de Otono' │ 'Teatro Almendra' │ 2 │ 840 │ 276 │ │ 1 │ 'evt-002' │ 'Noche de Monologos' │ 'Sala Boveda' │ 3 │ 360 │ 175 │ │ 2 │ 'evt-003' │ 'Festival de Jazz de Primavera' │ 'Auditorio Ribera' │ 2 │ 1800 │ 1360 │ └─────────┴───────────┴─────────────────────────────────┴────────────────────┴──────────┴──────────┴──────┘ Session ses-002-1: Noche de Monologos, 2 available MODULE CACHE IN ESM ┌─────────┬──────────────┬────────────────────────────┬──────────────────────┐ │ (index) │ module │ loadedAt │ data │ ├─────────┼──────────────┼────────────────────────────┼──────────────────────┤ │ 0 │ 'main' │ '2026-08-11T18:42:07.331Z' │ '3 events' │ │ 1 │ 'consumer-a' │ '2026-08-11T18:42:07.331Z' │ '3 events' │ │ 2 │ 'consumer-b' │ '2026-08-11T18:42:07.331Z' │ 'Noche de Monologos' │ └─────────┴──────────────┴────────────────────────────┴──────────────────────┘ Same timestamp in all three modules? true
Answers to the questions:
- If the top-level
awaittook 3 seconds, the three modules that importcatalog-data.mjswould wait those 3 seconds before running their first line, andmain.mjswould not start until then. The wait propagates through the whole dependency graph. That is why top-levelawaitshould be reserved for essential initialization: if the catalog could be loaded lazily or in the background, it would be better to export anasync loadCatalog()function and let each caller decide when to wait. - If the
awaitthrew an uncaught exception, the whole module would fail to evaluate. Since ES modules are instantiated before anything runs, the application would not start at all: the error would propagate to every importer and the process would die withERR_MODULE_NOT_FOUNDor the original error. Hence thetry/catchin the solution: it turns a fatal startup failure into a warning onstderrand an empty catalog. The application starts, reports the problem and runs in degraded mode, which is almost always preferable to not starting at all.
And the final conclusion, visible in the table: the module cache exists in ESM too. The three modules share the same timestamp because catalog-data.mjs was evaluated only once. The mechanism is different inside — a module registry keyed by URL instead of require.cache — but the observable property is identical: a module is a singleton within its process.
Conclusion
You have closed off the module system. You now know ES modules: named and default export, import with its variants, export * from for facades, and the recommendation to always prefer named exports for autocompletion, consistency and surprise-free re-exporting.
Above all, you understand the difference that explains everything: CommonJS is dynamic — require is a function that runs when the interpreter reaches it, with whatever path you like — while ESM is static: imports are declarations with literal paths, and the engine builds the complete graph, instantiates it and only then evaluates it. Every consequence flows from that: import errors caught before running, dead code elimination, hoisted imports, live bindings instead of copies, and top-level await.
You know how to enable it both ways — "type": "module" in the package.json, or the .mjs and .cjs extensions, which let you migrate a project piece by piece — and you know the new rules: extensions are mandatory in relative paths, index.js stops being special, strict mode is always on, and the five wrapper variables disappear, replaced by import.meta.url with fileURLToPath for __dirname, createRequire for require and pathToFileURL(process.argv[1]) to detect the main program.
You have mastered interoperability and its asymmetries: ESM can import CommonJS — always as the default, and with named exports when the static analyzer manages to detect them — but CommonJS cannot require ESM, because require is synchronous and ESM loading is asynchronous; the way through is await import(), at the cost of making the function that does it asynchronous.
And you have translated the whole of Escena Viva. The exercise's inventory was revealing: four kinds of mechanical change, zero changes to the logic. The Session and Event classes, their private fields, their getters, the SalesManager with its events: all identical. The module system is infrastructure, and well-separated logic never notices that it changed.
With this we close Module 2, the most conceptual of the course. You no longer see Node as a black box: you know its layers — V8, libuv, the bindings, the standard library — the four-thread thread pool and why blocking the main thread stops the entire server; you can predict line by line the execution order of any asynchronous program by walking the six phases of the event loop and its two priority queues; you handle the three forms of asynchrony — error-first callbacks, promises with async/await, and events with EventEmitter — and you know when each one applies; and you understand the two module systems the ecosystem lives with.
Escena Viva has stopped being a script with embedded data. It has src/domain/ with Session, Event and SalesManager, a facade in index.js, formatting utilities with no duplication, a data layer in catalog-data.js with a signature already prepared to become asynchronous, and a catalog.js that only reads arguments and presents. The totals are still the seed ones: 3000 capacity, 1811 sold, 1189 available.
And there is the last outstanding piece, the one that has been promised since Module 1: the catalog still lives inside the code. The data/events.json file has existed since the first lesson and we have not read it from the application even once. In Module 3: File System and I/O that changes. You will learn to read and write files with fs, to build paths that work on any operating system with path, and to process data/sales.csv with streams without loading it entirely into memory — because a season's sales file does not fit in V8's heap. Everything you have learned here about the event loop, promises and EventEmitters stops being theory at that moment: fs.promises are promises, streams are EventEmitters, and the difference between readFile and readFileSync is exactly the difference between a server that responds and one that freezes.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
