🚀 Building Real APIs with Express
In Module 4, you built HTTP servers from scratch with Node's http module — manual routing, manual body parsing, manual everything. Express is the most popular web framework for Node.js that handles all of that elegantly.
💡 Remember Module 4? You wrote if/else routing, manually collected chunks for POST bodies, and handled status codes by hand. Express simplifies ALL of that while giving you powerful new features like middleware!
🎯 By the end of this module, you will:
- Set up an Express server with clean routing
- Handle route parameters, query strings, and request bodies
- Understand the middleware pipeline and write custom middleware
- Build a complete REST API with full CRUD operations
- Master error handling (6-part deep dive: from crashes to production-ready)
- Validate input with express-validator
- Implement professional logging with Winston
- Design consistent API response patterns
Express Framework
Routing, middleware, body parsing
REST API
Full CRUD with proper HTTP methods
Error Handling
6-part guide: crash → production-ready
Validation & Logging
express-validator, Winston, API patterns
⚡ Express Setup
Express is a minimal, flexible Node.js web framework. Install it and create your first server in seconds:
# Initialize project & install Express
npm init -y
npm install express
npm install -D nodemon🏗️ Your First Express Server
Just three lines to create a working web server: import Express, create an app, start listening. Compare this to the 15+ lines you needed with the raw http module in Module 4! Each line of the code below is commented to explain what it does:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// Parse JSON bodies (for POST/PUT requests)
app.use(express.json());
// Your first route
app.get('/', (req, res) => {
res.json({ message: 'Hello from Express!' });
});
// Start the server
app.listen(PORT, () => {
console.log(`🚀 Server running at http://localhost:${PORT}`);
});# Terminal
$ npx nodemon server.js
🚀 Server running at http://localhost:3000
$ curl http://localhost:3000/
{"message":"Hello from Express!"}🔄 http Module vs Express
See how much simpler Express makes everything compared to the raw http module from Module 4:
The http module requires you to manually check URLs, set headers, and handle status codes. Express abstracts all of that away — you just declare your routes and handlers. This isn't just about less code; it's about fewer bugs, faster development, and cleaner architecture:
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World');
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
server.listen(3000);const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello World');
});
app.listen(3000);✨ Much Cleaner! No http.createServer(), no manual req.url parsing, no res.writeHead(). Express handles status codes, headers, and routing automatically — you focus on the logic!
🛣️ Routing & Parameters
Express routing maps HTTP methods and URL patterns to handler functions. No more if/else chains!
💡 What is Routing?
Think of routing as a reception desk at a hotel. When a guest (HTTP request) arrives, the receptionist (Express router) checks what they need (the URL path and HTTP method) and directs them to the right room (handler function). Without routing, you'd have one giant room handling everything — that's what raw if/else chains in the http module felt like!
📝 HTTP Methods → Express Methods
Each HTTP method has a specific semantic meaning. Using the right method tells other developers (and clients) what the endpoint does, even before reading the code. Express provides a matching method for each one:
app.get('/users', handler); // Read
app.post('/users', handler); // Create
app.put('/users/:id', handler); // Update (full replace)
app.patch('/users/:id', handler); // Update (partial)
app.delete('/users/:id', handler);// Delete🔑 Route Parameters (:id)
Route parameters are dynamic segments in the URL path. They let you capture values from the URL itself. When you write /users/:id, the :id part becomes a variable — Express extracts it and puts it in req.params. This is how you access a specific resource (one user, one product, one order).
app.get('/users/:id', (req, res) => {
const userId = req.params.id; // "42"
res.json({ id: userId });
});
// GET /users/42 → { id: "42" }❓ Query Strings (?key=value)
Query strings are optional filters added after the ? in a URL. Unlike route parameters (which identify which resource), query strings modify how you want the data — filtering, sorting, pagination, searching. Express automatically parses them into req.query as an object.
app.get('/search', (req, res) => {
const { q, page = 1 } = req.query;
res.json({ query: q, page });
});
// GET /search?q=node&page=2 → { query: "node", page: "2" }📂 Express Router (Organize Routes)
As your app grows, putting all routes in one file becomes unmanageable. express.Router() lets you create mini-applications — each with its own routes — that you can mount onto your main app. Think of it like organizing files into folders instead of dumping everything on your desktop.
// routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', getAllUsers);
router.post('/', createUser);
router.get('/:id', getUserById);
router.put('/:id', updateUser);
router.delete('/:id', deleteUser);
module.exports = router;
// server.js
const userRoutes = require('./routes/users');
app.use('/api/users', userRoutes);
// All routes prefixed with /api/users🔑 params vs query — When to Use Which?
req.params → identifies which resource: /users/42 (get user 42). req.query → modifies how to get data: /users?role=admin&sort=name (filter & sort). Rule of thumb: if it's required to find the resource, use params. If it's optional (filtering, sorting, pagination), use query strings.
⚠️ Route Order Matters!
Express matches routes top to bottom. If you define app.get('/users/new') AFTER app.get('/users/:id'), then /users/new will match :id = "new" instead! Always put specific routes before dynamic ones.
📦 Body Parsing
Express provides built-in middleware to parse request bodies. Remember collecting chunks manually in Module 4? Express does it all for you!
💡 What is a Request Body?
When a client sends data to your server (submitting a form, creating a user, uploading JSON), that data travels in the body of the HTTP request. GET requests typically have no body (they use query strings instead), while POST, PUT, and PATCH requests carry data in the body. By default, Express does not parse the body — you need to tell it how with middleware!
📦 express.json() — Parse JSON Bodies
This is the most commonly used body parser. It reads incoming JSON data (from API clients, fetch requests, mobile apps) and converts it into a JavaScript object available on req.body. Without this middleware, req.body would be undefined!
app.use(express.json());
app.post('/users', (req, res) => {
const userData = req.body; // ← Automatically parsed!
console.log('Received:', userData);
res.status(201).json({ message: 'User created', user: userData });
});
// POST /users Body: { "name": "Alice", "email": "alice@example.com" }
// → { "message": "User created", "user": { "name": "Alice", ... } }💡 Remember the http module?
With raw http, you had to manually listen to data and end events, concatenate chunks, and parse JSON. Express does all of that for you with express.json()! 🎉
📝 express.text() — Parse Plain Text
Sometimes clients send plain text instead of structured data (log messages, raw content, webhooks). This parser makes req.body a simple string instead of an object.
app.use(express.text());
app.post('/message', (req, res) => {
console.log('Received text:', req.body); // A string, not an object!
res.send(`You sent: ${req.body}`);
});📋 express.urlencoded() — Parse Form Data
HTML forms submit data in URL-encoded format (key1=value1&key2=value2). This parser handles traditional form submissions. The { extended: true } option allows nested objects in form data (using the qs library), while false only supports flat key-value pairs.
app.use(express.urlencoded({ extended: true }));
app.post('/login', (req, res) => {
console.log(req.body); // { username: 'alice', password: '12345' }
res.send(`Welcome ${req.body.username}`);
});
// Form: username=alice&password=12345🌐 express.static() — Serve Static Files
Not technically a body parser, but an essential built-in middleware! express.static() serves files directly from a folder — HTML, CSS, JavaScript, images — without writing any route handlers. Express checks the folder first; if a matching file exists, it sends it. Otherwise, the request continues to your routes.
// Serve all files from 'public' folder
app.use(express.static('public'));
// http://localhost:3000/index.html
// http://localhost:3000/style.css
// With virtual path prefix
app.use('/assets', express.static('public'));
// http://localhost:3000/assets/style.css
// Multiple folders
app.use(express.static('public'));
app.use(express.static('uploads'));
// Express checks 'public' first, then 'uploads'
// With cache headers
app.use(express.static('public', {
maxAge: '1d', etag: true
}));⚠️ Body Parser Comparison
express.json()
req.body → object (parsed JSON)
express.text()
req.body → string (plain text)
express.urlencoded()
req.body → object (form data)
💡 Pro Tip: Use Multiple Parsers Together
In most real apps, you'll use both express.json() and express.urlencoded() together — JSON for API clients and URL-encoded for HTML forms. Each parser only processes requests with a matching Content-Type header, so they don't interfere with each other!
🔗 Middleware: The Request Pipeline
Middleware is one of Express's most powerful features. Think of it as a queue of filters that every request passes through before reaching your route handler.
🎯 What is Middleware?
Middleware are functions that have access to the request (req), response (res), and the next function. Each middleware can:
- ✅ Execute any code
- ✅ Modify
reqandresobjects - ✅ End the request-response cycle
- ✅ Call
next()to pass control to the next middleware
🔄 Request Flow: The Middleware Queue
Every time a client sends an HTTP request to your Express server, it doesn't go directly to your route handler. Instead, it travels through a pipeline of middleware functions — one by one, in the order you defined them. Each middleware can inspect, modify, or reject the request before passing it along. Here's what that journey looks like:
Client sends an HTTP request (e.g., GET /api/users). It carries a method, URL, headers, and possibly a body. Express receives it and starts the middleware chain.
Records the request method, URL, and timestamp. Doesn't change anything — just observes. Then calls next() to pass the request forward.
Reads the raw request body, parses it as JSON, and attaches the result to req.body. Without this, req.body would be undefined. Calls next().
Verifies the user's token or session. If valid, it may attach req.user and call next(). If invalid, it stops the chain and sends a 401 response — the request never reaches the route handler!
Your actual business logic runs here. It has access to everything the middleware attached: req.body (from JSON parser), req.user (from auth), and the full req/res objects.
The handler calls res.json(), res.send(), or res.render() to send data back to the client. The request-response cycle ends here.
📐 The Middleware Signature
Every middleware function receives exactly 3 parameters (or 4 for error middleware). This is the universal pattern — memorize it:
// Every middleware looks like this:
function myMiddleware(req, res, next) {
// req → the incoming request object (URL, headers, body...)
// res → the response object (methods to send data back)
// next → function to call when you're done (pass to next middleware)
// Do your work here...
console.log('I am middleware!');
next(); // Pass control to the next middleware in the chain
}🏭 Think of It Like a Factory Conveyor Belt
Each middleware is a station on the belt. The request (product) moves through each station in order. Each station can inspect it (logger), add to it (JSON parser adds req.body), reject it (auth sends 401), or just let it pass. If any station calls next(), the product moves to the next station. If not — it stops there forever (the request hangs).
⚡ The next() Function: Critical!
You MUST call next() to pass control to the next middleware. If you forget, the request hangs forever!
Exception: If you send a response (res.send(), res.json()), you don't need next() because the request-response cycle ends there.
🛠️ Middleware Examples
Let's look at the most common middleware patterns you'll use in real applications. Notice how each one follows the same (req, res, next) signature and calls next() to pass control forward:
1. Logger Middleware
Application-LevelLogs every request that comes to your server:
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
next(); // ✅ Pass to next middleware
});2. Authentication Middleware
Route-SpecificChecks if user is authenticated before allowing access:
function requireAuth(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.user = { id: 123, name: 'Alice' };
next(); // ✅ Authenticated
}
// Protected route
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ message: 'Welcome!', user: req.user });
});3. Request Timing
PerformanceMeasures how long each request takes:
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} - ${duration}ms`);
});
next();
});4. Error Handler (4 parameters!)
Error HandlingError middleware has 4 parameters including err:
app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(500).json({ error: err.message });
});
// Trigger error in a route
app.get('/fail', (req, res, next) => {
next(new Error('Something went wrong!'));
});🎨 Types of Middleware
Express middleware comes in six categories. Understanding the difference helps you choose the right approach for each situation — from global logging to specific route protection:
Application-Level
Runs for all routes via app.use()
app.use(express.json());
app.use(logger);
app.use(cors());Router-Level
Bound to specific router instances
const router = express.Router();
router.use(auth);
router.get('/admin', handler);Route-Specific
Runs only for specific routes
app.get('/admin', auth, (req, res) => {
res.send('Admin page');
});Built-in
Express's built-in middleware
express.json()
express.text()
express.urlencoded()
express.static()Third-Party
npm packages you install
const cors = require('cors');
const helmet = require('helmet');
app.use(cors());
app.use(helmet());Error-Handling
Has 4 parameters (err, req, res, next)
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message
});
});⚠️ Order Matters!
Middleware executes in the order you define it. Always put express.json() BEFORE routes that need req.body. Error middleware must be LAST!
▶ 🎯 Complete Middleware Example
const express = require('express');
const app = express();
// 1️⃣ Application-level: runs for ALL requests
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
// 2️⃣ Built-in middleware
app.use(express.json());
// 3️⃣ Custom authentication middleware
function requireAuth(req, res, next) {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
req.user = { id: 123, name: 'Alice' };
next();
}
// 4️⃣ Public route (no middleware)
app.get('/', (req, res) => { res.send('Welcome!'); });
// 5️⃣ Protected route
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ message: 'Dashboard', user: req.user });
});
// 6️⃣ Multiple middleware in sequence
app.post('/admin', requireAuth, checkAdmin, (req, res) => {
res.send('Admin panel');
});
function checkAdmin(req, res, next) {
if (req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
next();
}
// 7️⃣ Error-handling middleware (MUST be last!)
app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(500).json({ error: 'Internal Server Error' });
});
app.listen(3000);🎓 Key Takeaways
- Middleware are functions that process requests in a queue/pipeline
- Always call
next()unless you send a response - Order matters! Middleware runs top-to-bottom
- Use middleware for logging, authentication, parsing, error handling
- Error middleware has 4 parameters:
(err, req, res, next) - Middleware can modify
reqandresfor later use
🔄 Complete REST API (CRUD)
REST (Representational State Transfer) is an architectural style for designing APIs. It uses HTTP methods to perform CRUD operations on resources.
🎯 What Makes an API "RESTful"?
REST follows simple but important rules: 1) Resources are identified by URLs (/users, /products/42). 2) HTTP methods define what you do to a resource. 3) Each request is stateless — the server doesn't remember previous requests. 4) Responses use standard HTTP status codes. These conventions make APIs predictable: if you know REST, you can use any REST API without reading docs first!
📋 CRUD = Create, Read, Update, Delete
CRUD stands for the four basic operations you can perform on any data. REST maps each operation to a specific HTTP method. This mapping is a universal convention — every developer worldwide follows it:
| Operation | HTTP Method | Route | Example |
|---|---|---|---|
| Create | POST | /api/users | Create a user |
| Read all | GET | /api/users | List all users |
| Read one | GET | /api/users/:id | Get user by ID |
| Update | PUT | /api/users/:id | Replace a user |
| Delete | DELETE | /api/users/:id | Remove a user |
🎯 Complete Todo API
Let's build a complete working API step by step. This Todo API demonstrates all five CRUD operations. Notice the patterns: we use an in-memory array (no database yet), auto-increment IDs, proper status codes, and input validation. Study each route carefully — you'll reuse these exact patterns in every API you build!
const express = require('express');
const app = express();
app.use(express.json());
let todos = [
{ id: 1, title: 'Learn Node.js', completed: false },
{ id: 2, title: 'Build an API', completed: false }
];
let nextId = 3;
// GET /todos — Get all todos
app.get('/todos', (req, res) => {
res.json(todos);
});
// GET /todos/:id — Get one todo
app.get('/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === parseInt(req.params.id));
if (!todo) return res.status(404).json({ error: 'Todo not found' });
res.json(todo);
});
// POST /todos — Create a todo
app.post('/todos', (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const newTodo = { id: nextId++, title, completed: false };
todos.push(newTodo);
res.status(201).json(newTodo);
});
// PUT /todos/:id — Update a todo
app.put('/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === parseInt(req.params.id));
if (!todo) return res.status(404).json({ error: 'Todo not found' });
const { title, completed } = req.body;
if (title !== undefined) todo.title = title;
if (completed !== undefined) todo.completed = completed;
res.json(todo);
});
// DELETE /todos/:id — Delete a todo
app.delete('/todos/:id', (req, res) => {
const index = todos.findIndex(t => t.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Not found' });
const deleted = todos.splice(index, 1)[0];
res.json({ message: 'Deleted', todo: deleted });
});
app.listen(3000, () => console.log('Todo API on http://localhost:3000'));🔍 Key Patterns to Notice
201 Created for POST (not 200!), 404 Not Found when the resource doesn't exist, 400 Bad Request for missing required data. Also notice: parseInt(req.params.id) — route params are always strings, so you must convert them to numbers for comparisons! These patterns repeat in every REST API.
📖 PUT vs PATCH — What's the Difference?
PUT = full replacement: you send the entire new object. PATCH = partial update: you only send the fields you want to change. In practice, many APIs use PUT for both (like our example above), but knowing the difference matters for API design interviews and documentation!
🚨 Error Handling in Express (6-Part Deep Dive)
Proper error handling is crucial for building robust applications. Without it, your server crashes on unexpected errors, leaving users with no response. Let's go from crashes to production-ready, step by step.
⚠️ Part 1: The Problem — Unhandled Errors Crash Your Server
// ❌ BAD: No error handling — this WILL crash!
app.get('/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === parseInt(req.params.id));
// What if todo is undefined? 💥 CRASH!
res.json({ task: todo.task }); // Cannot read property of undefined
});
// GET /todos/999 → 💥 Server crashes! All users lose connection!💥 What Happens When It Crashes?
Your entire server stops. Users get no response. You must manually restart. All connections lost. This is unacceptable in production!
✅ Part 2: Solution 1 — Try-Catch (Works, But Repetitive)
// ✅ Server doesn't crash, but...
app.get('/todos/:id', (req, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid ID' });
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: 'Not found' });
res.json(todo);
} catch (error) {
res.status(500).json({ error: 'Something went wrong' });
}
});
app.post('/todos', (req, res) => {
try { /* same try-catch pattern... */ } catch (error) { /* same... */ }
});
app.delete('/todos/:id', (req, res) => {
try { /* same try-catch pattern... */ } catch (error) { /* same... */ }
});
// 😩 Repetitive! DRY violation!🎯 Part 3: Solution 2 — asyncHandler Wrapper (Best Practice ⭐)
Instead of repeating try-catch, create a reusable wrapper function that automatically catches errors. Write error handling ONCE, use it everywhere!
💡 The Core Concept
A Higher-Order Function: takes your route handler, wraps it in try-catch, and returns the wrapped version. Express calls the wrapped function — if it throws, the error is caught automatically!
// The asyncHandler wrapper — write ONCE!
const asyncHandler = (fn) => {
return async (req, res, next) => {
try {
await fn(req, res, next);
} catch (error) {
next(error); // Pass to error middleware
}
};
};
// Now all routes are clean — no try-catch!
app.get('/todos/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: 'Not found' });
res.json(todo);
}));
app.post('/todos', asyncHandler(async (req, res) => {
const { task } = req.body;
if (!task) throw new Error('Task required'); // Caught by wrapper!
const newTodo = { id: todos.length + 1, task };
todos.push(newTodo);
res.json(newTodo);
}));
// 😊 Much cleaner! Error handling in ONE place!🔍 Error Flow
1️⃣ Error thrown in route → 2️⃣ asyncHandler catches it → 3️⃣ Calls next(error) → 4️⃣ Express error middleware handles it → 5️⃣ Response sent to client → 6️⃣ Server keeps running! ✅
🛡️ Part 4: Error Handling Middleware (Centralized Responses)
Now that asyncHandler catches errors and passes them via next(error), we need error middleware to send proper responses. Handle ALL errors in ONE place!
// 404 handler (no route matched) — before error middleware
app.use((req, res) => {
res.status(404).json({ error: 'Route not found', path: req.path });
});
// Error handler — MUST be LAST and have 4 parameters!
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
console.error(`[${statusCode}] ${err.message}`);
res.status(statusCode).json({
success: false,
error: process.env.NODE_ENV === 'development'
? err.message
: 'An error occurred',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});▶ 🔍 Understanding ...(condition && { property })
This is "Conditional Property Inclusion" using the spread operator:
// true && { stack: 'error' } → returns { stack: 'error' }
// false && { stack: 'error' } → returns false
// ...{ stack: err.stack } → adds stack property
// ...false → spreads nothing (ignored)
const isDev = true;
const obj = { name: 'error', ...(isDev && { details: 'info' }) };
// → { name: 'error', details: 'info' }
const isProd = false;
const obj = { name: 'error', ...(isProd && { details: 'info' }) };
// → { name: 'error' } — details NOT added✨ Part 5: Custom Error Classes (Enhancement)
Important: Custom error classes are NOT a catching solution! They help you CREATE better errors with proper status codes. They work best combined with asyncHandler.
⚠️ Common Misconception
asyncHandler = catches errors ✅ | Custom Error Classes = makes errors better ✨ | Together = clean code + meaningful errors! 🚀
// Custom error classes
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message) { super(message, 400); }
}
// ✅ BEST: Custom Errors + asyncHandler
app.get('/todos/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) throw new ValidationError('ID must be a number');
const todo = todos.find(t => t.id === id);
if (!todo) throw new NotFoundError('Todo');
res.json(todo);
}));🎯 Part 6: Complete Production-Ready Example
▶ 📄 Full Production-Ready Error Handling Code
const express = require('express');
const app = express();
app.use(express.json());
// 1️⃣ Custom Error Classes
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') { super(`${resource} not found`, 404); }
}
class ValidationError extends AppError {
constructor(message) { super(message, 400); }
}
// 2️⃣ asyncHandler Wrapper
const asyncHandler = (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (error) { next(error); }
};
// 3️⃣ Sample Data
const todos = [
{ id: 1, task: 'Learn Express', completed: false },
{ id: 2, task: 'Build API', completed: true }
];
// 4️⃣ Routes — clean with asyncHandler + Custom Errors
app.get('/todos', asyncHandler(async (req, res) => { res.json(todos); }));
app.get('/todos/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) throw new ValidationError('ID must be a valid number');
const todo = todos.find(t => t.id === id);
if (!todo) throw new NotFoundError('Todo');
res.json(todo);
}));
app.post('/todos', asyncHandler(async (req, res) => {
const { task } = req.body;
if (!task || task.trim() === '') throw new ValidationError('Task is required');
if (task.length > 100) throw new ValidationError('Task too long (max 100)');
const newTodo = { id: todos.length + 1, task: task.trim(), completed: false };
todos.push(newTodo);
res.status(201).json(newTodo);
}));
// 5️⃣ 404 Handler
app.use((req, res) => {
res.status(404).json({ success: false, error: 'Route not found', path: req.path });
});
// 6️⃣ Error Middleware (MUST be LAST!)
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
console.error(`[${statusCode}] ${err.message}`);
res.status(statusCode).json({
success: false, error: err.message || 'Internal Server Error'
});
});
app.listen(3000, () => console.log('✅ Server running on port 3000'));
// GET /todos/1 → 200 | GET /todos/999 → 404
// GET /todos/abc → 400 | POST /todos (empty) → 400
// GET /invalid → 404 (Route not found)🔑 Error Handling Key Takeaways
- 📌 Part 1: Unhandled errors crash your server — unacceptable!
- 📌 Part 2: Try-catch works but creates repetitive code
- 📌 Part 3: asyncHandler wrapper = DRY principle, write once
- 📌 Part 4: Error middleware catches all errors centrally
- 📌 Part 5: Custom error classes = proper status codes + messages
- ⭐ Best Practice: asyncHandler + Custom Errors + Error Middleware = Production-ready!
✅ Input Validation
Never trust user input! Validation ensures data is correct, complete, and safe before processing.
🔒 The Golden Rule of Backend Development
Never trust data from the client. Even if your frontend validates the data, anyone can bypass it using Postman, curl, or browser dev tools. Backend validation is your last line of defense — without it, attackers can inject malicious data, crash your server, or corrupt your database. Always validate on the server, no exceptions!
🤔 Why Validate Input?
Prevent Bugs
Invalid data causes crashes and unexpected behavior
Data Integrity
Keep your database clean and consistent
Better UX
Give users clear, actionable error messages
Enforce Rules
Ensure data meets business requirements
🔧 Manual Validation
The simplest approach: check each field yourself with if statements. This gives you full control but becomes repetitive and hard to maintain as your API grows. Notice how we collect all errors into an array and return them together — this gives users all the issues at once instead of one at a time:
app.post('/users', (req, res) => {
const { name, email, age } = req.body;
const errors = [];
if (!name || name.trim() === '') errors.push('Name is required');
if (!email) errors.push('Email is required');
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.push('Email must be valid');
if (age !== undefined) {
const ageNum = parseInt(age);
if (isNaN(ageNum)) errors.push('Age must be a number');
else if (ageNum < 0 || ageNum > 150) errors.push('Age: 0-150');
}
if (errors.length > 0)
return res.status(400).json({ success: false, errors });
res.status(201).json({ success: true, data: { name, email, age } });
});⚠️ Problem: Manual validation becomes messy and repetitive quickly. For complex apps, use a validation library!
📦 express-validator Library
express-validator is the most popular validation library for Express. It provides a clean, middleware-based API.
It works as middleware — you chain validation rules before your route handler. Each rule checks one field and adds an error if the check fails. After all rules run, you collect the errors with validationResult(req). This approach is declarative (you describe what you want, not how to check it) and much cleaner than manual validation:
npm install express-validatorconst { body, param, validationResult } = require('express-validator');
app.post('/users',
// Validation middleware chain
body('name').trim().notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 100 }).withMessage('Name: 2-100 chars'),
body('email').trim().notEmpty().withMessage('Email is required')
.isEmail().withMessage('Email must be valid').normalizeEmail(),
body('age').optional()
.isInt({ min: 0, max: 150 }).withMessage('Age: 0-150').toInt(),
// Route handler
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return res.status(400).json({ success: false, errors: errors.array() });
const { name, email, age } = req.body;
res.status(201).json({ success: true, data: { name, email, age } });
}
);📋 Common Validation Rules
express-validator provides dozens of built-in validators and sanitizers. Here are the ones you'll use most often — validators check the data, and sanitizers (like trim(), normalizeEmail()) clean it up automatically:
notEmpty()Field must not be emptyisEmail()Must be valid emailisInt()Must be integerisLength()Check string lengthisURL()Must be valid URLisStrongPassword()Check password strengthmatches()Match regex patternisIn()Value in allowed listcustom()Custom validation logicoptional()Field is optionaltrim()Remove whitespacenormalizeEmail()Standardize email▶ 🔥 Advanced Validation: Reusable Middleware & Custom Rules
// validators/userValidators.js — Reusable!
const { body, validationResult } = require('express-validator');
const userValidationRules = () => [
body('name').trim().notEmpty().isLength({ min: 2, max: 100 }),
body('email').trim().notEmpty().isEmail().normalizeEmail(),
body('age').optional().isInt({ min: 0, max: 150 }).toInt()
];
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return res.status(400).json({
success: false,
errors: errors.array().map(e => ({ field: e.path, message: e.msg }))
});
next();
};
// Usage — super clean!
app.post('/users', userValidationRules(), validate, (req, res) => {
res.status(201).json({ success: true, data: req.body });
});
// Custom validation functions
const userExists = async (value) => {
const user = await db.findUserByEmail(value);
if (user) throw new Error('Email already exists');
return true;
};
app.post('/register',
body('email').isEmail().custom(userExists),
body('password').isLength({ min: 8 })
.matches(/\d/).withMessage('Must contain a number')
.matches(/[A-Z]/).withMessage('Must contain uppercase'),
body('confirmPassword').custom((val, { req }) => {
if (val !== req.body.password) throw new Error('Passwords don\'t match');
return true;
}),
validate, handler
);📊 Logging Best Practices
Good logging is essential for debugging, monitoring, and understanding your app. console.log() is fine for learning, but production apps need structured, level-based logging.
💡 Why Logging Matters in Production
Imagine your API crashes at 3 AM. You can't attach a debugger or add console.log() after the fact. Logs are your only evidence. Good logging tells you exactly what happened, when, and why — like a flight recorder for your server. Without it, debugging production issues is like finding a needle in a haystack... blindfolded.
🤔 Why Move Beyond console.log()?
No Log Levels
Can't distinguish info, warnings, errors
No Structure
Hard to parse and search logs
No Persistence
Logs disappear on restart
Production Issues
Can't filter or control in production
📊 Log Levels
Log levels let you categorize messages by severity. In production, you typically only show info and above (hiding debug noise). In development, you show everything. This way, you don't drown in log data but can still find critical issues:
errorCritical issues. Server errors, crashes, failures.
logger.error('Database connection failed');warnPotential issues. Not critical but investigate.
logger.warn('API rate limit approaching');infoImportant events: server start, user actions.
logger.info('Server started on port 3000');debugDetailed debugging info. Only in development.
logger.debug('Query executed:', query);📦 Winston: Professional Logging
Winston is Node.js's most popular logging library. It supports multiple transports (outputs) — console, files, HTTP endpoints — so you can send logs wherever you need them. It also supports formats (JSON, colorized, timestamped) and log rotation (automatic file size limits):
npm install winston// config/logger.js
const winston = require('winston');
const isDev = process.env.NODE_ENV === 'development';
const logger = winston.createLogger({
level: isDev ? 'debug' : 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'api-server' },
transports: [
new winston.transports.Console({
format: isDev
? winston.format.combine(winston.format.colorize(), winston.format.simple())
: winston.format.json()
}),
new winston.transports.File({ filename: 'logs/error.log', level: 'error', maxsize: 5242880, maxFiles: 5 }),
new winston.transports.File({ filename: 'logs/combined.log', maxsize: 5242880, maxFiles: 5 })
]
});
module.exports = logger;
// Usage
const logger = require('./config/logger');
logger.info('Server started', { port: 3000, env: process.env.NODE_ENV });
logger.error('DB failed', { error: err.message, stack: err.stack });
logger.warn('High memory usage');
logger.debug('Request payload:', req.body);📝 What to Log (and What NOT to Log)
✅ DO Log
- Server start/stop events
- Incoming requests (method, path, IP)
- Database operations
- External API calls
- Auth attempts (success/failure)
- Errors with context
- Performance metrics
❌ DON'T Log
- 🔒 Passwords (EVER!)
- 🔑 API keys or tokens
- 💳 Credit card numbers
- 🔐 Session IDs
- 📱 Personal identifiable info (PII)
- 🗝️ Encryption keys
⚠️ Security Warning: Logging sensitive data can lead to security breaches! Attackers often gain access through log files. Always sanitize data before logging.
📤 API Response Patterns
Consistent API responses make your API easier to use and maintain. Clients know exactly what to expect.
💡 Why Does This Matter?
Imagine calling an API where success returns { user: {...} }, errors return { msg: "fail" }, and lists return [...]. Every endpoint has a different shape — your frontend code becomes a mess of special cases! A consistent format means your client-side error handling works the same way everywhere: just check response.success, grab response.data or response.error, done.
🤔 Why Consistent Responses Matter
Predictable
Clients know what to expect every time
Fewer Bugs
Consistent format reduces parsing errors
Self-Documenting
Clear response structure is easy to understand
Easy to Maintain
Standardized responses simplify refactoring
✅ Standard Response Format
Choose one format and use it everywhere. The most common pattern is a wrapper object with success (boolean), data (the actual content), and error (the message). Here's the format used by most professional APIs:
// Success response
{ "success": true, "data": { /* ... */ }, "message": "Optional message" }
// Error response
{ "success": false, "error": "Error message", "errors": [ /* details */ ] }
// Paginated response
{
"success": true,
"data": [ /* items */ ],
"pagination": { "page": 1, "limit": 10, "total": 100, "totalPages": 10 }
}🔧 Response Helper Class
Instead of manually typing the same { success: true, data: ... } structure in every route, create a helper class. This ensures consistency and reduces typos. If you ever need to change your response format, you change it in one place instead of 50 routes:
class ApiResponse {
static success(res, data, message = null, statusCode = 200) {
return res.status(statusCode).json({
success: true, data, ...(message && { message })
});
}
static error(res, message, statusCode = 500, errors = null) {
return res.status(statusCode).json({
success: false, error: message, ...(errors && { errors })
});
}
static paginated(res, data, page, limit, total) {
return res.status(200).json({
success: true, data,
pagination: {
page: parseInt(page), limit: parseInt(limit), total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total, hasPrev: page > 1
}
});
}
static created(res, data, msg = 'Created') { return this.success(res, data, msg, 201); }
static noContent(res) { return res.status(204).send(); }
static badRequest(res, msg, errors = null) { return this.error(res, msg, 400, errors); }
static unauthorized(res, msg = 'Unauthorized') { return this.error(res, msg, 401); }
static notFound(res, msg = 'Not found') { return this.error(res, msg, 404); }
}
// Usage
app.get('/users', async (req, res) => {
const users = await db.getUsers();
return ApiResponse.success(res, users);
});
app.post('/users', validate, async (req, res) => {
const user = await db.createUser(req.body);
return ApiResponse.created(res, user);
});📊 HTTP Status Codes Reference
Status codes are not arbitrary — they carry meaning. Clients, browsers, and tools (like Postman) use them to determine what happened. Using the right code makes your API self-documenting. Here are the ones you'll use most:
2xx Success
200 OKRequest successful201 CreatedResource created204 No ContentSuccess, no content4xx Client Errors
400 Bad RequestInvalid request401 UnauthorizedNot authenticated403 ForbiddenNo permission404 Not FoundResource not found422 UnprocessableValidation failed5xx Server Errors
500 InternalServer error503 UnavailableServer down✅ Best Practices
Building an API that works is one thing. Building an API that's maintainable, secure, and scalable is another. These best practices come from real production experience — follow them from the start and you'll avoid painful refactoring later:
Project Structure
my-api/
├── server.js
├── routes/
│ └── users.js
├── middleware/
│ └── auth.js
├── controllers/
│ └── userController.js
├── validators/
│ └── userValidators.js
├── config/
│ └── logger.js
├── utils/
│ └── response.js
├── .env
├── .gitignore
└── package.jsonEnvironment Variables
# .env file
PORT=3000
DB_URL=mongodb://localhost
JWT_SECRET=my-secret-key
NODE_ENV=development
# .gitignore
node_modules/
.env
logs/Use async/await
// Always use asyncHandler
app.get('/users', asyncHandler(
async (req, res) => {
const users = await db.getUsers();
ApiResponse.success(res, users);
}
));Consistent Responses
// ✅ Always same shape
res.json({
success: true,
data: users
});
res.status(400).json({
success: false,
error: 'Invalid input'
});You're Now a Full-Stack Developer!
You know JavaScript (browser), Node.js (server), and Express (web framework). You can build complete applications from frontend to backend! The journey doesn't stop here — keep building, keep learning! 🚀
📝 Summary
⚡ Express
Web framework. app.get/post/put/delete for clean routing.
🛣️ Routing
Params (:id), query strings, request body, Router for organization.
📦 Body Parsing
Built-in: express.json(), .text(), .urlencoded(), .static().
🔗 Middleware
Pipeline of functions. Logger, auth, CORS. Order matters!
🔄 REST API
CRUD operations mapped to HTTP methods on resource URLs.
🛡️ Error Handling
asyncHandler + custom errors + centralized error middleware.
✅ Validation
express-validator for clean, reusable input validation.
📊 Logging
Winston for structured, level-based, production-ready logging.
📤 API Patterns
Consistent response format, status codes, helper classes.