Chapter 15: Node ES Modules
What are ES Modules (ESM) and why they matter in Node.js
ES Modules (also called ECMAScript Modules) are the official, modern way to write modular JavaScript — the same syntax that browsers have used for years (import / export).
In Node.js, we had CommonJS (require / module.exports) from the beginning (2009). But since Node.js 12+ (and especially Node.js 14+ with full stability), ES Modules became the recommended standard.
Why switch to ESM in 2025–2026?
- Cleaner, more readable syntax (import vs require)
- Top-level await is allowed (very useful!)
- Better tree-shaking (modern bundlers remove unused code)
- Most new libraries (Prisma, Zod, Drizzle, Hono, Fastify v5, etc.) are ESM-only
- Same syntax as frontend (React, Next.js, Vite) → easier full-stack work
- Future-proof — CommonJS is slowly being phased out
Quick summary table: CommonJS vs ESM
| Feature | CommonJS | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| File extension | Optional | Usually required (.js) |
| Top-level await | Not allowed | Allowed |
| Dynamic import | require() anywhere | await import() (inside async) |
| package.json flag | Not needed | “type”: “module” |
| Modern library support 2026 | Declining | Dominant |
Chapter 2 – How to enable ES Modules in your project
There are two main ways to tell Node.js “use ESM”:
Way 1 – Recommended: Add “type”: “module” in package.json (most common)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// package.json { "name": "my-esm-project", "version": "1.0.0", "type": "module", // ← This line is very important! "main": "index.js", "scripts": { "start": "node index.js", "dev": "nodemon index.js" } } |
→ All .js files in your project are now treated as ES Modules.
Way 2 – Use .mjs extension (no package.json change needed)
|
0 1 2 3 4 5 6 7 |
index.mjs utils/date-utils.mjs |
→ Node.js automatically treats .mjs files as ES Modules.
Recommendation 2026: Use “type”: “module” + .js files → it’s cleaner, more consistent, and what almost everyone does now.
Chapter 3 – Basic ESM Syntax – Named Exports & Imports
3.1 Named exports (most common & recommended)
utils/date-utils.js
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// Named exports – very clean and explicit export const PI = 3.14159265359; export function formatDate(date) { return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); } export function getCurrentDateTime() { return new Date().toLocaleString('en-IN', { timeZone: 'Asia/Kolkata' }); } |
index.js (main file)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// Option A: Import specific named exports import { formatDate, getCurrentDateTime, PI } from './utils/date-utils.js'; console.log("Current time in Hyderabad:", getCurrentDateTime()); console.log("Formatted date:", formatDate(new Date())); console.log("PI:", PI); // Option B: Import everything as an object (also very common) import * as dateUtils from './utils/date-utils.js'; console.log(dateUtils.formatDate(new Date())); |
Run:
|
0 1 2 3 4 5 6 |
node index.js |
Output (example):
|
0 1 2 3 4 5 6 7 8 |
Current time in Hyderabad: 10/2/2026, 11:15:23 AM Formatted date: 10 Feb 2026 PI: 3.14159265359 |
Important rule: In ESM you must write the file extension → ./utils/date-utils.js (not ./utils/date-utils)
Chapter 4 – Default Exports (when there is one main thing)
logger.js
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Default export – one main thing per file export default class Logger { info(message) { console.log(`[INFO] ${new Date().toLocaleTimeString('en-IN')} - ${message}`); } error(message) { console.error(`[ERROR] ${new Date().toLocaleTimeString('en-IN')} - ${message}`); } warn(message) { console.warn(`[WARN] ${new Date().toLocaleTimeString('en-IN')} - ${message}`); } } |
Using it:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
// index.js import Logger from './logger.js'; // ← no curly braces for default const log = new Logger(); log.info("Server started successfully"); log.warn("Low disk space"); log.error("Database connection failed"); |
You can mix named and default:
|
0 1 2 3 4 5 6 7 |
export const VERSION = '1.2.3'; export default class Logger { ... } |
|
0 1 2 3 4 5 6 |
import Logger, { VERSION } from './logger.js'; |
Chapter 5 – Real-World Folder Structure with ESM
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
src/ ├── index.js ← entry point ├── config/ │ └── database.js ├── utils/ │ ├── date-utils.js │ └── logger.js ├── services/ │ └── user-service.js ├── controllers/ │ └── user-controller.js ├── routes/ │ └── user-routes.js └── middleware/ └── auth.js |
Example: user-service.js
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// src/services/user-service.js import { prisma } from '../config/database.js'; export async function findUserById(id) { return prisma.user.findUnique({ where: { id: Number(id) }, select: { id: true, name: true, email: true, createdAt: true } }); } export async function createUser(data) { return prisma.user.create({ data: { name: data.name, email: data.email, passwordHash: data.passwordHash } }); } |
Using in controller:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// src/controllers/user-controller.js import * as userService from '../services/user-service.js'; export const getUser = async (req, res) => { const user = await userService.findUserById(req.params.id); if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json(user); }; |
Chapter 6 – Top-Level Await (one of the best ESM features)
index.js (no need to wrap in async function!)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { readFile } from 'node:fs/promises'; import { config } from 'dotenv'; console.log("Starting application..."); // Top-level await – only works in ESM! const envContent = await readFile('.env', 'utf-8'); console.log("Raw .env content:", envContent); // Or better – use dotenv with top-level await config(); // works fine const data = await readFile('./data/users.json', 'utf-8'); const users = JSON.parse(data); console.log("Loaded users:", users.length); |
This is impossible in CommonJS — huge win for startup scripts!
Chapter 7 – Common Mistakes & How to Fix Them
| Mistake | Error Message / Problem | Fix |
|---|---|---|
| Forget “type”: “module” | SyntaxError: Cannot use import statement… | Add “type”: “module” to package.json |
| Forget .js extension | Cannot find module … | Always write ./file.js |
| Using require() in ESM file | ReferenceError: require is not defined | Convert to import |
| Putting import inside if/for block | SyntaxError: Import declarations … | Move imports to top level |
| Circular dependencies | Infinite loop or undefined values | Refactor or use lazy loading |
| Mixing CommonJS & ESM in same project | Hard-to-debug errors | Choose one system per project |
Chapter 8 – Summary & Recommendations (2026 style)
- Use ES Modules for all new projects — “type”: “module” + .js files
- Prefer named exports for most utilities & services
- Use default export when a file has one clear main thing (class, service, router…)
- Always write file extensions in imports
- Enjoy top-level await for clean startup code
- Most modern tools (Vite, Next.js, Prisma, Zod, Hono, Fastify) default to ESM
Homework / Practice Suggestions
- Create a small project with 6–8 modules (utils, services, controllers, routes)
- Write it first with CommonJS, then convert it fully to ESM
- Add top-level await to load config or connect to a database
- Try both named exports and default exports in the same file
- Intentionally create a circular dependency and see what happens (then fix it)
Would you like to go deeper into any of these topics next?
- Step-by-step conversion from CommonJS to ESM
- Barrel files (index.js re-exports)
- Dynamic import() for lazy loading
- Publishing dual CommonJS + ESM packages
- Handling circular dependencies in real projects
- Complete medium-sized project structure with ESM
Just tell me what you want to explore — I’ll continue with full, runnable examples. 😊
