Chapter 46: Node.js Express.js
Express.js in Node.js.
I will explain it as if we are sitting together right now:
- I open VS Code
- I create files one by one
- I explain why we are doing each step
- I show the exact commands I type in the terminal
- I highlight the most common beginner traps and intermediate mistakes
- I share real production patterns used in 2025–2026 by serious teams
- We build a small but realistic REST API together from zero
Let’s start from scratch — no skipping.
Step 1 – Realistic project initialization (what most people actually do)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# 1. Create project folder mkdir task-api-express cd task-api-express # 2. Initialize npm + set ESM (very important in 2025–2026) npm init -y npm pkg set type=module # 3. Install Express + essential middleware npm install express cors helmet compression dotenv # 4. Install development tools npm install -D \ typescript @types/node @types/express @types/cors \ tsx nodemon \ eslint prettier eslint-config-standard-with-typescript \ @typescript-eslint/parser @typescript-eslint/eslint-plugin \ zod |
Why these packages?
- cors — almost every API needs it
- helmet — basic security headers (very quick win)
- compression — gzip responses (reduces bandwidth 60–80%)
- dotenv — environment variables
- zod — runtime validation (very popular replacement for Joi)
- tsx — run TypeScript directly (no build step in dev)
- nodemon — auto-restart on file change
Step 2 – Realistic tsconfig.json (strict but practical)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", "sourceMap": true, "noEmitOnError": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } |
Why these settings?
- “module”: “NodeNext” + “moduleResolution”: “NodeNext” → best ESM support
- “strict”: true + “noImplicitAny”: true → catches many bugs early
- “esModuleInterop”: true → makes importing CommonJS modules nicer
Step 3 – Realistic folder structure (what most teams use)
|
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 |
task-api-express/ ├── src/ │ ├── config/ ← environment & constants │ │ └── env.ts │ ├── controllers/ ← HTTP handlers (thin layer) │ │ └── task.controller.ts │ ├── middleware/ ← reusable request handlers │ │ ├── error.middleware.ts │ │ └── validate.middleware.ts │ ├── routes/ ← route definitions │ │ └── task.routes.ts │ ├── schemas/ ← Zod schemas / DTOs │ │ └── task.schema.ts │ ├── services/ ← business logic │ │ └── task.service.ts │ ├── types/ ← shared types │ │ └── index.ts │ └── index.ts ← entry point ├── .env ├── .env.example ├── tsconfig.json ├── package.json └── README.md |
Why this structure?
- Controllers are thin → they just call services & handle HTTP
- Services contain real business logic (easy to test)
- Schemas are separate → reusable for validation & OpenAPI
- Middleware is reusable (auth, rate-limit, error, etc.)
- Very easy to grow into microservices or feature folders later
Step 4 – Environment variables + Zod validation
src/config/env.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { z } from 'zod' import 'dotenv/config' const envSchema = z.object({ PORT: z.coerce.number().default(5000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters') }) const env = envSchema.parse(process.env) export default env |
Why Zod here?
- Compile-time + runtime safety
- Beautiful error messages when .env is wrong
- Automatic type inference (env.PORT is number)
Step 5 – Basic Express server with TypeScript
src/index.ts
|
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 30 31 32 33 34 35 36 37 38 39 40 |
import express, { Express, Request, Response, NextFunction } from 'express' import cors from 'cors' import helmet from 'helmet' import compression from 'compression' import env from './config/env.js' import { errorHandler } from './middleware/error.middleware.js' import taskRoutes from './routes/task.routes.js' const app: Express = express() // Global middleware (order matters!) app.use(helmet()) // security headers app.use(compression()) // gzip responses app.use(cors()) // allow frontend domains app.use(express.json()) // parse JSON bodies // Routes app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', uptime: process.uptime(), environment: env.NODE_ENV, timestamp: new Date().toISOString() }) }) app.use('/api/tasks', taskRoutes) // Global error handler – MUST be last app.use(errorHandler) app.listen(env.PORT, () => { console.log(`🚀 Server running → http://localhost:${env.PORT}`) console.log(`Environment: ${env.NODE_ENV}`) }) |
Step 6 – Custom error handling (production essential)
src/middleware/error.middleware.ts
|
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 30 31 32 33 34 35 36 37 38 39 40 |
import { Request, Response, NextFunction } from 'express' import env from '../config/env.js' export class AppError extends Error { constructor( public statusCode: number, message: string, public isOperational = true ) { super(message) this.name = 'AppError' } } export function errorHandler( err: Error, req: Request, res: Response, next: NextFunction ) { if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, message: err.message, ...(env.NODE_ENV !== 'production' && { stack: err.stack }) }) } // Unknown / programming error console.error('Unhandled error:', err) return res.status(500).json({ success: false, message: 'Internal server error' }) } |
Why this pattern?
- Distinguishes expected errors (404, 401, validation) from bugs
- Never leaks stack traces in production
- Consistent JSON error shape for frontend
Step 7 – Input validation with Zod (very modern & strongly recommended)
src/schemas/task.schema.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { z } from 'zod' export const createTaskSchema = z.object({ title: z.string().min(1).max(100), description: z.string().max(500).optional(), dueDate: z.string().datetime().optional(), priority: z.enum(['low', 'medium', 'high']).default('medium'), completed: z.boolean().default(false) }) export const updateTaskSchema = createTaskSchema.partial() |
src/middleware/validate.middleware.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { Request, Response, NextFunction } from 'express' import { AnyZodObject } from 'zod' import { AppError } from './error.middleware.js' export function validateBody(schema: AnyZodObject) { return async (req: Request, res: Response, next: NextFunction) => { try { req.body = await schema.parseAsync(req.body) next() } catch (err) { next(new AppError(400, 'Validation failed', true)) } } } |
Usage in route
|
0 1 2 3 4 5 6 7 8 9 10 |
router.post( '/', validateBody(createTaskSchema), taskController.createTask ) |
Step 8 – Realistic controller example
src/controllers/task.controller.ts
|
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 30 31 32 33 |
import { Request, Response } from 'express' import { createTaskSchema, updateTaskSchema } from '../schemas/task.schema.js' import * as taskService from '../services/task.service.js' import { AppError } from '../middleware/error.middleware.js' export const createTask = async (req: Request, res: Response) => { const input = createTaskSchema.parse(req.body) const task = await taskService.createTask(input) res.status(201).json({ success: true, data: task }) } export const getTaskById = async (req: Request<{ id: string }>, res: Response) => { const task = await taskService.getTaskById(req.params.id) if (!task) { throw new AppError(404, 'Task not found') } res.json({ success: true, data: task }) } |
Summary – Modern Node.js + Express in 2025–2026 feels like this
You now have:
- ESM + top-level await
- Strict TypeScript
- Zod runtime validation
- Custom AppError + global error handler
- Clean layered architecture
- Automatic formatting & linting
- Security headers & compression
- Environment validation
This is the foundation used by most serious Express-based Node.js backends today.
Which direction would you like to go next?
- Add JWT authentication + protected routes
- Connect Prisma or Drizzle with full typing
- Implement rate limiting, logging (pino), request tracing
- Add unit & integration tests with Vitest
- Docker + production deployment checklist
- Migrate this project to Fastify or Hono (side-by-side comparison)
Just tell me what you want to build or understand next — I’ll continue with complete, realistic code. 😊
