Understanding Express Routing
Routing in Express refers to how an application's endpoints (URIs) respond to client requests. It defines how different parts of your application handle HTTP methods (GET, POST, PUT, DELETE, etc.).
The Mail Sorting Analogy
Express routing is like a mail sorting system in a post office:
- HTTP Methods (GET, POST, PUT, DELETE) are like different types of mail services (standard mail, express mail, registered mail)
- Routes (/users, /products, /orders) are like different destinations or departments
- Route Parameters (/users/:id) are like specific mailboxes within a department
- Route Handlers are like the mail clerks who process each type of mail for each destination
Just as mail is sorted and directed to its proper destination based on its address and service type, HTTP requests in Express are routed to the appropriate handler based on the URL path and HTTP method.
Basic Routing
Express routes are defined using methods that correspond to HTTP methods. Each route method takes a path and a handler function.
Basic Route Structure
app.METHOD(PATH, HANDLER)
Where:
appis an instance of ExpressMETHODis an HTTP request method (lowercase): get, post, put, delete, etc.PATHis a path on the serverHANDLERis the function executed when the route is matched
Basic Route Examples
const express = require('express');
const app = express();
// GET method route
app.get('/', (req, res) => {
res.send('Hello World!');
});
// POST method route
app.post('/', (req, res) => {
res.send('Got a POST request');
});
// PUT method route
app.put('/user/:id', (req, res) => {
res.send(`Got a PUT request at /user/${req.params.id}`);
});
// DELETE method route
app.delete('/user/:id', (req, res) => {
res.send(`Got a DELETE request at /user/${req.params.id}`);
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Each route handles a specific HTTP method and URL path. The handler functions take request and response objects as parameters.
Route Parameters
Route parameters are named URL segments used to capture values at specific positions in the URL. The captured values are stored in the req.params object.
Route Parameters Example
// Route with one parameter
app.get('/users/:userId', (req, res) => {
const userId = req.params.userId;
res.send(`User ID: ${userId}`);
});
// Route with multiple parameters
app.get('/users/:userId/posts/:postId', (req, res) => {
const userId = req.params.userId;
const postId = req.params.postId;
res.send(`User ID: ${userId}, Post ID: ${postId}`);
});
When a client makes a request to /users/123, the userId parameter will be 123. Similarly, a request to /users/123/posts/456 will set userId to 123 and postId to 456.
Real-world Example: Product API
Consider an e-commerce API where you need to retrieve product information:
// Get all products
app.get('/api/products', (req, res) => {
// Fetch all products from database
const products = fetchAllProducts();
res.json(products);
});
// Get a specific product
app.get('/api/products/:productId', (req, res) => {
const productId = req.params.productId;
const product = findProductById(productId);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
});
// Get all reviews for a product
app.get('/api/products/:productId/reviews', (req, res) => {
const productId = req.params.productId;
const reviews = findReviewsByProductId(productId);
res.json(reviews);
});
// Get a specific review for a product
app.get('/api/products/:productId/reviews/:reviewId', (req, res) => {
const { productId, reviewId } = req.params;
const review = findReviewByProductIdAndReviewId(productId, reviewId);
if (!review) {
return res.status(404).json({ error: 'Review not found' });
}
res.json(review);
});
This pattern follows RESTful principles, where the URL structure reflects the hierarchical relationship between resources (products have many reviews).
Query Parameters
Query parameters are used to send optional data to the server as key-value pairs in the URL after a question mark. They're accessible via the req.query object.
Query Parameters Example
// Route that handles query parameters
app.get('/search', (req, res) => {
const query = req.query.q;
const category = req.query.category || 'all';
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
console.log(`Search query: ${query}`);
console.log(`Category: ${category}`);
console.log(`Page: ${page}, Limit: ${limit}`);
// Search logic would go here
res.send({
query,
category,
page,
limit,
results: [`Result for ${query} in ${category}`]
});
});
When a client makes a request to /search?q=express&category=framework&page=2&limit=20, the req.query object will contain:
{
q: "express",
category: "framework",
page: "2",
limit: "20"
}
Notice that all query parameter values are strings, which is why we need to convert numeric values using parseInt().
Real-world Example: Filtering and Pagination
Query parameters are particularly useful for implementing filtering, sorting, and pagination in APIs:
app.get('/api/products', (req, res) => {
// Extract query parameters with defaults
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const sort = req.query.sort || 'createdAt';
const order = req.query.order || 'desc';
const category = req.query.category;
const minPrice = req.query.minPrice ? parseFloat(req.query.minPrice) : undefined;
const maxPrice = req.query.maxPrice ? parseFloat(req.query.maxPrice) : undefined;
// Calculate pagination values
const skip = (page - 1) * limit;
// Build database query
let dbQuery = {};
if (category) {
dbQuery.category = category;
}
if (minPrice !== undefined || maxPrice !== undefined) {
dbQuery.price = {};
if (minPrice !== undefined) dbQuery.price.$gte = minPrice;
if (maxPrice !== undefined) dbQuery.price.$lte = maxPrice;
}
// Example: Fetch products from database
// const products = await Product.find(dbQuery)
// .sort({ [sort]: order === 'asc' ? 1 : -1 })
// .skip(skip)
// .limit(limit);
// const totalProducts = await Product.countDocuments(dbQuery);
// Mock response for demonstration
const products = [`Product for query: ${JSON.stringify(dbQuery)}`];
const totalProducts = 100;
res.json({
products,
pagination: {
currentPage: page,
totalPages: Math.ceil(totalProducts / limit),
totalItems: totalProducts,
itemsPerPage: limit
}
});
});
This endpoint could be accessed with a URL like /api/products?category=electronics&minPrice=100&maxPrice=500&page=2&limit=10&sort=price&order=asc to get the second page of electronics products priced between $100 and $500, sorted by price in ascending order.
Route Handlers
Route handlers are functions that are executed when a matching route is found. They have access to the request and response objects, as well as the next middleware function.
Single Handler Function
app.get('/user/:id', (req, res) => {
const userId = req.params.id;
// Logic to fetch user by ID
res.send(`User details for ID: ${userId}`);
});
Multiple Handler Functions
You can provide multiple handler functions that behave like middleware for a specific route:
function validateUserId(req, res, next) {
// Validate user ID format
const userId = req.params.id;
if (!userId.match(/^[0-9a-fA-F]{24}$/)) {
return res.status(400).send('Invalid user ID format');
}
next();
}
function checkUserExists(req, res, next) {
// Check if user exists in database
const userId = req.params.id;
const user = findUserById(userId);
if (!user) {
return res.status(404).send('User not found');
}
// Attach user to request object for later use
req.user = user;
next();
}
function respondWithUser(req, res) {
// User is already attached to request by previous middleware
res.json(req.user);
}
// Route with multiple handlers
app.get('/user/:id', validateUserId, checkUserExists, respondWithUser);
This approach allows for better code organization and reusability. Each function performs a specific task, following the single responsibility principle.
Express Router
The Express Router is a class that helps organize routes into modular, mountable route handlers. It works like a mini application, capable of performing middleware and routing functions.
Creating and Using Express Router
const express = require('express');
const app = express();
// Create a router instance
const userRouter = express.Router();
// Define routes on the router
userRouter.get('/', (req, res) => {
res.send('Get all users');
});
userRouter.post('/', (req, res) => {
res.send('Create a new user');
});
userRouter.get('/:id', (req, res) => {
res.send(`Get user with ID: ${req.params.id}`);
});
userRouter.put('/:id', (req, res) => {
res.send(`Update user with ID: ${req.params.id}`);
});
userRouter.delete('/:id', (req, res) => {
res.send(`Delete user with ID: ${req.params.id}`);
});
// Mount the router on the app
app.use('/users', userRouter);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
In this example, all routes defined on the userRouter are prefixed with /users when mounted on the app. This allows for better organization of route handlers by resource type.
Modular Route Organization
In a real-world application, you would typically organize routes in separate files:
File: routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
// User routes
router.get('/', userController.getAllUsers);
router.post('/', userController.createUser);
router.get('/:id', userController.getUserById);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);
module.exports = router;
File: routes/productRoutes.js
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
// Product routes
router.get('/', productController.getAllProducts);
router.post('/', productController.createProduct);
router.get('/:id', productController.getProductById);
router.put('/:id', productController.updateProduct);
router.delete('/:id', productController.deleteProduct);
module.exports = router;
File: app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const productRoutes = require('./routes/productRoutes');
const app = express();
// Mount route modules
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
This approach creates a clean, modular, and maintainable codebase. Each resource has its own router file, and related controller logic is separated into controller files.
Middleware Deep Dive
Let's explore middleware in more depth, as it's one of the most powerful features of Express.js.
The Production Line Analogy
Middleware in Express is like a production line in a factory:
- Raw materials (the HTTP request) enter the factory
- Each workstation (middleware function) performs a specific operation
- The product moves from one workstation to the next in a predefined order
- Each workstation can modify the product, reject defective products, or pass them along
- At the end of the line, the finished product (HTTP response) is shipped out
Just as a factory can have different production lines for different products, Express can have different middleware chains for different routes.
Middleware Execution Flow
Key points about middleware execution:
- Middleware executes in the order it's defined
- Each middleware can end the request-response cycle by sending a response
- Each middleware must call
next()to pass control to the next function, otherwise the request will hang - Middleware can call
next()with an error to trigger error-handling middleware - Route handlers are essentially middleware that typically ends the request-response cycle
Middleware Functions in Detail
A middleware function has access to three objects:
request: The HTTP request objectresponse: The HTTP response objectnext: A function to pass control to the next middleware
Anatomy of a Middleware Function
function middleware(req, res, next) {
// 1. Process the request
console.log(`Request received: ${req.method} ${req.url}`);
// 2. Modify request or response objects (optional)
req.customProperty = 'some value';
// 3. End the request-response cycle (optional)
if (someCondition) {
return res.status(403).send('Forbidden');
}
// 4. Call the next middleware
next();
}
Common Middleware Operations
- Execute code: Run any code for processing, logging, validation, etc.
- Modify request/response objects: Add, remove, or modify properties on the request or response
- End the request-response cycle: Send a response to the client
- Call the next middleware: Pass control to the next middleware in the stack
Middleware Examples
// Logging middleware
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
next();
});
// Request time tracking middleware
app.use((req, res, next) => {
req.requestTime = Date.now();
next();
});
// Authentication middleware
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Authorization header required' });
}
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Invalid authorization format' });
}
// Verify token (simplified example)
if (token === 'valid-token') {
req.user = { id: '123', name: 'John Doe' };
next();
} else {
res.status(401).json({ error: 'Invalid token' });
}
});
// Response time header middleware
app.use((req, res, next) => {
const start = Date.now();
// Listen for when response is finished
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`Request to ${req.url} took ${duration}ms`);
});
next();
});
These examples demonstrate different types of middleware for logging, request augmentation, authentication, and performance monitoring.
Error Handling Middleware
Error handling middleware in Express has a special signature with four parameters: err, req, res, and next. Express recognizes this as error-handling middleware by the number of arguments.
Basic Error Handling
app.get('/users/:id', (req, res, next) => {
// Example: trying to find a user
const userId = req.params.id;
// If user not found, create an error
if (userId === '0') {
// Pass error to the next middleware
const err = new Error('User not found');
err.statusCode = 404;
return next(err);
}
res.send(`User: ${userId}`);
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
// Set status code (default to 500 if not specified)
const statusCode = err.statusCode || 500;
// Send error response
res.status(statusCode).json({
error: {
message: err.message,
status: statusCode
}
});
});
When next() is called with an argument, Express skips all non-error middleware and passes control to the first error-handling middleware.
Comprehensive Error Handling
In a production application, you might implement more sophisticated error handling:
// Custom Error Classes
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 400);
}
}
// Route that might generate errors
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await findUserById(req.params.id);
if (!user) {
throw new NotFoundError(`User with ID ${req.params.id} not found`);
}
res.json(user);
} catch (err) {
next(err); // Pass error to error handling middleware
}
});
// Error handling middleware
app.use((err, req, res, next) => {
// Log error for developers
console.error('ERROR 💥', err);
// Default values
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// Different responses based on environment
if (process.env.NODE_ENV === 'development') {
// Detailed error for development
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
} else {
// Simplified error for production
if (err.isOperational) {
// Operational, trusted error: send message to client
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
// Programming or other unknown error: don't leak error details
console.error('ERROR 💥', err);
res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
}
});
This approach uses custom error classes to standardize error handling across the application. It also distinguishes between operational errors (expected errors like "user not found") and programming errors (unexpected bugs).
Built-in Middleware
Express provides several built-in middleware functions that handle common tasks:
express.json()
Parses incoming requests with JSON payloads and makes the data available on req.body.
app.use(express.json());
app.post('/api/users', (req, res) => {
console.log(req.body); // Contains parsed JSON data
res.json({ received: req.body });
});
express.urlencoded()
Parses incoming requests with URL-encoded payloads (typically from HTML forms) and makes the data available on req.body.
app.use(express.urlencoded({ extended: true }));
app.post('/login', (req, res) => {
const { username, password } = req.body;
console.log(`Login attempt: ${username}`);
res.send(`Form submission received: ${username}`);
});
express.static()
Serves static files such as HTML, CSS, images, and JavaScript files.
app.use(express.static('public'));
// Now files in the 'public' directory are available at the root path
// For example, 'public/css/style.css' is accessible at '/css/style.css'
You can also specify a virtual path prefix:
app.use('/static', express.static('public'));
// Now files in the 'public' directory are available under the '/static' path
// For example, 'public/css/style.css' is accessible at '/static/css/style.css'
Popular Third-party Middleware
Many third-party middleware packages extend Express's functionality:
morgan
HTTP request logger middleware with various logging formats.
const morgan = require('morgan');
// Use predefined format
app.use(morgan('dev')); // :method :url :status :response-time ms
// Custom format
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
helmet
Helps secure Express apps by setting various HTTP headers.
const helmet = require('helmet');
// Use helmet with default configuration
app.use(helmet());
// Or configure specific headers
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "example.com"]
}
},
xssFilter: true
})
);
cors
Enables Cross-Origin Resource Sharing (CORS).
const cors = require('cors');
// Enable CORS for all routes
app.use(cors());
// Configure CORS for specific origins
app.use(cors({
origin: ['https://example.com', 'https://subdomain.example.com'],
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// CORS for a specific route
app.get('/api/public-data', cors(), (req, res) => {
res.json({ data: 'This data is accessible from any origin' });
});
compression
Compresses response bodies for improved performance.
const compression = require('compression');
// Use compression middleware
app.use(compression());
// With options
app.use(compression({
level: 6, // Compression level (0-9)
threshold: 1024 // Only compress responses larger than 1KB
}));
cookie-parser
Parses Cookie header and populates req.cookies with an object keyed by cookie names.
const cookieParser = require('cookie-parser');
// Basic usage
app.use(cookieParser());
// With a secret for signed cookies
app.use(cookieParser('your-secret-key'));
app.get('/get-cookie', (req, res) => {
console.log('Cookies:', req.cookies); // Regular cookies
console.log('Signed Cookies:', req.signedCookies); // Signed cookies
res.send('Check the console for cookies');
});
app.get('/set-cookie', (req, res) => {
// Set a regular cookie
res.cookie('user', 'john', { maxAge: 900000, httpOnly: true });
// Set a signed cookie
res.cookie('authenticated', 'true', { signed: true });
res.send('Cookies set');
});
Creating Custom Middleware
You can create your own middleware functions to handle specific requirements of your application.
Basic Custom Middleware
// Request logger middleware
function requestLogger(req, res, next) {
const timestamp = new Date().toISOString();
const method = req.method;
const url = req.url;
const ip = req.ip || req.connection.remoteAddress;
console.log(`[${timestamp}] ${method} ${url} - ${ip}`);
next();
}
// Use the custom middleware
app.use(requestLogger);
This simple middleware logs details about each incoming request.
Middleware with Configuration Options
// Rate limiting middleware factory
function rateLimit(options = {}) {
const {
windowMs = 60 * 1000, // 1 minute
max = 100, // 100 requests per window
message = 'Too many requests, please try again later.'
} = options;
// Store request counts for each IP
const requestCounts = {};
// Clean up old entries every minute
setInterval(() => {
const now = Date.now();
Object.keys(requestCounts).forEach(ip => {
if (now - requestCounts[ip].timestamp > windowMs) {
delete requestCounts[ip];
}
});
}, 60 * 1000);
// Return the middleware function
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
// Initialize or update request count
if (!requestCounts[ip]) {
requestCounts[ip] = {
count: 1,
timestamp: Date.now()
};
} else {
const entry = requestCounts[ip];
const timeSinceFirstRequest = Date.now() - entry.timestamp;
if (timeSinceFirstRequest > windowMs) {
// Reset if window has passed
entry.count = 1;
entry.timestamp = Date.now();
} else {
// Increment count
entry.count++;
}
}
// Check if limit exceeded
if (requestCounts[ip].count > max) {
return res.status(429).send(message);
}
next();
};
}
// Use the rate limiting middleware
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // 100 requests per 15 minutes
}));
// Use with different settings for specific routes
app.use('/api/login', rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5 // 5 login attempts per hour
}));
This example creates a configurable rate limiting middleware that restricts the number of requests a client can make within a specified time window.
Real-world Example: Authentication Middleware
In a real application, you might implement authentication middleware to protect routes:
const jwt = require('jsonwebtoken');
function authenticate(options = {}) {
const {
required = true,
roles = []
} = options;
return (req, res, next) => {
// Get token from header
const authHeader = req.headers.authorization;
let token;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
}
// If token is missing
if (!token) {
if (required) {
return res.status(401).json({
status: 'fail',
message: 'Authentication required. Please provide a token.'
});
} else {
// Token is optional, proceed without authentication
return next();
}
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if user exists (in a real app, you'd query the database)
const user = findUserById(decoded.id);
if (!user) {
return res.status(401).json({
status: 'fail',
message: 'The user for this token no longer exists.'
});
}
// Check if token is expired or user changed password
if (decoded.iat < user.passwordChangedAt) {
return res.status(401).json({
status: 'fail',
message: 'User recently changed password. Please log in again.'
});
}
// Check role-based access
if (roles.length > 0 && !roles.includes(user.role)) {
return res.status(403).json({
status: 'fail',
message: 'You do not have permission to perform this action.'
});
}
// Grant access - attach user to request
req.user = user;
next();
} catch (err) {
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
status: 'fail',
message: 'Invalid token. Please log in again.'
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
status: 'fail',
message: 'Your token has expired. Please log in again.'
});
}
return res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
};
}
// Use the authentication middleware
app.get(
'/api/user/profile',
authenticate(), // Token required
(req, res) => {
res.json({
status: 'success',
data: {
user: req.user
}
});
}
);
// Optional authentication
app.get(
'/api/products',
authenticate({ required: false }), // Token optional
(req, res) => {
// If authenticated, could show personalized products
const products = getProducts(req.user);
res.json({
status: 'success',
data: {
products
}
});
}
);
// Role-based access control
app.delete(
'/api/products/:id',
authenticate({ roles: ['admin', 'manager'] }), // Only certain roles can access
(req, res) => {
deleteProduct(req.params.id);
res.json({
status: 'success',
message: 'Product deleted'
});
}
);
This authentication middleware verifies JWT tokens, checks user permissions, and protects routes based on configurable options.
Practical Exercises
Try these exercises to practice and reinforce your understanding of Express routing and middleware:
Exercise 1: Create a Router-based API
Objective: Create a RESTful API using Express Routers for different resources.
Tasks:
- Create a Node.js project and install Express
- Create separate router files for 'users' and 'posts' resources
- Implement CRUD operations for each resource
- Mount the routers at appropriate paths in your main app file
- Test your API using a tool like Postman or cURL
Exercise 2: Custom Authentication Middleware
Objective: Implement a simple authentication system using custom middleware.
Tasks:
- Create a middleware function that checks for an API key in request headers
- If a valid API key is provided, allow the request to proceed
- If no API key or an invalid one is provided, return a 401 Unauthorized response
- Apply this middleware to protect certain routes in your application
- Create both protected and public routes to test your middleware
Exercise 3: Error Handling System
Objective: Implement a comprehensive error handling system for an Express application.
Tasks:
- Create custom error classes for different types of errors (e.g., NotFoundError, ValidationError)
- Implement a global error handling middleware
- Add try/catch blocks to your route handlers and pass errors to the error handler
- Create routes that deliberately trigger different types of errors
- Format error responses differently based on error type and environment (development/production)